diff --git a/.github/workflows/roadmap-closure.yml b/.github/workflows/roadmap-closure.yml new file mode 100644 index 0000000..bb9e1fd --- /dev/null +++ b/.github/workflows/roadmap-closure.yml @@ -0,0 +1,36 @@ +name: Roadmap closure guard + +on: + issues: + types: [closed] + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + verify-closure: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Verify roadmap closure evidence + id: verify + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: python tools/check_roadmap_closures.py issue-event + - name: Reopen an invalid roadmap closure + if: steps.verify.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + ISSUE_URL: ${{ github.event.issue.html_url }} + run: | + gh issue reopen "$ISSUE_URL" + gh issue comment "$ISSUE_URL" --body "This roadmap item was reopened automatically because its committed closure evidence contract was not satisfied. See the failed Roadmap closure guard run for the exact missing evidence." + exit 1 diff --git a/.github/workflows/xevious-project.yml b/.github/workflows/xevious-project.yml index fd9e790..92552d2 100644 --- a/.github/workflows/xevious-project.yml +++ b/.github/workflows/xevious-project.yml @@ -2,12 +2,15 @@ name: Xevious project on: pull_request: + types: [opened, synchronize, reopened, edited, labeled, unlabeled, ready_for_review] push: branches: - main permissions: contents: read + issues: read + pull-requests: read jobs: verify: @@ -29,6 +32,11 @@ jobs: env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} run: python tools/check_mechanics_record.py "$BASE_SHA" + - name: Verify roadmap issue closures + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + run: python tools/check_roadmap_closures.py pr runtime-harness: # A pre-playtest regression tripwire, NOT a gameplay gate: it runs the shipped build diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 6b9d005..a6ac8e4 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -80,6 +80,23 @@ preserving the lifecycle and collision interfaces. What must be built is normatively grouped in the spec's [build order](spec/build-plan.md); this table stages it. +### GitHub delivery model + +The committed [dependency-aware roadmap](roadmap/README.md) projects this plan into GitHub without +making the Project board a second source of truth. Capability issues are milestone-free parents; +their native sub-issues are the independently closable component leaves. Cross-capability work is +composed by one delivery slice and one pull request closing each completed leaf separately. + +A leaf cannot close while its specification or another declared blocker is open, while its required +mechanics evidence is absent, or before the operator approves the playable build at the exact tested +commit. Work that cannot be exercised until a consumer exists stays open and joins the consumer's +delivery slice. Draft-spec work is visible but provisional and non-closable. Imported history records +the original delivering pull request without retroactively certifying it under today's controls. + +[`docs/roadmap/manifest.json`](roadmap/manifest.json) is the complete criterion-to-leaf mapping and +owns the derived milestone, blocker, slice, and proof metadata. The table below remains the readable +engineering sequence. + Every slice extracts only the costumes it needs and updates its catalog rows plus a `docs/mechanics/` record. A slice closes only when its declared behavior, provenance, automated validation, deterministic build, and @@ -91,7 +108,7 @@ operator-runnable Scratch check agree. | 2. Game director and state reset — current | Stage-owned title, ready, playing, player-dead, respawning, and game-over states; serialized reset scopes and cancellation. Depends on slice 1 only for the current canonical source. | Only Stage writes director variables; every normal state change uses one transition procedure; cleanup finishes before entry; reset handlers terminate; obsolete `begin`/`death` control is absent. | Exercise the state/input/reset contract recorded in the spec's core game systems document. Record `003-game-director-and-state-reset.md` and mark SYS-01 present. | | 2a. Regression recovery — next | Restore the ten regressed behaviors and remove the two invented presentations recorded in the fidelity audit (tracked as issue #13); supersedes the abandoned recovery branch. Depends on slice 2 and the settled spec. | Every audit item B1-B10 restored per its spec section or preserved-baseline marker; A1/A2 removed; the operator playtest gate passes. | Play the playtest checklist end to end. Update affected mechanics records. | | 3. Entity pool and collision foundation | Shared clone lifecycle, collision groups, single-hit resolution, off-screen cleanup, and air/ground/bullet/effect fixtures. Depends on SYS-01. | Repeated worst-case spawn/removal leaks no state; groups cannot cross-hit or double-resolve; measured clone load stays responsive. | Run the clone spike and each collision fixture in Scratch 3. Record SYS-02 and SYS-03. | -| — built as issue #14 | Delivered SYS-02 (64-slot array, reset-clear, player-shot 3-cap), the SYS-04 centralized ordered walk + `tick`, and the SYS-03 single-hit path + five-group vocabulary — with **SYS-04 pulled forward from slice 7** per #14's scope. All but the shot cap are dormant, foundation-only: no enemy participant or stream consumer exists yet, so the clone spike, live collision fixtures, entity-leak, and seeded-determinism playtests run when the first participants land. Records 004–006. | | | +| — foundation delivered by PR #20; capability parent #14 remains open | Delivered the 64-slot array, reset-clear, player-shot 3-cap, centralized ordered walk + `tick`, and the single-hit path + five-group vocabulary. The live entity-lifecycle, collision-resolution, and shared-random-consumer obligations remain open component leaves in slice 8 because they require an enemy participant. Records 004–006 are prior evidence, not completion of those live criteria. | | | | 4. Score, HUD, lives, death, and respawn | Score/high-score cap, object awards, HUD, lives, bonus thresholds, collision death, safe respawn, and game over. Depends on slices 2–3. | Awards occur once; HUD matches state; life loss, bonus awards, respawn, and last-life game over repeat deterministically. | Run score/life fixtures through ordinary and last-life deaths. Record ECO-01–04 and PLY-02. | | — built as issue #16 (Part of #15) | Delivered the life economy: ECO-01 (single scoring path, 9,999,990 cap, running high score), ECO-02 (digit + life-icon HUD from the CC BY 3.0 HUD font), ECO-03 (starting craft, per-setting bonus thresholds/increments, the disable sentinel, and the at-cap grant quirk from the committed tables), the ECO-04 GAME OVER presentation (128-frame hold) + best-five verdict, and PLY-02's lives-driven respawn-vs-game-over decision, no-invulnerability respawn, and terrain-restart-on-death. A debug **S** fixture drives scoring and the D/G keys trigger death until a real attacker lands, so in-play object awards and real collision death (slice 8), the stop-after-two DIP setting (index unpinned), the Bonus Flag (a ground secret), and the initials-entry screen (slice 19) are deferred — ECO-01/02 are present (operator-playtest-gated), ECO-03/04 and PLY-02 partial. Both #16 and #15 stay open. Records 009–014. | | | | 5. Area clock and scheduler foundation | Monotonic terrain position, table representation, ordered event dispatch, area boundary seam, and one small schedule fixture. Depends on slices 2–3. | Position never rewinds during a life; fixture events fire once in order; transitions leave no old-area work. | Accelerate and pause the fixture around its boundaries. Record AREA-01 and AREA-02 foundation. | diff --git a/docs/roadmap/PR34_HANDOFF.md b/docs/roadmap/PR34_HANDOFF.md new file mode 100644 index 0000000..67374a6 --- /dev/null +++ b/docs/roadmap/PR34_HANDOFF.md @@ -0,0 +1,13 @@ +# PR #34 roadmap handoff + +PR [#34](https://github.com/StarshipSuperjam/xevious/pull/34) remains the active slice-7 build in its existing Claude session. The roadmap migration did not change its branch, commits, draft state, body, labels, milestone, or Project fields. + +Before that session submits the PR for merge: + +1. Replace the stale `Part of #17` reference with `Closes #56` and `Part of #18`. +2. Do not close a slice-8 integration leaf. Enemy-dependent gameplay acceptance stays open until enemies exist in the playable build. +3. Reconcile the branch onto current `main` so the roadmap closure check runs. +4. After the operator tests the exact head commit, first add the required commit-specific playtest record, then apply `playtest-approved`. The label event refreshes the required closure check against the current comment and head. +5. Verify GitHub's computed closing-issue list contains only [#56](https://github.com/StarshipSuperjam/xevious/issues/56). + +Issue #56 is the leaf for difficulty models and live state. Parent capability [#18](https://github.com/StarshipSuperjam/xevious/issues/18) remains open until all of its native sub-issues close. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md new file mode 100644 index 0000000..e47d2af --- /dev/null +++ b/docs/roadmap/README.md @@ -0,0 +1,107 @@ +# Dependency-aware Xevious roadmap + +The roadmap is designed to keep a delivery from claiming completion before it +can be exercised. Its committed desired state is +[`manifest.json`](manifest.json). GitHub Issues, milestones, native sub-issue +links, and the Xevious Project board are projections of that file. + +## Ownership + +- `docs/spec/build-plan.md` owns the capability phases and their order. +- `docs/BUILD_PLAN.md` owns the engineering delivery sequence. +- `manifest.json` decomposes that sequence into capability parents, atomic + component leaves, blockers, milestones, proof levels, and delivery slices. +- `criteria.json` is the independent exact obligation roster; validation fails + if a criterion is dropped, invented, or assigned twice. +- GitHub Issues own discussion and delivery evidence for each projected item. +- Project #4 is a replaceable operational view. Its delivery fields never + become the only record of scope or dependency. + +A capability parent has no milestone and never closes directly from a pull +request. Its native sub-issues are the independently closable units. A pull +request may compose leaves from several capability parents, but it uses one +`Closes #N` line for every leaf it actually completes. + +## Closure evidence + +The required project check reads the manifest and migration journal before a +pull request may close a roadmap leaf. It rejects: + +- a capability parent as a close target; +- a provisional leaf whose specification is not settled; +- a leaf with an open blocker, unless the same pull request closes it; +- a gameplay or operator-tested leaf without the `playtest-approved` label + and a repository-owner comment identifying the exact tested head commit; +- a mechanics-bearing leaf without matching updated mechanics evidence; +- a leaf without changed automated success and failure evidence; +- a leaf whose prerequisite delivery slices still contain open work. + +The marker recorded after a successful operator test is: + +```text + +``` + +Automated tests added by a delivering PR identify both sides of every atomic +obligation with comments such as: + +```text +# roadmap-evidence: SYS-02 success +# roadmap-evidence: SYS-02 failure +``` + +Only markers on lines newly added by that PR count. Existing comments or +unrelated tests cannot satisfy the closure gate. + +The issue-closure workflow applies the same contract to a manually closed +roadmap item and reopens an invalid closure. The protected-branch merge and the +operator's review remain the binding gate. + +## Provisional work and imported history + +Leaves under a `draft` specification are visible so the full plan is visible, +but they carry `spec:draft`, remain non-executable, and cannot close. Settling +their owning specification is an explicit blocker. + +Completed slices are imported as history leaves with their original delivering +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. + +## Migration and recovery + +[`migration.json`](migration.json) is the resumable journal. The migration: + +1. validates the manifest and stops before writes on any mismatch; +2. snapshots milestones, Project fields/items/views, and every protected field + of active PR #34; +3. creates or converts parents and leaves by stable `roadmap-key`, journaling + each returned issue and Project identity; +4. attaches native parent relationships, milestones, and derived Project + fields in separate passes; +5. closes imported-history leaves only after their evidence and relationships + exist; +6. reads the complete live state back and proves exact parentage, milestones, + state, uniqueness, and that PR #34 did not change. + +An interrupted migration is resumed by rerunning `apply`; it rolls forward from +stable keys and journaled IDs. Issue deletion or silent closure is never used as +rollback because GitHub history and notifications cannot be undone. If PR #36 +is abandoned, the journal and `roadmap-migration: PR #36` markers identify the +incomplete projection to resume or explicitly supersede. + +Useful commands: + +```bash +python3 tools/roadmap.py validate +python3 tools/roadmap.py plan +python3 tools/roadmap.py snapshot +python3 tools/roadmap.py apply +python3 tools/roadmap.py reconcile +python3 tools/roadmap.py handoff +``` + +Existing Project views and the Engine-owned summary fields are immutable to the +migration. It adds manifest-owned views for delivery leaves, capability +parents, and imported history, plus the derived `Roadmap role`, `Delivery +slice`, and `Proof level` fields. diff --git a/docs/roadmap/criteria.json b/docs/roadmap/criteria.json new file mode 100644 index 0000000..45a7a73 --- /dev/null +++ b/docs/roadmap/criteria.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "criteria": [ + "AIR-01.toroid", "AIR-02.torkan", "AIR-03.zoshi", "AIR-04.jara", "AIR-05.kapi", "AIR-06.terrazi", + "AIR-07.zakato", "AIR-08.brag-zakato", "AIR-09.sheonite", "AIR-10.spario", "AIR-11.bacura", + "AIR-12.pool", "AIR-12.radiating", "AIR-12.standard", + "AREA-01.clock", "AREA-01.visual-terrain", "AREA-02.air-dispatch", "AREA-02.data", "AREA-02.ground-dispatch", "AREA-03.tables", "AREA-04.loop", + "BOSS-01.lifecycle", "BOSS-02.defenses", "BOSS-03.core", "BUILD-01.sprite-proof", + "CAB-01.attract", "CAB-02.one-player", "CAB-02.two-player", "CAB-03.alternation", "CAB-04.high-scores", "CAB-05.presentation", + "DIF-01.model", "DIF-01.play", "DIF-02.model", "DIF-02.play", "DIF-03.model", "DIF-03.play", + "ECO-01.air-awards", "ECO-01.ground-awards", "ECO-01.path", "ECO-02.one-player", "ECO-02.two-player", + "ECO-03.bonus-flag", "ECO-03.stop-after-two", "ECO-03.tables", "ECO-04.high-score-route", "ECO-04.presentation", + "FORM-01.model", "FORM-01.play", + "GND-01.barra", "GND-01.variants", "GND-02.zolbak", "GND-03.logram", "GND-04.derota", "GND-05.boza", "GND-06.grobda", "GND-07.domogram", + "PLY-01.movement", "PLY-02.air-trigger", "PLY-02.decision", "PLY-RECOVERY.audit", + "PRES-01.framing", "PRES-02.hud-yellow", "RELEASE-01.full-soak", "RELEASE-02.audit", + "SEC-01.sol-tower", "SEC-02.bonus-flag", "SEC-03.hidden-credit", + "SYS-01.director-reset", "SYS-02.foundation", "SYS-02.live", "SYS-03.foundation", "SYS-03.live", "SYS-04.consumer", "SYS-04.stream", + "WPN-01.bacura-bounce", "WPN-01.flight", "WPN-02.air-hit", "WPN-03.crosshair", "WPN-03.target-lock", "WPN-04.bomb-flight", "WPN-05.ground-hit" + ] +} diff --git a/docs/roadmap/manifest.json b/docs/roadmap/manifest.json new file mode 100644 index 0000000..853e09f --- /dev/null +++ b/docs/roadmap/manifest.json @@ -0,0 +1,126 @@ +{ + "version": 1, + "repository": "StarshipSuperjam/xevious", + "project": { + "owner": "StarshipSuperjam", + "number": 4, + "node_id": "PVT_kwHOAf1Ylc4BgLx1", + "views": [ + {"name": "Delivery leaves", "layout": "TABLE_LAYOUT", "filter": "is:issue roadmap-role:Leaf"}, + {"name": "Capability parents", "layout": "TABLE_LAYOUT", "filter": "is:issue roadmap-role:Parent"}, + {"name": "Imported history", "layout": "TABLE_LAYOUT", "filter": "is:issue roadmap-role:\"Imported history\""} + ] + }, + "excluded_catalog_prefixes": ["EX-"], + "slice_dependencies": { + "7": ["4", "5", "6"], + "8": ["3", "4", "7"], + "9": ["3", "4", "5", "6", "7"], + "10": ["8"], + "11": ["8", "10"], + "12": ["9"], + "13": ["12"], + "14": ["4", "6", "9", "12"], + "15": ["3", "6", "7", "11", "12", "13"], + "16": ["9", "11", "15"], + "17": ["2", "4", "6", "8"], + "18": ["4", "6", "17"], + "19": ["4", "17", "18"], + "20": ["1", "2", "2a", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19"], + "21": ["1", "2", "2a", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20"] + }, + "parents": [ + {"key": "build", "title": "Capability: reproducible Scratch build and assets", "spec": null, "spec_status": "settled"}, + {"key": "core", "title": "Build: core game systems to their settled description", "issue": 14, "spec": "docs/spec/core-game-systems.md", "spec_status": "locked"}, + {"key": "player", "title": "Build: player craft and weapons to their settled description", "issue": 15, "spec": "docs/spec/player-craft-and-weapons.md", "spec_status": "locked"}, + {"key": "economy", "title": "Build: scoring, lives, and game over to their settled description", "issue": 16, "spec": "docs/spec/scoring-lives-and-game-over.md", "spec_status": "locked"}, + {"key": "area", "title": "Build: area progression and terrain to their settled description", "issue": 17, "spec": "docs/spec/area-progression-and-terrain.md", "spec_status": "locked"}, + {"key": "difficulty", "title": "Build: difficulty and formations to their settled description", "issue": 18, "spec": "docs/spec/difficulty-and-formations.md", "spec_status": "locked"}, + {"key": "air", "title": "Capability: aerial enemies", "spec": "docs/spec/aerial-enemies.md", "spec_status": "draft"}, + {"key": "ground", "title": "Capability: ground objects", "spec": "docs/spec/ground-objects.md", "spec_status": "draft"}, + {"key": "secrets", "title": "Capability: secrets", "spec": "docs/spec/secrets.md", "spec_status": "draft"}, + {"key": "andor", "title": "Capability: Andor Genesis", "spec": "docs/spec/andor-genesis.md", "spec_status": "draft"}, + {"key": "cabinet", "title": "Capability: cabinet flow", "spec": "docs/spec/cabinet-flow.md", "spec_status": "draft"}, + {"key": "presentation", "title": "Capability: audio and presentation", "spec": "docs/spec/audio-and-presentation.md", "spec_status": "draft"}, + {"key": "release", "title": "Capability: full-game release validation", "spec": null, "spec_status": "provisional"} + ], + "leaves": [ + {"key":"build.sprite-proof","parent":"build","title":"History: deterministic sprite-extraction proof","slice":"1","milestone":"Recovery","status":"history","delivered_by":8,"criteria":["BUILD-01.sprite-proof"],"proof":"historical"}, + {"key":"core.director-reset","parent":"core","title":"History: game director and reset lifecycle","slice":"2","milestone":"Foundation","status":"history","delivered_by":9,"criteria":["SYS-01.director-reset"],"proof":"historical"}, + {"key":"player.regression-recovery","parent":"player","issue":13,"title":"History: regression recovery","slice":"2a","milestone":"Recovery","status":"history","delivered_by":19,"criteria":["PLY-RECOVERY.audit"],"proof":"historical"}, + {"key":"player.movement-and-flight","parent":"player","title":"History: movement, bounds, blaster flight, and crosshair coupling","slice":"3","milestone":"Recovery","status":"history","delivered_by":21,"criteria":["PLY-01.movement","WPN-01.flight","WPN-03.crosshair"],"proof":"historical"}, + {"key":"player.death-decision","parent":"player","title":"History: lives-driven death, respawn, and game-over decision","slice":"4","milestone":"Foundation","status":"history","delivered_by":22,"criteria":["PLY-02.decision"],"proof":"historical"}, + {"key":"player.bullet-pool","parent":"player","title":"History: enemy-bullet pool and collision windows","slice":"3","milestone":"Foundation","status":"history","delivered_by":21,"criteria":["AIR-12.pool"],"proof":"historical"}, + {"key":"core.entity-foundation","parent":"core","title":"History: entity, collision, and shared-random foundations","slice":"3","milestone":"Foundation","status":"history","delivered_by":20,"criteria":["SYS-02.foundation","SYS-03.foundation","SYS-04.stream"],"proof":"historical","records":["SYS-02","SYS-03","SYS-04"]}, + {"key":"economy.score-hud","parent":"economy","title":"History: score path and one-player HUD foundation","slice":"4","milestone":"Foundation","status":"history","delivered_by":22,"criteria":["ECO-01.path","ECO-02.one-player"],"proof":"historical"}, + {"key":"economy.life-tables","parent":"economy","title":"History: starting craft and bonus-life tables","slice":"4","milestone":"Foundation","status":"history","delivered_by":22,"criteria":["ECO-03.tables"],"proof":"historical"}, + {"key":"economy.game-over","parent":"economy","title":"History: game-over presentation and qualification decision","slice":"4","milestone":"Foundation","status":"history","delivered_by":22,"criteria":["ECO-04.presentation"],"proof":"historical"}, + {"key":"area.clock","parent":"area","title":"History: area clock, checkpoint, and boundary seam","slice":"5","milestone":"Campaign","status":"history","delivered_by":28,"criteria":["AREA-01.clock"],"proof":"historical"}, + {"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":"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":"difficulty.live-pressure","parent":"difficulty","title":"Complete in-game formation selection, adaptive pressure, and fire permission","slice":"8","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":"provisional","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":"provisional","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"]}, + {"key":"area.ground-dispatch","parent":"area","title":"Dispatch scheduled ground events into live objects","slice":"9","milestone":"Enemies","status":"planned","blocked_by":["ground.barra","ground.logram"],"criteria":["AREA-02.ground-dispatch"],"proof":"playable","records":["AREA-02"]}, + {"key":"ground.barra","parent":"ground","title":"Barra placement, fire, bombing, scoring, and cleanup","slice":"9","milestone":"Enemies","status":"provisional","criteria":["GND-01.barra"],"proof":"playable","records":["GND-01"]}, + {"key":"ground.logram","parent":"ground","title":"Logram open-fire-close cycle and bombing","slice":"9","milestone":"Enemies","status":"provisional","criteria":["GND-03.logram"],"proof":"playable","records":["GND-03"]}, + + {"key":"air.torkan","parent":"air","title":"Torkan family behavior","slice":"10","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-02.torkan"],"proof":"playable","records":["AIR-02"]}, + {"key":"air.zoshi","parent":"air","title":"Zoshi variants and distinct behavior","slice":"10","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-03.zoshi"],"proof":"playable","records":["AIR-03"]}, + {"key":"air.jara","parent":"air","title":"Jara family behavior","slice":"10","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-04.jara"],"proof":"playable","records":["AIR-04"]}, + {"key":"air.kapi","parent":"air","title":"Kapi family behavior","slice":"10","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-05.kapi"],"proof":"playable","records":["AIR-05"]}, + {"key":"air.terrazi","parent":"air","title":"Terazzi family behavior","slice":"10","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-06.terrazi"],"proof":"playable","records":["AIR-06"]}, + + {"key":"player.bacura-bounce","parent":"player","title":"Complete Bacura blaster bounce behavior","slice":"11","milestone":"Enemies","status":"planned","blocked_by":["air.bacura"],"criteria":["WPN-01.bacura-bounce"],"proof":"playable","records":["WPN-01"]}, + {"key":"air.zakato","parent":"air","title":"Zakato and Garu Zakato families","slice":"11","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-07.zakato"],"proof":"playable","records":["AIR-07"]}, + {"key":"air.sheonite","parent":"air","title":"Sheonite pair, pincer, and docking behavior","slice":"11","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-09.sheonite"],"proof":"playable","records":["AIR-09"]}, + {"key":"air.spario","parent":"air","title":"Giddo and Brag Spario behavior","slice":"11","milestone":"Enemies","status":"provisional","blocked_by":["air.zakato"],"criteria":["AIR-10.spario"],"proof":"playable","records":["AIR-10"]}, + {"key":"air.bacura","parent":"air","title":"Bacura resistance, collision, and cleanup","slice":"11","milestone":"Enemies","status":"provisional","blocked_by":["air.toroid"],"criteria":["AIR-11.bacura"],"proof":"playable","records":["AIR-11"]}, + {"key":"air.special-pairs","parent":"air","title":"Brag and Garu Zakato teleport, explosion, and fire behavior","slice":"11","milestone":"Enemies","status":"provisional","blocked_by":["air.zakato"],"criteria":["AIR-08.brag-zakato"],"proof":"playable","records":["AIR-08"]}, + {"key":"air.radiating-bullets","parent":"air","title":"Radiating bullets and mixed bullet pressure","slice":"11","milestone":"Enemies","status":"provisional","blocked_by":["air.zakato"],"criteria":["AIR-12.radiating"],"proof":"playable","records":["AIR-12"]}, + + {"key":"ground.barra-variants","parent":"ground","title":"Garu Barra and Barra family variants","slice":"12","milestone":"Enemies","status":"provisional","blocked_by":["ground.barra"],"criteria":["GND-01.variants"],"proof":"playable","records":["GND-01"]}, + {"key":"ground.zolbak","parent":"ground","title":"Zolbak destruction and pressure reduction","slice":"12","milestone":"Enemies","status":"provisional","blocked_by":["difficulty.live-pressure"],"criteria":["GND-02.zolbak"],"proof":"playable","records":["GND-02"]}, + {"key":"ground.derota","parent":"ground","title":"Derota and Garu Derota behavior","slice":"12","milestone":"Enemies","status":"provisional","blocked_by":["ground.logram"],"criteria":["GND-04.derota"],"proof":"playable","records":["GND-04"]}, + {"key":"ground.boza-logram","parent":"ground","title":"Boza Logram composite hit ordering","slice":"13","milestone":"Enemies","status":"provisional","blocked_by":["ground.logram"],"criteria":["GND-05.boza"],"proof":"playable","records":["GND-05"]}, + {"key":"ground.grobda","parent":"ground","title":"Grobda variants, reactions, and water cleanup","slice":"13","milestone":"Enemies","status":"provisional","blocked_by":["ground.barra"],"criteria":["GND-06.grobda"],"proof":"playable","records":["GND-06"]}, + {"key":"ground.domogram","parent":"ground","title":"Domogram patrol paths, fire, and cleanup","slice":"13","milestone":"Enemies","status":"provisional","blocked_by":["ground.barra"],"criteria":["GND-07.domogram"],"proof":"playable","records":["GND-07"]}, + + {"key":"secrets.sol-tower","parent":"secrets","title":"Sol Tower reveal, rise, destruction, and rewards","slice":"14","milestone":"Boss and secrets","status":"provisional","criteria":["SEC-01.sol-tower"],"proof":"playable","records":["SEC-01"]}, + {"key":"secrets.bonus-flag","parent":"secrets","title":"Bonus Flag reveal, random placement, collection, and award","slice":"14","milestone":"Boss and secrets","status":"provisional","criteria":["SEC-02.bonus-flag"],"proof":"playable","records":["SEC-02"]}, + {"key":"economy.bonus-flag-award","parent":"economy","title":"Complete configured Bonus Flag life award","slice":"14","milestone":"Boss and secrets","status":"planned","blocked_by":["secrets.bonus-flag"],"criteria":["ECO-03.bonus-flag"],"proof":"playable","records":["ECO-03"]}, + {"key":"secrets.hidden-credit","parent":"secrets","title":"Hidden credit event trigger and presentation","slice":"14","milestone":"Boss and secrets","status":"provisional","criteria":["SEC-03.hidden-credit"],"proof":"playable","records":["SEC-03"]}, + + {"key":"andor.lifecycle","parent":"andor","title":"Andor composite lifecycle, alignment, arrival, and departure","slice":"15","milestone":"Boss and secrets","status":"provisional","criteria":["BOSS-01.lifecycle"],"proof":"playable","records":["BOSS-01"]}, + {"key":"andor.defenses","parent":"andor","title":"Andor ports, armor, Bragza, and fire permissions","slice":"16","milestone":"Boss and secrets","status":"provisional","blocked_by":["andor.lifecycle"],"criteria":["BOSS-02.defenses"],"proof":"playable","records":["BOSS-02"]}, + {"key":"andor.core-destruction","parent":"andor","title":"Andor core path, scoring, destruction, and cleanup","slice":"16","milestone":"Boss and secrets","status":"provisional","blocked_by":["andor.lifecycle","andor.defenses"],"criteria":["BOSS-03.core"],"proof":"playable","records":["BOSS-03"]}, + + {"key":"cabinet.attract-credits","parent":"cabinet","title":"Attract sequence, credits, demo death, and one-player flow","slice":"17","milestone":"Cabinet","status":"provisional","criteria":["CAB-01.attract","CAB-02.one-player"],"proof":"playable","records":["CAB-01","CAB-02"]}, + {"key":"cabinet.two-player","parent":"cabinet","title":"Two-player alternation and independent player state","slice":"18","milestone":"Cabinet","status":"provisional","blocked_by":["cabinet.attract-credits"],"criteria":["CAB-02.two-player","CAB-03.alternation"],"proof":"playable","records":["CAB-02","CAB-03"]}, + {"key":"economy.two-player-hud","parent":"economy","title":"Complete two-player HUD state","slice":"18","milestone":"Cabinet","status":"planned","blocked_by":["cabinet.two-player"],"criteria":["ECO-02.two-player"],"proof":"playable","records":["ECO-02"]}, + {"key":"cabinet.high-scores","parent":"cabinet","title":"High-score ranking, ten-character initials, and return flow","slice":"19","milestone":"Cabinet","status":"provisional","blocked_by":["cabinet.attract-credits","cabinet.two-player"],"criteria":["CAB-04.high-scores"],"proof":"playable","records":["CAB-04"]}, + {"key":"economy.game-over-routing","parent":"economy","title":"Complete game-over routing through high-score entry","slice":"19","milestone":"Cabinet","status":"planned","blocked_by":["cabinet.high-scores"],"criteria":["ECO-04.high-score-route"],"proof":"playable","records":["ECO-04"]}, + + {"key":"area.visual-terrain","parent":"area","title":"Couple visual terrain and framing to the area clock","slice":"20","milestone":"Polish","status":"planned","blocked_by":["presentation.framing"],"criteria":["AREA-01.visual-terrain"],"proof":"playable","records":["AREA-01"]}, + {"key":"presentation.framing","parent":"presentation","issue":30,"title":"Choose and implement the playfield framing","slice":"20","milestone":"Polish","status":"provisional","criteria":["PRES-01.framing"],"proof":"playable"}, + {"key":"presentation.hud-yellow","parent":"economy","issue":31,"title":"Render the high-score value in yellow","slice":"20","milestone":"Polish","status":"planned","criteria":["PRES-02.hud-yellow"],"proof":"playable","records":["ECO-02"]}, + {"key":"economy.stop-after-two","parent":"economy","title":"Resolve and implement the stop-after-two bonus-life setting","slice":"20","milestone":"Polish","status":"planned","criteria":["ECO-03.stop-after-two"],"proof":"playable","records":["ECO-03"]}, + {"key":"presentation.audio-animation","parent":"presentation","title":"Audio cues, animation timing, and presentation fidelity","slice":"20","milestone":"Polish","status":"provisional","criteria":["CAB-05.presentation"],"proof":"playable","records":["CAB-05"]}, + + {"key":"release.full-soak","parent":"release","title":"Full-game area, clone, performance, and cabinet soak","slice":"21","milestone":"Polish","status":"provisional","blocked_by":["andor.core-destruction","cabinet.high-scores","presentation.audio-animation"],"criteria":["RELEASE-01.full-soak"],"proof":"playable"}, + {"key":"release.audit","parent":"release","title":"Release provenance, license, catalog, and documentation audit","slice":"21","milestone":"Polish","status":"provisional","blocked_by":["release.full-soak"],"criteria":["RELEASE-02.audit"],"proof":"operator"} + ] +} diff --git a/docs/roadmap/migration.json b/docs/roadmap/migration.json new file mode 100644 index 0000000..756e600 --- /dev/null +++ b/docs/roadmap/migration.json @@ -0,0 +1,1428 @@ +{ + "claim_pr": 36, + "leaves": { + "air.bacura": { + "id": 5135023740, + "node_id": "I_kwDOBueNKs8AAAABMhI-fA", + "number": 81, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttc8", + "url": "https://github.com/StarshipSuperjam/xevious/issues/81" + }, + "air.jara": { + "id": 5135023094, + "node_id": "I_kwDOBueNKs8AAAABMhI79g", + "number": 74, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtcM", + "url": "https://github.com/StarshipSuperjam/xevious/issues/74" + }, + "air.kapi": { + "id": 5135023195, + "node_id": "I_kwDOBueNKs8AAAABMhI8Ww", + "number": 75, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtcU", + "url": "https://github.com/StarshipSuperjam/xevious/issues/75" + }, + "air.radiating-bullets": { + "id": 5135023920, + "node_id": "I_kwDOBueNKs8AAAABMhI_MA", + "number": 83, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtdQ", + "url": "https://github.com/StarshipSuperjam/xevious/issues/83" + }, + "air.sheonite": { + "id": 5135023566, + "node_id": "I_kwDOBueNKs8AAAABMhI9zg", + "number": 79, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttcs", + "url": "https://github.com/StarshipSuperjam/xevious/issues/79" + }, + "air.spario": { + "id": 5135023644, + "node_id": "I_kwDOBueNKs8AAAABMhI-HA", + "number": 80, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttc0", + "url": "https://github.com/StarshipSuperjam/xevious/issues/80" + }, + "air.special-pairs": { + "id": 5135023817, + "node_id": "I_kwDOBueNKs8AAAABMhI-yQ", + "number": 82, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtdE", + "url": "https://github.com/StarshipSuperjam/xevious/issues/82" + }, + "air.standard-bullets": { + "id": 5135022305, + "node_id": "I_kwDOBueNKs8AAAABMhI44Q", + "number": 66, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttbw", + "url": "https://github.com/StarshipSuperjam/xevious/issues/66" + }, + "air.terrazi": { + "id": 5135023295, + "node_id": "I_kwDOBueNKs8AAAABMhI8vw", + "number": 76, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttcg", + "url": "https://github.com/StarshipSuperjam/xevious/issues/76" + }, + "air.torkan": { + "id": 5135022906, + "node_id": "I_kwDOBueNKs8AAAABMhI7Og", + "number": 72, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttb0", + "url": "https://github.com/StarshipSuperjam/xevious/issues/72" + }, + "air.toroid": { + "id": 5135022215, + "node_id": "I_kwDOBueNKs8AAAABMhI4hw", + "number": 65, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttbs", + "url": "https://github.com/StarshipSuperjam/xevious/issues/65" + }, + "air.zakato": { + "id": 5135023490, + "node_id": "I_kwDOBueNKs8AAAABMhI9gg", + "number": 78, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttco", + "url": "https://github.com/StarshipSuperjam/xevious/issues/78" + }, + "air.zoshi": { + "id": 5135023005, + "node_id": "I_kwDOBueNKs8AAAABMhI7nQ", + "number": 73, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttb8", + "url": "https://github.com/StarshipSuperjam/xevious/issues/73" + }, + "andor.core-destruction": { + "id": 5135025242, + "node_id": "I_kwDOBueNKs8AAAABMhJEWg", + "number": 96, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Twas", + "url": "https://github.com/StarshipSuperjam/xevious/issues/96" + }, + "andor.defenses": { + "id": 5135025152, + "node_id": "I_kwDOBueNKs8AAAABMhJEAA", + "number": 95, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Twak", + "url": "https://github.com/StarshipSuperjam/xevious/issues/95" + }, + "andor.lifecycle": { + "id": 5135025030, + "node_id": "I_kwDOBueNKs8AAAABMhJDhg", + "number": 94, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Twag", + "url": "https://github.com/StarshipSuperjam/xevious/issues/94" + }, + "area.air-dispatch": { + "id": 5135022116, + "node_id": "I_kwDOBueNKs8AAAABMhI4JA", + "number": 64, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TsoU", + "url": "https://github.com/StarshipSuperjam/xevious/issues/64" + }, + "area.clock": { + "id": 5135021054, + "node_id": "I_kwDOBueNKs8AAAABMhIz_g", + "number": 53, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tshc", + "url": "https://github.com/StarshipSuperjam/xevious/issues/53" + }, + "area.ground-dispatch": { + "id": 5135022568, + "node_id": "I_kwDOBueNKs8AAAABMhI56A", + "number": 69, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsqc", + "url": "https://github.com/StarshipSuperjam/xevious/issues/69" + }, + "area.scheduler-data": { + "id": 5135021144, + "node_id": "I_kwDOBueNKs8AAAABMhI0WA", + "number": 54, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TsjM", + "url": "https://github.com/StarshipSuperjam/xevious/issues/54" + }, + "area.schedules-loop": { + "id": 5135021233, + "node_id": "I_kwDOBueNKs8AAAABMhI0sQ", + "number": 55, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsjk", + "url": "https://github.com/StarshipSuperjam/xevious/issues/55" + }, + "area.visual-terrain": { + "id": 5135025830, + "node_id": "I_kwDOBueNKs8AAAABMhJGpg", + "number": 102, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ts6U", + "url": "https://github.com/StarshipSuperjam/xevious/issues/102" + }, + "build.sprite-proof": { + "id": 5135020110, + "node_id": "I_kwDOBueNKs8AAAABMhIwTg", + "number": 45, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtA8", + "url": "https://github.com/StarshipSuperjam/xevious/issues/45" + }, + "cabinet.attract-credits": { + "id": 5135025329, + "node_id": "I_kwDOBueNKs8AAAABMhJEsQ", + "number": 97, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Twaw", + "url": "https://github.com/StarshipSuperjam/xevious/issues/97" + }, + "cabinet.high-scores": { + "id": 5135025611, + "node_id": "I_kwDOBueNKs8AAAABMhJFyw", + "number": 100, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwbA", + "url": "https://github.com/StarshipSuperjam/xevious/issues/100" + }, + "cabinet.two-player": { + "id": 5135025414, + "node_id": "I_kwDOBueNKs8AAAABMhJFBg", + "number": 98, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Twa4", + "url": "https://github.com/StarshipSuperjam/xevious/issues/98" + }, + "core.collision-resolution": { + "id": 5135021538, + "node_id": "I_kwDOBueNKs8AAAABMhI14g", + "number": 58, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtFU", + "url": "https://github.com/StarshipSuperjam/xevious/issues/58" + }, + "core.director-reset": { + "id": 5135020223, + "node_id": "I_kwDOBueNKs8AAAABMhIwvw", + "number": 46, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtFI", + "url": "https://github.com/StarshipSuperjam/xevious/issues/46" + }, + "core.entity-foundation": { + "id": 5135256753, + "node_id": "I_kwDOBueNKs8AAAABMhXMsQ", + "number": 107, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2T5Tc", + "url": "https://github.com/StarshipSuperjam/xevious/issues/107" + }, + "core.entity-lifecycle": { + "id": 5135021441, + "node_id": "I_kwDOBueNKs8AAAABMhI1gQ", + "number": 57, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtFM", + "url": "https://github.com/StarshipSuperjam/xevious/issues/57" + }, + "core.random-consumer": { + "id": 5135021627, + "node_id": "I_kwDOBueNKs8AAAABMhI2Ow", + "number": 59, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtFc", + "url": "https://github.com/StarshipSuperjam/xevious/issues/59" + }, + "difficulty.live-pressure": { + "id": 5135021710, + "node_id": "I_kwDOBueNKs8AAAABMhI2jg", + "number": 60, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TsmU", + "url": "https://github.com/StarshipSuperjam/xevious/issues/60" + }, + "difficulty.models-live-state": { + "id": 5135021345, + "node_id": "I_kwDOBueNKs8AAAABMhI1IQ", + "number": 56, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TsjQ", + "url": "https://github.com/StarshipSuperjam/xevious/issues/56" + }, + "economy.air-awards": { + "id": 5135022000, + "node_id": "I_kwDOBueNKs8AAAABMhI3sA", + "number": 63, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TsnA", + "url": "https://github.com/StarshipSuperjam/xevious/issues/63" + }, + "economy.bonus-flag-award": { + "id": 5135024866, + "node_id": "I_kwDOBueNKs8AAAABMhJC4g", + "number": 92, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ts1s", + "url": "https://github.com/StarshipSuperjam/xevious/issues/92" + }, + "economy.game-over": { + "id": 5135020967, + "node_id": "I_kwDOBueNKs8AAAABMhIzpw", + "number": 52, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TshU", + "url": "https://github.com/StarshipSuperjam/xevious/issues/52" + }, + "economy.game-over-routing": { + "id": 5135025730, + "node_id": "I_kwDOBueNKs8AAAABMhJGQg", + "number": 101, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ts5g", + "url": "https://github.com/StarshipSuperjam/xevious/issues/101" + }, + "economy.ground-awards": { + "id": 5135022488, + "node_id": "I_kwDOBueNKs8AAAABMhI5mA", + "number": 68, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwaE", + "url": "https://github.com/StarshipSuperjam/xevious/issues/68" + }, + "economy.life-tables": { + "id": 5135020849, + "node_id": "I_kwDOBueNKs8AAAABMhIzMQ", + "number": 51, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsg4", + "url": "https://github.com/StarshipSuperjam/xevious/issues/51" + }, + "economy.score-hud": { + "id": 5135020755, + "node_id": "I_kwDOBueNKs8AAAABMhIy0w", + "number": 50, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsgw", + "url": "https://github.com/StarshipSuperjam/xevious/issues/50" + }, + "economy.stop-after-two": { + "id": 5135026258, + "node_id": "I_kwDOBueNKs8AAAABMhJIUg", + "number": 103, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ts7g", + "url": "https://github.com/StarshipSuperjam/xevious/issues/103" + }, + "economy.two-player-hud": { + "id": 5135025517, + "node_id": "I_kwDOBueNKs8AAAABMhJFbQ", + "number": 99, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ts48", + "url": "https://github.com/StarshipSuperjam/xevious/issues/99" + }, + "ground.barra": { + "id": 5135022661, + "node_id": "I_kwDOBueNKs8AAAABMhI6RQ", + "number": 70, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttio", + "url": "https://github.com/StarshipSuperjam/xevious/issues/70" + }, + "ground.barra-variants": { + "id": 5135024045, + "node_id": "I_kwDOBueNKs8AAAABMhI_rQ", + "number": 84, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttiw", + "url": "https://github.com/StarshipSuperjam/xevious/issues/84" + }, + "ground.boza-logram": { + "id": 5135024377, + "node_id": "I_kwDOBueNKs8AAAABMhJA-Q", + "number": 87, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tti8", + "url": "https://github.com/StarshipSuperjam/xevious/issues/87" + }, + "ground.derota": { + "id": 5135024276, + "node_id": "I_kwDOBueNKs8AAAABMhJAlA", + "number": 86, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tti4", + "url": "https://github.com/StarshipSuperjam/xevious/issues/86" + }, + "ground.domogram": { + "id": 5135024552, + "node_id": "I_kwDOBueNKs8AAAABMhJBqA", + "number": 89, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtjE", + "url": "https://github.com/StarshipSuperjam/xevious/issues/89" + }, + "ground.grobda": { + "id": 5135024457, + "node_id": "I_kwDOBueNKs8AAAABMhJBSQ", + "number": 88, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtjA", + "url": "https://github.com/StarshipSuperjam/xevious/issues/88" + }, + "ground.logram": { + "id": 5135022759, + "node_id": "I_kwDOBueNKs8AAAABMhI6pw", + "number": 71, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ttis", + "url": "https://github.com/StarshipSuperjam/xevious/issues/71" + }, + "ground.zolbak": { + "id": 5135024137, + "node_id": "I_kwDOBueNKs8AAAABMhJACQ", + "number": 85, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tti0", + "url": "https://github.com/StarshipSuperjam/xevious/issues/85" + }, + "player.air-collision-death": { + "id": 5135021826, + "node_id": "I_kwDOBueNKs8AAAABMhI3Ag", + "number": 61, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsmg", + "url": "https://github.com/StarshipSuperjam/xevious/issues/61" + }, + "player.bacura-bounce": { + "id": 5135023383, + "node_id": "I_kwDOBueNKs8AAAABMhI9Fw", + "number": 77, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TsvA", + "url": "https://github.com/StarshipSuperjam/xevious/issues/77" + }, + "player.blaster-air-hit": { + "id": 5135021920, + "node_id": "I_kwDOBueNKs8AAAABMhI3YA", + "number": 62, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsm4", + "url": "https://github.com/StarshipSuperjam/xevious/issues/62" + }, + "player.bullet-pool": { + "id": 5135020655, + "node_id": "I_kwDOBueNKs8AAAABMhIybw", + "number": 49, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tseo", + "url": "https://github.com/StarshipSuperjam/xevious/issues/49" + }, + "player.death-decision": { + "id": 5135020563, + "node_id": "I_kwDOBueNKs8AAAABMhIyEw", + "number": 48, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsf0", + "url": "https://github.com/StarshipSuperjam/xevious/issues/48" + }, + "player.ground-targeting": { + "id": 5135022403, + "node_id": "I_kwDOBueNKs8AAAABMhI5Qw", + "number": 67, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tspk", + "url": "https://github.com/StarshipSuperjam/xevious/issues/67" + }, + "player.movement-and-flight": { + "id": 5135020478, + "node_id": "I_kwDOBueNKs8AAAABMhIxvg", + "number": 47, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TseY", + "url": "https://github.com/StarshipSuperjam/xevious/issues/47" + }, + "player.regression-recovery": { + "id": 5104514779, + "node_id": "I_kwDOBueNKs8AAAABMEC22w", + "number": 13, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Tsdc", + "url": "https://github.com/StarshipSuperjam/xevious/issues/13" + }, + "presentation.audio-animation": { + "id": 5135026346, + "node_id": "I_kwDOBueNKs8AAAABMhJIqg", + "number": 104, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwbI", + "url": "https://github.com/StarshipSuperjam/xevious/issues/104" + }, + "presentation.framing": { + "id": 5122351345, + "node_id": "I_kwDOBueNKs8AAAABMVDg8Q", + "number": 30, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGDA", + "url": "https://github.com/StarshipSuperjam/xevious/issues/30" + }, + "presentation.hud-yellow": { + "id": 5122353927, + "node_id": "I_kwDOBueNKs8AAAABMVDrBw", + "number": 31, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGDI", + "url": "https://github.com/StarshipSuperjam/xevious/issues/31" + }, + "release.audit": { + "id": 5135026532, + "node_id": "I_kwDOBueNKs8AAAABMhJJZA", + "number": 106, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwbY", + "url": "https://github.com/StarshipSuperjam/xevious/issues/106" + }, + "release.full-soak": { + "id": 5135026437, + "node_id": "I_kwDOBueNKs8AAAABMhJJBQ", + "number": 105, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwbU", + "url": "https://github.com/StarshipSuperjam/xevious/issues/105" + }, + "secrets.bonus-flag": { + "id": 5135024763, + "node_id": "I_kwDOBueNKs8AAAABMhJCew", + "number": 91, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwaM", + "url": "https://github.com/StarshipSuperjam/xevious/issues/91" + }, + "secrets.hidden-credit": { + "id": 5135024939, + "node_id": "I_kwDOBueNKs8AAAABMhJDKw", + "number": 93, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwaU", + "url": "https://github.com/StarshipSuperjam/xevious/issues/93" + }, + "secrets.sol-tower": { + "id": 5135024646, + "node_id": "I_kwDOBueNKs8AAAABMhJCBg", + "number": 90, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwaI", + "url": "https://github.com/StarshipSuperjam/xevious/issues/90" + } + }, + "parents": { + "air": { + "id": 5135019709, + "node_id": "I_kwDOBueNKs8AAAABMhIuvQ", + "number": 38, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtbA", + "url": "https://github.com/StarshipSuperjam/xevious/issues/38" + }, + "andor": { + "id": 5135019888, + "node_id": "I_kwDOBueNKs8AAAABMhIvcA", + "number": 41, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwZw", + "url": "https://github.com/StarshipSuperjam/xevious/issues/41" + }, + "area": { + "id": 5104653557, + "node_id": "I_kwDOBueNKs8AAAABMELU9Q", + "number": 17, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGC0", + "url": "https://github.com/StarshipSuperjam/xevious/issues/17" + }, + "build": { + "id": 5135019004, + "node_id": "I_kwDOBueNKs8AAAABMhIr_A", + "number": 37, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2Ts_o", + "url": "https://github.com/StarshipSuperjam/xevious/issues/37" + }, + "cabinet": { + "id": 5135019950, + "node_id": "I_kwDOBueNKs8AAAABMhIvrg", + "number": 42, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwZ4", + "url": "https://github.com/StarshipSuperjam/xevious/issues/42" + }, + "core": { + "id": 5104653368, + "node_id": "I_kwDOBueNKs8AAAABMELUOA", + "number": 14, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TtEQ", + "url": "https://github.com/StarshipSuperjam/xevious/issues/14" + }, + "difficulty": { + "id": 5104653601, + "node_id": "I_kwDOBueNKs8AAAABMELVIQ", + "number": 18, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCI", + "url": "https://github.com/StarshipSuperjam/xevious/issues/18" + }, + "economy": { + "id": 5104653489, + "node_id": "I_kwDOBueNKs8AAAABMELUsQ", + "number": 16, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCQ", + "url": "https://github.com/StarshipSuperjam/xevious/issues/16" + }, + "ground": { + "id": 5135019770, + "node_id": "I_kwDOBueNKs8AAAABMhIu-g", + "number": 39, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TthY", + "url": "https://github.com/StarshipSuperjam/xevious/issues/39" + }, + "player": { + "id": 5104653423, + "node_id": "I_kwDOBueNKs8AAAABMELUbw", + "number": 15, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGDM", + "url": "https://github.com/StarshipSuperjam/xevious/issues/15" + }, + "presentation": { + "id": 5135020007, + "node_id": "I_kwDOBueNKs8AAAABMhIv5w", + "number": 43, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwZ8", + "url": "https://github.com/StarshipSuperjam/xevious/issues/43" + }, + "release": { + "id": 5135020059, + "node_id": "I_kwDOBueNKs8AAAABMhIwGw", + "number": 44, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwaA", + "url": "https://github.com/StarshipSuperjam/xevious/issues/44" + }, + "secrets": { + "id": 5135019818, + "node_id": "I_kwDOBueNKs8AAAABMhIvKg", + "number": 40, + "project_item_id": "PVTI_lAHOAf1Ylc4BgLx1zg2TwZk", + "url": "https://github.com/StarshipSuperjam/xevious/issues/40" + } + }, + "phase": "reconciled", + "project": { + "node_id": "PVT_kwHOAf1Ylc4BgLx1", + "number": 4, + "owner": "StarshipSuperjam" + }, + "project_fields": { + "Assignees": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFE", + "name": "Assignees", + "type": "ProjectV2Field" + }, + "Closed": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFw", + "name": "Closed", + "type": "ProjectV2Field" + }, + "Created": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFo", + "name": "Created", + "type": "ProjectV2Field" + }, + "Delivery slice": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZo1M", + "name": "Delivery slice", + "type": "ProjectV2Field" + }, + "Known issues": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTGA", + "name": "Known issues", + "type": "ProjectV2Field" + }, + "Labels": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFM", + "name": "Labels", + "type": "ProjectV2Field" + }, + "Last synced": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTGE", + "name": "Last synced", + "type": "ProjectV2Field" + }, + "Linked pull requests": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFQ", + "name": "Linked pull requests", + "type": "ProjectV2Field" + }, + "Milestone": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFU", + "name": "Milestone", + "type": "ProjectV2Field" + }, + "Needs your review": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTF8", + "name": "Needs your review", + "type": "ProjectV2Field" + }, + "Parent issue": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFg", + "name": "Parent issue", + "type": "ProjectV2Field" + }, + "Proof level": { + "id": "PVTSSF_lAHOAf1Ylc4BgLx1zhaZo1Q", + "name": "Proof level", + "options": [ + { + "id": "25ba2bb9", + "name": "Playable" + }, + { + "id": "41a4b342", + "name": "Operator" + }, + { + "id": "75e2634e", + "name": "Historical" + } + ], + "type": "ProjectV2SingleSelectField" + }, + "Repository": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFY", + "name": "Repository", + "type": "ProjectV2Field" + }, + "Reviewers": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFc", + "name": "Reviewers", + "type": "ProjectV2Field" + }, + "Roadmap role": { + "id": "PVTSSF_lAHOAf1Ylc4BgLx1zhaZo1I", + "name": "Roadmap role", + "options": [ + { + "id": "66acf524", + "name": "Parent" + }, + { + "id": "3644043a", + "name": "Leaf" + }, + { + "id": "192fbf73", + "name": "Imported history" + } + ], + "type": "ProjectV2SingleSelectField" + }, + "Status": { + "id": "PVTSSF_lAHOAf1Ylc4BgLx1zhaZTFI", + "name": "Status", + "options": [ + { + "id": "f75ad846", + "name": "Backlog" + }, + { + "id": "9e651aa9", + "name": "Ready" + }, + { + "id": "47fc9ee4", + "name": "In progress" + }, + { + "id": "98236657", + "name": "Done" + } + ], + "type": "ProjectV2SingleSelectField" + }, + "Sub-issues progress": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFk", + "name": "Sub-issues progress", + "type": "ProjectV2Field" + }, + "Title": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFA", + "name": "Title", + "type": "ProjectV2Field" + }, + "Updated": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFs", + "name": "Updated", + "type": "ProjectV2Field" + }, + "What's being built": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTF0", + "name": "What's being built", + "type": "ProjectV2Field" + }, + "What's next": { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTF4", + "name": "What's next", + "type": "ProjectV2Field" + }, + "Work type": { + "id": "PVTSSF_lAHOAf1Ylc4BgLx1zhaZTGI", + "name": "Work type", + "options": [ + { + "id": "9be0a3ca", + "name": "Feature" + }, + { + "id": "ed320d02", + "name": "Bug" + }, + { + "id": "8726eac8", + "name": "Tech debt" + }, + { + "id": "dd8ac791", + "name": "Research" + } + ], + "type": "ProjectV2SingleSelectField" + } + }, + "project_synced": true, + "project_views": { + "Capability parents": { + "filter": "is:issue roadmap-role:Parent", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSUEg", + "layout": "TABLE_LAYOUT", + "name": "Capability parents" + }, + "Delivery leaves": { + "filter": "is:issue roadmap-role:Leaf", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSUEY", + "layout": "TABLE_LAYOUT", + "name": "Delivery leaves" + }, + "Imported history": { + "filter": "is:issue roadmap-role:\"Imported history\"", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSUEk", + "layout": "TABLE_LAYOUT", + "name": "Imported history" + } + }, + "repository": "StarshipSuperjam/xevious", + "snapshot": { + "milestones": [ + { + "closed_at": null, + "closed_issues": 1, + "created_at": "2026-08-09T20:47:19Z", + "creator": { + "avatar_url": "https://avatars.githubusercontent.com/u/33380501?v=4", + "events_url": "https://api.github.com/users/StarshipSuperjam/events{/privacy}", + "followers_url": "https://api.github.com/users/StarshipSuperjam/followers", + "following_url": "https://api.github.com/users/StarshipSuperjam/following{/other_user}", + "gists_url": "https://api.github.com/users/StarshipSuperjam/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/StarshipSuperjam", + "id": 33380501, + "login": "StarshipSuperjam", + "node_id": "MDQ6VXNlcjMzMzgwNTAx", + "organizations_url": "https://api.github.com/users/StarshipSuperjam/orgs", + "received_events_url": "https://api.github.com/users/StarshipSuperjam/received_events", + "repos_url": "https://api.github.com/users/StarshipSuperjam/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/StarshipSuperjam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/StarshipSuperjam/subscriptions", + "type": "User", + "url": "https://api.github.com/users/StarshipSuperjam", + "user_view_type": "public" + }, + "description": null, + "due_on": null, + "html_url": "https://github.com/StarshipSuperjam/xevious/milestone/1", + "id": 17216774, + "labels_url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/1/labels", + "node_id": "MI_kwDOBueNKs4BBrUG", + "number": 1, + "open_issues": 1, + "state": "open", + "title": "Recovery", + "updated_at": "2026-08-10T00:12:27Z", + "url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/1" + }, + { + "closed_at": null, + "closed_issues": 1, + "created_at": "2026-08-09T20:47:19Z", + "creator": { + "avatar_url": "https://avatars.githubusercontent.com/u/33380501?v=4", + "events_url": "https://api.github.com/users/StarshipSuperjam/events{/privacy}", + "followers_url": "https://api.github.com/users/StarshipSuperjam/followers", + "following_url": "https://api.github.com/users/StarshipSuperjam/following{/other_user}", + "gists_url": "https://api.github.com/users/StarshipSuperjam/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/StarshipSuperjam", + "id": 33380501, + "login": "StarshipSuperjam", + "node_id": "MDQ6VXNlcjMzMzgwNTAx", + "organizations_url": "https://api.github.com/users/StarshipSuperjam/orgs", + "received_events_url": "https://api.github.com/users/StarshipSuperjam/received_events", + "repos_url": "https://api.github.com/users/StarshipSuperjam/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/StarshipSuperjam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/StarshipSuperjam/subscriptions", + "type": "User", + "url": "https://api.github.com/users/StarshipSuperjam", + "user_view_type": "public" + }, + "description": null, + "due_on": null, + "html_url": "https://github.com/StarshipSuperjam/xevious/milestone/2", + "id": 17216775, + "labels_url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/2/labels", + "node_id": "MI_kwDOBueNKs4BBrUH", + "number": 2, + "open_issues": 1, + "state": "open", + "title": "Foundation", + "updated_at": "2026-08-10T03:53:20Z", + "url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/2" + }, + { + "closed_at": null, + "closed_issues": 0, + "created_at": "2026-08-09T20:47:20Z", + "creator": { + "avatar_url": "https://avatars.githubusercontent.com/u/33380501?v=4", + "events_url": "https://api.github.com/users/StarshipSuperjam/events{/privacy}", + "followers_url": "https://api.github.com/users/StarshipSuperjam/followers", + "following_url": "https://api.github.com/users/StarshipSuperjam/following{/other_user}", + "gists_url": "https://api.github.com/users/StarshipSuperjam/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/StarshipSuperjam", + "id": 33380501, + "login": "StarshipSuperjam", + "node_id": "MDQ6VXNlcjMzMzgwNTAx", + "organizations_url": "https://api.github.com/users/StarshipSuperjam/orgs", + "received_events_url": "https://api.github.com/users/StarshipSuperjam/received_events", + "repos_url": "https://api.github.com/users/StarshipSuperjam/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/StarshipSuperjam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/StarshipSuperjam/subscriptions", + "type": "User", + "url": "https://api.github.com/users/StarshipSuperjam", + "user_view_type": "public" + }, + "description": null, + "due_on": null, + "html_url": "https://github.com/StarshipSuperjam/xevious/milestone/3", + "id": 17216776, + "labels_url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/3/labels", + "node_id": "MI_kwDOBueNKs4BBrUI", + "number": 3, + "open_issues": 2, + "state": "open", + "title": "Campaign", + "updated_at": "2026-08-09T20:47:38Z", + "url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/3" + }, + { + "closed_at": null, + "closed_issues": 0, + "created_at": "2026-08-09T20:47:20Z", + "creator": { + "avatar_url": "https://avatars.githubusercontent.com/u/33380501?v=4", + "events_url": "https://api.github.com/users/StarshipSuperjam/events{/privacy}", + "followers_url": "https://api.github.com/users/StarshipSuperjam/followers", + "following_url": "https://api.github.com/users/StarshipSuperjam/following{/other_user}", + "gists_url": "https://api.github.com/users/StarshipSuperjam/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/StarshipSuperjam", + "id": 33380501, + "login": "StarshipSuperjam", + "node_id": "MDQ6VXNlcjMzMzgwNTAx", + "organizations_url": "https://api.github.com/users/StarshipSuperjam/orgs", + "received_events_url": "https://api.github.com/users/StarshipSuperjam/received_events", + "repos_url": "https://api.github.com/users/StarshipSuperjam/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/StarshipSuperjam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/StarshipSuperjam/subscriptions", + "type": "User", + "url": "https://api.github.com/users/StarshipSuperjam", + "user_view_type": "public" + }, + "description": null, + "due_on": null, + "html_url": "https://github.com/StarshipSuperjam/xevious/milestone/4", + "id": 17216777, + "labels_url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/4/labels", + "node_id": "MI_kwDOBueNKs4BBrUJ", + "number": 4, + "open_issues": 0, + "state": "open", + "title": "Enemies", + "updated_at": "2026-08-09T20:47:20Z", + "url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/4" + }, + { + "closed_at": null, + "closed_issues": 0, + "created_at": "2026-08-09T20:47:20Z", + "creator": { + "avatar_url": "https://avatars.githubusercontent.com/u/33380501?v=4", + "events_url": "https://api.github.com/users/StarshipSuperjam/events{/privacy}", + "followers_url": "https://api.github.com/users/StarshipSuperjam/followers", + "following_url": "https://api.github.com/users/StarshipSuperjam/following{/other_user}", + "gists_url": "https://api.github.com/users/StarshipSuperjam/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/StarshipSuperjam", + "id": 33380501, + "login": "StarshipSuperjam", + "node_id": "MDQ6VXNlcjMzMzgwNTAx", + "organizations_url": "https://api.github.com/users/StarshipSuperjam/orgs", + "received_events_url": "https://api.github.com/users/StarshipSuperjam/received_events", + "repos_url": "https://api.github.com/users/StarshipSuperjam/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/StarshipSuperjam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/StarshipSuperjam/subscriptions", + "type": "User", + "url": "https://api.github.com/users/StarshipSuperjam", + "user_view_type": "public" + }, + "description": null, + "due_on": null, + "html_url": "https://github.com/StarshipSuperjam/xevious/milestone/5", + "id": 17216778, + "labels_url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/5/labels", + "node_id": "MI_kwDOBueNKs4BBrUK", + "number": 5, + "open_issues": 0, + "state": "open", + "title": "Boss and secrets", + "updated_at": "2026-08-09T20:47:20Z", + "url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/5" + }, + { + "closed_at": null, + "closed_issues": 0, + "created_at": "2026-08-09T20:47:21Z", + "creator": { + "avatar_url": "https://avatars.githubusercontent.com/u/33380501?v=4", + "events_url": "https://api.github.com/users/StarshipSuperjam/events{/privacy}", + "followers_url": "https://api.github.com/users/StarshipSuperjam/followers", + "following_url": "https://api.github.com/users/StarshipSuperjam/following{/other_user}", + "gists_url": "https://api.github.com/users/StarshipSuperjam/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/StarshipSuperjam", + "id": 33380501, + "login": "StarshipSuperjam", + "node_id": "MDQ6VXNlcjMzMzgwNTAx", + "organizations_url": "https://api.github.com/users/StarshipSuperjam/orgs", + "received_events_url": "https://api.github.com/users/StarshipSuperjam/received_events", + "repos_url": "https://api.github.com/users/StarshipSuperjam/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/StarshipSuperjam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/StarshipSuperjam/subscriptions", + "type": "User", + "url": "https://api.github.com/users/StarshipSuperjam", + "user_view_type": "public" + }, + "description": null, + "due_on": null, + "html_url": "https://github.com/StarshipSuperjam/xevious/milestone/6", + "id": 17216779, + "labels_url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/6/labels", + "node_id": "MI_kwDOBueNKs4BBrUL", + "number": 6, + "open_issues": 0, + "state": "open", + "title": "Cabinet", + "updated_at": "2026-08-09T20:47:21Z", + "url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/6" + }, + { + "closed_at": null, + "closed_issues": 0, + "created_at": "2026-08-09T20:47:21Z", + "creator": { + "avatar_url": "https://avatars.githubusercontent.com/u/33380501?v=4", + "events_url": "https://api.github.com/users/StarshipSuperjam/events{/privacy}", + "followers_url": "https://api.github.com/users/StarshipSuperjam/followers", + "following_url": "https://api.github.com/users/StarshipSuperjam/following{/other_user}", + "gists_url": "https://api.github.com/users/StarshipSuperjam/gists{/gist_id}", + "gravatar_id": "", + "html_url": "https://github.com/StarshipSuperjam", + "id": 33380501, + "login": "StarshipSuperjam", + "node_id": "MDQ6VXNlcjMzMzgwNTAx", + "organizations_url": "https://api.github.com/users/StarshipSuperjam/orgs", + "received_events_url": "https://api.github.com/users/StarshipSuperjam/received_events", + "repos_url": "https://api.github.com/users/StarshipSuperjam/repos", + "site_admin": false, + "starred_url": "https://api.github.com/users/StarshipSuperjam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/StarshipSuperjam/subscriptions", + "type": "User", + "url": "https://api.github.com/users/StarshipSuperjam", + "user_view_type": "public" + }, + "description": null, + "due_on": null, + "html_url": "https://github.com/StarshipSuperjam/xevious/milestone/7", + "id": 17216780, + "labels_url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/7/labels", + "node_id": "MI_kwDOBueNKs4BBrUM", + "number": 7, + "open_issues": 0, + "state": "open", + "title": "Polish", + "updated_at": "2026-08-09T20:47:21Z", + "url": "https://api.github.com/repos/StarshipSuperjam/xevious/milestones/7" + } + ], + "pr34": { + "baseRefName": "main", + "body": "Draft claim for slice 7 \u2014 difficulty and formations (DIF-01/02/03 + FORM-01). Plan approved; risk assessment and depth choice in chat; contract filled at submit. Part of #17.", + "headRefName": "claude/xevious-difficulty-formations", + "headRefOid": "cd68e27ab3dc0cb562d30d22771b2ac8561eaaf9", + "isDraft": true, + "labels": [], + "milestone": null, + "number": 34, + "projectItems": [ + { + "status": { + "name": "In progress", + "optionId": "47fc9ee4" + }, + "title": "Xevious Project Board" + } + ], + "state": "OPEN", + "title": "Feature: difficulty director and normal flying formations, live", + "url": "https://github.com/StarshipSuperjam/xevious/pull/34" + }, + "project_fields": { + "fields": [ + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFA", + "name": "Title", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFE", + "name": "Assignees", + "type": "ProjectV2Field" + }, + { + "id": "PVTSSF_lAHOAf1Ylc4BgLx1zhaZTFI", + "name": "Status", + "options": [ + { + "id": "f75ad846", + "name": "Backlog" + }, + { + "id": "9e651aa9", + "name": "Ready" + }, + { + "id": "47fc9ee4", + "name": "In progress" + }, + { + "id": "98236657", + "name": "Done" + } + ], + "type": "ProjectV2SingleSelectField" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFM", + "name": "Labels", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFQ", + "name": "Linked pull requests", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFU", + "name": "Milestone", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFY", + "name": "Repository", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFc", + "name": "Reviewers", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFg", + "name": "Parent issue", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFk", + "name": "Sub-issues progress", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFo", + "name": "Created", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFs", + "name": "Updated", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTFw", + "name": "Closed", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTF0", + "name": "What's being built", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTF4", + "name": "What's next", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTF8", + "name": "Needs your review", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTGA", + "name": "Known issues", + "type": "ProjectV2Field" + }, + { + "id": "PVTF_lAHOAf1Ylc4BgLx1zhaZTGE", + "name": "Last synced", + "type": "ProjectV2Field" + }, + { + "id": "PVTSSF_lAHOAf1Ylc4BgLx1zhaZTGI", + "name": "Work type", + "options": [ + { + "id": "9be0a3ca", + "name": "Feature" + }, + { + "id": "ed320d02", + "name": "Bug" + }, + { + "id": "8726eac8", + "name": "Tech debt" + }, + { + "id": "dd8ac791", + "name": "Research" + } + ], + "type": "ProjectV2SingleSelectField" + } + ], + "totalCount": 19 + }, + "project_items": { + "items": [ + { + "content": { + "body": "Builds to: [difficulty and formations](https://github.com/StarshipSuperjam/xevious/blob/main/docs/spec/difficulty-and-formations.md) (settled).\n\nDeliver DIF-01\u201303 and FORM-01 to the document's acceptance criteria: the AI level with fold-back, the score-adaptive re-tune, formation selection over the committed table including negative indices, and the fire-permission mask plumbing. Mechanics-record and playtest-checklist gates apply.", + "number": 18, + "repository": "StarshipSuperjam/xevious", + "title": "Build: difficulty and formations to their settled description", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/18" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCI", + "milestone": { + "description": "", + "dueOn": "", + "title": "Campaign" + }, + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "In progress", + "title": "Build: difficulty and formations to their settled description", + "work type": "Feature" + }, + { + "content": { + "body": "> *A green mechanical check below shows this change conforms to the engine's rules \u2014 not that it is correct. What covers correctness is the behavioural steps in **Review** and your own read of the change; a green check is never a substitute for that. **Your merge is the binding gate.***\n>\n> *About those checks: only the one that runs when the change is proposed for merge can stop a risky merge \u2014 a check that ran while the change was still being written is early advice. Each check is itself proven against a deliberately broken example it must catch, so a passing check can't be one that quietly did nothing \u2014 but that proves the check works, not that this change is right. And a check that could not run leaves its area unverified.*\n\n## Purpose\n\n**Give Xevious a durable GitHub Projects roadmap with Engine-owned status summaries.**\n\n- Installs the optional GitHub Projects sync module.\n- Creates and connects a repo-specific board modeled on the useful structure of the Engine Template roadmap.\n\n*Impact: maintainers get one dependency-aware place to see roadmap, active work, maintenance, and unslotted work.*\n\n## Scope\n\n**Install the board integration and wire it into Engine session startup.**\n\n- Adds the `github-projects-sync` module, its setup skill, operation guide, implementation, and tests.\n- Adds startup and resume hooks for Claude Code and Codex.\n- Ignores local board coordinates under `.engine/projects-sync/`.\n- Regenerates `.engine/knowledge/graph.json` and `.engine/self-map.md`; two generated indexes changed and no project work was lost.\n- Creates and links [Xevious Project Board](https://github.com/users/StarshipSuperjam/projects/4), with 11 current issues and pull requests seeded.\n\n**Change profile** \u2014 the shape of this pull request at a glance:\n\n- **Size:** 14 files changed, +1367 / \u22122 lines.\n- **Kinds of thing touched:** 3 tools, 2 codex-skills, 1 module, 1 operation, 1 skill, 6 other files (not in the engine's map).\n- **Where:** .engine/tools, .agents/skills, .engine, .claude, .claude/skills, .codex, .engine/knowledge, .engine/modules, .engine/operations, .gitignore.\n- **Shape:** 2 commits on this branch \u2014 a standalone change unless a `Part of #N` line below says it is one slice of a larger effort.\n\n_This is a description, not a gate \u2014 it never blocks a merge. It is here so you can weigh the change by what it touches, not by its line count._\n\n*Impact: the repo gains a live board plus idempotent Engine summaries without making the board the source of truth.*\n\n### Behaviors\n\n**The board stays useful without replacing repository truth.**\n\n- Engine sync resolves and updates only its five owned fields \u2014 `test_projects_sync.py`.\n- Missing GitHub access or board configuration degrades quietly \u2014 `test_projects_sync.py`.\n- Startup/resume hooks refresh board summaries without blocking the session \u2014 Engine CI and self-tests.\n- Board auto-add captures new repository issues and pull requests; its views separate phase composition, capability roadmap, current work, maintenance/fidelity, and unslotted work \u2014 live board verification.\n\n## Out of scope\n\n**No gameplay scope or unrelated issue/PR metadata is changed.**\n\n- Does not alter Xevious runtime code.\n- Does not retitle or relink the in-flight difficulty/formations PR #34.\n- Does not make GitHub Projects canonical; issues, PRs, milestones, specs, docs, and `BUILD_PLAN.md` remain authoritative.\n\n*Impact: this is reversible project-management infrastructure, isolated from game behavior and concurrent work.*\n\n## Risk\n\n**The main risk is stale or unavailable board metadata when GitHub access is missing.**\n\n- Sync is intentionally best-effort and does not block Engine startup.\n- Local board identifiers are ignored rather than committed.\n- New Codex hooks require operator re-approval through `/hooks` after merge.\n\n*Impact: a sync failure can make summaries stale, but cannot change canonical project state or block development.*\n\n## Validation\n\n**Module behavior and the full Engine installation passed validation.**\n\n- `python -m unittest discover -s tools/projects_sync -p 'test_projects_sync.py' -b`: 27 tests passed.\n- `projects_sync.py demo`: passed idempotency, ownership, degradation, and setup-disclosure checks.\n- `tools/validate.py --suite CI`: passed with no hard findings.\n- `tools/selftest.py`: 4,908 checks passed in 236 seconds.\n- `projects_sync.py check`: board connected and auto-add enabled.\n- Close-linkage preflight found no issues this PR would close.\n\n*Impact: approvers can rely on both focused coverage and the full Engine regression suite.*\n\n## Review\n\n**I made this contained, reversible integration with a quick review; no extra review passes ran.**\n\n- Confirmed the template board's useful fields, statuses, work types, and view purposes before adapting them.\n- Verified the Xevious board is linked to the repository, has five purpose-built views, 19 fields, and 11 seeded items.\n- All findings in scope were handled; the adjacent PR #34 issue-link mismatch was preserved for its owning session.\n- No settled product description applies to this standalone setup change, so there are no spec-derived acceptance steps.\n\n
\nIf the merge button is greyed but every check passed, it may be an unresolved review conversation \u2014 here's what that means and what you can do.\n\nGitHub won't let a pull request merge while a review comment on it is still marked unresolved \u2014 even when all the automated checks are green. The merge button just greys out. This isn't a failure; it's a comment someone left that hasn't been settled yet.\n\nYou can settle it yourself. Read the comment, decide you're genuinely satisfied it's been handled, and then mark it resolved \u2014 resolving it is you accepting the point it raised, so it's worth doing only once you've read it, never as a formality to get past the button. The engine never resolves one of these for you: a comment flagged a concern, and clearing it unread would defeat the reason it's there.\n\nTo find it: open the pull request's **Conversation** tab (or **Files changed**) and look for the comment with a **Resolve conversation** button. If a rebase or a force-push moved the lines the comment was attached to, GitHub marks it **outdated** and hides it \u2014 on the **Conversation** tab, expand the collapsed **outdated** comments to reach it, read it there, and only then resolve it.\n
\n\n*Impact: this records the chosen review depth and leaves the final judgment with the maintainer.*\n\n## Demonstration\n\n**Run the live connection check and inspect the board views.**\n\n- Run `uv run --directory .engine --frozen -- python tools/projects_sync/projects_sync.py check`: it reports project 4 connected with auto-add on; a broken configuration reports the missing connection instead.\n- Open [Xevious Project Board](https://github.com/users/StarshipSuperjam/projects/4) and switch among Phase composition, Capability roadmap, Current work, Maintenance and fidelity, and Unslotted; each view applies its stated filter to the seeded repo work.\n\n*Impact: the operator can directly observe both integration health and the roadmap design.*\n\n## Files of interest\n\n**Start with the module implementation, hook wiring, and setup contract.**\n\n- `.engine/tools/projects_sync/projects_sync.py`\n- `.engine/tools/projects_sync/test_projects_sync.py`\n- `.engine/operations/projects-sync-setup.md`\n- `.codex/hooks.json`\n- `.claude/settings.json`\n\n*Impact: these files determine sync behavior, coverage, and when refreshes run.*\n\n## AI involvement\n\n**Codex reviewed the reference board, chose the Xevious-specific view design, installed the module, configured the live board, and authored this PR.**\n\n- Structural module files came from the Engine's module manager.\n- Board classification and view choices were AI-driven, based on current issues, milestones, PRs, and `BUILD_PLAN.md`.\n- All repository edits and live project mutations remain subject to maintainer review and merge.\n\n*Impact: AI judgment is load-bearing in the board information architecture; module installation and validation were mechanical.*\n", + "number": 35, + "repository": "StarshipSuperjam/xevious", + "title": "Feature: Add the Xevious roadmap board", + "type": "PullRequest", + "url": "https://github.com/StarshipSuperjam/xevious/pull/35" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCM", + "labels": [ + "guardrail-ack" + ], + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Done", + "title": "Feature: Add the Xevious roadmap board" + }, + { + "content": { + "body": "Builds to: [scoring, lives, and game over](https://github.com/StarshipSuperjam/xevious/blob/main/docs/spec/scoring-lives-and-game-over.md) (settled).\n\nDeliver ECO-01\u201304 to the document's acceptance criteria: the single scoring path, generated data equal to docs/spec/data/scores.json (master table, lives, bonus tables, high-score defaults with project placeholder initials), the cap and its recorded quirk, HUD, and the game-over flow. Mechanics-record and playtest-checklist gates apply.", + "number": 16, + "repository": "StarshipSuperjam/xevious", + "title": "Build: scoring, lives, and game over to their settled description", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/16" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCQ", + "milestone": { + "description": "", + "dueOn": "", + "title": "Foundation" + }, + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "Build: scoring, lives, and game over to their settled description", + "work type": "Feature" + }, + { + "content": { + "body": "Low-priority cleanup surfaced by the slice-4 (PR #22) pre-submission reviews (technical-integrity + divergence-hunter). None block; grouped here so they aren't lost.\n\n1. **Share helpers instead of copying them.** `tools/hud_glyphs.py` copy-pastes `_require_keys`, `_read_json`, and `_prior_output_records` from `tools/sprite_extractor.py` even though it already `import`s it and reuses its PNG/hash/canvas primitives. The copies have already drifted: `hud_glyphs._prior_output_records` dropped the per-record type validation that `sprite_extractor._prior_output_records` has. Call through `se.*`.\n\n2. **Add a post-downscale \"still has ink\" check in `render_glyphs`.** `_binarize_glyph` asserts the binarized crop is non-blank, but nothing re-checks the final 25\u00d725 costume after `_place_on_canvas` + nearest-neighbor downscale. Today's 23+8 glyphs are safely non-blank (240\u2013420 opaque px each), so this is latent \u2014 but a future manifest edit (thinner threshold, shifted rect, different font) could silently emit an invisible HUD digit with no signal from `check`. Every other numeric fact in the pipeline is defended by an explicit check; this one gap should be too.\n\n3. **HUD clone rebuild frequency.** Each HUD digit clone recomputes floor/mod/join/switch-costume every frame while visible, and the whole ~30-clone HUD is torn down and rebuilt on *every* `director enter` (every state transition), not just score/craft-changing ones. Fine at today's scale (well under Scratch's ~300-clone ceiling); worth watching if HUD-visible transitions become more frequent.\n\n4. **Two ECO-03 contracts are coarser than their docstrings.** `_eco03_failures`'s `cap-quirk` and `bonus-enabled-guard` checks assert the matching reporter (`score == cap`, `next bonus > 0`) exists *somewhere* on the Stage, not that it is wired into the grant branch / guard. The wiring is correct today and the negative fixtures do redden the tests, so this hides no current bug \u2014 but a future regression that left the reporter present while disconnecting it from its `if` could slip past. Tighten if this economy is refactored.\n", + "number": 24, + "repository": "StarshipSuperjam/xevious", + "title": "hud_glyphs.py cleanup: share extractor helpers, add post-downscale ink check, HUD clone frequency", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/24" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCc", + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "hud_glyphs.py cleanup: share extractor helpers, add post-downscale ink check, HUD clone frequency", + "work type": "Tech debt" + }, + { + "content": { + "body": "## What\n`.github/workflows/xevious-project.yml` (the `verify` job, ~lines 29\u201331) runs the mechanics-record check with:\n\n```\nbase = github.event.pull_request.base.sha || github.event.before\n```\n\nOn a branch's **first `push` event**, `github.event.before` is the all-zero SHA (`0000000000000000000000000000000000000000`). `check_mechanics_record.py` then runs `git diff --name-only ...`, which fails because that ref doesn't exist \u2014 erroring the job rather than checking anything.\n\n## Why it doesn't bite today\nOur draft-PR build flow triggers on `pull_request` events, where `base.sha` is a valid ref, so the fallback is never reached. This is a **dormant** robustness gap, not a live failure \u2014 surfaced by the slice-6 feasibility review as pre-existing and out of scope for that slice.\n\n## Suggested fix\nGuard the empty/zero base: when `github.event.before` is absent or all-zeros (or on the initial push), skip the mechanics-record diff check or fall back to the merge-base against the default branch, rather than diffing against a nonexistent ref.\n\n## Scope\nSmall, isolated CI-workflow change; no gameplay/generator impact. Not blocking any slice.\n", + "number": 33, + "repository": "StarshipSuperjam/xevious", + "title": "CI: mechanics-record check errors on a branch's first raw push (all-zero base SHA)", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/33" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCk", + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "CI: mechanics-record check errors on a branch's first raw push (all-zero base SHA)", + "work type": "Bug" + }, + { + "content": { + "body": "Draft claim for slice 7 \u2014 difficulty and formations (DIF-01/02/03 + FORM-01). Plan approved; risk assessment and depth choice in chat; contract filled at submit. Part of #17.", + "number": 34, + "repository": "StarshipSuperjam/xevious", + "title": "Feature: difficulty director and normal flying formations, live", + "type": "PullRequest", + "url": "https://github.com/StarshipSuperjam/xevious/pull/34" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGCo", + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "In progress", + "title": "Feature: difficulty director and normal flying formations, live" + }, + { + "content": { + "body": "Builds to: [area progression and terrain](https://github.com/StarshipSuperjam/xevious/blob/main/docs/spec/area-progression-and-terrain.md) (settled).\n\nDeliver AREA-01\u201304 to the document's acceptance criteria: the scroll clock, schedule consumption of the committed data for all 16 areas, the 16\u21927 loop, continuous terrain, and the death-restart rule with its near-end checkpoint (replacing the recorded interim preserve-terrain fixture). Mechanics-record and playtest-checklist gates apply.", + "number": 17, + "repository": "StarshipSuperjam/xevious", + "title": "Build: area progression and terrain to their settled description", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/17" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGC0", + "milestone": { + "description": "", + "dueOn": "", + "title": "Campaign" + }, + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "Build: area progression and terrain to their settled description", + "work type": "Feature" + }, + { + "content": { + "body": "Surfaced by the slice-4 (life economy, PR #22) pre-submission technical-integrity review.\n\n**What.** `tools/sprite_extractor.py`'s `expected_project` reinserts its generated target (`toroid_sprite_proof`) with `layerOrder = max(current targets) + 1`, recomputed from whatever is in `project.json` on every `generate`/`check`. `tools/game_director.py`'s `_ensure_hud_target` assigns the `hud` target's `layerOrder` once, at creation, and never touches it again. Because of that asymmetry the committed `layerOrder` integers depend on the order the generators are run in.\n\n**Evidence.** Regenerating from scratch (strip `hud`, run game_director \u2192 hud_glyphs \u2192 sprite_extractor) yields `hud=18 / toroid_sprite_proof=19`, whereas the committed tip has `hud=16 / toroid_sprite_proof=17`. All three `check`s pass either way (the committed tree IS a simultaneous fixpoint, so this does NOT threaten the current build or its merge), but `toroid_sprite_proof` moved from `15` (origin/main) to `17` in PR #22 even though `sprite_extractor.py` has zero diff there \u2014 it was reshuffled as a side effect of inserting `hud`.\n\n**Why it matters.** A future `sprite_extractor.py generate` (e.g. to add one unrelated sprite frame) will silently renumber other targets' `layerOrder`, producing diff noise unrelated to the change, and is a reconcile hazard if two branches each insert a new target off the same base. No test catches this: `test_two_clean_processes_build_identical_bytes` re-packages the committed `project.json` twice; it never re-derives it from a different generation order.\n\n**Fix options (pick one).** Pin `sprite_extractor`'s inserted target's `layerOrder` deterministically the way `game_director` does (stop recomputing `max+1`); or accept the churn and add a test that re-derives `project.json` from a canonical generation order and asserts stable `layerOrder`. Pre-existing behavior; not blocking slice 4.\n", + "number": 23, + "repository": "StarshipSuperjam/xevious", + "title": "layerOrder is not a stable fixpoint across generator regeneration orders", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/23" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGC4", + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "layerOrder is not a stable fixpoint across generator regeneration orders", + "work type": "Tech debt" + }, + { + "content": { + "body": "## What you see\nThe play area sits in a **landscape letterbox** \u2014 black bands top and bottom, a wide-but-short green field \u2014 rather than the tall field of the arcade cabinet. It reads as \"squished.\"\n\n## What actually produces it (not a Scratch viewport accident)\nThe bands are **deliberately-drawn framing**, not the Scratch player centering the stage. The build carries four full-stage border sprites \u2014 `frame_t`, `frame_b`, `frame_l`, `frame_r` (bitmap-resolution-2 costumes over the whole 480\u00d7360 stage) \u2014 and the craft physically collides with them (`tools/game_director.py`, the `frame_*`/`target_*` correction blocks ~1755). `frame_t` and `frame_b` are the top/bottom black bands; they inset the play area into a landscape strip. `hud` is `layerOrder` 16, **above** the borders (9\u201312), so the borders don't hide the HUD.\n\n## Recorded context\n`docs/spec/core-game-systems.md:139` already records the governing fact: *\"the arcade's portrait playfield does not map onto the 480\u00d7360 landscape stage at any one stage-units-per-pixel factor without either shrinking the play area or pushing the crosshair off screen,\"* so the port uses the landscape stage and tunes spatial constants (movement/shot/lead) independently. **The landscape orientation is a settled port decision; the letterbox band height *inside* it was never pinned.**\n\n## Determination (you asked whether the letterbox is appropriate for best gameplay)\n**The wide letterbox band is the weakest of the options for a vertical shooter.** In Xevious, vertical extent *is* look-ahead \u2014 the room to see aerial enemies and ground targets coming up-screen and to place bombs ahead with the lead crosshair. A short band throws that away and doesn't use the stage's height. Two defensible directions:\n\n- **(A) Portrait pillarbox** \u2014 a tall, narrow play column with black bars on the **sides**. Maximum arcade fidelity and look-ahead. Cost: the column is narrow on a landscape monitor (Scratch can't rotate the stage), so the image is small.\n- **(B) Full-height landscape** \u2014 fill the 480\u00d7360 stage top-to-bottom, borders only as thin frames. Matches the recorded landscape-port decision and uses every pixel, at reduced fidelity to the arcade's tall field.\n\nThe current thin letterbox is **neither**. Recommendation: make this a conscious choice rather than an inherited default. I lean **(B)** for consistency with the already-recorded landscape-port decision and because it maximizes the usable image \u2014 unless you want to prioritize arcade fidelity, in which case **(A)**, accepting the narrow column.\n\n## Where it belongs\nThe presentation-coupling reconciliation (coupling the terrain visual to the area clock, real framing) is **already time-boxed to the presentation slice (20)** per record `015` / the AREA-01 plan. This issue is the placeholder for that framing decision \u2014 a design call for the operator, not a silent change.\n\n## What I could not verify\n- Exact band pixel heights: the `frame_*`/backdrop art is baked from the preserved 2017 baseline into the built `.sb3`, not stored under `src/xevious/assets/`, so I did not measure it here.\n- I cannot render the build headlessly (the `harness/` is rendererless), so the on-screen play-area aspect is an operator-eyes confirmation, not something I measured.\n\nRelates to #17 (area progression and terrain).\n", + "number": 30, + "repository": "StarshipSuperjam/xevious", + "title": "Presentation: play area is a landscape letterbox, not the arcade's portrait field \u2014 decide framing (slice 20)", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/30" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGDA", + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "Presentation: play area is a landscape letterbox, not the arcade's portrait field \u2014 decide framing (slice 20)", + "work type": "Research" + }, + { + "content": { + "body": "## What you see\nAt the top of the screen the **score and high-score render as two identical white 7-digit readouts** (in the screenshot both read `0220000`). With equal values they're impossible to tell apart. You asked to at least make the high score **yellow** to distinguish it. You also noted the arcade's HUD header isn't fully reproduced but are content treating that as a feature given the squished layout.\n\n## What's already built (so we don't rebuild it)\nPer record `012-hud.md` and `tools/hud_glyphs.py`, the HUD **already generates**: a yellow **\"HIGH SCORE\"** label and a flashing white **\"1UP\"** label (the `hs/*` yellow-recolor glyph set), plus 7-digit white value clones for **both** score and high score. `hud` is `layerOrder` 16 \u2014 above the frame borders \u2014 so nothing occludes it. The yellow-ink recolor path exists in `hud_glyphs.py` today, but **only for the HIGH SCORE label letters, not the digits**: value digits are all white `digit/` costumes.\n\nSo the confusion isn't a missing header \u2014 it's that **both numeric values are the same white**.\n\n## The ask (small, low-risk)\nRender the **high-score value digits yellow** so they read distinctly from the white current score. Mechanism already exists: add a yellow digit set (e.g. `digit-y/`) that reuses the same source crops as `digit/` \u2014 a **pure recolor**, exactly like the `hs/*` letters, no new crop \u2014 and switch the high-score digit clones (`HUD_ROLE` high-score group) to it. Structural change to `tools/hud_glyphs.py` (yellow digit costumes) + `tools/game_director.py` (high-score clones select the yellow set). Guardable by the existing `_eco02_failures` contract.\n\n## Deferred / linked (not this issue)\nWhether to build a more arcade-faithful compact **header bar** is tied to the aspect-ratio / framing decision (the letterbox issue, presentation slice 20). You lean \"feature, not bug\" on the full header, so this issue is scoped to the yellow high-score value only.\n\n## Caveat\nI'm reporting what the **generator produces** (record `012` + the layer-order check), not a pixel confirmation of what renders on your screen \u2014 the `harness/` can't render. Your screenshot is the ground truth that both values read white.\n\nRelates to #16 (scoring, lives, and game over \u2014 ECO-02 HUD).\n", + "number": 31, + "repository": "StarshipSuperjam/xevious", + "title": "HUD: score and high-score values are both white and indistinguishable \u2014 make the high-score value yellow", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/31" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGDI", + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "HUD: score and high-score values are both white and indistinguishable \u2014 make the high-score value yellow", + "work type": "Bug" + }, + { + "content": { + "body": "Builds to: [player craft and weapons](https://github.com/StarshipSuperjam/xevious/blob/main/docs/spec/player-craft-and-weapons.md) (settled).\n\nDeliver PLY-01/02 and WPN-01\u201305 plus the enemy-bullet rules to the document's acceptance criteria: movement, blaster cadence and caps, crosshair, one-bomb lockout with the recorded windows, death/respawn with no invulnerability, and the bullet pool. The Recovery phase (issue #13) restores the regressed subset first. Mechanics-record and playtest-checklist gates apply.", + "number": 15, + "repository": "StarshipSuperjam/xevious", + "title": "Build: player craft and weapons to their settled description", + "type": "Issue", + "url": "https://github.com/StarshipSuperjam/xevious/issues/15" + }, + "id": "PVTI_lAHOAf1Ylc4BgLx1zg2TGDM", + "milestone": { + "description": "", + "dueOn": "", + "title": "Recovery" + }, + "repository": "https://github.com/StarshipSuperjam/xevious", + "status": "Backlog", + "title": "Build: player craft and weapons to their settled description", + "work type": "Feature" + } + ], + "totalCount": 11 + }, + "project_views": { + "data": { + "viewer": { + "projectV2": { + "id": "PVT_kwHOAf1Ylc4BgLx1", + "title": "Xevious Project Board", + "views": { + "nodes": [ + { + "filter": "is:issue is:open has:milestone", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSQz8", + "layout": "TABLE_LAYOUT", + "name": "Phase composition" + }, + { + "filter": "is:issue is:open work-type:Feature", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSQ0A", + "layout": "TABLE_LAYOUT", + "name": "Capability roadmap" + }, + { + "filter": "is:open status:Ready,\"In progress\"", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSQ0E", + "layout": "BOARD_LAYOUT", + "name": "Current work" + }, + { + "filter": "is:issue is:open work-type:Bug,\"Tech debt\",Research", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSQ0I", + "layout": "TABLE_LAYOUT", + "name": "Maintenance and fidelity" + }, + { + "filter": "is:issue is:open no:milestone", + "id": "PVTV_lAHOAf1Ylc4BgLx1zgLSQ0M", + "layout": "TABLE_LAYOUT", + "name": "Unslotted" + } + ] + } + } + } + } + } + }, + "version": 1 +} diff --git a/docs/spec/build-plan.md b/docs/spec/build-plan.md index fbeeaa9..ae5cc63 100644 --- a/docs/spec/build-plan.md +++ b/docs/spec/build-plan.md @@ -6,6 +6,10 @@ its work against. Sequencing detail within phases lives in [the engineering plan built, the engineering plan for how it is staged. The regression-recovery work (issue #13) is the first item of the Recovery phase. +The [dependency-aware roadmap manifest](../roadmap/manifest.json) is the checked projection from these +capabilities and the engineering slices to GitHub component leaves. It does not change this order. A +leaf under a draft capability document is provisional and cannot close until that document is settled. + | Phase | Capability | Doc | | --- | --- | --- | | Recovery | Player craft and weapons | [Player craft and weapons](player-craft-and-weapons.md) | diff --git a/tests/test_roadmap.py b/tests/test_roadmap.py new file mode 100644 index 0000000..ab53c90 --- /dev/null +++ b/tests/test_roadmap.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +from pathlib import Path +import sys +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] + + +def load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +roadmap = load_module("roadmap", ROOT / "tools" / "roadmap.py") +closures = load_module("check_roadmap_closures", ROOT / "tools" / "check_roadmap_closures.py") + + +class RoadmapManifestTests(unittest.TestCase): + def setUp(self) -> None: + self.manifest = json.loads((ROOT / "docs" / "roadmap" / "manifest.json").read_text()) + + def test_committed_manifest_is_complete_and_acyclic(self) -> None: + self.assertEqual([], roadmap.validate_manifest(self.manifest)) + + def test_duplicate_atomic_criterion_is_rejected(self) -> None: + changed = copy.deepcopy(self.manifest) + changed["leaves"][1]["criteria"].append(changed["leaves"][0]["criteria"][0]) + self.assertTrue(any("assigned to both" in item for item in roadmap.validate_manifest(changed))) + + def test_unsettled_spec_cannot_gain_executable_leaf(self) -> None: + changed = copy.deepcopy(self.manifest) + leaf = next(item for item in changed["leaves"] if item["key"] == "air.toroid") + leaf["status"] = "planned" + self.assertTrue(any("must be provisional" in item for item in roadmap.validate_manifest(changed))) + + def test_blocker_cycle_is_rejected(self) -> None: + changed = copy.deepcopy(self.manifest) + first = next(item for item in changed["leaves"] if item["key"] == "air.toroid") + second = next(item for item in changed["leaves"] if item["key"] == "air.standard-bullets") + first["blocked_by"] = [second["key"]] + second["blocked_by"] = [first["key"]] + self.assertTrue(any("blocker cycle" in item for item in roadmap.validate_manifest(changed))) + + def test_issue_body_carries_stable_identity_and_closure_contract(self) -> None: + parent = next(item for item in self.manifest["parents"] if item["key"] == "air") + leaf = next(item for item in self.manifest["leaves"] if item["key"] == "air.toroid") + body = roadmap.leaf_body(leaf, parent) + self.assertIn("", body) + self.assertIn("Executable now: **no**", body) + self.assertIn("## Closure rule", body) + + def test_exact_criterion_roster_rejects_removed_or_bogus_obligation(self) -> None: + for replacement in ([], ["SYS-01.nonsense"]): + changed = copy.deepcopy(self.manifest) + leaf = next(item for item in changed["leaves"] if item["key"] == "core.director-reset") + leaf["criteria"] = replacement + self.assertTrue(any("criterion roster differs" in item for item in roadmap.validate_manifest(changed))) + + def test_invalid_status_and_global_key_collision_are_rejected(self) -> None: + changed = copy.deepcopy(self.manifest) + changed["leaves"][0]["status"] = "ready-ish" + changed["leaves"][1]["key"] = changed["parents"][0]["key"] + failures = roadmap.validate_manifest(changed) + self.assertTrue(any("invalid roadmap status" in item for item in failures)) + self.assertTrue(any("globally unique" in item for item in failures)) + + def test_missing_title_and_slice_dependency_cycle_are_rejected(self) -> None: + changed = copy.deepcopy(self.manifest) + changed["parents"][0]["title"] = "" + changed["slice_dependencies"]["8"] = ["9"] + changed["slice_dependencies"]["9"] = ["8"] + failures = roadmap.validate_manifest(changed) + self.assertTrue(any("parent title is required" in item for item in failures)) + self.assertTrue(any("slice dependency cycle" in item for item in failures)) + + def test_late_slices_declare_the_build_plan_prerequisites(self) -> None: + dependencies = self.manifest["slice_dependencies"] + self.assertIn("7", dependencies["8"]) + self.assertIn("10", dependencies["11"]) + self.assertEqual({"1", "2", "2a", *map(str, range(3, 21))}, set(dependencies["21"])) + + def test_fresh_and_version_one_snapshot_view_shapes_are_read(self) -> None: + view = {"id": "view", "name": "Existing"} + fresh = {"snapshot": {"project_views": {"data": {"node": {"views": {"nodes": [view]}}}}}} + old = {"snapshot": {"project_views": {"data": {"viewer": {"projectV2": {"views": {"nodes": [view]}}}}}}} + self.assertEqual([view], roadmap.snapshotted_views(fresh)) + self.assertEqual([view], roadmap.snapshotted_views(old)) + + +class ClosureGuardTests(unittest.TestCase): + def setUp(self) -> None: + self.manifest = { + "repository": "StarshipSuperjam/xevious", + "parents": [{"key": "cap"}], + "leaves": [ + {"key": "done", "status": "history", "proof": "historical"}, + {"key": "ready", "status": "planned", "proof": "playable", "blocked_by": ["done"], "records": ["SYS-01"], "criteria": ["SYS-01.ready"]}, + {"key": "future", "status": "provisional", "proof": "playable"}, + ], + } + self.migration = { + "parents": {"cap": {"number": 10}}, + "leaves": { + "done": {"number": 11}, + "ready": {"number": 12}, + "future": {"number": 13}, + }, + } + self.pr = { + "number": 20, + "body": "Closes #12", + "labels": [{"name": "playtest-approved"}], + "base": {"sha": "a" * 40}, + "head": {"sha": "b" * 40}, + } + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[12]", "ROADMAP_ISSUE_STATES_JSON": '{"11":"closed"}'}, clear=False) + @mock.patch.object(closures, "file_at_revision", return_value="Mechanic: SYS-01\n") + @mock.patch.object(closures, "added_test_lines", return_value="# roadmap-evidence: SYS-01 success\n# roadmap-evidence: SYS-01 failure") + @mock.patch.object(closures, "changed_files", return_value=["docs/mechanics/099-test.md", "tests/test_feature.py"]) + def test_exact_commit_playtest_record_allows_ready_leaf(self, _changed, _added, _content) -> None: + comment = [{"user": {"login": "StarshipSuperjam"}, "body": f""}] + with mock.patch.dict("os.environ", {"ROADMAP_COMMENTS_JSON": json.dumps(comment)}, clear=False): + self.assertEqual([], closures.validate_pr(self.pr, self.manifest, self.migration)) + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[12]", "ROADMAP_ISSUE_STATES_JSON": '{"11":"closed"}', "ROADMAP_COMMENTS_JSON": "[]"}, clear=False) + @mock.patch.object(closures, "file_at_revision", return_value="Mechanic: SYS-01\n") + @mock.patch.object(closures, "added_test_lines", return_value="# roadmap-evidence: SYS-01 success\n# roadmap-evidence: SYS-01 failure") + @mock.patch.object(closures, "changed_files", return_value=["docs/mechanics/099-test.md", "tests/test_feature.py"]) + def test_label_without_exact_commit_record_is_rejected(self, _changed, _added, _content) -> None: + failures = closures.validate_pr(self.pr, self.manifest, self.migration) + self.assertTrue(any("exact tested head commit" in item for item in failures)) + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[13]"}, clear=False) + @mock.patch.object(closures, "added_test_lines", return_value="") + @mock.patch.object(closures, "changed_files", return_value=[]) + def test_provisional_leaf_is_rejected(self, _changed, _added) -> None: + failures = closures.validate_pr(self.pr, self.manifest, self.migration) + self.assertTrue(any("provisional" in item for item in failures)) + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[10]"}, clear=False) + @mock.patch.object(closures, "added_test_lines", return_value="") + @mock.patch.object(closures, "changed_files", return_value=[]) + def test_capability_parent_is_rejected(self, _changed, _added) -> None: + failures = closures.validate_pr(self.pr, self.manifest, self.migration) + self.assertTrue(any("capability parent" in item for item in failures)) + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[12]", "ROADMAP_ISSUE_STATES_JSON": '{"11":"open"}', "ROADMAP_COMMENTS_JSON": "[]"}, clear=False) + @mock.patch.object(closures, "file_at_revision", return_value="Mechanic: SYS-01\n") + @mock.patch.object(closures, "added_test_lines", return_value="# roadmap-evidence: SYS-01 success\n# roadmap-evidence: SYS-01 failure") + @mock.patch.object(closures, "changed_files", return_value=["docs/mechanics/099-test.md", "tests/test_feature.py"]) + def test_open_blocker_is_rejected(self, _changed, _added, _content) -> None: + failures = closures.validate_pr(self.pr, self.manifest, self.migration) + self.assertTrue(any("blocked by open" in item for item in failures)) + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[12]", "ROADMAP_ISSUE_STATES_JSON": '{"11":"closed"}', "ROADMAP_COMMENTS_JSON": "[]"}, clear=False) + @mock.patch.object(closures, "file_at_revision", return_value="Mechanic: OTHER-99\n") + @mock.patch.object(closures, "added_test_lines", return_value="# roadmap-evidence: SYS-01 success\n# roadmap-evidence: SYS-01 failure") + @mock.patch.object(closures, "changed_files", return_value=["docs/mechanics/099-unrelated.md", "tests/test_feature.py"]) + def test_unrelated_mechanics_record_is_rejected(self, _changed, _added, _content) -> None: + failures = closures.validate_pr(self.pr, self.manifest, self.migration) + self.assertTrue(any("mechanics evidence for SYS-01" in item for item in failures)) + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[12]", "ROADMAP_ISSUE_STATES_JSON": '{"11":"closed"}', "ROADMAP_COMMENTS_JSON": "[]"}, clear=False) + @mock.patch.object(closures, "file_at_revision", return_value="Mechanic: SYS-01\n") + @mock.patch.object(closures, "added_test_lines", return_value="") + @mock.patch.object(closures, "changed_files", return_value=["docs/mechanics/099-test.md"]) + def test_missing_automated_evidence_is_rejected(self, _changed, _added, _content) -> None: + failures = closures.validate_pr(self.pr, self.manifest, self.migration) + self.assertTrue(any("success and failure evidence markers" in item for item in failures)) + + @mock.patch.dict("os.environ", {"ROADMAP_CLOSURES_JSON": "[12]", "ROADMAP_ISSUE_STATES_JSON": '{"11":"closed"}', "ROADMAP_COMMENTS_JSON": "[]"}, clear=False) + @mock.patch.object(closures, "changed_files", return_value=["docs/mechanics/099-test.md", "tests/test_unrelated.py"]) + @mock.patch.object(closures, "added_test_lines", return_value="# unrelated pre-existing SYS-01 mention") + def test_unrelated_automated_evidence_is_rejected(self, _added, _changed) -> None: + with mock.patch.object( + closures, + "file_at_revision", + side_effect=lambda _sha, path: "Mechanic: SYS-01\n" if path.startswith("docs/mechanics/") else "test OTHER-99\n", + ): + failures = closures.validate_pr(self.pr, self.manifest, self.migration) + self.assertTrue(any("success and failure evidence markers for SYS-01" in item for item in failures)) + + @mock.patch.object(closures, "source_pr_for_closed_issue", return_value=20) + @mock.patch.object(closures, "load_pr") + def test_manual_closure_requires_merged_pr_that_closes_the_leaf(self, load_pr, _source) -> None: + load_pr.return_value = {**self.pr, "merged_at": None} + event = {"issue": {"number": 12}} + failures = closures.validate_issue_event(event, self.manifest, self.migration) + self.assertTrue(any("merged delivering" in item for item in failures)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check_roadmap_closures.py b/tools/check_roadmap_closures.py new file mode 100644 index 0000000..c29f936 --- /dev/null +++ b/tools/check_roadmap_closures.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Refuse roadmap closures that are provisional, blocked, or unproved.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "docs" / "roadmap" / "manifest.json" +MIGRATION = ROOT / "docs" / "roadmap" / "migration.json" +PLAYTEST_LABEL = "playtest-approved" +PLAYTEST_MARKER = re.compile(r"") +CLOSE_LINE = re.compile(r"(?im)^\s*(?:close[sd]?|fixe[sd]?|resolve[sd]?)\s+#(\d+)\s*$") + + +class ClosureError(RuntimeError): + pass + + +def read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ClosureError(f"cannot read {path.relative_to(ROOT)}: {exc}") from exc + if not isinstance(value, dict): + raise ClosureError(f"{path.relative_to(ROOT)} must be a JSON object") + return value + + +def gh(*args: str) -> Any: + result = subprocess.run(["gh", *args], cwd=ROOT, text=True, capture_output=True, check=False) + if result.returncode: + raise ClosureError(result.stderr.strip() or result.stdout.strip()) + if not result.stdout.strip(): + return None + return json.loads(result.stdout) + + +def indexes(manifest: dict[str, Any], migration: dict[str, Any]) -> tuple[dict[int, dict[str, Any]], dict[int, dict[str, Any]], dict[str, int]]: + parents_by_number: dict[int, dict[str, Any]] = {} + leaves_by_number: dict[int, dict[str, Any]] = {} + numbers_by_key: dict[str, int] = {} + parent_defs = {parent["key"]: parent for parent in manifest["parents"]} + for key, row in migration.get("parents", {}).items(): + number = int(row["number"]) + parents_by_number[number] = parent_defs[key] + numbers_by_key[key] = number + leaf_defs = {leaf["key"]: leaf for leaf in manifest["leaves"]} + for key, row in migration.get("leaves", {}).items(): + number = int(row["number"]) + leaves_by_number[number] = leaf_defs[key] + numbers_by_key[key] = number + return parents_by_number, leaves_by_number, numbers_by_key + + +def changed_files(base: str, head: str) -> list[str]: + result = subprocess.run( + ["git", "diff", "--name-only", f"{base}...{head}"], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + raise ClosureError(result.stderr.strip()) + return [line for line in result.stdout.splitlines() if line] + + +def file_at_revision(revision: str, path: str) -> str: + result = subprocess.run( + ["git", "show", f"{revision}:{path}"], cwd=ROOT, text=True, capture_output=True, check=False + ) + return result.stdout if result.returncode == 0 else "" + + +def added_test_lines(base: str, head: str) -> str: + result = subprocess.run( + ["git", "diff", "--unified=0", f"{base}...{head}", "--", "tests"], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + raise ClosureError(result.stderr.strip()) + return "\n".join( + line[1:] for line in result.stdout.splitlines() if line.startswith("+") and not line.startswith("+++") + ) + + +def paginated(endpoint: str) -> list[dict[str, Any]]: + value = gh("api", "--paginate", "--slurp", endpoint) + pages = value if not value or isinstance(value[0], list) else [value] + return [item for page in pages for item in page] + + +def issue_state(repo: str, number: int) -> str: + injected = os.environ.get("ROADMAP_ISSUE_STATES_JSON") + if injected: + return json.loads(injected).get(str(number), "open") + issue = gh("api", f"repos/{repo}/issues/{number}") + return issue["state"] + + +def computed_closures(repo: str, pr: dict[str, Any]) -> set[int]: + injected = os.environ.get("ROADMAP_CLOSURES_JSON") + if injected: + return {int(item) for item in json.loads(injected)} + if os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"): + value = gh("pr", "view", str(pr["number"]), "--repo", repo, "--json", "closingIssuesReferences") + return {int(item["number"]) for item in value.get("closingIssuesReferences", [])} + return {int(number) for number in CLOSE_LINE.findall(pr.get("body") or "")} + + +def playtest_recorded(repo: str, pr: dict[str, Any]) -> bool: + labels = {item["name"] if isinstance(item, dict) else item for item in pr.get("labels", [])} + if PLAYTEST_LABEL not in labels: + return False + injected = os.environ.get("ROADMAP_COMMENTS_JSON") + comments = json.loads(injected) if injected else paginated(f"repos/{repo}/issues/{pr['number']}/comments?per_page=100") + owner = repo.split("/", 1)[0].lower() + head = pr["head"]["sha"] + for comment in comments: + author = (comment.get("user") or {}).get("login", "").lower() + match = PLAYTEST_MARKER.search(comment.get("body") or "") + if author == owner and match and match.group(1) == head: + return True + return False + + +def validate_pr(pr: dict[str, Any], manifest: dict[str, Any], migration: dict[str, Any]) -> list[str]: + repo = manifest["repository"] + parents_by_number, leaves_by_number, numbers_by_key = indexes(manifest, migration) + closures = computed_closures(repo, pr) + failures: list[str] = [] + files = changed_files(pr["base"]["sha"], pr["head"]["sha"]) if closures else [] + added_tests = added_test_lines(pr["base"]["sha"], pr["head"]["sha"]) if closures else "" + for number in sorted(closures): + if number in parents_by_number: + failures.append(f"PR closes capability parent #{number}; close its independently complete leaves instead") + leaf = leaves_by_number.get(number) + if not leaf: + continue + if leaf["status"] == "provisional": + failures.append(f"#{number} ({leaf['key']}) is provisional until its specification is settled") + continue + for blocker in leaf.get("blocked_by", []): + blocker_number = numbers_by_key[blocker] + if blocker_number not in closures and issue_state(repo, blocker_number) != "closed": + failures.append(f"#{number} ({leaf['key']}) is blocked by open #{blocker_number} ({blocker})") + for dependency_slice in manifest.get("slice_dependencies", {}).get(str(leaf.get("slice")), []): + for dependency in manifest["leaves"]: + if str(dependency["slice"]) != dependency_slice or dependency["status"] == "history": + continue + dependency_number = numbers_by_key[dependency["key"]] + if dependency_number not in closures and issue_state(repo, dependency_number) != "closed": + failures.append( + f"#{number} ({leaf['key']}) depends on open slice-{dependency_slice} " + f"leaf #{dependency_number} ({dependency['key']})" + ) + if leaf["proof"] in {"playable", "operator"} and not playtest_recorded(repo, pr): + failures.append( + f"#{number} ({leaf['key']}) requires `playtest-approved` plus an owner comment " + f"recording the exact tested head commit" + ) + if leaf.get("records"): + mechanics_files = [path for path in files if path.startswith("docs/mechanics/") and path.endswith(".md")] + evidence = "\n".join(file_at_revision(pr["head"]["sha"], path) for path in mechanics_files) + for record in leaf["records"]: + if not re.search(rf"(? int | None: + timeline = paginated(f"repos/{repo}/issues/{number}/timeline?per_page=100") + candidates: list[int] = [] + for event in timeline: + source = event.get("source", {}).get("issue", {}) + if source.get("pull_request") and source.get("number"): + candidates.append(int(source["number"])) + return candidates[-1] if candidates else None + + +def load_pr(repo: str, number: int) -> dict[str, Any]: + value = gh("api", f"repos/{repo}/pulls/{number}") + issue = gh("api", f"repos/{repo}/issues/{number}") + value["labels"] = issue.get("labels", []) + return value + + +def validate_issue_event(event: dict[str, Any], manifest: dict[str, Any], migration: dict[str, Any]) -> list[str]: + issue = event["issue"] + number = int(issue["number"]) + parents_by_number, leaves_by_number, _ = indexes(manifest, migration) + if number in parents_by_number: + return [f"capability parent #{number} cannot close directly"] + leaf = leaves_by_number.get(number) + if not leaf or leaf["status"] == "history": + return [] + pr_number = source_pr_for_closed_issue(manifest["repository"], number) + if pr_number is None: + return [f"roadmap leaf #{number} closed without a delivering pull request"] + pr = load_pr(manifest["repository"], pr_number) + if not pr.get("merged_at"): + return [f"roadmap leaf #{number} closed without a merged delivering pull request"] + if number not in computed_closures(manifest["repository"], pr): + return [f"roadmap leaf #{number} is not in PR #{pr_number}'s computed closing list"] + return validate_pr(pr, manifest, migration) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["pr", "issue-event"]) + parser.add_argument("--event", type=Path, default=Path(os.environ.get("GITHUB_EVENT_PATH", ""))) + args = parser.parse_args(argv) + manifest = read_json(MANIFEST) + migration = read_json(MIGRATION) + if migration.get("phase") not in {"applied", "reconciled"}: + print("roadmap migration is not active; closure projection not enforced yet") + return 0 + event = read_json(args.event) + if args.mode == "pr": + payload_pr = event.get("pull_request") + if not payload_pr and event.get("issue", {}).get("pull_request"): + payload_pr = load_pr(manifest["repository"], int(event["issue"]["number"])) + if not payload_pr: + raise ClosureError("event does not identify a pull request") + # Always load current labels/body/head instead of trusting a stale rerun payload. + current_pr = load_pr(manifest["repository"], int(payload_pr["number"])) + failures = validate_pr(current_pr, manifest, migration) + else: + failures = validate_issue_event(event, manifest, migration) + if failures: + print("roadmap closure refused:\n- " + "\n- ".join(failures), file=sys.stderr) + return 1 + print("roadmap closures satisfy the committed evidence contract") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ClosureError as exc: + print(f"roadmap closure check failed closed: {exc}", file=sys.stderr) + raise SystemExit(2) from exc diff --git a/tools/roadmap.py b/tools/roadmap.py new file mode 100644 index 0000000..81f105e --- /dev/null +++ b/tools/roadmap.py @@ -0,0 +1,905 @@ +#!/usr/bin/env python3 +"""Validate and reconcile the dependency-aware Xevious GitHub roadmap. + +The committed manifest is authoritative. GitHub Issues and Project #4 are a +projection. Writes are idempotent by the stable ``roadmap-key`` marker and are +journaled after every successful external mutation so an interrupted run rolls +forward instead of creating duplicates. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess +import sys +import time +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = ROOT / "docs" / "roadmap" / "manifest.json" +MIGRATION_PATH = ROOT / "docs" / "roadmap" / "migration.json" +CATALOG_PATH = ROOT / "docs" / "MECHANICS_CATALOG.md" +CRITERIA_PATH = ROOT / "docs" / "roadmap" / "criteria.json" +KEY_MARKER = "" +PROPOSED_MARKER = "" + + +class RoadmapError(RuntimeError): + """The desired roadmap or its live projection is unsafe to apply.""" + + +def read_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RoadmapError(f"cannot read {path.relative_to(ROOT)}: {exc}") from exc + if not isinstance(value, dict): + raise RoadmapError(f"{path.relative_to(ROOT)} must contain a JSON object") + return value + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def gh(*args: str, input_text: str | None = None) -> Any: + command = ["gh", *args] + result = subprocess.run( + command, + cwd=ROOT, + input=input_text, + text=True, + capture_output=True, + check=False, + ) + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() + raise RoadmapError(f"{' '.join(command)} failed: {detail}") + output = result.stdout.strip() + if not output: + return None + try: + return json.loads(output) + except json.JSONDecodeError: + return output + + +def catalog_ids() -> set[str]: + text = CATALOG_PATH.read_text(encoding="utf-8") + return set(re.findall(r"^\| ([A-Z]+-[0-9]+) \|", text, flags=re.MULTILINE)) + + +def validate_manifest(manifest: dict[str, Any]) -> list[str]: + failures: list[str] = [] + if manifest.get("version") != 1: + failures.append("manifest version must be 1") + parents = manifest.get("parents") + leaves = manifest.get("leaves") + if not isinstance(parents, list) or not isinstance(leaves, list): + return failures + ["parents and leaves must be lists"] + + key_pattern = re.compile(r"^[a-z0-9]+(?:[.-][a-z0-9]+)*$") + parent_keys = [item.get("key") for item in parents if isinstance(item, dict)] + leaf_keys = [item.get("key") for item in leaves if isinstance(item, dict)] + if len(parent_keys) != len(set(parent_keys)): + failures.append("parent keys must be unique") + if len(leaf_keys) != len(set(leaf_keys)): + failures.append("leaf keys must be unique") + if set(parent_keys) & set(leaf_keys): + failures.append("parent and leaf keys must be globally unique") + for key in [*parent_keys, *leaf_keys]: + if not isinstance(key, str) or not key_pattern.fullmatch(key): + failures.append(f"invalid stable roadmap key {key!r}") + parent_map = {item.get("key"): item for item in parents if isinstance(item, dict)} + leaf_map = {item.get("key"): item for item in leaves if isinstance(item, dict)} + project = manifest.get("project", {}) + if not isinstance(project, dict) or not all(project.get(field) for field in ("owner", "number", "node_id")): + failures.append("project owner, number, and node_id are required") + for parent in parents: + if not isinstance(parent, dict): + continue + if not parent.get("title"): + failures.append(f"{parent.get('key', '')}: parent title is required") + if parent.get("spec_status") not in {"settled", "locked", "draft", "provisional"}: + failures.append(f"{parent.get('key', '')}: invalid specification status") + + criteria: dict[str, str] = {} + for leaf in leaves: + if not isinstance(leaf, dict): + failures.append("every leaf must be an object") + continue + key = leaf.get("key", "") + if not leaf.get("title"): + failures.append(f"{key}: leaf title is required") + parent = parent_map.get(leaf.get("parent")) + if parent is None: + failures.append(f"{key}: unknown parent {leaf.get('parent')!r}") + if not leaf.get("milestone"): + failures.append(f"{key}: every leaf needs a milestone") + if not leaf.get("slice"): + failures.append(f"{key}: every leaf needs a delivery slice") + if leaf.get("proof") not in {"playable", "operator", "historical"}: + failures.append(f"{key}: invalid proof level") + if leaf.get("status") not in {"planned", "provisional", "history"}: + failures.append(f"{key}: invalid roadmap status") + if leaf.get("status") == "history" and not leaf.get("delivered_by"): + failures.append(f"{key}: history needs a delivering PR") + if parent and parent.get("spec_status") in {"draft", "provisional"}: + if leaf.get("status") != "provisional": + failures.append(f"{key}: work under an unsettled parent must be provisional") + for blocker in leaf.get("blocked_by", []): + if blocker not in leaf_map: + failures.append(f"{key}: unknown blocker {blocker}") + if blocker == key: + failures.append(f"{key}: cannot block itself") + leaf_criteria = leaf.get("criteria") + if not isinstance(leaf_criteria, list) or not leaf_criteria: + failures.append(f"{key}: needs at least one atomic criterion") + continue + for criterion in leaf_criteria: + if criterion in criteria: + failures.append( + f"criterion {criterion} is assigned to both {criteria[criterion]} and {key}" + ) + criteria[criterion] = key + + # A cycle would make closure impossible even if every individual blocker exists. + visiting: set[str] = set() + visited: set[str] = set() + + def visit(key: str) -> None: + if key in visiting: + failures.append(f"blocker cycle includes {key}") + return + if key in visited: + return + visiting.add(key) + for blocker in leaf_map.get(key, {}).get("blocked_by", []): + visit(blocker) + visiting.remove(key) + visited.add(key) + + for key in leaf_map: + visit(key) + + excluded = tuple(manifest.get("excluded_catalog_prefixes", [])) + expected = {item for item in catalog_ids() if not item.startswith(excluded)} + covered = {criterion.split(".", 1)[0] for criterion in criteria} + missing = sorted(expected - covered) + if missing: + failures.append("catalog mechanics without an atomic roadmap criterion: " + ", ".join(missing)) + slices = {str(leaf.get("slice")) for leaf in leaves} + expected_slices = {str(number) for number in range(1, 22)} | {"2a"} + if slices != expected_slices: + failures.append( + "delivery slices differ from 1–21 plus 2a: missing " + + ", ".join(sorted(expected_slices - slices)) + + "; extra " + + ", ".join(sorted(slices - expected_slices)) + ) + try: + required_criteria = set(read_json(CRITERIA_PATH)["criteria"]) + except (RoadmapError, KeyError, TypeError) as exc: + failures.append(f"cannot load exact criterion roster: {exc}") + else: + actual_criteria = set(criteria) + if actual_criteria != required_criteria: + failures.append( + f"atomic criterion roster differs: missing {sorted(required_criteria-actual_criteria)}; " + f"extra {sorted(actual_criteria-required_criteria)}" + ) + + slice_dependencies = manifest.get("slice_dependencies", {}) + for delivery_slice, dependencies in slice_dependencies.items(): + if delivery_slice not in slices: + failures.append(f"slice dependency target {delivery_slice} is unknown") + for dependency in dependencies: + if dependency not in slices: + failures.append(f"slice {delivery_slice} has unknown dependency {dependency}") + if dependency == delivery_slice: + failures.append(f"slice {delivery_slice} cannot depend on itself") + slice_visiting: set[str] = set() + slice_visited: set[str] = set() + + def visit_slice(delivery_slice: str) -> None: + if delivery_slice in slice_visiting: + failures.append(f"slice dependency cycle includes {delivery_slice}") + return + if delivery_slice in slice_visited: + return + slice_visiting.add(delivery_slice) + for dependency in slice_dependencies.get(delivery_slice, []): + visit_slice(dependency) + slice_visiting.remove(delivery_slice) + slice_visited.add(delivery_slice) + + for delivery_slice in slice_dependencies: + visit_slice(delivery_slice) + return failures + + +def verify_project_identity(manifest: dict[str, Any]) -> None: + project = manifest["project"] + query = ( + "query { viewer { login projectV2(number:" + f'{int(project["number"])}' + ") { id } } node(id:" + + json.dumps(project["node_id"]) + ") { ... on ProjectV2 { id owner { ... on User { login } ... on Organization { login } } } } }" + ) + data = gh("api", "graphql", "-f", f"query={query}")["data"] + selected = data.get("viewer", {}).get("projectV2") + node = data.get("node") + if not selected or not node or selected.get("id") != project["node_id"] or node.get("id") != project["node_id"]: + raise RoadmapError("Project owner, number, and node ID do not resolve to one Project") + if node.get("owner", {}).get("login", "").lower() != str(project["owner"]).lower(): + raise RoadmapError("Project node owner differs from the manifest") + + +def marker(key: str) -> str: + return KEY_MARKER.format(key=key) + + +def parent_body(parent: dict[str, Any]) -> str: + spec = parent.get("spec") + spec_line = f"Builds to `{spec}`." if spec else "Builds to the outcome and engineering plan." + return "\n".join( + [ + marker(parent["key"]), + PROPOSED_MARKER, + "", + spec_line, + "", + "This is a capability tracker. It is complete only when every native sub-issue is closed.", + "Capability parents intentionally have no milestone; milestones measure delivery leaves only.", + "", + f"Specification status: **{parent['spec_status']}**.", + "The committed desired state is `docs/roadmap/manifest.json`; the Project board is a derived view.", + ] + ) + + +def leaf_body(leaf: dict[str, Any], parent: dict[str, Any], manifest: dict[str, Any] | None = None) -> str: + blockers = leaf.get("blocked_by", []) + blocker_text = ", ".join(f"`{item}`" for item in blockers) if blockers else "None" + slice_dependencies = (manifest or {}).get("slice_dependencies", {}).get(str(leaf["slice"]), []) + slice_text = ", ".join(f"slice {item}" for item in slice_dependencies) if slice_dependencies else "None" + records = leaf.get("records", []) + record_text = ", ".join(f"`{item}`" for item in records) if records else "None declared" + provisional = parent.get("spec_status") in {"draft", "provisional"} + executable = not provisional and leaf.get("status") != "history" + lines = [ + marker(leaf["key"]), + PROPOSED_MARKER, + "", + f"Parent capability: `{leaf['parent']}`.", + f"Delivery slice: **{leaf['slice']}**. Milestone: **{leaf['milestone']}**.", + f"Roadmap state: **{leaf['status']}**. Executable now: **{'yes' if executable else 'no'}**.", + "", + "## Atomic obligations", + "", + *[f"- `{criterion}`" for criterion in leaf["criteria"]], + "", + "## Blockers", + "", + f"- {blocker_text}", + f"- Required delivery slices: {slice_text}", + ] + if provisional: + lines += [ + f"- `{parent.get('spec') or parent['key']}` must be settled before this leaf may close.", + ] + lines += [ + "", + "## Completion evidence", + "", + f"- Proof level: **{leaf['proof']}**.", + f"- Mechanics records/catalog rows: {record_text}.", + "- Automated evidence must add `roadmap-evidence: ID success` and `roadmap-evidence: ID failure` markers for every obligation ID, attached to tests that exercise those paths.", + "- A gameplay-affecting PR stays draft until the operator approves the playable `.sb3` at its tested commit.", + "", + "## Closure rule", + "", + "This leaf closes only when all obligations above are delivered, every blocker is closed (or closes in the same PR), the required evidence is present, and the repository closure check passes.", + ] + if leaf.get("status") == "history": + lines += [ + "", + "## Imported history", + "", + f"Delivered by PR #{leaf['delivered_by']}. This records original delivery evidence; it does not retroactively certify the work under today's closure controls.", + ] + return "\n".join(lines) + + +def api_issue(repo: str, number: int) -> dict[str, Any]: + value = gh("api", f"repos/{repo}/issues/{number}") + if not isinstance(value, dict): + raise RoadmapError(f"issue #{number} returned an unexpected response") + return value + + +def all_issues(repo: str) -> list[dict[str, Any]]: + value = gh("api", "--paginate", "--slurp", f"repos/{repo}/issues?state=all&per_page=100") + if not isinstance(value, list): + raise RoadmapError("issue inventory returned an unexpected response") + pages = value if not value or isinstance(value[0], list) else [value] + return [item for page in pages for item in page if "pull_request" not in item] + + +def live_key_index(repo: str) -> dict[str, dict[str, Any]]: + found: dict[str, dict[str, Any]] = {} + pattern = re.compile(r"") + for issue in all_issues(repo): + match = pattern.search(issue.get("body") or "") + if not match: + continue + key = match.group(1) + if key in found: + raise RoadmapError(f"live roadmap key {key} appears on more than one issue") + found[key] = issue + return found + + +def migration_template(manifest: dict[str, Any]) -> dict[str, Any]: + return { + "version": 1, + "phase": "planned", + "claim_pr": 36, + "repository": manifest["repository"], + "project": manifest["project"], + "parents": {}, + "leaves": {}, + "project_fields": {}, + "snapshot": {}, + } + + +def live_plan(manifest: dict[str, Any]) -> dict[str, Any]: + repo = manifest["repository"] + verify_project_identity(manifest) + keyed = live_key_index(repo) + explicit_parent_numbers = {int(parent["issue"]) for parent in manifest["parents"] if parent.get("issue")} + explicit_leaf_numbers = {int(leaf["issue"]) for leaf in manifest["leaves"] if leaf.get("issue")} + if explicit_parent_numbers & explicit_leaf_numbers: + raise RoadmapError("one existing issue is assigned as both parent and leaf") + for item in [*manifest["parents"], *manifest["leaves"]]: + number = item.get("issue") + if not number: + continue + issue = api_issue(repo, int(number)) + body = issue.get("body") or "" + found = re.search(r"", body) + if found and found.group(1) != item["key"]: + raise RoadmapError(f"existing issue #{number} carries conflicting roadmap key {found.group(1)}") + project_number, project_owner = project_coordinates(manifest) + fields = gh("project", "field-list", project_number, "--owner", project_owner, "--format", "json") + field_names = {field["name"] for field in fields["fields"]} + return { + "parents": { + "create": sum(not item.get("issue") and item["key"] not in keyed for item in manifest["parents"]), + "reuse_or_update": sum(bool(item.get("issue")) or item["key"] in keyed for item in manifest["parents"]), + }, + "leaves": { + "create": sum(not item.get("issue") and item["key"] not in keyed for item in manifest["leaves"]), + "reuse_or_update": sum(bool(item.get("issue")) or item["key"] in keyed for item in manifest["leaves"]), + "close_as_imported_history": sum(item["status"] == "history" for item in manifest["leaves"]), + }, + "project_fields_to_create": sorted({"Roadmap role", "Delivery slice", "Proof level"} - field_names), + "protected_pr": 34, + "protected_pr_writes": 0, + } + + +def snapshot(manifest: dict[str, Any], journal: dict[str, Any]) -> None: + if journal.get("phase") != "planned" or journal.get("snapshot"): + raise RoadmapError("snapshot is immutable once captured; start a new migration journal to replace it") + verify_project_identity(manifest) + repo = manifest["repository"] + project_number, project_owner = project_coordinates(manifest) + project = gh("project", "field-list", project_number, "--owner", project_owner, "--format", "json") + items = gh("project", "item-list", project_number, "--owner", project_owner, "--limit", "1000", "--format", "json") + if items.get("totalCount") != len(items.get("items", [])): + raise RoadmapError("Project item snapshot is truncated") + pr34 = gh( + "pr", "view", "34", "--repo", repo, "--json", + "number,title,state,isDraft,body,headRefName,headRefOid,baseRefName,labels,milestone,projectItems,url", + ) + views = gh( + "api", "graphql", "-f", + f'query=query {{ node(id:"{manifest["project"]["node_id"]}") {{ ... on ProjectV2 {{ id title views(first:50) {{ nodes {{ id name layout filter }} }} }} }} }}', + ) + milestones = gh("api", f"repos/{repo}/milestones?state=all&per_page=100") + journal["snapshot"] = { + "project_fields": project, + "project_items": items, + "project_views": views, + "milestones": milestones, + "pr34": pr34, + } + journal["phase"] = "snapshotted" + write_json(MIGRATION_PATH, journal) + + +def milestone_numbers(journal: dict[str, Any]) -> dict[str, int]: + rows = journal.get("snapshot", {}).get("milestones", []) + return {row["title"]: row["number"] for row in rows} + + +def issue_labels(role: str, leaf: dict[str, Any] | None = None) -> list[str]: + labels = [f"roadmap:{role}"] + if leaf: + labels.append(f"slice:{leaf['slice']}") + labels.append(f"proof:{leaf['proof']}") + if leaf["status"] == "provisional": + labels.append("spec:draft") + if leaf["status"] == "history": + labels.append("roadmap:history") + return labels + + +LABELS = { + "roadmap:parent": ("Capability tracker; milestones belong to its delivery leaves", "5319E7"), + "roadmap:leaf": ("Independently closable roadmap component", "0E8A16"), + "roadmap:history": ("Imported delivery history; not retroactively certified", "6E7781"), + "spec:draft": ("Blocked until its product specification is settled", "D4C5F9"), + "proof:playable": ("Requires operator approval of the playable Scratch build", "FBCA04"), + "proof:operator": ("Requires an operator-run acceptance or audit", "B60205"), + "proof:historical": ("Uses original delivery evidence only", "6E7781"), + "playtest-approved": ("Operator approved the playable build at the recorded commit", "0E8A16"), +} +for value in ["1", "2", "2a", *[str(number) for number in range(3, 22)]]: + LABELS[f"slice:{value}"] = (f"Delivery slice {value}; derived from the committed roadmap", "1D76DB") + + +def ensure_label(repo: str, name: str, description: str, color: str) -> None: + encoded = name.replace("/", "%2F") + try: + gh("api", f"repos/{repo}/labels/{encoded}") + except RoadmapError: + gh("api", "--method", "POST", f"repos/{repo}/labels", "-f", f"name={name}", "-f", f"description={description}", "-f", f"color={color}") + + +def patch_issue(repo: str, number: int, *, title: str, body: str, labels: list[str], milestone: int | None, state: str = "open") -> dict[str, Any]: + args = ["api", "--method", "PATCH", f"repos/{repo}/issues/{number}", "-f", f"title={title}", "-f", f"body={body}", "-f", f"state={state}"] + for label in labels: + args += ["-f", f"labels[]={label}"] + if milestone is None: + args += ["-F", "milestone=null"] + else: + args += ["-F", f"milestone={milestone}"] + value = gh(*args) + if not isinstance(value, dict): + raise RoadmapError(f"issue #{number} update returned an unexpected response") + return value + + +def create_issue(repo: str, *, title: str, body: str, labels: list[str], milestone: int | None) -> dict[str, Any]: + args = ["api", "--method", "POST", f"repos/{repo}/issues", "-f", f"title={title}", "-f", f"body={body}"] + for label in labels: + args += ["-f", f"labels[]={label}"] + if milestone is not None: + args += ["-F", f"milestone={milestone}"] + value = gh(*args) + if not isinstance(value, dict): + raise RoadmapError("issue create returned an unexpected response") + return value + + +def add_sub_issue(repo: str, parent_number: int, child_id: int) -> None: + current = gh("api", f"repos/{repo}/issues/{parent_number}/sub_issues") + if any(item.get("id") == child_id for item in (current or [])): + return + gh("api", "--method", "POST", f"repos/{repo}/issues/{parent_number}/sub_issues", "-F", f"sub_issue_id={child_id}") + + +def project_coordinates(manifest: dict[str, Any]) -> tuple[str, str]: + return str(manifest["project"]["number"]), str(manifest["project"]["owner"]) + + +def ensure_project_fields(manifest: dict[str, Any], journal: dict[str, Any]) -> None: + number, owner = project_coordinates(manifest) + current = gh("project", "field-list", number, "--owner", owner, "--format", "json") + by_name = {field["name"]: field for field in current["fields"]} + desired = { + "Roadmap role": ("SINGLE_SELECT", "Parent,Leaf,Imported history"), + "Delivery slice": ("TEXT", None), + "Proof level": ("SINGLE_SELECT", "Playable,Operator,Historical"), + } + for name, (kind, options) in desired.items(): + if name not in by_name: + args = ["project", "field-create", number, "--owner", owner, "--name", name, "--data-type", kind, "--format", "json"] + if options: + args += ["--single-select-options", options] + gh(*args) + refreshed = gh("project", "field-list", number, "--owner", owner, "--format", "json") + journal["project_fields"] = {field["name"]: field for field in refreshed["fields"]} + write_json(MIGRATION_PATH, journal) + + +def graphql_batch(operations: list[str], *, size: int = 20) -> None: + """Run small mutation batches so Project reconciliation does not exhaust quota.""" + for offset in range(0, len(operations), size): + query = "mutation {\n" + "\n".join(operations[offset : offset + size]) + "\n}" + gh("api", "graphql", "-f", f"query={query}") + + +def project_value(field: dict[str, Any], value: str) -> str: + if field["type"] == "ProjectV2SingleSelectField": + options = {option["name"]: option["id"] for option in field.get("options", [])} + if value not in options: + raise RoadmapError(f"Project field {field['name']} has no option {value}") + return f'{{singleSelectOptionId:"{options[value]}"}}' + return f'{{text:{json.dumps(value)}}}' + + +def sync_project(manifest: dict[str, Any], journal: dict[str, Any]) -> None: + """Add roadmap content once, then set all derived fields in batched mutations.""" + project_id = journal["project"]["node_id"] + number, owner = project_coordinates(manifest) + current = gh("project", "item-list", number, "--owner", owner, "--limit", "1000", "--format", "json") + if current.get("totalCount") != len(current.get("items", [])): + raise RoadmapError("Project item inventory is truncated") + by_url = { + item.get("content", {}).get("url"): item + for item in current.get("items", []) + if item.get("content", {}).get("url") + } + desired = [] + for parent in manifest["parents"]: + record = journal["parents"][parent["key"]] + desired.append((parent["key"], record, {"Roadmap role": "Parent", "Work type": "Feature", "Status": "Backlog"})) + for leaf in manifest["leaves"]: + record = journal["leaves"][leaf["key"]] + role = "Imported history" if leaf["status"] == "history" else "Leaf" + desired.append((leaf["key"], record, { + "Roadmap role": role, + "Delivery slice": str(leaf["slice"]), + "Proof level": leaf["proof"].title(), + "Work type": "Feature", + "Status": "Done" if leaf["status"] == "history" else "Backlog", + })) + + missing = [(key, record) for key, record, _ in desired if record["url"] not in by_url] + add_ops = [ + f'a{index}:addProjectV2ItemById(input:{{projectId:"{project_id}",contentId:"{record["node_id"]}"}}){{item{{id}}}}' + for index, (_, record) in enumerate(missing) + ] + graphql_batch(add_ops) + if missing: + # ProjectV2 mutations are occasionally eventually consistent with its + # paginated item query. Bound the retry instead of creating again. + for attempt in range(3): + if attempt: + time.sleep(2) + current = gh("project", "item-list", number, "--owner", owner, "--limit", "1000", "--format", "json") + if current.get("totalCount") != len(current.get("items", [])): + raise RoadmapError("Project item inventory is truncated") + by_url = { + item.get("content", {}).get("url"): item + for item in current.get("items", []) + if item.get("content", {}).get("url") + } + if all(record["url"] in by_url for _, record in missing): + break + + update_ops: list[str] = [] + for index, (key, record, values) in enumerate(desired): + item = by_url.get(record["url"]) + if not item: + raise RoadmapError(f"Project item missing after add: {key}") + record["project_item_id"] = item["id"] + for field_name, value in values.items(): + field = journal["project_fields"][field_name] + encoded = project_value(field, value) + alias = f'u{len(update_ops)}' + update_ops.append( + f'{alias}:updateProjectV2ItemFieldValue(input:{{projectId:"{project_id}",itemId:"{item["id"]}",fieldId:"{field["id"]}",value:{encoded}}}){{projectV2Item{{id}}}}' + ) + graphql_batch(update_ops) + journal["project_synced"] = True + write_json(MIGRATION_PATH, journal) + + +def ensure_project_views(manifest: dict[str, Any], journal: dict[str, Any]) -> None: + query = f'query {{ node(id:"{journal["project"]["node_id"]}") {{ ... on ProjectV2 {{ views(first:50) {{ nodes {{ id name layout filter }} }} }} }} }}' + current = gh("api", "graphql", "-f", f"query={query}") + nodes = current["data"]["node"]["views"]["nodes"] + by_name = {node["name"]: node for node in nodes} + owned: dict[str, dict[str, Any]] = {} + for view in manifest["project"].get("views", []): + node = by_name.get(view["name"]) + if not node: + mutation = ( + "mutation { createProjectV2View(input:{" + f'projectId:"{journal["project"]["node_id"]}",' + f'name:{json.dumps(view["name"])},layout:{view["layout"]}' + "}) { projectV2View { id name layout filter } } }" + ) + created = gh("api", "graphql", "-f", f"query={mutation}") + node = created["data"]["createProjectV2View"]["projectV2View"] + if node.get("filter") != view["filter"]: + mutation = ( + "mutation { updateProjectV2View(input:{" + f'viewId:"{node["id"]}",filter:{json.dumps(view["filter"])}' + "}) { projectV2View { id name layout filter } } }" + ) + updated = gh("api", "graphql", "-f", f"query={mutation}") + node = updated["data"]["updateProjectV2View"]["projectV2View"] + owned[view["name"]] = node + journal["project_views"] = owned + write_json(MIGRATION_PATH, journal) + + +def apply(manifest: dict[str, Any], journal: dict[str, Any]) -> None: + failures = validate_manifest(manifest) + if failures: + raise RoadmapError("manifest invalid:\n- " + "\n- ".join(failures)) + if journal.get("phase") not in {"snapshotted", "applying", "applied", "reconciled"}: + raise RoadmapError("run snapshot before apply") + verify_project_identity(manifest) + repo = manifest["repository"] + if api_issue(repo, 34).get("pull_request") is None: + raise RoadmapError("PR #34 identity check failed") + journal["phase"] = "applying" + write_json(MIGRATION_PATH, journal) + + for name, (description, color) in LABELS.items(): + ensure_label(repo, name, description, color) + + live = live_key_index(repo) + milestones = milestone_numbers(journal) + parents = {parent["key"]: parent for parent in manifest["parents"]} + + for parent in manifest["parents"]: + existing = parent.get("issue") or journal["parents"].get(parent["key"], {}).get("number") + issue = live.get(parent["key"]) + if issue: + existing = issue["number"] + if existing: + issue = patch_issue(repo, int(existing), title=parent["title"], body=parent_body(parent), labels=issue_labels("parent"), milestone=None, state="open") + else: + issue = create_issue(repo, title=parent["title"], body=parent_body(parent), labels=issue_labels("parent"), milestone=None) + journal["parents"][parent["key"]] = {"number": issue["number"], "id": issue["id"], "node_id": issue["node_id"], "url": issue["html_url"]} + write_json(MIGRATION_PATH, journal) + + for leaf in manifest["leaves"]: + existing = leaf.get("issue") or journal["leaves"].get(leaf["key"], {}).get("number") + issue = live.get(leaf["key"]) + if issue: + existing = issue["number"] + milestone = milestones.get(leaf["milestone"]) + if milestone is None: + raise RoadmapError(f"missing milestone {leaf['milestone']}") + body = leaf_body(leaf, parents[leaf["parent"]], manifest) + labels = issue_labels("leaf", leaf) + if existing: + desired_state = "closed" if leaf["status"] == "history" and issue and issue.get("state") == "closed" else "open" + issue = patch_issue(repo, int(existing), title=leaf["title"], body=body, labels=labels, milestone=milestone, state=desired_state) + else: + issue = create_issue(repo, title=leaf["title"], body=body, labels=labels, milestone=milestone) + journal["leaves"][leaf["key"]] = {"number": issue["number"], "id": issue["id"], "node_id": issue["node_id"], "url": issue["html_url"]} + write_json(MIGRATION_PATH, journal) + + for leaf in manifest["leaves"]: + parent_number = journal["parents"][leaf["parent"]]["number"] + child_id = journal["leaves"][leaf["key"]]["id"] + add_sub_issue(repo, parent_number, child_id) + + ensure_project_fields(manifest, journal) + sync_project(manifest, journal) + ensure_project_views(manifest, journal) + + # Historical leaves close last, after their parents, milestone, evidence and Project records exist. + for leaf in manifest["leaves"]: + if leaf["status"] != "history": + continue + number = journal["leaves"][leaf["key"]]["number"] + patch_issue(repo, number, title=leaf["title"], body=leaf_body(leaf, parents[leaf["parent"]], manifest), labels=issue_labels("leaf", leaf), milestone=milestones[leaf["milestone"]], state="closed") + + journal["phase"] = "applied" + write_json(MIGRATION_PATH, journal) + + +def compare_pr34(journal: dict[str, Any]) -> list[str]: + before = journal.get("snapshot", {}).get("pr34") + if not before: + return ["PR #34 snapshot is absent"] + now = gh( + "pr", "view", "34", "--repo", journal["repository"], "--json", + "number,title,state,isDraft,body,headRefName,headRefOid,baseRefName,labels,milestone,projectItems,url", + ) + return [] if now == before else ["PR #34 changed during the roadmap migration"] + + +def snapshotted_views(journal: dict[str, Any]) -> list[dict[str, Any]]: + data = journal.get("snapshot", {}).get("project_views", {}).get("data", {}) + views = data.get("node", {}).get("views", {}).get("nodes") + if views is not None: + return views + return data.get("viewer", {}).get("projectV2", {}).get("views", {}).get("nodes", []) + + +def reconcile(manifest: dict[str, Any], journal: dict[str, Any]) -> list[str]: + failures = validate_manifest(manifest) + if journal.get("phase") not in {"applied", "reconciled"}: + failures.append("migration is not applied") + return failures + repo = manifest["repository"] + parents = {parent["key"]: parent for parent in manifest["parents"]} + live = live_key_index(repo) + expected_keys = set(parents) | {leaf["key"] for leaf in manifest["leaves"]} + if set(live) != expected_keys: + failures.append(f"live roadmap keys differ: missing {sorted(expected_keys-set(live))}; extra {sorted(set(live)-expected_keys)}") + milestones = milestone_numbers(journal) + child_parents: dict[int, list[int]] = {} + for parent in manifest["parents"]: + parent_number = journal["parents"][parent["key"]]["number"] + for child in gh("api", f"repos/{repo}/issues/{parent_number}/sub_issues") or []: + child_parents.setdefault(child["number"], []).append(parent_number) + for parent in manifest["parents"]: + issue = live.get(parent["key"]) + if issue: + record = journal["parents"][parent["key"]] + if issue["number"] != record["number"] or issue["node_id"] != record["node_id"] or issue["html_url"] != record["url"]: + failures.append(f"{parent['key']}: live issue identity differs from the journal") + if issue.get("milestone") is not None: + failures.append(f"{parent['key']}: parent must not have a milestone") + if issue.get("state") != "open": + failures.append(f"{parent['key']}: parent must remain open") + if issue.get("title") != parent["title"] or (issue.get("body") or "") != parent_body(parent): + failures.append(f"{parent['key']}: title or generated body drifted") + if {label["name"] for label in issue.get("labels", [])} != set(issue_labels("parent")): + failures.append(f"{parent['key']}: labels drifted") + for leaf in manifest["leaves"]: + issue = live.get(leaf["key"]) + if not issue: + continue + record = journal["leaves"][leaf["key"]] + if issue["number"] != record["number"] or issue["node_id"] != record["node_id"] or issue["html_url"] != record["url"]: + failures.append(f"{leaf['key']}: live issue identity differs from the journal") + actual = issue.get("milestone", {}).get("number") if issue.get("milestone") else None + if actual != milestones.get(leaf["milestone"]): + failures.append(f"{leaf['key']}: wrong milestone") + should_close = leaf["status"] == "history" + if (issue.get("state") == "closed") != should_close: + failures.append(f"{leaf['key']}: wrong open/closed state") + expected_body = leaf_body(leaf, parents[leaf["parent"]], manifest) + if issue.get("title") != leaf["title"] or (issue.get("body") or "") != expected_body: + failures.append(f"{leaf['key']}: title or generated body drifted") + if {label["name"] for label in issue.get("labels", [])} != set(issue_labels("leaf", leaf)): + failures.append(f"{leaf['key']}: labels drifted") + intended_parent = journal["parents"][leaf["parent"]]["number"] + if child_parents.get(issue["number"], []) != [intended_parent]: + failures.append(f"{leaf['key']}: native parent link differs from #{intended_parent}") + + project_number, project_owner = project_coordinates(manifest) + project = gh("project", "item-list", project_number, "--owner", project_owner, "--limit", "1000", "--format", "json") + if project.get("totalCount") != len(project.get("items", [])): + failures.append("Project item inventory is truncated") + project_by_url: dict[str, list[dict[str, Any]]] = {} + for item in project.get("items", []): + url = item.get("content", {}).get("url") + if url: + project_by_url.setdefault(url, []).append(item) + expected_project: list[tuple[str, str, dict[str, str]]] = [] + for parent in manifest["parents"]: + record = journal["parents"][parent["key"]] + expected_project.append((parent["key"], record["url"], { + "roadmap role": "Parent", "work type": "Feature", "status": "Backlog", + })) + for leaf in manifest["leaves"]: + record = journal["leaves"][leaf["key"]] + expected_project.append((leaf["key"], record["url"], { + "roadmap role": "Imported history" if leaf["status"] == "history" else "Leaf", + "delivery slice": str(leaf["slice"]), + "proof level": leaf["proof"].title(), + "work type": "Feature", + "status": "Done" if leaf["status"] == "history" else "Backlog", + })) + for key, url, expected in expected_project: + items = project_by_url.get(url, []) + if len(items) != 1: + failures.append(f"{key}: expected exactly one Project item, found {len(items)}") + continue + item = items[0] + for field, value in expected.items(): + if str(item.get(field, "")) != value: + failures.append(f"{key}: Project {field} is {item.get(field)!r}, expected {value!r}") + view_query = f'query {{ node(id:"{journal["project"]["node_id"]}") {{ ... on ProjectV2 {{ views(first:50) {{ nodes {{ id name layout filter }} }} }} }} }}' + view_data = gh("api", "graphql", "-f", f"query={view_query}") + view_nodes = view_data["data"]["node"]["views"]["nodes"] + views_by_name = {node["name"]: node for node in view_nodes} + for expected in manifest["project"].get("views", []): + actual = views_by_name.get(expected["name"]) + if not actual: + failures.append(f"Project view {expected['name']!r} is missing") + elif actual.get("layout") != expected["layout"] or actual.get("filter") != expected["filter"]: + failures.append(f"Project view {expected['name']!r} differs from the manifest") + snap_views = snapshotted_views(journal) + live_views_by_id = {node["id"]: node for node in view_nodes} + for expected in snap_views: + if live_views_by_id.get(expected["id"]) != expected: + failures.append(f"pre-existing Project view {expected['name']!r} changed") + current_fields = gh("project", "field-list", project_number, "--owner", project_owner, "--format", "json")["fields"] + live_fields_by_id = {field["id"]: field for field in current_fields} + for expected in journal.get("snapshot", {}).get("project_fields", {}).get("fields", []): + if live_fields_by_id.get(expected["id"]) != expected: + failures.append(f"pre-existing Project field {expected['name']!r} changed") + failures += compare_pr34(journal) + if not failures: + journal["phase"] = "reconciled" + write_json(MIGRATION_PATH, journal) + return failures + + +def render_handoff(manifest: dict[str, Any], journal: dict[str, Any]) -> str: + number = journal.get("leaves", {}).get("difficulty.models-live-state", {}).get("number") + if not number: + raise RoadmapError("slice-7 issue has not been created") + return "\n".join( + [ + "PR #34 remains the active slice-7 build. Before submission:", + f"1. Replace the stale `Part of #17` with `Closes #{number}` and `Part of #18`.", + "2. Do not close any slice-8 integration leaf; enemy-dependent play criteria remain open.", + "3. Reconcile onto current main so the roadmap closure check runs.", + "4. After the operator tests the exact head commit, record the playtest marker and apply `playtest-approved`.", + f"5. Verify GitHub's computed closing-issue list contains only #{number}.", + ] + ) + + +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("--live", action="store_true", help="include a read-only live mutation diff") + args = parser.parse_args(argv) + manifest = read_json(MANIFEST_PATH) + journal = read_json(MIGRATION_PATH) if MIGRATION_PATH.exists() else migration_template(manifest) + if args.command == "validate": + failures = validate_manifest(manifest) + if failures: + print("\n".join(f"- {failure}" for failure in failures), file=sys.stderr) + return 1 + print(f"roadmap manifest valid: {len(manifest['parents'])} parents, {len(manifest['leaves'])} leaves") + return 0 + if args.command == "snapshot": + snapshot(manifest, journal) + print("live roadmap, Project #4, milestones, and PR #34 snapshotted") + return 0 + if args.command == "plan": + failures = validate_manifest(manifest) + if failures: + print("\n".join(f"- {failure}" for failure in failures), file=sys.stderr) + return 1 + value = {"parents": len(manifest["parents"]), "leaves": len(manifest["leaves"]), "history": sum(leaf["status"] == "history" for leaf in manifest["leaves"]), "provisional": sum(leaf["status"] == "provisional" for leaf in manifest["leaves"])} + if args.live: + value["live_diff"] = live_plan(manifest) + print(json.dumps(value, indent=2)) + return 0 + if args.command == "apply": + apply(manifest, journal) + print("roadmap desired state applied; run reconcile") + return 0 + if args.command == "reconcile": + failures = reconcile(manifest, journal) + if failures: + print("\n".join(f"- {failure}" for failure in failures), file=sys.stderr) + return 1 + print("roadmap live state matches the committed manifest; PR #34 is unchanged") + return 0 + if args.command == "handoff": + print(render_handoff(manifest, journal)) + return 0 + raise AssertionError(args.command) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except RoadmapError as exc: + print(f"roadmap error: {exc}", file=sys.stderr) + raise SystemExit(2) from exc