Files
arah2/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md
T

262 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 06-turn-by-turn-guidance-live-driving
plan: 04
type: execute
wave: 2
depends_on: [06-01, 06-03]
files_modified:
- routing/scripts/build-road-graph.mjs
- routing/scripts/upload-road-graph.mjs
- routing/scripts/measure-route-cpu.mjs
- routing/src/http-source.ts
- routing/src/index.ts
- routing/data/.gitignore
- bot/src/routing/worker-router.ts
- bot/src/webhook.ts
- bot/src/config.ts
- bot/wrangler.jsonc
- bot/test/route-privacy.test.mjs
- bot/test/webhook.test.mjs
- docs/ROAD-GRAPH.md
autonomous: true
requirements: [GUID-02]
user_setup:
- service: cloudflare-r2
why: "D-01: the KL road graph hosts in R2 (first repo binding); forks upload their own"
env_vars: []
dashboard_config:
- task: "R2 bucket araharah-graphs must exist before upload + real routing"
location: "`npx wrangler r2 bucket create araharah-graphs` (executor attempts it; on wrangler-auth failure this becomes the checkpoint — Cloudflare dashboard → R2 → Create bucket works too)"
must_haves:
truths:
- "A maintainer can rebuild the KL road graph from Overpass with one committed script — $0, fork-friendly (D-01)"
- "The real graph binary lands inside D-01's 30–80 MB envelope with the stats gate printing its measurement"
- "/api/route answers an arbitrary current position with a real A* route + named steps — engine A is the guidance engine (D-03)"
- "/api/route without a valid Bearer claim is 401 — the routing oracle is closed (T-06-01)"
- "A missing R2 graph degrades to honest null → the client's router-pending state (D-03), never an error"
- "A* CPU on the real graph is measured and printed, p95 inside the 10 ms free-plan budget (A1)"
artifacts:
- path: "routing/scripts/build-road-graph.mjs"
provides: "Overpass extract → CSR binary (maintainer tool, collect-then-write, stats gate)"
- path: "routing/src/http-source.ts"
provides: "createHttpRoutingSource — web's RoutingSource backend #2"
exports: ["createHttpRoutingSource"]
- path: "bot/src/routing/worker-router.ts"
provides: "R2 graph load (isolate-memoized) + snap + A* + steps → RouteResult"
- path: "bot/wrangler.jsonc"
provides: "r2_buckets binding ROAD_GRAPH (still exactly ONE kv_namespaces — GE5)"
- path: "docs/ROAD-GRAPH.md"
provides: "fork setup: bucket, upload, licensing, envelope law"
key_links:
- from: "bot/src/webhook.ts"
to: "bot/src/routing/worker-router.ts"
via: "/api/route arm (Bearer-claim gated like /api/location)"
pattern: "handleRoute|/api/route"
- from: "bot/src/routing/worker-router.ts"
to: "env.ROAD_GRAPH"
via: "module-scope promise memo — 1 R2 GET per cold isolate"
pattern: "ROAD_GRAPH\\.get"
- from: "routing/src/http-source.ts"
to: "RoutingSource"
via: "implements the Phase 5 seam — network fail → null (fail-honest)"
pattern: "RoutingSource"
---
<objective>
Engine A's server surface (D-01): the real KL graph build tool (Overpass → CSR binary), the R2 hosting + upload path with fork docs, the isolate-memoized Worker router behind a Bearer-gated `/api/route`, the client-side `createHttpRoutingSource` RoutingSource backend #2, RP1/RP2 re-pinned in the same change, and the A*-CPU measurement gate printed and enforced.
Purpose: GUID-02's rerouting becomes REAL — arbitrary current positions route for real, $0, no VPS. This is the phase's top technical risk (A1: 10 ms free-plan CPU), so the measurement gate is a deliverable, not a hope.
Output: build/upload/measure scripts + http-source + worker-router + `/api/route` + re-pinned gates + docs, real graph built and measured.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
<interfaces>
<!-- Engine A pure core exists from plan 06-01 (routing/src, ALL exported from index.ts): -->
decodeGraph(buffer: ArrayBuffer): RoadGraph | null
snapToGraph(graph: RoadGraph, point: RoutePoint): { node: number; distanceM: number } | null
routeAstar(graph: RoadGraph, originNode: number, goalNode: number): { nodes: number[]; seconds: number; distanceM: number } | null
deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[]
// RoadGraph carries bbox + maxSpeedKmh + nameAt(id)
<!-- PINNED CSR BINARY LAYOUT (from 06-01 — the build script MUST produce exactly this):
header: magic u32 0x41524731 | version u32 1 | nodeCount u32 | edgeCount u32 | nameCount u32 |
namePoolLen u32 | bbox 4×f64 | maxSpeedKmh f32
nodes: f64 lat[] ++ f64 lng[] · offsets: u32 edgeStart[n+1] · edges: u32 target[] ++ u8 flags[]
(bit0 oneway-reversed) · weights: f32 seconds[] · edgeName: u32 nameId[] (0xffffffff unnamed)
names: u32 nameOffset[cnt+1] ++ u8 pool[] -->
<!-- The Worker route arm copies webhook.ts's Bearer-claim posture (plan 06-03 task 2). -->
<!-- Envelope service exists from plan 06-03; bot/package.json already has "@araharah/routing": "*". -->
<!-- RP gate re-pin (PATTERNS): RP1 shrinks to "no UNAUTHENTICATED / proxy-shaped routing"
(the handler MUST derive identity from the Bearer claim; no forwarding to external
routing hosts); RP2 now ALLOWS @araharah/routing in bot/src (subpath-with-extension
imports only) and bans any OTHER new workspace dependency beyond
@araharah/search + @araharah/routing. GE5 stays: exactly ONE kv_namespaces. -->
<!-- graph binary is a BUILD ARTIFACT, not committed: routing/data/.gitignore ignores *.bin
(forks build + upload their own; committed = scripts + docs + mini-graph tests). -->
</interfaces>
**Isolate memoization is the cache that works** (RESEARCH): `caches.default` is useless here — Bearer requests auto-bypass Workers Cache and cron is never cached. Module-scope promise memo, 1 R2 GET per cold isolate.
</context>
<tasks>
<task type="auto">
<name>task 1: Road-graph build tool (Overpass → CSR binary) + real KL graph</name>
<files>routing/scripts/build-road-graph.mjs, routing/data/.gitignore</files>
<read_first>
- routing/scripts/fetch-route-fixtures.mjs lines 1–64, 110–142, 185–248 (THE maintainer-tool pattern to copy: #!/usr/bin/env node, dense MAINTAINER-TIME header + Usage + Exit codes, named constants up top, fail() helper, pre-flight validation, collect-then-write, post-write stats + "next:" hint)
- routing/test/fixtures/build-mini-graph.mjs + routing/src/graph.ts (the exact encodeGraph contract + pinned CSR layout this script must produce)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md A7 (Overpass response size/timeout) + Pitfall 1 (road-class filter) + Open Q 4 (bbox derivation)
- search/src/gazetteer.ts getEntries (bbox extremes source)
</read_first>
<behavior>
- `node routing/scripts/build-road-graph.mjs` fetches the Overpass extract for the KL bbox, encodes the pinned CSR binary, writes routing/data/kl-roadgraph.v1.bin, prints node/edge counts + binary size + bbox, exits 0
- binary size > 80 MB (D-01 envelope) → exit 1 with the stats printed (the stats gate)
- ways with highway in the drivable class set only: motorway, trunk, primary, secondary, tertiary, residential, unclassified, living_street (footway/cycleway/service/path excluded — Pitfall 1)
- oneway=yes → flags bit0 on the reverse direction entry; maxspeed tags → edge seconds; class defaults: motorway 100, trunk 80, primary 60, secondary 50, tertiary 40, residential 30, unclassified 30, living_street 20 km/h
- way name tags → the name pool (unnamed ways → UNNAMED_NAME_ID)
- `--check <file>` mode: decode with decodeGraph, print stats, exit 0/1 — no network
- a second run with the committed file present and `--check` green needs no refetch (artifact, not input)
</behavior>
<action>
Write `routing/scripts/build-road-graph.mjs` in the fetch-route-fixtures.mjs shell: header naming the D-01 boundary ("A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, web/src, or routing/src imports it"), named constants (`OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"`, `REQUEST_TIMEOUT_MS = 300_000`, `USER_AGENT`, `MAX_BINARY_BYTES = 80 * 1024 * 1024`, `DRIVABLE_CLASSES` + `CLASS_SPEED_KMH` tables, `BBOX_MARGIN_DEG = 0.05`), bbox COMPUTED at run start from `@araharah/search/src/gazetteer.ts` getEntries() + `routing/test/fixtures/golden-pairs.json` pair endpoints ± margin (printed; Open Q 4's mechanical pinning), the Overpass query `way[highway~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|living_street)$"](bbox); out geom;`, response → nodes deduplicated by rounded coord → edges with oneway/flags/speed/name → `encodeGraph` (import `../src/graph.ts`) → collect-then-write (committed file untouched on any failure, exit 1) → stats gate (size ≤ MAX_BINARY_BYTES else exit 1) → post-write summary + `next:` hint naming the upload command. `--check` mode decodes and re-prints stats. Also create `routing/data/.gitignore` containing `*.bin` (the binary is an ODbL-derived build artifact; committed artifacts are the script, docs, and the tiny test mini-graph).
Then RUN it (network fetch, minutes) and confirm the stats gate passes; record the printed counts in the plan summary.
</action>
<verify>
<automated>node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin && node -e "const s=require('fs').statSync('routing/data/kl-roadgraph.v1.bin'); if(s.size>80*1024*1024) process.exit(1); console.log('graph bytes:', s.size)"</automated>
</verify>
<acceptance_criteria>
- routing/scripts/build-road-graph.mjs exists with the maintainer-tool header, named constants (no inline magic), collect-then-write, and the 80 MB stats gate
- routing/data/kl-roadgraph.v1.bin exists, > 1 MB, ≤ 80 MB, and passes `--check` (decodeGraph green)
- routing/data/.gitignore contains `*.bin` and `git status --porcelain routing/data` shows no .bin staged
- printed stats include nodeCount, edgeCount, binary size, bbox (executor records them in the SUMMARY)
- grep `footway|cycleway|service|path` → absent from DRIVABLE_CLASSES (Pitfall 1)
</acceptance_criteria>
<done>The real KL graph builds reproducibly from Overpass inside the D-01 envelope with the class filter and stats gate standing guard</done>
</task>
<task type="auto" tdd="true">
<name>task 2: http-source (backend #2) + worker-router + Bearer-gated /api/route + RP re-pins</name>
<files>routing/src/http-source.ts, routing/src/index.ts, bot/src/routing/worker-router.ts, bot/src/webhook.ts, bot/src/config.ts, bot/wrangler.jsonc, bot/test/route-privacy.test.mjs, bot/test/webhook.test.mjs</files>
<read_first>
- routing/src/fixture-source.ts lines 35–95, 134–189 (the backend-factory skeleton: slug guard first, lazy memoize, defensive validation, D-03 honest nulls — copy the posture)
- bot/src/webhook.ts (the /api/location arm to mirror: Bearer claim → 401, body cap, category logs; route-dispatch skeleton)
- bot/src/config.ts (the typed Env contract — one line per field with the trailing law comment)
- bot/wrangler.jsonc (JSONC with law-bearing comments; GE5 asserts exactly ONE kv_namespaces entry)
- bot/test/route-privacy.test.mjs lines 1–24 (RP1/RP2 as they stand — the doc-comments name Phase 6 as the re-pinning owner)
- search/src/gazetteer.ts getEntryById(id) (destination resolution server-side)
- bot/test/webhook.test.mjs (offline pattern; extend for the /api/route block with an R2 fake: `{ get: async () => ({ arrayBuffer: async () => miniGraphBuffer }) }`)
</read_first>
<behavior>
- createHttpRoutingSource: POST {origin, destinationId} to endpoint with Bearer token from the injected getter; non-200, malformed body, or thrown fetch → null (fail-honest — T-05-04's .catch(()=>null) law, network edition); a valid RouteResult-shaped response passes defensive validation (destinationId match, options 1..3, finite numbers) → RouteResult
- worker-router handleRoute: no/invalid Bearer → 401 [route] rejected: unauthorized; body > 4096 → 413; non-numeric/out-of-bbox origin lat/lng → 400 [route] rejected: malformed (V5 clamp-to-bbox validation); unknown destinationId → 200 null-ish? NO — unknown destination or snap miss or absent graph → Response JSON `null` with 200 (the null contract carries over HTTP: honest router-pending, never a 500)
- happy path through the R2-faked mini-graph: real origin/dest resolve → 200 RouteResult with backend "engine-a", one option (NO alternatives — RESEARCH anti-pattern: alternatives never during the guidance path), steps carrying kind/name/distance/polyline6
- graph object absent (R2 fake returns null) → 200 null (D-03 designed degradation)
- RP1 re-pinned: a /api/route handler WITHOUT the Bearer-claim derivation fails the gate; RP2 re-pinned: @araharah/routing subpath imports allowed in bot/src; any other new workspace dep fails
- GE5 still green: exactly ONE kv_namespaces entry (r2_buckets is a different binding class)
</behavior>
<action>
1. `routing/src/http-source.ts`: `createHttpRoutingSource(opts: { endpoint: string; bearer: () => string | null; fetchImpl?: typeof fetch }): RoutingSource` — name "engine-a"; POST JSON {origin, destinationId}; Authorization only when bearer() returns non-null; response.json() → defensive shape check (copy fixture-source's isUsablePair style: every field type-checked, non-finite refused, try/catch → null); export from `routing/src/index.ts` alongside a header-note update (backend #2 live, ladder: fixture → engine-a → engine B documented post-v1).
2. `bot/src/routing/worker-router.ts`: module-scope `let graphP: Promise<RoadGraph | null> | null = null;` + `loadGraph(env)` — `env.ROAD_GRAPH.get(GRAPH_KEY)` with `GRAPH_KEY = "kl-roadgraph.v1.bin"`, `.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()))`, catch → null (a corrupt binary degrades honest); memo never reset (per-isolate). `handleRoute(req, env): Promise<Response>`: Bearer claim (extract + getClaimByToken — reuse webhook.ts's exact helper posture), body-cap 4096 (`MAX_ROUTE_BODY_CHARS`), origin {lat,lng} numeric + inside graph.bbox (400 otherwise), `getEntryById(destinationId)` (import `@araharah/search/src/gazetteer.ts`) → null miss → 200 null; snapToGraph origin + destination → null miss → 200 null; routeAstar → null → 200 null; else build RouteResult `{ destinationId, origin, options: [{ geometry: { points: nodePath points, cumulativeDistanceM: deriveCumulativeDistanceM(points) }, summary: { distanceM, durationS }, steps: deriveSteps(...) }], backend: "engine-a" }` → 200 JSON.
3. `bot/src/webhook.ts`: add the `/api/route` arm delegating to handleRoute (405 guard for non-POST). `bot/src/config.ts`: add `ROAD_GRAPH: R2Bucket; // D-01 graph binary — R2 binding, first in the repo (never a KV namespace — GE5)`. `bot/wrangler.jsonc`: add `"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }]` with a law comment; kv_namespaces stays exactly one entry.
4. RE-PIN `bot/test/route-privacy.test.mjs` in the same change: RP1 → the /api/route arm must reference the Bearer-claim derivation (structural: the handler path calls the claim helper; a bare unauthenticated fetch-forward shape fails); no external routing hostnames (`grep` for non-Telegram/non-Cloudflare API hosts). RP2 → allow-list `@araharah/search` + `@araharah/routing` in bot/src imports; any other `@araharah/*` fails. Extend `bot/test/webhook.test.mjs` with the /api/route behavior block (R2 mini-graph fake + mini graph binary loaded from routing/test/fixtures/mini-graph.bin via fs).
</action>
<verify>
<automated>npm test -w bot && npm run routing:test && npm run bot:typecheck && npm run routing:typecheck</automated>
</verify>
<acceptance_criteria>
- routing/src/http-source.ts exports createHttpRoutingSource (name "engine-a"); index.ts re-exports it
- bot/src/routing/worker-router.ts: module-scope graphP memo (grep `graphP ??=` or equivalent), GRAPH_KEY = "kl-roadgraph.v1.bin", null on absent/corrupt graph
- webhook.test.mjs: 401 unauth, 413 cap, 400 malformed origin, 200-null on unknown dest / snap miss / absent graph, 200 RouteResult happy path via R2 fake, backend === "engine-a", options.length === 1
- route-privacy.test.mjs: RP1 (auth-bound routing) + RP2 (allow-list search+routing only) re-pinned and green; GE5 green (one kv_namespaces)
- bot/src/config.ts carries the ROAD_GRAPH: R2Bucket line with a law comment
- both typechecks clean
</acceptance_criteria>
<done>The routing oracle is Bearer-gated and live in dev (R2 faked in tests), the client seam has its backend #2, honest-null degradation is proven at every miss class, and the negative-space gates were re-pinned by the change that invalidated them</done>
</task>
<task type="auto">
<name>task 3: R2 bucket + upload script + fork docs + A*-CPU measurement gate</name>
<files>routing/scripts/upload-road-graph.mjs, routing/scripts/measure-route-cpu.mjs, docs/ROAD-GRAPH.md</files>
<read_first>
- routing/scripts/build-road-graph.mjs (task 1 — the "next:" hint names this script; keep the maintainer-tool shell identical)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pitfall 1 (CPU measurement mandate), Assumption A1 (mitigation ladder), Environment Availability (R2 bucket "not yet created")
- web/test/build-artifact.test.mjs BA9 (the "budget gates print their measurement" precedent this gate follows)
</read_first>
<behavior>
- upload-road-graph.mjs: verifies routing/data/kl-roadgraph.v1.bin exists (> 1 MB), shells `npx wrangler r2 object put araharah-graphs/kl-roadgraph.v1.bin --file routing/data/kl-roadgraph.v1.bin --remote`, prints the key + next steps; missing file → exit 1 with the build hint
- measure-route-cpu.mjs: loads the real binary, runs 25 A* routes between sampled node pairs spread across the bbox, prints per-route CPU ms + p95, exits 1 if p95 > 8 ms (20% margin under the 10 ms free-plan budget — A1); absent binary → exit 1 with the build hint
- R2 bucket `araharah-graphs` exists on the account (wrangler CLI; on auth failure the executor surfaces a human checkpoint with the exact command — the bucket create is the one step a keyless machine cannot do)
- docs/ROAD-GRAPH.md documents: build → check → upload → deploy sequence, the fork contract (your own bucket, your own upload), ODbL attribution for the OSM-derived graph, the 30–80 MB envelope law, and the p95 CPU gate
</behavior>
<action>
1. Attempt `npx wrangler r2 bucket create araharah-graphs`. If wrangler reports auth/permission failure, create a `checkpoint:human-action` note in the run log with the exact command + dashboard path (Cloudflare dashboard → R2 → Create bucket) and continue with dev-only verification — flag it in the SUMMARY as the phase's user_setup item. If it succeeds, proceed.
2. Write `routing/scripts/upload-road-graph.mjs` (fetch-route-fixtures.mjs shell) and RUN it (when the bucket exists) — verify with `npx wrangler r2 object get araharah-graphs/kl-roadgraph.v1.bin --file /tmp/opencode/graph-roundtrip.bin --remote` + `--check` round-trip (or `r2 object info`).
3. Write and RUN `routing/scripts/measure-route-cpu.mjs` (Node: `process.hrtime.bigint()` around routeAstar; pairs sampled from graph nodes across the bbox quadrants + the fixture-corpus endpoints snapped via snapToGraph). Record the printed p95 in the SUMMARY. If p95 > 8 ms, apply A1's mitigation ladder IN ORDER and re-measure: (a) drop tertiary/residential from DRIVABLE_CLASSES and rebuild (tighter graph), (b) bidirectional A* variant, (c) document Workers Paid as an explicit fork decision (breaks $0 — requires user sign-off; stop and surface it).
4. Write `docs/ROAD-GRAPH.md` per the behavior list (concise — one page).
</action>
<verify>
<automated>node routing/scripts/measure-route-cpu.mjs 2>&1 | tail -3 && node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin</automated>
</verify>
<acceptance_criteria>
- measure-route-cpu.mjs prints "p95" + a millisecond number and exits 0 (p95 ≤ 8 ms), or the SUMMARY records which A1 mitigation was applied and the new p95
- upload-road-graph.mjs exists with the maintainer shell; if the bucket exists the round-trip get + --check proves the upload
- docs/ROAD-GRAPH.md exists covering: bucket create, upload command, ODbL attribution, envelope law, CPU gate
- the R2 bucket state (created / needs-user) is recorded in the SUMMARY
</acceptance_criteria>
<done>Graph hosting is fork-ready ($0, scripted, documented), the binary is measured against the free-plan CPU ceiling with a printed verdict, and A1's risk is retired or explicitly escalated</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Internet → /api/route | Untrusted callers would love a free routing oracle; identity via Bearer claim only |
| Request body → A* | Origin coordinates are untrusted input reaching a compute-heavy path |
| R2 object → decodeGraph | Fork-uploaded binary (covered by 06-01's header validation — decodeGraph null on any mismatch) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06-01 | DoS / Elevation of Privilege | /api/route | mitigate | Bearer-gated exactly like /api/location (401 class tested); the 10 ms CPU cap is the natural per-request rate limiter; body cap 4096 (413). RP1 re-pinned to fail any unauthenticated/proxy-shaped routing arm. |
| T-06-01b | Denial of Service (compute) | handleRoute | mitigate | V5: origin lat/lng type+range-validated against graph.bbox BEFORE snap/A* (400 class); unknown destinationId short-circuits before any graph work; alternatives generation prohibited on this path (single best route — RESEARCH anti-pattern). |
| T-06-03 | Tampering | loadGraph → decodeGraph | mitigate | decodeGraph's magic+version+size check (06-01) → null → designed router-pending (D-03); upload script is maintainer-surface only; corrupt binary in R2 degrades honest, never crashes the Worker. |
| T-06-04 | Information Disclosure | handleRoute logs | mitigate | Category-only logging (`[route] rejected: <category>`) — never coordinates, ids, or payloads (the webhook law extended). |
| — | Information Disclosure (fork) | docs/ROAD-GRAPH.md | accept | The graph is public OSM-derived data (ODbL-attributed) — hosting it is not a privacy surface; documented licensing keeps forks honest. |
</threat_model>
<verification>
- `npm run routing:test && npm test -w bot` green; both typechecks clean
- Real graph: `--check` green, size inside the 30–80 MB envelope, stats printed
- CPU gate: p95 ≤ 8 ms printed (A1 retired) or escalation recorded
- RP1/RP2 re-pinned green; GE5 (one KV namespace) still green
</verification>
<success_criteria>
Engine A routes real positions for real: build → upload → Bearer-gated `/api/route` → client `createHttpRoutingSource` — all $0, fork-friendly, honestly degrading when the graph is absent, and measured against the free-plan CPU ceiling. GUID-02's "recalculates from the current position" now has an engine behind it.
</success_criteria>
<output>
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md`
</output>