From 7fe8c6aa65a7aa0efb96b008012908d78d980d92 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 07:46:13 +0800 Subject: [PATCH] docs(06): create phase plans (8 plans, 4 waves) --- .planning/ROADMAP.md | 57 +++- .../06-01-PLAN.md | 252 ++++++++++++++++ .../06-02-PLAN.md | 246 +++++++++++++++ .../06-03-PLAN.md | 205 +++++++++++++ .../06-04-PLAN.md | 261 ++++++++++++++++ .../06-05-PLAN.md | 281 ++++++++++++++++++ .../06-06-PLAN.md | 253 ++++++++++++++++ .../06-07-PLAN.md | 269 +++++++++++++++++ .../06-08-PLAN.md | 172 +++++++++++ 9 files changed, 1986 insertions(+), 10 deletions(-) create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-01-PLAN.md create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-02-PLAN.md create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-03-PLAN.md create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-06-PLAN.md create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-07-PLAN.md create mode 100644 .planning/phases/06-turn-by-turn-guidance-live-driving/06-08-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1db8c2e..f8c2fef 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -24,8 +24,8 @@ Decimal phases appear between their surrounding integers in numeric order. - [ ] **Phase 1: Design Tokens + Rams Foundation** - Distill the Telemetry Rams system into a single token package and Rams-styled map basemap, with a mockup-vs-implementation approval artifact — before any screen is built - [ ] **Phase 2: Bot Skeleton + initData Security** - grammY bot on one Cloudflare Worker: /start, command menu, /privacy, and HMAC-validated identity with TTL-only KV storage - [x] **Phase 3: Mini App Shell + Geolocation** - Telegram WebApp shell that boots, authenticates, and reads a reliable live location across the Android/iOS/Desktop client matrix (completed 2026-10-23) -- [ ] **Phase 4: Destination Search + KL Gazetteer** - Free-text KL search that resolves landmarks despite Malay abbreviations and typos, venue cards, and Mini App launch with destination preloaded -- [ ] **Phase 5: Routing + Route Display** - Valhalla-backed route calculation shown as a Rams route line with quiet alternative cards; the full search→route→render loop lands +- [x] **Phase 4: Destination Search + KL Gazetteer** - Free-text KL search that resolves landmarks despite Malay abbreviations and typos, venue cards, and Mini App launch with destination preloaded (completed 2026-10-23) +- [x] **Phase 5: Routing + Route Display** - Valhalla-backed route calculation shown as a Rams route line with quiet alternative cards; the full search→route→render loop lands (completed 2026-10-23) - [ ] **Phase 6: Turn-by-Turn Guidance (Live Driving)** - Client guidance state machine: maneuver banner, missed-turn auto-recalculation, honest ETA, haptics, seamless resume, and backgrounded chat turn-cards - [ ] **Phase 7: Hardening + Private Launch** - Device-matrix QA, graceful failure states, privacy-by-construction audit, and golden-route regression before real drivers @@ -128,14 +128,14 @@ Notes: Plans: **Wave 1** *(the shared foundation — a fourth npm workspace, `search/`)*: -- [ ] 04-01-PLAN.md — `@araharah/search` workspace: the `SearchSource`/`GazetteerEntry` contract, the JUPEM-derived abbreviation table as editable DATA, the symmetric normalizer, the hand-rolled OSA distance, the disjoint-tier scorer, the curated KL gazetteer with locals' aliases, the lazy three-environment loader, plus the root GPL-3.0 `LICENSE` and the ODbL-1.0 data notice (D-04/05/06/07/08/09/15) +- [x] 04-01-PLAN.md — `@araharah/search` workspace: the `SearchSource`/`GazetteerEntry` contract, the JUPEM-derived abbreviation table as editable DATA, the symmetric normalizer, the hand-rolled OSA distance, the disjoint-tier scorer, the curated KL gazetteer with locals' aliases, the lazy three-environment loader, plus the root GPL-3.0 `LICENSE` and the ODbL-1.0 data notice (D-04/05/06/07/08/09/15) **Wave 2** *(parallel — the measurement instrument and the chat surface)*: -- [ ] 04-02-PLAN.md — the pinned golden corpus (D-13), the D-14 bake-off runner + the in-suite Nyquist gate, the Overpass top-up tool (D-06), the JSON→SQLite converter (D-05), and `docs/SEARCH-QUALITY.md` recording the measured result and the escalation lever (SRCH-01, SRCH-02) -- [ ] 04-03-PLAN.md — the `/cari` command (D-01), the single-message `sendVenue` card with 2-4 inline alternates (D-02/D-10), the honest out-of-area refusal, the OPEN deep link carrying a plain gazetteer id with NO crypto (D-11), `MINI_APP_URL` config, and the four `commands.test.mjs` landmines defused in the same change (SRCH-03, ONBD-02) +- [x] 04-02-PLAN.md — the pinned golden corpus (D-13), the D-14 bake-off runner + the in-suite Nyquist gate, the Overpass top-up tool (D-06), the JSON→SQLite converter (D-05), and `docs/SEARCH-QUALITY.md` recording the measured result and the escalation lever (SRCH-01, SRCH-02) +- [x] 04-03-PLAN.md — the `/cari` command (D-01), the single-message `sendVenue` card with 2-4 inline alternates (D-02/D-10), the honest out-of-area refusal, the OPEN deep link carrying a plain gazetteer id with NO crypto (D-11), `MINI_APP_URL` config, and the four `commands.test.mjs` landmines defused in the same change (SRCH-03, ONBD-02) **Wave 3** *(the webview hand-off + the blocking device gate)*: -- [ ] 04-04-PLAN.md — the two-carrier destination reader (`?dest=` primary, `initData.start_param` secondary) with one shared validation, the memory-only resolver, the honest "destination unavailable" state, ONBD-03's minimal `addToHomeScreen`/`checkHomeScreenStatus` hooks behind a Bot API 8.0 guard, the build-artifact drift gate, the destination-preload capture, and the blocking Android/iOS/Desktop device gate (ONBD-02, ONBD-03) +- [x] 04-04-PLAN.md — the two-carrier destination reader (`?dest=` primary, `initData.start_param` secondary) with one shared validation, the memory-only resolver, the honest "destination unavailable" state, ONBD-03's minimal `addToHomeScreen`/`checkHomeScreenStatus` hooks behind a Bot API 8.0 guard, the build-artifact drift gate, the destination-preload capture, and the blocking Android/iOS/Desktop device gate (ONBD-02, ONBD-03) **Cross-cutting constraints** *(must_haves.truths asserted by 2+ plans)*: - The deep-link carrier correction (plain id via `?dest=` URL param read from `location.search`, `start_param` secondary, NO crypto) — plans 03, 04 @@ -156,12 +156,31 @@ Notes: **Success Criteria** (what must be TRUE): 1. A user sees their calculated route drawn on the Rams-styled map — grayscale basemap grounding with the accent route line — from their current location to the chosen destination (RTNG-01) 2. The user can choose among up to 3 alternatives presented as quiet Rams cards (time, distance, one-line difference) and the selection redraws (RTNG-02) - 3. The golden-route corpus (10–20 real KL origin/destination pairs) routes cleanly through self-hosted Valhalla (Malaysia extract) behind the Worker proxy — no dead ends, no geometry failures, latency and Worker subrequest budgets holding with instrumentation visible -**Plans**: TBD + 3. The golden-route corpus (10–20 real KL pairs) routes cleanly through the fixture RoutingSource backend with bundle-size and type gates holding; the RoutingSource interface carries the documented backend ladder (fixtures → client-side WASM router → Valhalla-on-VPS) and the "router pending" state is deliberate and designed *(SC3 amended 2026-10-24 per 05-CONTEXT.md — the fixture-backend pivot; RTNG-01's "calculated route" is satisfied by fixture calculation, the arbitrary-origin router is a documented deferral)* +**Plans**: 5 plans + +Plans: + +**Wave 1** *(parallel foundations — the fifth npm workspace + the frozen Phase 6 contract)*: +- [x] 05-01-PLAN.md — the `routing/` workspace (`@araharah/routing`): the `RoutingSource` contract with the documented backend ladder (D-01/D-04), the route model's day-one step-geometry + snapped-progress fields (D-07), the polyline6 codec (external-vector-tested), the geo math, the fixture backend with the 200 m matching law + honest-null pending semantics (D-03), the committed empty fixture schema with ODbL header, and the three-environment loader proof (RTNG-01) +- [x] 05-03-PLAN.md — the `guidance_sessions` envelope as a TYPES-ONLY frozen contract (D-06/D-07): envelope + inline route snapshot types, the 7200 s TTL constant, the `guidance:` key scheme, and seven GE gates proving no KV namespace, no runtime writes, no premature consumer (Phase 6 builds on the frozen shape) + +**Wave 2** *(blocked on Wave 1 completion — the data product and the visible surface, in parallel)*: +- [x] 05-02-PLAN.md — the golden-pair corpus: 15 pinned public KL pairs (D-02), the maintainer fetch tool against the public OSRM demo (D-12 boundary in its header), the committed populated fixture dataset, and the RT1–RT10 offline tolerance gate as CLI + in-suite Nyquist gate — public-origin proof, geometry↔summary consistency, codec round-trip, size budget, alternatives floor (RTNG-01, RTNG-02) +- [x] 05-04-PLAN.md — the web route surface per the approved 05-UI-SPEC: the four-layer orange casing stack added once after building-3d (D-09), origin ring + destination pin, the bottom route card + alternates chips (D-08), the designed router-pending card (D-03), fit-bounds camera law, the auto-consult wiring, the two-state BackButton walk (D-10), and the RS1–RS10 gates (RTNG-01, RTNG-02) + +**Wave 3** *(blocked on Wave 2 completion — the phase's closing gates)*: +- [x] 05-05-PLAN.md — the verification close: BA9 bundle-budget gate (≤ 200 KB gz, measured + printed) + BA10 fixture-parity over the production build (D-11), the five `?fixpair=`-driven route capture legs + the DSGN-02 route-gallery artifact, and the Worker-side no-route-proxy gates (D-05) (RTNG-01, RTNG-02) + +**Cross-cutting constraints** *(must_haves.truths asserted by 2+ plans)*: +- Honest-null pending semantics (an unknown destination / uncovered origin resolves to `null` and renders the designed pending card — never a blank map, never a fake route, D-03) — plans 01, 04, 05 +- No logging / no storage / no runtime network in the routing path (D-12/D-15 analogs; the Worker gains no `/api/route`) — plans 01 (module zero-laws), 03 (types-only envelope), 04 (state-token signals), 05 (RP gates) +- The capture signal is a STATE TOKEN, never coordinates (`ARAHARAH-ROUTE state=drawn|pending`, paired with DOM text checks) — plans 04 (RS8 source law), 05 (capture legs) +- Fixtures are committed PUBLIC data — every pinned origin provably equals a gazetteer landmark coordinate; the bundle carries every pairId; the harness knob accepts pairIds only — plans 02 (RT2b), 05 (BA10, ?fixpair) Notes: - GUID-05/06 upstream design obligations land here deliberately: the route model carries step geometry + snapped-progress support from day one, and the ephemeral `guidance_sessions` KV envelope (id, chat ref, route snapshot ref, ~2h TTL) is designed here so the backgrounded turn-card service in Phase 6 has its session contract already defined. -- Standard patterns (no research phase needed): documented preprocess pipeline for Valhalla + the graph/tiles build; no exotic APIs. +- Standard patterns (no research phase needed — light pass only): fixture precompute against the public OSRM demo server at BUILD time (maintainer machine, fixed public coordinates) + committed encoded-polyline storage; no exotic APIs. *(The "documented preprocess pipeline for Valhalla + graph/tiles build" note is superseded by the fixture-backend pivot — 05-CONTEXT.md D-01/D-04; Valhalla-on-VPS is RoutingSource backend #3, documented, not shipped.)* **UI hint**: yes ### Phase 6: Turn-by-Turn Guidance (Live Driving) @@ -174,7 +193,25 @@ Notes: 3. Minimizing the app mid-drive switches the bot to pushing chat turn-cards (map snapshot + next maneuver + ETA, best-effort projection) for the remainder of the trip (GUID-05, served by the Phase 5-designed ephemeral guidance session); re-opening mid-trip resumes live guidance seamlessly at the progressed position (GUID-06) 4. The Telegram BackButton walks the full app states (search → routes → guidance → back to search) and never dead-ends (PX-01 — full state graph only exists now); closing during guidance requires passing the "abandon route?" confirmation, with safe areas respected and vertical-swipe-close disabled during guidance (PX-03) 5. Ending a trip deletes the ephemeral guidance session and shows the "trip ended, data deleted" messaging — completing the in-app half of PRIV-01's promise at the trip boundary -**Plans**: TBD +**Plans**: 8 plans + +Plans: + +**Wave 1** *(parallel foundations — the pure cores + the envelope going live)*: +- [ ] 06-01-PLAN.md — Engine A pure core: pinned CSR binary codec (with street-name table), GPS-fix snapping, typed-array A* + step/turn derivation, proven against a committed mini-graph by A*≡Dijkstra equivalence (D-01/D-05, GUID-02) +- [ ] 06-02-PLAN.md — Guidance pure core: the D-04 reroute state machine with named constants, D-07 EMA+deadband ETA, D-15 distance throttle, four committed D-16 drive fixtures + replay/eta suites (GUID-02/03/04/06) +- [ ] 06-03-PLAN.md — The frozen envelope goes LIVE: mint/get/delete TTL-only service + Bearer-gated /api/guidance POST/GET/DELETE (claim-only chat), GE4–GE7 re-pinned in the same change (GUID-05, D-14 server half) + +**Wave 2** *(blocked on Wave 1 — the real graph + the cockpit chrome, in parallel)*: +- [ ] 06-04-PLAN.md — Engine A server surface: Overpass→CSR build tool + real KL graph, R2 bucket/upload/fork docs, isolate-memoized worker-router behind Bearer-gated /api/route, client http-source backend #2, RP1/RP2 re-pins, A*-CPU measurement gate (D-01/D-03, GUID-02) +- [ ] 06-05-PLAN.md — Guidance chrome per 06-UI-SPEC: committed 8-glyph set, banner/list/guidance-card/confirm/trip-end builders, close-protection + haptics capability guards, strings keys en+ms, shell.css guidance rules, guidance-surface suite + RS10/BA9 bumps (GUID-01/04, PX-03) + +**Wave 3** *(blocked on Wave 2 — integration)*: +- [ ] 06-06-PLAN.md — Guidance integration: D-10 locked-follow camera, the fix pump (banner/ETA/haptics/reroute), PX-01 full BackButton state graph, minimize-POST/resume-GET/trip-end-DELETE envelope flows, RS6 re-pin (GUID-02/05/06, PX-01/03, D-14 web half) +- [ ] 06-07-PLAN.md — Cron turn-cards: pure projectTurnCards + the repo's first scheduled() handler + triggers.crons, honest street-less caption copy, KV-write-budget pin, committed 8-glyph PNG set via sharp (GUID-05, D-11/12/13) + +**Wave 4** *(the phase's closing gates)*: +- [ ] 06-08-PLAN.md — Verification close: ?drive= replay knob + seven guidance capture legs + DSGN-02 gallery, then the ONE REAL DRIVE device gate (blocking checkpoint) (D-16, all reqs) Notes: - Highest behavioral uncertainty (reroute triggers, ETA smoothing, battery) — research recommends `/gsd-research-phase` (backgrounded turn-card "best-effort projection" framing + static map snapshot generation; product decision needed for what "good enough" means minimized). diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-01-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-01-PLAN.md new file mode 100644 index 0000000..4cde22b --- /dev/null +++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-01-PLAN.md @@ -0,0 +1,252 @@ +--- +phase: 06-turn-by-turn-guidance-live-driving +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - routing/src/graph.ts + - routing/src/snap.ts + - routing/src/astar.ts + - routing/src/types.ts + - routing/src/index.ts + - routing/test/graph.test.mjs + - routing/test/snap.test.mjs + - routing/test/astar.test.mjs + - routing/test/fixtures/build-mini-graph.mjs + - routing/test/fixtures/mini-graph.bin +autonomous: true +requirements: [GUID-02] +must_haves: + truths: + - "A road graph binary decodes into typed arrays with zero per-node JS objects (D-01)" + - "A tampered/truncated/mismatched graph binary decodes to null — never a throw, never a half-built graph (T-06-03)" + - "A GPS fix snaps to the nearest graph node via edge-segment projection with cos(lat)-scaled equirectangular math (D-05)" + - "A* finds the same optimal path as brute-force Dijkstra on the fixture mini-graph for every node pair (GUID-02 engine)" + - "One-way edges are respected by A* and ignored by snapping" + - "Route steps carry turn kind, street name, distance, and polyline6 geometry derived from the node path" + artifacts: + - path: "routing/src/graph.ts" + provides: "CSR binary decode/encode, header magic+version+size validation, street-name pool" + exports: ["decodeGraph", "encodeGraph", "GRAPH_MAGIC", "GRAPH_VERSION"] + - path: "routing/src/snap.ts" + provides: "point-to-edge snapping with memoized coarse grid index" + exports: ["snapToGraph"] + - path: "routing/src/astar.ts" + provides: "typed-array A* + step derivation + shared turn classifier" + exports: ["routeAstar", "deriveSteps", "classifyTurn"] + - path: "routing/test/fixtures/mini-graph.bin" + provides: "committed hand-built tiny CSR — the A* correctness ground truth" + key_links: + - from: "routing/src/astar.ts" + to: "routing/src/graph.ts" + via: "typed-array reads over the decoded CSR" + pattern: "graph\\.edgeStart\\[" + - from: "routing/src/astar.ts" + to: "routing/src/polyline.ts" + via: "step geometry encoded via encodePolyline6" + pattern: "encodePolyline6" + - from: "routing/test/astar.test.mjs" + to: "routing/src/astar.ts" + via: "A* vs Dijkstra equivalence on mini-graph" + pattern: "routeAstar" +--- + + +Engine A's pure data plane (D-01): the CSR road-graph binary codec, GPS-fix snapping, and a typed-array A* — the guidance engine's foundation, Node-testable with a committed mini-graph fixture before any Worker or R2 surface exists. + +Purpose: GUID-02's rerouting is REAL this phase because a real router exists. Everything server-side (plan 04) and every replay (plan 02) builds on these three pure modules. +Output: `routing/src/{graph,snap,astar}.ts` + committed mini-graph binary + three RED→GREEN suites, all exports registered in `routing/src/index.ts`. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + +export interface RoutingSource { + readonly name: string; + route(req: RouteRequest): Promise; +} +// RouteRequest = { origin: RoutePoint; destinationId: string } +// RouteResult = { destinationId, origin, options: RouteOption[], backend: string } +// RouteOption = { geometry: RouteGeometry, summary: RouteSummary, steps: RouteStep[] } +// RouteGeometry= { points: RoutePoint[]; cumulativeDistanceM: number[] } (same length, [0]===0) +// RouteStep = { distanceM?, durationS?, name?, geometry? } — this plan ADDS `kind?` (additive optional) + +// Existing pure helpers (reuse, never re-implement): +export function haversineM(a: RoutePoint, b: RoutePoint): number; // routing/src/geo.ts +export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[]; // routing/src/geo.ts +export function encodePolyline6(points: readonly PolyPoint[]): string; // routing/src/polyline.ts + +// Import convention (routing/src/index.ts header): consumers import the EXPLICIT +// SUBPATH WITH EXTENSION — "@araharah/routing/src/index.ts". A bare specifier +// resolves to NOTHING. New exports are added to index.ts as named exports only. + + +**RESEARCH.md Pattern 1 (CSR binary layout) and Pattern 2 (typed-array A*) are the authoritative references** — no analog exists in the repo (PATTERNS.md "No Analog Found"). This plan PINS the binary layout; plans 04 (real graph build) and 07 (cron projection) depend on it. + +**PINNED CSR BINARY LAYOUT (little-endian, zero-copy DataView/TypedArray views):** +``` +header: magic u32 (0x41524731 = "ARG1") | version u32 (=1) | nodeCount u32 | edgeCount u32 + | nameCount u32 | namePoolLen u32 | bbox 4×f64 (minLat, minLng, maxLat, maxLng) | maxSpeedKmh f32 +nodes: f64 lat[nodeCount] ++ f64 lng[nodeCount] +offsets: u32 edgeStart[nodeCount + 1] (CSR row pointers) +edges: u32 edgeTarget[edgeCount] ++ u8 edgeFlags[edgeCount] (flags bit0: oneway-reversed) +weights: f32 edgeSeconds[edgeCount] (speed-derived travel time) +edgeName: u32 edgeNameId[edgeCount] (index into name table; 0xFFFFFFFF = unnamed) +names: u32 nameOffset[nameCount + 1] ++ u8 namePool[namePoolLen] (UTF-8, non-null-terminated) +``` +Street names are NOT optional: GUID-01's banner renders `RouteStep.name` verbatim, and Overpass ways carry `name` tags. The name table is the layout's answer. + +**Constants (named exports, never inline):** `GRAPH_MAGIC = 0x41524731`, `GRAPH_VERSION = 1`, `UNNAMED_NAME_ID = 0xffffffff`. + + + + + + task 1: Mini-graph fixture + CSR graph codec (graph.ts) — RED→GREEN + routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin, routing/src/graph.ts, routing/test/graph.test.mjs + + - routing/src/polyline.ts (the codec posture: pure functions, decode/encode pair, round-trip-proven by tests — copy this discipline) + - routing/src/geo.ts (the pure-module law header: no I/O, no logging, no storage) + - routing/test/fixture-source.test.mjs + routing/test/harness.mjs (the suite style: `import { test } from "./harness.mjs"`, runner auto-discovers) + - routing/src/types.ts (RoutePoint shape consumed by the codec) + + + - encodeGraph → decodeGraph round-trips node coordinates, edge targets, flags, weights, and names exactly + - decodeGraph on a buffer with wrong magic (e.g. 0xdeadbeef) → null (T-06-03 fail-honest) + - decodeGraph on a buffer with wrong version (≠1) → null + - decodeGraph on a TRUNCATED buffer (byte length ≠ header-declared size) → null + - decodeGraph returns typed-array VIEWS over the input buffer (changing a decoded lat byte flips the view — zero-copy proof) + - decoded name pool: edge with nameId 0 → "Jalan Mini 1"; edge with UNNAMED_NAME_ID → name undefined + + +1. Write `routing/test/graph.test.mjs` FIRST (RED): a dynamic `import("../src/graph.ts")` (Node 24 type-stripping, the `fixture-source.test.mjs` pattern) asserting the behaviors above. Run `npm run routing:test` — the import failure IS the red state. +2. PIN the mini-graph: `routing/test/fixtures/build-mini-graph.mjs` builds a hand-designed 5×5 grid graph in a ~0.002°×0.002° bbox near KL center (lat 3.1500–3.1520, lng 101.6900–101.6920): 25 nodes, ~80 directed edge entries, three named "streets" ("Jalan Mini 1" N–S horizontal rows, "Jalan Mini 2" E–W columns, "Jalan Persiaran Mini" the perimeter), exactly ONE oneway pair (two adjacent nodes with flags bit0 set on the reverse edge), two speed classes (60 and 30 km/h → weights = segment-meters / (kmh/3.6)). The script imports `encodeGraph` from `../src/graph.ts` and writes `mini-graph.bin`. Commit BOTH script and binary. +3. Implement `routing/src/graph.ts` (GREEN) with exactly the PINNED CSR layout above: `decodeGraph(buffer: ArrayBuffer): RoadGraph | null` (header validation: magic, version, exact byte-size check against header-declared counts — any mismatch → null; then zero-copy TypedArray views), `encodeGraph(input): ArrayBuffer`, and the exported `RoadGraph` interface: `{ nodeCount, edgeCount, nameCount, lat: Float64Array, lng: Float64Array, edgeStart: Uint32Array, edgeTarget: Uint32Array, edgeFlags: Uint8Array, edgeSeconds: Float32Array, edgeNameId: Uint32Array, nameAt(id: number): string | undefined, bbox: {minLat,minLng,maxLat,maxLng}, maxSpeedKmh: number }`. +4. Add `kind?:` to `RouteStep` in `routing/src/types.ts` — ADDITIVE OPTIONAL field, type `"depart" | "straight" | "left" | "right" | "slight-left" | "slight-right" | "uturn" | "arrive"`, doc comment "Engine A populates this (06-01); fixture-world steps stay empty arrays (D-10)". Do not touch any other line of types.ts. + + + npm run routing:test && npm run routing:typecheck + + + - `routing/src/graph.ts` exists and exports decodeGraph, encodeGraph, GRAPH_MAGIC, GRAPH_VERSION, RoadGraph + - `routing/test/fixtures/mini-graph.bin` is committed and > 1000 bytes + - grep `0x41524731` matches in routing/src/graph.ts + - grep `expirationTtl|console\.|fetch\(` matches ZERO lines in routing/src/graph.ts (pure-module law) + - graph.test.mjs contains tests asserting: round-trip, bad magic → null, bad version → null, truncated → null, zero-copy view mutation + + graph.test.mjs green; mini-graph.bin committed; decodeGraph fails honest (null) on every malformed-header class; RouteStep gains the optional `kind` field with no other types.ts change + + + + task 2: GPS-fix snapping (snap.ts) — RED→GREEN + routing/src/snap.ts, routing/test/snap.test.mjs + + - routing/src/graph.ts (the RoadGraph shape just created in task 1 — snap consumes it) + - routing/src/geo.ts (haversineM — the ONLY distance authority; RESEARCH Pitfall 8: scale lng by cos(lat), never raw Δdegrees) + - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 4 (server graph-snap vs client progress-snap — this task is the SERVER authority, D-05) + + + - snapToGraph at a node's exact coordinates → that node, distanceM ≈ 0 + - snapToGraph mid-edge → the CLOSER endpoint node, distanceM = haversine distance to it (never a fabricated mid-node) + - snapToGraph outside bbox + SNAP_BBOX_MARGIN_DEG margin → null (honest miss — router-pending upstream, D-03) + - oneway edges: snapping IGNORES direction (a fix near a oneway segment still snaps to its endpoints) + - two snaps for the same RoadGraph instance reuse the memoized grid (WeakMap — observable via a build-counter or timing assertion kept loose) + + +1. RED: `routing/test/snap.test.mjs` loads `mini-graph.bin` via decodeGraph and asserts the behaviors above. +2. GREEN: `routing/src/snap.ts` — `snapToGraph(graph: RoadGraph, point: RoutePoint): SnapResult | null` where `SnapResult = { node: number; distanceM: number }`. Implementation: coarse grid index (cell ~0.002°) mapping cell-key → node ids, built ONCE per graph and memoized in a module-scope `WeakMap` (LAW 1 lazy memoize — PATTERNS shared pattern); candidate nodes from the 3×3 cell neighborhood; final distance via point-to-SEGMENT equirectangular projection (lng scaled by cos(lat)) over each candidate's edges, returning the closer endpoint of the best segment. `SNAP_BBOX_MARGIN_DEG = 0.01` named export. All distances through haversineM / cos-scaled math — never raw Δdegrees (Pitfall 8). + + + npm run routing:test + + + - routing/src/snap.ts exports snapToGraph and SNAP_BBOX_MARGIN_DEG + - snap.test.mjs asserts: exact-node hit, mid-edge closer-endpoint, outside-bbox → null, oneway-direction ignored + - grep `cos(` matches routing/src/snap.ts (the Pitfall-8 lng scaling) + - grep `WeakMap` matches routing/src/snap.ts (the per-graph memo) + + snap suite green; snapping is O(local neighborhood) not O(n) per fix; misses resolve null (the honest signal plans 04/06 turn into router-pending) + + + + task 3: Typed-array A* + step derivation (astar.ts) — RED→GREEN + routing/src/astar.ts, routing/test/astar.test.mjs, routing/src/index.ts + + - routing/src/graph.ts + routing/src/snap.ts (tasks 1–2 outputs — astar consumes both) + - routing/src/index.ts (add the new named exports in the same change — PATTERNS import-convention law) + - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 2 (binary heap of indices, allocation-free hot loop, haversine÷maxSpeed admissible heuristic) + Anti-Patterns (no JS-object graphs, no Map in the hot loop) + + + - routeAstar(origin, goal) equals brute-force Dijkstra's optimal cost for EVERY (origin, goal) pair of the mini-graph (exhaustive 25×25 equivalence sweep — the correctness gate RESEARCH mandates) + - oneway respected: the pair with the reversed-oneway edge routes the long way, matching Dijkstra + - unreachable pair → null (honest miss) + - deriveSteps on a path with a 90° bearing change → a step with kind "left" (or "right"), street name from the name table, distanceM > 0, geometry = polyline6 of the step's points + - deriveSteps always starts with kind "depart" and ends with kind "arrive" + - classifyTurn(0, -90) → "left"; classifyTurn(0, 90) → "right"; classifyTurn(0, 20) → "straight"; classifyTurn(0, 40) → "slight-left"; classifyTurn(0, 175) → "uturn" + + +1. RED: `routing/test/astar.test.mjs` — include a test-local brute-force Dijkstra over the mini-graph typed arrays (plain arrays are FINE in tests) and the exhaustive equivalence sweep. +2. GREEN: `routing/src/astar.ts`: + - `routeAstar(graph: RoadGraph, originNode: number, goalNode: number): AstarResult | null`, `AstarResult = { nodes: number[]; seconds: number; distanceM: number }`. Open set = binary heap over preallocated `f32 key[]` + `u32 node[]` + `u32 heap[]` arrays (grew by doubling); g-scores `f64[]` indexed by node; closed `u8[]`; NO Map, NO objects in the hot loop. Heuristic: `haversineM(node, goal) / (graph.maxSpeedKmh / 3.6)` — admissible + consistent (maxSpeedKmh comes from the header). distanceM summed post-hoc via haversineM over the node path. A bidirectional variant is ALLOWED (D-01 discretion) but the equivalence sweep is the gate either way. + - `classifyTurn(prevBearingDeg: number, nextBearingDeg: number): GuidanceTurnKind` — bearing delta normalized to (−180, 180]; named thresholds: `TURN_STRAIGHT_MAX_DEG = 25`, `TURN_SLIGHT_MAX_DEG = 60`, `TURN_UTURN_MIN_DEG = 150`. EXPORTED — plan 07's cron projection imports this exact classifier (one authority for "what is a turn"). + - `deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[]` — walk the node path, split at edges where classifyTurn crosses out of "straight" or the street name changes; each step: `kind` (depart first / arrive last / classified mid-path), `name` from graph.nameAt (omit when unnamed), `distanceM` (haversine sum over the step's points), `geometry` via encodePolyline6. +3. `routing/src/index.ts`: add named exports — `decodeGraph, encodeGraph, GRAPH_MAGIC, GRAPH_VERSION` from graph.ts; `snapToGraph, SNAP_BBOX_MARGIN_DEG` from snap.ts; `routeAstar, deriveSteps, classifyTurn, TURN_STRAIGHT_MAX_DEG, TURN_SLIGHT_MAX_DEG, TURN_UTURN_MIN_DEG` from astar.ts. Named exports only; update the header comment's backend ladder note: backend #2 is now "engine-a" (D-01 supersedes the "wasm-router" note — engine B is the documented post-v1 evolution, D-02). + + + npm run routing:test && npm run routing:typecheck + + + - astar.test.mjs contains an exhaustive A*-vs-Dijkstra equivalence test over the mini-graph (25×25 pairs) + - routing/src/astar.ts contains NO `new Map(` and NO object literal inside its hot loop (grep `new Map` → 0 matches in astar.ts) + - routing/src/astar.ts exports routeAstar, deriveSteps, classifyTurn + - routing/src/index.ts exports decodeGraph, snapToGraph, routeAstar, classifyTurn, createHttpRoutingSource is NOT here yet (plan 04 adds it) + - grep `0xffffffff|UNNAMED_NAME_ID` matches routing/src/graph.ts + + A* provably optimal on the fixture graph, oneway-aware, allocation-free; steps carry kind/name/distance/polyline6; classifyTurn exported as the shared turn authority; index.ts ladder note records D-01/D-02 + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| R2 object → Worker decode | The graph binary is fork-uploaded maintainer data (D-01) — a hostile/corrupt file must never produce a running router on garbage | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-03 | Tampering | decodeGraph (routing/src/graph.ts) | mitigate | Header magic + version + exact byte-size validation before any view is built; ANY mismatch → null (fail-honest). Worker side (plan 04) turns null into designed router-pending, never a crash. | +| T-06-03b | Denial of Service (local) | decodeGraph | mitigate | Size check happens BEFORE allocating views — a hostile huge-count header on a small buffer rejects on the byte-length comparison, not on allocation. | +| T-06-05 | Information Disclosure | snap/astar (pure modules) | mitigate-by-design | The modules are pure: no logging, no storage, no network (the geo.ts law). Coordinates exist only in the caller's frame — asserted by the grep gate (no console/fetch in the module). | + + + +- `npm run routing:test` — all three new suites green, existing suites untouched-green +- `npm run routing:typecheck` — clean +- A* ≡ Dijkstra exhaustive on the mini-graph (the committed correctness ground truth) +- Malformed binary classes (magic/version/size) all → null + + + +Engine A's pure data plane exists and is proven: the pinned CSR codec round-trips and fails honest, snapping is local-window correct with the cos(lat) law, A* matches brute force on every mini-graph pair, and steps carry the turn kinds + street names + geometry every downstream consumer (banner, replay, cron) needs. No Worker code, no R2, no network — those are plan 04's surface. + + + +After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-01-SUMMARY.md` + diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-PLAN.md new file mode 100644 index 0000000..8870c5d --- /dev/null +++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-PLAN.md @@ -0,0 +1,246 @@ +--- +phase: 06-turn-by-turn-guidance-live-driving +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - web/src/guidance/guidance.ts + - web/src/guidance/eta.ts + - web/src/guidance/watch.ts + - web/test/guidance-replay.test.mjs + - web/test/guidance-eta.test.mjs + - web/test/fixtures/drives/corridor-follow.json + - web/test/fixtures/drives/off-route-excursion.json + - web/test/fixtures/drives/missed-turn-excursion.json + - web/test/fixtures/drives/gps-jitter.json +autonomous: true +requirements: [GUID-02, GUID-03, GUID-04, GUID-06] +must_haves: + truths: + - "A recorded drive replays through the state machine asserting every D-04 transition, the storm guard, and arrival (D-16)" + - "A missed turn silently triggers exactly ONE reroute request — storm-guard holds under excursion storms (GUID-02)" + - "GPS jitter never leaves ON_ROUTE — the accuracy-aware threshold + 3-consecutive-fixes + 8s grace absorb it" + - "ETA is EMA-smoothed with the ±1 min / ±5% deadband — no visible whiplash across a jitter stream (D-07)" + - "Haptic effects fire once per edge: approach at 300 m, impact at the maneuver, success at arrival (D-08)" + - "Guidance work runs only on ≥10 m moved or ≥5 s elapsed — the D-15 distance throttle is pure and tested" + artifacts: + - path: "web/src/guidance/guidance.ts" + provides: "THE pure D-04 state machine + route projection + named constants" + exports: ["createGuidanceSession", "stepGuidance", "projectAlongRoute", "OFF_ROUTE_THRESHOLD_M", "OFF_ROUTE_CONSECUTIVE_FIXES", "OFF_ROUTE_GRACE_MS", "STORM_GUARD_MS", "MISSED_TURN_PAST_MANEUVER_M", "ARRIVAL_THRESHOLD_M", "APPROACH_HAPTIC_M", "GUIDANCE_START_ALONG_M"] + - path: "web/src/guidance/eta.ts" + provides: "EMA + deadband honest ETA (D-07)" + exports: ["updateEta", "EMA_ETA_ALPHA", "DEADBAND_MIN_S", "DEADBAND_REMAINING_PCT"] + - path: "web/src/guidance/watch.ts" + provides: "watchPosition wrapper + ≥10 m / ≥5 s distance throttle (D-15)" + exports: ["shouldAcceptFix", "createGuidanceWatch", "THROTTLE_MIN_MOVE_M", "THROTTLE_MIN_ELAPSED_MS"] + - path: "web/test/fixtures/drives/corridor-follow.json" + provides: "D-16 drive replay: clean corridor follow to arrival" + key_links: + - from: "web/src/guidance/guidance.ts" + to: "routing/src/geo.ts" + via: "haversineM for deviation distances (never raw Δdegrees)" + pattern: "haversineM" + - from: "web/test/guidance-replay.test.mjs" + to: "web/src/guidance/guidance.ts" + via: "drive fixtures replayed through stepGuidance" + pattern: "stepGuidance" + - from: "web/test/guidance-replay.test.mjs" + to: "routing/test/fixtures/golden-pairs.json" + via: "drives derived from a real committed route line" + pattern: "golden-pairs" +--- + + +The guidance brain: THE pure, Node-testable D-04 reroute state machine with named replay-tuned constants, the D-07 honest ETA (EMA + deadband), the D-15 distance throttle, and the four D-16 drive fixtures — proven by replay tests before any DOM, map, or Worker surface exists. + +Purpose: every guidance behavior the user experiences (silent self-heal, honest ETA, haptics, seamless resume) is decided HERE, replay-verifiable in Node in seconds. Plans 05/06 only render and wire what this plan decides. +Output: `web/src/guidance/{guidance,eta,watch}.ts` + 4 committed drive fixtures + replay/eta suites green. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + +export type Fix = { + lat: number; lng: number; + accuracyM: number | null; + headingDeg: number | null; + ts: number; + source: FixSource; +}; + + +// RoutePoint {lat,lng}; RouteStep {distanceM?, durationS?, name?, geometry?, kind?} +// deriveCumulativeDistanceM(points) -> number[] (meters along the line, [0]===0) +// haversineM(a, b) -> meters + + +| OFF_ROUTE_THRESHOLD_M | 50 | accuracy-aware: max(35, 2 × accuracyM), capped 100 | +| OFF_ROUTE_CONSECUTIVE_FIXES | 3 | GPS-jitter rejection | +| OFF_ROUTE_GRACE_MS | 8000 | grace window before acting (D-04) | +| STORM_GUARD_MS | 20000 | min time between recalculations (D-04) | +| MISSED_TURN_PAST_MANEUVER_M | 40 | off-route past the maneuver shortens grace (D-04) | +| ARRIVAL_THRESHOLD_M | 25 | snapped progress ≥ end-of-route (D-14) | +| APPROACH_HAPTIC_M | 300 | maneuver-approach haptic (D-08 / UI-SPEC) | +| GUIDANCE_START_ALONG_M | 30 | route drawn + first fix ≥ 30 m along → guidance starts | +| EMA_ETA_ALPHA (plan task 2) | 0.25 | per-fix smoothing (D-07) | +| DEADBAND_MIN_S | 60 | ±1 min display deadband (D-07) | +| DEADBAND_REMAINING_PCT | 0.05 | ±5% remaining deadband (D-07) | +| THROTTLE_MIN_MOVE_M | 10 | D-15 distance throttle | +| THROTTLE_MIN_ELAPSED_MS | 5000 | D-15 time floor | + + +**Test style:** `web/test/location-chain.test.mjs`'s stub-install + restore-in-finally and the harness registry (`import { test } from "./harness.mjs"`). Node-24 type-stripping dynamic imports of `.ts` sources. NO DOM, NO maplibre, NO console in the three modules (the route.ts law — console state tokens are the CONTROLLER's job, plan 06). + + + + + + task 1: Drive fixtures + THE D-04 state machine (guidance.ts) — RED→GREEN + web/test/fixtures/drives/*.json, web/src/guidance/guidance.ts, web/test/guidance-replay.test.mjs + + - web/src/location/types.ts (the six-field Fix — fixtures are Fix-shaped JSON streams, PATTERNS law) + - routing/test/fixtures/golden-pairs.json (the committed real KL route lines drives are derived from) + - web/src/route/route.ts lines 30–52 (the pure-injected-deps + named-constants posture this module follows) + - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 3 (the state machine + constants table) and Pitfall 6 (reroute storm: after ON_ROUTE, deviation is measured against the NEW route only) + + + - corridor-follow.json replays: state stays "on_route" throughout, ZERO reroute-requested effects, haptic approach fires once ~300 m before the final maneuver, arrival effect fires at the end (D-16) + - off-route-excursion.json: 3 consecutive off-threshold fixes spanning ≥8 s → state "off_route" → reroute-requested effect; after reroute-applied → "on_route"; a SECOND excursion injected immediately → NO second reroute-requested within STORM_GUARD_MS (Pitfall 6) + - missed-turn-excursion.json: off-route ≥ MISSED_TURN_PAST_MANEUVER_M past the maneuver point → grace shortens → reroute-requested fires earlier than the standard grace + - gps-jitter.json: ±15 m alternating fixes with accuracyM 20 (threshold max(35, 2×20)=40 m) → state NEVER leaves "on_route", zero reroute requests + - guidance start gate: a stationary first fix (< GUIDANCE_START_ALONG_M along) → not started; a fix ≥ 30 m along → started (PX-03 protections may bind) + - reroute-failed while rerouting → state "no_reroute" (the honest degradation, D-03) + + +1. RED: write `web/test/guidance-replay.test.mjs` — a `replayDrive(fixtureName, overrides?)` helper: loads the fixture JSON, builds the GuidanceRouteModel from the SAME golden-pair route the drive was derived from (`decodePolyline6` → points → `deriveCumulativeDistanceM` → steps synthesized at the fixture's recorded maneuver indices with the fixture's turn kinds), then folds each Fix through `stepGuidance` and records `{snap, effects}` frames. Assertions per behavior list. The initial run fails on import (module absent) — the RED state. +2. Build the four fixtures FIRST as committed JSON, each `{ "_notice": "synthetic drive derived from golden-pairs.json ; D-16 replay fixture — Fix-shaped", "pairId": "...", "maneuverIdx": [i0, i1, ...], "fixes": [ {lat,lng,accuracyM,headingDeg,ts,source}, ... ] }`: + - Decode the pair's polyline6, sample along the line at ~8 m intervals (~1 Hz at 30 km/h) for corridor-follow (accuracyM 8, headingDeg from bearing, source "webview"). + - off-route-excursion: corridor prefix, then a perpendicular offset growing to ~80 m (accuracyM 10 — inside the honest threshold), held ≥ 12 s / 4 fixes, then rejoin. + - missed-turn-excursion: follow to one recorded maneuver index, then continue straight past it ≥ 60 m off the new leg, then rejoin. + - gps-jitter: corridor with alternating ±15 m perpendicular noise, accuracyM 20, timestamps 1 s apart. + Derive coordinates programmatically (a small throwaway generator inline in the test run or a scratch node -e) and COMMIT the resulting JSON — golden-pairs data is committed public ODbL, so derived drives carry the same `_notice` attribution line. +3. GREEN: `web/src/guidance/guidance.ts` — pure, no DOM/map/console imports: + - `GuidanceState = "idle" | "on_route" | "off_route" | "rerouting" | "resuming" | "no_reroute"`; `GuidanceRouteModel = { points: RoutePoint[]; cumulative: number[]; steps: RouteStep[]; destinationId: string; destinationName: string }`. + - `projectAlongRoute(points, cumulative, fix, fromIdx): { idx: number; snapped: RoutePoint; alongM: number; remainingM: number }` — walk FORWARD from fromIdx only (the anti-pattern law: never global nearest-point per fix), point-to-segment in cos(lat)-scaled equirectangular space, remainingM from cumulative difference (never raw fix-to-destination distance, D-07). + - `createGuidanceSession(model, firstFix, nowMs): GuidanceSnapshot` + `stepGuidance(snap: GuidanceSnapshot, event: GuidanceEvent, nowMs): { snap: GuidanceSnapshot; effects: GuidanceEffect[] }` — a pure reducer over `GuidanceEvent = { type: "fix"; fix: Fix } | { type: "reroute-started" } | { type: "reroute-failed" } | { type: "reroute-applied"; model: GuidanceRouteModel }`. `GuidanceEffect = { type: "haptic"; cue: "approach" | "maneuver" | "arrive" } | { type: "reroute-requested"; origin: RoutePoint } | { type: "arrival" }`. All constants from the context table as named exports. After reroute-applied, deviation is measured against the NEW model only (Pitfall 6). Effects are once-per-edge: approach fires once per maneuver (reset on reroute-apply), maneuver haptic when progress crosses a step boundary, arrive once. +4. Structural assertions in the same suite: `grep`-style source scan — guidance.ts contains no `console.`, no `document`, no `maplibre`, no `localStorage` (the pure-module + no-storage laws, RS10-analog). + + + npm test -w web + + + - all four JSON fixtures exist under web/test/fixtures/drives/ and each carries a `_notice` attribution line and Fix-shaped `fixes` + - guidance-replay.test.mjs asserts: corridor (on_route, 0 reroute, arrival), excursion (exactly 1 reroute-requested, storm guard holds), missed-turn (shortened grace), jitter (never off_route) + - grep `console\.|document\.|maplibre|localStorage` → 0 matches in web/src/guidance/guidance.ts + - named constants exported: OFF_ROUTE_THRESHOLD_M=50, OFF_ROUTE_CONSECUTIVE_FIXES=3, OFF_ROUTE_GRACE_MS=8000, STORM_GUARD_MS=20000, MISSED_TURN_PAST_MANEUVER_M=40, ARRIVAL_THRESHOLD_M=25, APPROACH_HAPTIC_M=300, GUIDANCE_START_ALONG_M=30 (suite asserts the exported values, RS4 style) + - grep `haversineM` matches web/src/guidance/guidance.ts (one distance authority) + + Every D-04 transition, the storm guard, missed-turn shortening, jitter absorption, arrival, and the D-08 effect stream are replay-proven in Node; the machine is pure with zero browser imports + + + + task 2: Honest ETA — EMA + deadband (eta.ts) — RED→GREEN + web/src/guidance/eta.ts, web/test/guidance-eta.test.mjs + + - web/src/guidance/guidance.ts (task 1 — remainingM/remainingS come from projectAlongRoute, never from fix-to-destination distance) + - routing/src/geo.ts (the named-constants pure-module posture) + - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md D-07 + Assumption A5 (α ≈ 0.25/fix, ±1 min / ±5% deadband) + + + - first update seeds the EMA directly (no smoothing toward zero) + - a raw remaining-time sequence with ±40 s jitter produces a displayed sequence whose successive DISPLAYED values never differ by less than the deadband allows — updates only when |new − displayed| ≥ 60 s AND ≥ 5% of remaining (either bound passes → update) + - a genuine 5-minute drop updates the display within 2 fixes + - remaining distance follows the same deadband (±5%) + - no input ever reads a raw fix→destination distance (the function consumes remainingS/remainingM computed by the caller from the route model — signature enforces D-07) + + +1. RED: `web/test/guidance-eta.test.mjs` — feed synthetic remaining-S/M sequences (jitter, drop, plateau) through `updateEta`; assert deadband behavior + the EMA trajectory (α = 0.25 convergence: after n fixes, value ≈ within 10% of target). +2. GREEN: `web/src/guidance/eta.ts` — `updateEta(prev: EtaState | null, remainingS: number, remainingM: number): EtaState` where `EtaState = { etaS: number; remainingM: number; displayedEtaS: number; displayedRemainingM: number }`. EMA: `smoothed = α × raw + (1 − α) × smoothed` per axis; display update only past BOTH deadbands? NO — past EITHER deadband (±1 min on time OR ±5% on distance triggers that axis's refresh; axes independent). Constants named + exported. Also export `composeEtaClock(etaS, nowMs): string` — 24-hour `HH:MM` = now + displayed etaS (the UI-SPEC instrument format; en/ms identical). + + + npm test -w web + + + - web/src/guidance/eta.ts exports updateEta, composeEtaClock, EMA_ETA_ALPHA=0.25, DEADBAND_MIN_S=60, DEADBAND_REMAINING_PCT=0.05 + - guidance-eta.test.mjs asserts: seed, jitter-holds-display, real-drop-updates, independent axes + - grep `console\.|Fix` → updateEta's signature takes numbers, not a Fix (D-07's "never raw fix-to-destination" is structural) + - composeEtaClock("…", fixedMs) returns exactly `HH:MM` 24-hour format for known inputs + + ETA smoothing is proven whiplash-free under jitter and responsive to real changes; the deadband constants are exported and pinned by test + + + + task 3: watchPosition + distance throttle (watch.ts) + web/src/guidance/watch.ts, web/test/guidance-replay.test.mjs + + - web/src/location/webview.ts (the promise-wrapped callback + settled latch + hard-timeout + errors-resolve-null posture watch.ts extends) + - web/src/location/chain.ts lines 1–20 (the D-05 cadence note this phase finally flips to watchPosition) + - .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Distance throttle (D-15)" code example (the exact accept rule) + + + - shouldAcceptFix(null, fix, 0) → true (first fix always accepted) + - 9 m moved, 1 s elapsed → false; 10 m moved → true; 11 m at 1 s → true; 3 m at 5001 ms → true; 3 m at 4999 ms → false + - createGuidanceWatch starts watchPosition with { enableHighAccuracy: true, maximumAge: 0 } (structural assertion on the recorded options) + - stopWatch() clears the watcher id (the privacy-minimal stop — D-14's "the watch stops") + + +1. Extend `web/test/guidance-replay.test.mjs` with a throttle block (the pure rules are replay-adjacent; keep the suite count at the VALIDATION map's three): assert the shouldAcceptFix truth table and, with a stubbed `geolocation.watchPosition` (the location-chain stub style), that createGuidanceWatch wires high-accuracy options and stopWatch calls clearWatch with the issued id. +2. `web/src/guidance/watch.ts`: `shouldAcceptFix(last: Fix | null, fix: Fix, lastAcceptedTs: number): boolean` — accept when `last === null || haversineM(last, fix) >= THROTTLE_MIN_MOVE_M || fix.ts - lastAcceptedTs >= THROTTLE_MIN_ELAPSED_MS` (≥10 m moved OR ≥5 s elapsed, whichever first — D-15); `createGuidanceWatch(geo: Geolocation, onFix: (fix: Fix) => void): { stop(): void }` — watchPosition high-accuracy, normalizes the payload into the six-field Fix (`accuracyM ?? null, headingDeg ?? null, source: "webview"` — the telegram.ts normalization precedent), feeds ONLY accepted fixes to onFix. + + + npm test -w web + + + - web/src/guidance/watch.ts exports shouldAcceptFix, createGuidanceWatch, THROTTLE_MIN_MOVE_M=10, THROTTLE_MIN_ELAPSED_MS=5000 + - the replay suite's throttle block asserts the full accept/reject truth table + - structural assertion: watchPosition called with enableHighAccuracy true and maximumAge 0 + - grep `haversineM` matches web/src/guidance/watch.ts + + The D-15 gated-streaming law is flipped ON: a high-accuracy watch whose feed is distance-throttled, fully Node-tested, with a clean stop for trip end + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| GPS stream → state machine | Position fixes are device-local sensor input; the machine is pure so nothing about a fix outlives the caller's frame | +| reroute-requested effect → network (plan 06) | The ONLY in-drive position ever transmitted is a reroute origin | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-05 | Information Disclosure | guidance.ts / watch.ts | mitigate-by-design | On-route fixes NEVER leave the device: the machine computes deviation/progress/ETA locally; the only network-bound position is the reroute-requested effect's origin (one point, on a real excursion). Structural gate: no fetch/console/localStorage in any of the three modules. | +| T-06-05b | Information Disclosure | watch.ts | mitigate | The watch STOPS at trip end (`stop()` exported + wired in plan 06) — no location flow after the trip; replay/structural tests pin the stop path's existence. | +| — | DoS (resource) | stepGuidance hot loop | accept | Pure reducer with a forward-walking projection window (O(local)); no unbounded memory; replay fixtures prove bounded work per fix. | + + + +- `npm test -w web` — replay + eta suites green (all four drives), existing web suites untouched-green +- All D-04/D-07/D-08/D-14/D-15 constants exported with pinned values +- Zero browser/DOM/network imports in web/src/guidance/ + + + +The guidance brain is done and replay-proven: the four recorded drives exercise every transition (corridor → arrival, excursion → exactly one reroute + storm guard, missed turn → shortened grace, jitter → absorbed), ETA never whiplashes, haptics fire once per edge, and the watch throttles at ≥10 m / ≥5 s. Plans 05/06/08 render, wire, and capture this — they never re-decide it. + + + +After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-SUMMARY.md` + diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-PLAN.md new file mode 100644 index 0000000..0c4365e --- /dev/null +++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-PLAN.md @@ -0,0 +1,205 @@ +--- +phase: 06-turn-by-turn-guidance-live-driving +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - bot/src/kv/guidance-sessions.ts + - bot/src/webhook.ts + - bot/package.json + - bot/test/guidance-envelope.test.mjs + - bot/test/privacy.test.mjs + - bot/test/webhook.test.mjs +autonomous: true +requirements: [GUID-05] +must_haves: + truths: + - "A guidance envelope is minted server-side with chat_id from the Bearer claim — NEVER from the request body (T-06-02)" + - "Every envelope write carries expirationTtl: GUIDANCE_SESSION_TTL_SECONDS (7200) — the TTL-only law holds (GE1)" + - "A malformed or foreign-shaped envelope reads as null — the cron can never push from garbage (fail-honest)" + - "GET and DELETE resolve the envelope only for the OWNING chat — cross-chat read/delete is 404" + - "Trip-end DELETE removes the KV record actively — D-14's server half" + artifacts: + - path: "bot/src/kv/guidance-sessions.ts" + provides: "mint/get/delete service over the FROZEN 4-field envelope (shape unchanged — GE2 holds)" + exports: ["mintGuidanceSession", "getGuidanceSession", "deleteGuidanceSession", "GUIDANCE_SESSION_TTL_SECONDS"] + - path: "bot/src/webhook.ts" + provides: "POST / GET / DELETE /api/guidance (Bearer-claim gated)" + key_links: + - from: "bot/src/webhook.ts" + to: "bot/src/kv/guidance-sessions.ts" + via: "handlers call the service; chat_id comes from claim only" + pattern: "mintGuidanceSession|getGuidanceSession|deleteGuidanceSession" + - from: "bot/src/kv/guidance-sessions.ts" + to: "chat_sessions KV" + via: "put with expirationTtl GUIDANCE_SESSION_TTL_SECONDS" + pattern: "expirationTtl: GUIDANCE_SESSION_TTL_SECONDS" + - from: "bot/test/privacy.test.mjs" + to: "bot/src/kv/guidance-sessions.ts" + via: "PG1 storageModules includes the new write path" + pattern: "guidance-sessions" +--- + + +The Phase 5-frozen `GuidanceSessionEnvelope` goes LIVE (D-11's precondition): the types-only module becomes a TTL-only KV service (mint/get/delete), and `/api/guidance` POST/GET/DELETE land on the Worker — chat identity from the Bearer claim only, every write TTL'd at 7200 s, GE4–GE7 re-pinned in the same change. + +Purpose: GUID-05's turn-cards and GUID-06's resume both read this envelope; D-14's trip-end deletion writes its absence. The envelope SHAPE stays frozen (GE1/GE2/GE3 unchanged) — this plan adds behavior, never fields. +Output: envelope service + three endpoints + re-pinned gates, all bot suites green. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + +export const GUIDANCE_SESSION_TTL_SECONDS = 7200; +export interface GuidanceRouteSnapshot { + destinationId: string; destinationName: string; + distanceM: number; durationS: number; + geometry: string; // polyline6 (the routing workspace's storage format) + startedAtSec: number; // resume-progress anchor (GUID-06) +} +export interface GuidanceSessionEnvelope { + id: string; // crypto.randomUUID() at mint (T-02-05) + chat_id: number; // server-side from the Bearer claim — never the body + route_snapshot: GuidanceRouteSnapshot; + created_at: number; // seconds-since-epoch +} +// Key scheme: guidance: → JSON envelope + + +const match = (req.headers.get("authorization") ?? "").match(/^Bearer ([A-Za-z0-9-]+)$/); +const claim = match ? await getClaimByToken(env.chat_sessions, match[1]) : null; +// → 401 {error:"unauthorized"} + category-only log on miss + + + + + + + + + + + + + task 1: Envelope service (mint/get/delete) + GE4–GE7 re-pins + PG1 + bot/src/kv/guidance-sessions.ts, bot/test/guidance-envelope.test.mjs, bot/test/privacy.test.mjs + + - bot/src/kv/guidance-sessions.ts (the frozen types — this task ADDS a service below the types; the four envelope fields and six snapshot fields stay byte-identical) + - bot/src/kv/location-pin.ts (the exact write/read/delete trio + defensive-read shape to copy) + - bot/src/kv/sessions.ts lines 53–90 (KvLike seam + crypto.randomUUID precedent) + - bot/test/guidance-envelope.test.mjs (GE1–GE7 as they stand — each gate's doc-comment names its Phase 6 re-pin) + - bot/test/privacy.test.mjs lines 25–49 (PG1 storageModules list) + - bot/test/sessions.test.mjs (the Map-backed KV fake style) + + + - mintGuidanceSession generates a crypto.randomUUID() id, stamps created_at = now, puts `guidance:` with expirationTtl: GUIDANCE_SESSION_TTL_SECONDS, returns the envelope + - getGuidanceSession on a well-formed record → the envelope; on missing key → null; on malformed JSON → null; on a record with a 5th envelope field / missing field / wrong-typed chat_id / non-finite distanceM → null (defensive read — a corrupt record can never reach the cron, the location-pin law) + - getGuidanceSession rejects ids that fail the UUID shape check WITHOUT touching KV (V5: no arbitrary key probing) + - deleteGuidanceSession removes the key (void resolve) + + +1. RED: extend `bot/test/guidance-envelope.test.mjs` — keep GE1 (TTL constant 7200), GE2 (field freeze: the envelope interface has EXACTLY id/chat_id/route_snapshot/created_at — strengthen the structural scan to fail on any 5th property), GE3 (inline snapshot) UNCHANGED in meaning; RE-PIN per the context box: GE4 now asserts the service exports mint/get/delete AND the types remain; GE5 still asserts exactly ONE kv_namespaces entry in wrangler.jsonc; GE6 still asserts the Env's chat_sessions field; GE7 now asserts webhook.ts (or any bot/src consumer) MAY import guidance-sessions service functions. Add the behavioral Map-fake tests per the behavior list. +2. GREEN: add to `bot/src/kv/guidance-sessions.ts` (below the frozen types): `interface GuidanceSessionKv { get(key: string): Promise; put(key: string, value: string, opts: { expirationTtl: number }): Promise; delete(key: string): Promise; }`, then `mintGuidanceSession(kv, chatId: number, snapshot: GuidanceRouteSnapshot, nowSec?: number): Promise`, `getGuidanceSession(kv, id: string): Promise` (UUID regex `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` guard → null; try/catch parse; exact-field + type + finite/range validation mirroring location-pin's read), `deleteGuidanceSession(kv, id: string): Promise`. Update the module header: the FROZEN note becomes "shape frozen (GE2); the service (Phase 6, plan 06-03) owns every write; every write carries expirationTtl: GUIDANCE_SESSION_TTL_SECONDS". +3. PG1: add `"kv/guidance-sessions.ts"` to `privacy.test.mjs`'s `storageModules` list (PATTERNS caution 2 — the moment the file gains a write path). PG1 then mechanically enforces every `.put(` line carries an expirationTtl referencing an ALL-CAPS constant. + + + npm test -w bot + + + - bot/src/kv/guidance-sessions.ts exports mintGuidanceSession, getGuidanceSession, deleteGuidanceSession; GUIDANCE_SESSION_TTL_SECONDS still 7200 + - grep `expirationTtl: GUIDANCE_SESSION_TTL_SECONDS` matches bot/src/kv/guidance-sessions.ts + - guidance-envelope.test.mjs: GE1/GE2/GE3 present and passing UNCHANGED in meaning; GE4–GE7 re-pinned and passing + - privacy.test.mjs storageModules includes "kv/guidance-sessions.ts"; `npm test -w bot` green (PG1 passes over the new file) + - behavioral tests prove: TTL'd put, defensive null on 4 malformed-record classes, UUID guard short-circuit + + The frozen envelope has a TTL-only service beneath it; the negative-space gates re-pinned in the same change; zero shape drift (GE2 green) + + + + task 2: /api/guidance POST + GET + DELETE (Bearer-claim gated) + bot/src/webhook.ts, bot/package.json, bot/test/webhook.test.mjs + + - bot/src/webhook.ts (the route-dispatch skeleton lines 54–84, the handleLocation Bearer-claim block lines 143–161, the MAX_INITDATA_CHARS body-cap + pre/post-read double check lines 91–119, category-only logging law lines 37–43) + - bot/src/kv/guidance-sessions.ts (task 1 service) + - bot/test/webhook.test.mjs (the undici offline Request/Response pattern + claim-minting test helper) + - routing/src/polyline.ts decodePolyline6 (snapshot geometry validation — charset + round-decode) + + + - POST /api/guidance without/with-invalid Bearer → 401 {"error":"unauthorized"} + `[guidance] rejected: unauthorized` category log (never the token, never the body) + - POST body > MAX_GUIDANCE_BODY_CHARS (8192) → 413; body with non-numeric distanceM / negative durationS / non-integer startedAtSec / destinationId failing the slug pattern / geometry that fails decodePolyline6 → 400 `[guidance] rejected: malformed` + - POST happy path → 201 {"id": ""}; KV fake recorded the put with expirationTtl 7200; a chat_id IN THE BODY is IGNORED (injection probe: body chat_id 999 ≠ claim chat_id → stored envelope carries the CLAIM's chat_id — T-06-02) + - GET /api/guidance?id= for the owning chat → 200 envelope JSON; foreign/absent/malformed id → 404 + - DELETE /api/guidance?id= for the owning chat → 200 {"deleted":true} + key gone; foreign/absent → 404 (cross-chat deletion impossible — T-06-02) + - non-POST/GET/DELETE methods → 405 + + +1. `bot/package.json`: add `"@araharah/routing": "*"` to dependencies (beside `"@araharah/search": "*"`); run `npm install` to link the workspace. +2. RED: extend `bot/test/webhook.test.mjs` with the guidance-endpoint block using the existing offline pattern (undici Request/Response, KV Map-fake, claim-minting helper). +3. GREEN in `bot/src/webhook.ts`: add three route arms beside the existing `/api/location` arm, copying its exact posture — Bearer-claim extraction first (401 + `[guidance] rejected: unauthorized`), `MAX_GUIDANCE_BODY_CHARS = 8192` named constant with the pre/post-read double check (413), hand-rolled snapshot validation (no zod — RESEARCH V5): destinationId against the slug pattern, destinationName non-empty string ≤ 200 chars, distanceM/durationS finite ≥ 0, startedAtSec integer > 0, geometry a string that `decodePolyline6` (import `@araharah/routing/src/polyline.ts`) decodes to ≥ 2 points without throwing. POST → `mintGuidanceSession(kv, claim.chat_id, validated)` → 201 {id}. GET/DELETE → `getGuidanceSession` then `envelope.chat_id === claim.chat_id` else 404; DELETE calls `deleteGuidanceSession`. All logs category-only: `[guidance] …` — never ids, never coordinates, never payloads (D-14 law). + + + npm test -w bot && npm run bot:typecheck + + + - bot/package.json dependencies include "@araharah/routing": "*" + - webhook.test.mjs asserts all behavior-list rows: 401 class, 413 cap, 400 malformed classes, 201 with claim-owned chat_id (injection probe), GET/DELETE own-vs-foreign, 405 + - grep `MAX_GUIDANCE_BODY_CHARS = 8192` matches bot/src/webhook.ts + - grep `console\.` in the guidance arms shows category-only literals — no `${…}` interpolation of body/claim values (pattern: `\[guidance\] rejected: \w+`) + - `npm run bot:typecheck` clean + + Guidance sessions are live over HTTP with the claim-only chat law, honest body validation, and the TTL-only service beneath — GUID-05's and GUID-06's server surface, and D-14's delete, ready for consumers + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Webview → /api/guidance | Untrusted client posts snapshots; identity and chat ownership come ONLY from the validated Bearer claim | +| KV record → cron (plan 07) | The cron trusts only defensively-validated envelopes | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-06-02 | Tampering / Spoofing | POST /api/guidance | mitigate | chat_id minted server-side from the Bearer claim; a client-supplied chat_id is structurally ignored (injection probe test proves it). Envelope ids are crypto.randomUUID() (unguessable). | +| T-06-02b | Elevation of Privilege (cross-chat) | GET/DELETE /api/guidance | mitigate | Both resolve the record and compare envelope.chat_id === claim.chat_id — foreign/absent → 404 (indistinguishable, no existence oracle). | +| T-06-04 | Information Disclosure | handlers + service | mitigate | Category-only logging (`[guidance] rejected: `); no ids/coordinates/payloads in any log line; TTL 7200 s on every write (GE1/PG1); active delete = D-14's server half. | +| — | DoS (body volume) | POST /api/guidance | mitigate | MAX_GUIDANCE_BODY_CHARS 8192 with the pre/post-read double check (the WR-01 pattern); 10 ms CPU cap is the natural rate limiter. | +| — | Repudiation | — | accept | No user-owned writes beyond the user's own ephemeral envelope; nothing to repudiate persists past TTL. | + + + +- `npm test -w bot` — envelope + webhook suites green; GE1–GE7 + PG1 all green +- `npm run bot:typecheck` clean +- Injection probe (body chat_id ≠ claim chat_id) proves claim-only identity + + + +The envelope is LIVE: minted server-side, claim-owned, TTL-only, defensively read, actively deletable — with GE1/GE2/GE3 still frozen and GE4–GE7 re-pinned in the same change. Plans 06 (web flows) and 07 (cron) build directly on these endpoints. + + + +After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-SUMMARY.md` + diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md new file mode 100644 index 0000000..386ed83 --- /dev/null +++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-PLAN.md @@ -0,0 +1,261 @@ +--- +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" +--- + + +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. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + +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) + + + + + + + + + + + + +**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. + + + + + + task 1: Road-graph build tool (Overpass → CSR binary) + real KL graph + routing/scripts/build-road-graph.mjs, routing/data/.gitignore + + - 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) + + + - `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 ` 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) + + +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. + + + 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)" + + + - 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) + + The real KL graph builds reproducibly from Overpass inside the D-01 envelope with the class filter and stats gate standing guard + + + + task 2: http-source (backend #2) + worker-router + Bearer-gated /api/route + RP re-pins + 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 + + - 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 }) }`) + + + - 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) + + +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 | 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`: 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). + + + npm test -w bot && npm run routing:test && npm run bot:typecheck && npm run routing:typecheck + + + - 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 + + 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 + + + + task 3: R2 bucket + upload script + fork docs + A*-CPU measurement gate + routing/scripts/upload-road-graph.mjs, routing/scripts/measure-route-cpu.mjs, docs/ROAD-GRAPH.md + + - 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) + + + - 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 + + +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). + + + 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 + + + - 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 + + 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 + + + + + +## 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: `) — 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. | + + + +- `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 + + + +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. + + + +After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md` + diff --git a/.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md new file mode 100644 index 0000000..97ff0e9 --- /dev/null +++ b/.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-PLAN.md @@ -0,0 +1,281 @@ +--- +phase: 06-turn-by-turn-guidance-live-driving +plan: 05 +type: execute +wave: 2 +depends_on: [06-02] +files_modified: + - web/src/guidance/glyphs/depart.svg + - web/src/guidance/glyphs/straight.svg + - web/src/guidance/glyphs/left.svg + - web/src/guidance/glyphs/right.svg + - web/src/guidance/glyphs/slight-left.svg + - web/src/guidance/glyphs/slight-right.svg + - web/src/guidance/glyphs/uturn.svg + - web/src/guidance/glyphs/arrive.svg + - web/src/guidance/glyphs.ts + - web/src/guidance/banner.ts + - web/src/guidance/haptics.ts + - web/src/guidance/close-protection.ts + - web/src/telegram/webapp.ts + - web/src/i18n/strings.ts + - web/src/shell.css + - web/test/guidance-surface.test.mjs + - web/test/route-surface.test.mjs + - web/test/build-artifact.test.mjs + - web/test/copy-gate.test.mjs +autonomous: true +requirements: [GUID-01, GUID-04, PX-03] +must_haves: + truths: + - "The maneuver banner renders distance + arrow + street per the UI-SPEC anatomy, with REROUTING/RESUMING/NO REROUTE calm states and the machine-blink gantry (GUID-01, D-04/D-06)" + - "The maneuver list collapses/expands over the map with NEXT/FUTURE/PAST row states and tap-out collapse (GUID-01)" + - "The guidance card shows ETA + remaining + progress bar + END TRIP — the route card evolved (D-06)" + - "The abandon-route confirm card guards every guidance exit with the exact en/ms copy (D-09/PX-03)" + - "Haptics fire through capability-guarded calls and no-op cleanly on old clients (GUID-04/D-08)" + - "Close protection engages on guidance start and releases at trip end (disableVerticalSwipes 7.7+, closing confirmation 6.2+) (PX-03)" + - "All guidance copy lives in the strings module in en + ms — never inline (the copy gates)" + artifacts: + - path: "web/src/guidance/glyphs.ts" + provides: "the 8-glyph committed set as raw SVG (single source; PNGs rasterize from these files in plan 07)" + - path: "web/src/guidance/banner.ts" + provides: "banner + maneuver list + guidance card + confirm card + trip-end card builders (createElement + textContent)" + - path: "web/src/guidance/haptics.ts" + provides: "capability-guarded haptic dispatch (approach/maneuver/arrive)" + exports: ["fireHaptic"] + - path: "web/src/guidance/close-protection.ts" + provides: "engage/release close protection (version-gated)" + exports: ["engageCloseProtection", "releaseCloseProtection"] + - path: "web/src/i18n/strings.ts" + provides: "the 18 new ShellStrings keys, en + ms, exact UI-SPEC copy" + key_links: + - from: "web/src/guidance/banner.ts" + to: "web/src/i18n/strings.ts" + via: "every label via strings[locale] — zero inline copy" + pattern: "strings\\[locale\\]" + - from: "web/src/guidance/banner.ts" + to: "web/src/guidance/glyphs.ts" + via: "inline SVG from the committed glyph sources" + pattern: "GLYPHS" + - from: "web/src/guidance/close-protection.ts" + to: "web/src/telegram/webapp.ts" + via: "optional members + isVersionAtLeast guards" + pattern: "isVersionAtLeast" +--- + + +The guidance cockpit surface per 06-UI-SPEC: the 8-glyph committed set, the maneuver banner + collapsible list + guidance card + abandon-confirm + trip-end builders, the close-protection and haptics capability guards, and all copy in the strings module — every element token-only, radius-0, no shadows, machine-instant state swaps. + +Purpose: GUID-01/PX-03's visible behavior and GUID-04's haptic channel. Builders are pure DOM composition (Node-testable, the route-card law); plan 06 wires them to the state machine. +Output: glyphs + 5 builders + 2 guard modules + webapp contract members + strings keys + shell.css guidance rules + the guidance-surface suite + RS10/BA9/copy-gate bumps. + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md +@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md +@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md + + + + + + + + + + + + + + + + + + + + task 1: The 8-glyph committed set + glyphs.ts + strings keys + web/src/guidance/glyphs/*.svg, web/src/guidance/glyphs.ts, web/src/i18n/strings.ts, web/test/copy-gate.test.mjs + + - .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md "The 8-glyph set" table (the EXACT geometry per glyph) + "Maneuver Banner" ink hierarchy + - web/src/i18n/strings.ts (the ShellStrings interface + en/ms records — append, never reorder) + - web/test/copy-gate.test.mjs (what it enumerates — extend in the same change) + - theme/src/tokens.css (verify --color-signal-orange #EB8008 — SVGs use currentColor; PNG rasterization in plan 07 injects the literals) + + + - glyphs.ts exports GLYPHS: Record for exactly ["depart","straight","left","right","slight-left","slight-right","uturn","arrive"], each value the raw SVG string of the committed file (?raw import — the committed SVGs are the single source) + - every SVG: viewBox "0 0 32 32", stroke currentColor, stroke-width 3, square linecaps, orthogonal/45° segments only (the geometry table verbatim: depart = 4×4 filled square + vertical shaft; uturn = machined staple, no arc; arrive = shaft ending on a 12×12 filled square) + - every new ShellStrings key exists in BOTH en and ms with the exact UI-SPEC values (the interface gains the 18 keys with doc comments) + + +1. Create the 8 SVG files in `web/src/guidance/glyphs/` per the UI-SPEC geometry table — hand-authored, 32×32 grid, `fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"`, filled squares as ``. Keep every segment orthogonal or 45° (the orthogonal law — the uturn staple is the proof case). +2. `web/src/guidance/glyphs.ts`: `import depart from "./glyphs/depart.svg?raw"; …` (Vite ?raw), `export type GlyphKind = "depart" | "straight" | "left" | "right" | "slight-left" | "slight-right" | "uturn" | "arrive";`, `export const GLYPH_KINDS: readonly GlyphKind[]` (the 8 in order), `export const GLYPHS: Record`, and `export function svgHost(kind: GlyphKind): SVGSVGElement` — a `