docs(06): create phase plans (8 plans, 4 waves)
This commit is contained in:
+47
-10
@@ -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:<id>` 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).
|
||||
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
<!-- The seam engine A plugs into (routing/src/types.ts, frozen in Phase 5): -->
|
||||
export interface RoutingSource {
|
||||
readonly name: string;
|
||||
route(req: RouteRequest): Promise<RouteResult | null>;
|
||||
}
|
||||
// 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.
|
||||
</interfaces>
|
||||
|
||||
**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`.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 1: Mini-graph fixture + CSR graph codec (graph.ts) — RED→GREEN</name>
|
||||
<files>routing/test/fixtures/build-mini-graph.mjs, routing/test/fixtures/mini-graph.bin, routing/src/graph.ts, routing/test/graph.test.mjs</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run routing:test && npm run routing:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: GPS-fix snapping (snap.ts) — RED→GREEN</name>
|
||||
<files>routing/src/snap.ts, routing/test/snap.test.mjs</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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<RoadGraph, Grid>` (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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run routing:test</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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)
|
||||
</acceptance_criteria>
|
||||
<done>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)</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 3: Typed-array A* + step derivation (astar.ts) — RED→GREEN</name>
|
||||
<files>routing/src/astar.ts, routing/test/astar.test.mjs, routing/src/index.ts</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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"
|
||||
</behavior>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run routing:test && npm run routing:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
<!-- The Fix shape replay fixtures and the machine consume (web/src/location/types.ts): -->
|
||||
export type Fix = {
|
||||
lat: number; lng: number;
|
||||
accuracyM: number | null;
|
||||
headingDeg: number | null;
|
||||
ts: number;
|
||||
source: FixSource;
|
||||
};
|
||||
|
||||
<!-- Route model pieces from @araharah/routing/src/index.ts: -->
|
||||
// RoutePoint {lat,lng}; RouteStep {distanceM?, durationS?, name?, geometry?, kind?}
|
||||
// deriveCumulativeDistanceM(points) -> number[] (meters along the line, [0]===0)
|
||||
// haversineM(a, b) -> meters
|
||||
|
||||
<!-- RESEARCH.md Pattern 3 constants table (D-04) — named, replay-tuned, never inline: -->
|
||||
| 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 |
|
||||
</interfaces>
|
||||
|
||||
**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).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 1: Drive fixtures + THE D-04 state machine (guidance.ts) — RED→GREEN</name>
|
||||
<files>web/test/fixtures/drives/*.json, web/src/guidance/guidance.ts, web/test/guidance-replay.test.mjs</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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 <pairId>; 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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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)
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: Honest ETA — EMA + deadband (eta.ts) — RED→GREEN</name>
|
||||
<files>web/src/guidance/eta.ts, web/test/guidance-eta.test.mjs</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>ETA smoothing is proven whiplash-free under jitter and responsive to real changes; the deadband constants are exported and pinned by test</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 3: watchPosition + distance throttle (watch.ts)</name>
|
||||
<files>web/src/guidance/watch.ts, web/test/guidance-replay.test.mjs</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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")
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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/
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
<!-- THE FROZEN CONTRACT (bot/src/kv/guidance-sessions.ts — DO NOT ADD FIELDS; GE2 stays): -->
|
||||
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:<id> → JSON envelope
|
||||
|
||||
<!-- The Bearer-claim precedent to copy verbatim (bot/src/webhook.ts handleLocation, T-03-01): -->
|
||||
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
|
||||
|
||||
<!-- Existing analogs: bot/src/kv/location-pin.ts (write/read/delete trio + defensive read),
|
||||
bot/src/kv/sessions.ts (KvLike seam + crypto.randomUUID), bot/test/sessions.test.mjs (Map-fake). -->
|
||||
|
||||
<!-- polyline validation: bot gains "@araharah/routing": "*" in bot/package.json THIS plan
|
||||
(PATTERNS workspace-import rule: import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"). -->
|
||||
|
||||
<!-- Gate re-pin law (PATTERNS "Gate re-pins ship WITH the change that invalidates them"):
|
||||
GE4 (types-only) → service exists; GE5 stays EXACTLY ONE kv_namespaces (R2 later is a
|
||||
different binding class — never a second KV namespace); GE6 → chat_sessions Env field
|
||||
unchanged; GE7 → bot/src may import guidance-sessions SERVICE functions.
|
||||
GE1 (TTL 7200), GE2 (field freeze), GE3 (inline snapshot) stay UNCHANGED. -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 1: Envelope service (mint/get/delete) + GE4–GE7 re-pins + PG1</name>
|
||||
<files>bot/src/kv/guidance-sessions.ts, bot/test/guidance-envelope.test.mjs, bot/test/privacy.test.mjs</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- mintGuidanceSession generates a crypto.randomUUID() id, stamps created_at = now, puts `guidance:<id>` 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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<string | null>; put(key: string, value: string, opts: { expirationTtl: number }): Promise<void>; delete(key: string): Promise<void>; }`, then `mintGuidanceSession(kv, chatId: number, snapshot: GuidanceRouteSnapshot, nowSec?: number): Promise<GuidanceSessionEnvelope>`, `getGuidanceSession(kv, id: string): Promise<GuidanceSessionEnvelope | null>` (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<void>`. 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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)</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: /api/guidance POST + GET + DELETE (Bearer-claim gated)</name>
|
||||
<files>bot/src/webhook.ts, bot/package.json, bot/test/webhook.test.mjs</files>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- 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": "<uuid>"}; 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=<uuid> for the owning chat → 200 envelope JSON; foreign/absent/malformed id → 404
|
||||
- DELETE /api/guidance?id=<uuid> 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
|
||||
</behavior>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot && npm run bot:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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: <category>`); 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Engine A's server surface (D-01): the real KL graph build tool (Overpass → CSR binary), the R2 hosting + upload path with fork docs, the isolate-memoized Worker router behind a Bearer-gated `/api/route`, the client-side `createHttpRoutingSource` RoutingSource backend #2, RP1/RP2 re-pinned in the same change, and the A*-CPU measurement gate printed and enforced.
|
||||
|
||||
Purpose: GUID-02's rerouting becomes REAL — arbitrary current positions route for real, $0, no VPS. This is the phase's top technical risk (A1: 10 ms free-plan CPU), so the measurement gate is a deliverable, not a hope.
|
||||
Output: build/upload/measure scripts + http-source + worker-router + `/api/route` + re-pinned gates + docs, real graph built and measured.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Engine A pure core exists from plan 06-01 (routing/src, ALL exported from index.ts): -->
|
||||
decodeGraph(buffer: ArrayBuffer): RoadGraph | null
|
||||
snapToGraph(graph: RoadGraph, point: RoutePoint): { node: number; distanceM: number } | null
|
||||
routeAstar(graph: RoadGraph, originNode: number, goalNode: number): { nodes: number[]; seconds: number; distanceM: number } | null
|
||||
deriveSteps(graph: RoadGraph, nodes: number[]): RouteStep[]
|
||||
// RoadGraph carries bbox + maxSpeedKmh + nameAt(id)
|
||||
|
||||
<!-- PINNED CSR BINARY LAYOUT (from 06-01 — the build script MUST produce exactly this):
|
||||
header: magic u32 0x41524731 | version u32 1 | nodeCount u32 | edgeCount u32 | nameCount u32 |
|
||||
namePoolLen u32 | bbox 4×f64 | maxSpeedKmh f32
|
||||
nodes: f64 lat[] ++ f64 lng[] · offsets: u32 edgeStart[n+1] · edges: u32 target[] ++ u8 flags[]
|
||||
(bit0 oneway-reversed) · weights: f32 seconds[] · edgeName: u32 nameId[] (0xffffffff unnamed)
|
||||
names: u32 nameOffset[cnt+1] ++ u8 pool[] -->
|
||||
|
||||
<!-- The Worker route arm copies webhook.ts's Bearer-claim posture (plan 06-03 task 2). -->
|
||||
|
||||
<!-- Envelope service exists from plan 06-03; bot/package.json already has "@araharah/routing": "*". -->
|
||||
|
||||
<!-- RP gate re-pin (PATTERNS): RP1 shrinks to "no UNAUTHENTICATED / proxy-shaped routing"
|
||||
(the handler MUST derive identity from the Bearer claim; no forwarding to external
|
||||
routing hosts); RP2 now ALLOWS @araharah/routing in bot/src (subpath-with-extension
|
||||
imports only) and bans any OTHER new workspace dependency beyond
|
||||
@araharah/search + @araharah/routing. GE5 stays: exactly ONE kv_namespaces. -->
|
||||
|
||||
<!-- graph binary is a BUILD ARTIFACT, not committed: routing/data/.gitignore ignores *.bin
|
||||
(forks build + upload their own; committed = scripts + docs + mini-graph tests). -->
|
||||
</interfaces>
|
||||
|
||||
**Isolate memoization is the cache that works** (RESEARCH): `caches.default` is useless here — Bearer requests auto-bypass Workers Cache and cron is never cached. Module-scope promise memo, 1 R2 GET per cold isolate.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: Road-graph build tool (Overpass → CSR binary) + real KL graph</name>
|
||||
<files>routing/scripts/build-road-graph.mjs, routing/data/.gitignore</files>
|
||||
<read_first>
|
||||
- routing/scripts/fetch-route-fixtures.mjs lines 1–64, 110–142, 185–248 (THE maintainer-tool pattern to copy: #!/usr/bin/env node, dense MAINTAINER-TIME header + Usage + Exit codes, named constants up top, fail() helper, pre-flight validation, collect-then-write, post-write stats + "next:" hint)
|
||||
- routing/test/fixtures/build-mini-graph.mjs + routing/src/graph.ts (the exact encodeGraph contract + pinned CSR layout this script must produce)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md A7 (Overpass response size/timeout) + Pitfall 1 (road-class filter) + Open Q 4 (bbox derivation)
|
||||
- search/src/gazetteer.ts getEntries (bbox extremes source)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `node routing/scripts/build-road-graph.mjs` fetches the Overpass extract for the KL bbox, encodes the pinned CSR binary, writes routing/data/kl-roadgraph.v1.bin, prints node/edge counts + binary size + bbox, exits 0
|
||||
- binary size > 80 MB (D-01 envelope) → exit 1 with the stats printed (the stats gate)
|
||||
- ways with highway in the drivable class set only: motorway, trunk, primary, secondary, tertiary, residential, unclassified, living_street (footway/cycleway/service/path excluded — Pitfall 1)
|
||||
- oneway=yes → flags bit0 on the reverse direction entry; maxspeed tags → edge seconds; class defaults: motorway 100, trunk 80, primary 60, secondary 50, tertiary 40, residential 30, unclassified 30, living_street 20 km/h
|
||||
- way name tags → the name pool (unnamed ways → UNNAMED_NAME_ID)
|
||||
- `--check <file>` mode: decode with decodeGraph, print stats, exit 0/1 — no network
|
||||
- a second run with the committed file present and `--check` green needs no refetch (artifact, not input)
|
||||
</behavior>
|
||||
<action>
|
||||
Write `routing/scripts/build-road-graph.mjs` in the fetch-route-fixtures.mjs shell: header naming the D-01 boundary ("A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, web/src, or routing/src imports it"), named constants (`OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"`, `REQUEST_TIMEOUT_MS = 300_000`, `USER_AGENT`, `MAX_BINARY_BYTES = 80 * 1024 * 1024`, `DRIVABLE_CLASSES` + `CLASS_SPEED_KMH` tables, `BBOX_MARGIN_DEG = 0.05`), bbox COMPUTED at run start from `@araharah/search/src/gazetteer.ts` getEntries() + `routing/test/fixtures/golden-pairs.json` pair endpoints ± margin (printed; Open Q 4's mechanical pinning), the Overpass query `way[highway~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|living_street)$"](bbox); out geom;`, response → nodes deduplicated by rounded coord → edges with oneway/flags/speed/name → `encodeGraph` (import `../src/graph.ts`) → collect-then-write (committed file untouched on any failure, exit 1) → stats gate (size ≤ MAX_BINARY_BYTES else exit 1) → post-write summary + `next:` hint naming the upload command. `--check` mode decodes and re-prints stats. Also create `routing/data/.gitignore` containing `*.bin` (the binary is an ODbL-derived build artifact; committed artifacts are the script, docs, and the tiny test mini-graph).
|
||||
Then RUN it (network fetch, minutes) and confirm the stats gate passes; record the printed counts in the plan summary.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin && node -e "const s=require('fs').statSync('routing/data/kl-roadgraph.v1.bin'); if(s.size>80*1024*1024) process.exit(1); console.log('graph bytes:', s.size)"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- routing/scripts/build-road-graph.mjs exists with the maintainer-tool header, named constants (no inline magic), collect-then-write, and the 80 MB stats gate
|
||||
- routing/data/kl-roadgraph.v1.bin exists, > 1 MB, ≤ 80 MB, and passes `--check` (decodeGraph green)
|
||||
- routing/data/.gitignore contains `*.bin` and `git status --porcelain routing/data` shows no .bin staged
|
||||
- printed stats include nodeCount, edgeCount, binary size, bbox (executor records them in the SUMMARY)
|
||||
- grep `footway|cycleway|service|path` → absent from DRIVABLE_CLASSES (Pitfall 1)
|
||||
</acceptance_criteria>
|
||||
<done>The real KL graph builds reproducibly from Overpass inside the D-01 envelope with the class filter and stats gate standing guard</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: http-source (backend #2) + worker-router + Bearer-gated /api/route + RP re-pins</name>
|
||||
<files>routing/src/http-source.ts, routing/src/index.ts, bot/src/routing/worker-router.ts, bot/src/webhook.ts, bot/src/config.ts, bot/wrangler.jsonc, bot/test/route-privacy.test.mjs, bot/test/webhook.test.mjs</files>
|
||||
<read_first>
|
||||
- routing/src/fixture-source.ts lines 35–95, 134–189 (the backend-factory skeleton: slug guard first, lazy memoize, defensive validation, D-03 honest nulls — copy the posture)
|
||||
- bot/src/webhook.ts (the /api/location arm to mirror: Bearer claim → 401, body cap, category logs; route-dispatch skeleton)
|
||||
- bot/src/config.ts (the typed Env contract — one line per field with the trailing law comment)
|
||||
- bot/wrangler.jsonc (JSONC with law-bearing comments; GE5 asserts exactly ONE kv_namespaces entry)
|
||||
- bot/test/route-privacy.test.mjs lines 1–24 (RP1/RP2 as they stand — the doc-comments name Phase 6 as the re-pinning owner)
|
||||
- search/src/gazetteer.ts getEntryById(id) (destination resolution server-side)
|
||||
- bot/test/webhook.test.mjs (offline pattern; extend for the /api/route block with an R2 fake: `{ get: async () => ({ arrayBuffer: async () => miniGraphBuffer }) }`)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- createHttpRoutingSource: POST {origin, destinationId} to endpoint with Bearer token from the injected getter; non-200, malformed body, or thrown fetch → null (fail-honest — T-05-04's .catch(()=>null) law, network edition); a valid RouteResult-shaped response passes defensive validation (destinationId match, options 1..3, finite numbers) → RouteResult
|
||||
- worker-router handleRoute: no/invalid Bearer → 401 [route] rejected: unauthorized; body > 4096 → 413; non-numeric/out-of-bbox origin lat/lng → 400 [route] rejected: malformed (V5 clamp-to-bbox validation); unknown destinationId → 200 null-ish? NO — unknown destination or snap miss or absent graph → Response JSON `null` with 200 (the null contract carries over HTTP: honest router-pending, never a 500)
|
||||
- happy path through the R2-faked mini-graph: real origin/dest resolve → 200 RouteResult with backend "engine-a", one option (NO alternatives — RESEARCH anti-pattern: alternatives never during the guidance path), steps carrying kind/name/distance/polyline6
|
||||
- graph object absent (R2 fake returns null) → 200 null (D-03 designed degradation)
|
||||
- RP1 re-pinned: a /api/route handler WITHOUT the Bearer-claim derivation fails the gate; RP2 re-pinned: @araharah/routing subpath imports allowed in bot/src; any other new workspace dep fails
|
||||
- GE5 still green: exactly ONE kv_namespaces entry (r2_buckets is a different binding class)
|
||||
</behavior>
|
||||
<action>
|
||||
1. `routing/src/http-source.ts`: `createHttpRoutingSource(opts: { endpoint: string; bearer: () => string | null; fetchImpl?: typeof fetch }): RoutingSource` — name "engine-a"; POST JSON {origin, destinationId}; Authorization only when bearer() returns non-null; response.json() → defensive shape check (copy fixture-source's isUsablePair style: every field type-checked, non-finite refused, try/catch → null); export from `routing/src/index.ts` alongside a header-note update (backend #2 live, ladder: fixture → engine-a → engine B documented post-v1).
|
||||
2. `bot/src/routing/worker-router.ts`: module-scope `let graphP: Promise<RoadGraph | null> | null = null;` + `loadGraph(env)` — `env.ROAD_GRAPH.get(GRAPH_KEY)` with `GRAPH_KEY = "kl-roadgraph.v1.bin"`, `.then(o => o === null ? null : decodeGraph(await o.arrayBuffer()))`, catch → null (a corrupt binary degrades honest); memo never reset (per-isolate). `handleRoute(req, env): Promise<Response>`: Bearer claim (extract + getClaimByToken — reuse webhook.ts's exact helper posture), body-cap 4096 (`MAX_ROUTE_BODY_CHARS`), origin {lat,lng} numeric + inside graph.bbox (400 otherwise), `getEntryById(destinationId)` (import `@araharah/search/src/gazetteer.ts`) → null miss → 200 null; snapToGraph origin + destination → null miss → 200 null; routeAstar → null → 200 null; else build RouteResult `{ destinationId, origin, options: [{ geometry: { points: nodePath points, cumulativeDistanceM: deriveCumulativeDistanceM(points) }, summary: { distanceM, durationS }, steps: deriveSteps(...) }], backend: "engine-a" }` → 200 JSON.
|
||||
3. `bot/src/webhook.ts`: add the `/api/route` arm delegating to handleRoute (405 guard for non-POST). `bot/src/config.ts`: add `ROAD_GRAPH: R2Bucket; // D-01 graph binary — R2 binding, first in the repo (never a KV namespace — GE5)`. `bot/wrangler.jsonc`: add `"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }]` with a law comment; kv_namespaces stays exactly one entry.
|
||||
4. RE-PIN `bot/test/route-privacy.test.mjs` in the same change: RP1 → the /api/route arm must reference the Bearer-claim derivation (structural: the handler path calls the claim helper; a bare unauthenticated fetch-forward shape fails); no external routing hostnames (`grep` for non-Telegram/non-Cloudflare API hosts). RP2 → allow-list `@araharah/search` + `@araharah/routing` in bot/src imports; any other `@araharah/*` fails. Extend `bot/test/webhook.test.mjs` with the /api/route behavior block (R2 mini-graph fake + mini graph binary loaded from routing/test/fixtures/mini-graph.bin via fs).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot && npm run routing:test && npm run bot:typecheck && npm run routing:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- routing/src/http-source.ts exports createHttpRoutingSource (name "engine-a"); index.ts re-exports it
|
||||
- bot/src/routing/worker-router.ts: module-scope graphP memo (grep `graphP ??=` or equivalent), GRAPH_KEY = "kl-roadgraph.v1.bin", null on absent/corrupt graph
|
||||
- webhook.test.mjs: 401 unauth, 413 cap, 400 malformed origin, 200-null on unknown dest / snap miss / absent graph, 200 RouteResult happy path via R2 fake, backend === "engine-a", options.length === 1
|
||||
- route-privacy.test.mjs: RP1 (auth-bound routing) + RP2 (allow-list search+routing only) re-pinned and green; GE5 green (one kv_namespaces)
|
||||
- bot/src/config.ts carries the ROAD_GRAPH: R2Bucket line with a law comment
|
||||
- both typechecks clean
|
||||
</acceptance_criteria>
|
||||
<done>The routing oracle is Bearer-gated and live in dev (R2 faked in tests), the client seam has its backend #2, honest-null degradation is proven at every miss class, and the negative-space gates were re-pinned by the change that invalidated them</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 3: R2 bucket + upload script + fork docs + A*-CPU measurement gate</name>
|
||||
<files>routing/scripts/upload-road-graph.mjs, routing/scripts/measure-route-cpu.mjs, docs/ROAD-GRAPH.md</files>
|
||||
<read_first>
|
||||
- routing/scripts/build-road-graph.mjs (task 1 — the "next:" hint names this script; keep the maintainer-tool shell identical)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pitfall 1 (CPU measurement mandate), Assumption A1 (mitigation ladder), Environment Availability (R2 bucket "not yet created")
|
||||
- web/test/build-artifact.test.mjs BA9 (the "budget gates print their measurement" precedent this gate follows)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- upload-road-graph.mjs: verifies routing/data/kl-roadgraph.v1.bin exists (> 1 MB), shells `npx wrangler r2 object put araharah-graphs/kl-roadgraph.v1.bin --file routing/data/kl-roadgraph.v1.bin --remote`, prints the key + next steps; missing file → exit 1 with the build hint
|
||||
- measure-route-cpu.mjs: loads the real binary, runs 25 A* routes between sampled node pairs spread across the bbox, prints per-route CPU ms + p95, exits 1 if p95 > 8 ms (20% margin under the 10 ms free-plan budget — A1); absent binary → exit 1 with the build hint
|
||||
- R2 bucket `araharah-graphs` exists on the account (wrangler CLI; on auth failure the executor surfaces a human checkpoint with the exact command — the bucket create is the one step a keyless machine cannot do)
|
||||
- docs/ROAD-GRAPH.md documents: build → check → upload → deploy sequence, the fork contract (your own bucket, your own upload), ODbL attribution for the OSM-derived graph, the 30–80 MB envelope law, and the p95 CPU gate
|
||||
</behavior>
|
||||
<action>
|
||||
1. Attempt `npx wrangler r2 bucket create araharah-graphs`. If wrangler reports auth/permission failure, create a `checkpoint:human-action` note in the run log with the exact command + dashboard path (Cloudflare dashboard → R2 → Create bucket) and continue with dev-only verification — flag it in the SUMMARY as the phase's user_setup item. If it succeeds, proceed.
|
||||
2. Write `routing/scripts/upload-road-graph.mjs` (fetch-route-fixtures.mjs shell) and RUN it (when the bucket exists) — verify with `npx wrangler r2 object get araharah-graphs/kl-roadgraph.v1.bin --file /tmp/opencode/graph-roundtrip.bin --remote` + `--check` round-trip (or `r2 object info`).
|
||||
3. Write and RUN `routing/scripts/measure-route-cpu.mjs` (Node: `process.hrtime.bigint()` around routeAstar; pairs sampled from graph nodes across the bbox quadrants + the fixture-corpus endpoints snapped via snapToGraph). Record the printed p95 in the SUMMARY. If p95 > 8 ms, apply A1's mitigation ladder IN ORDER and re-measure: (a) drop tertiary/residential from DRIVABLE_CLASSES and rebuild (tighter graph), (b) bidirectional A* variant, (c) document Workers Paid as an explicit fork decision (breaks $0 — requires user sign-off; stop and surface it).
|
||||
4. Write `docs/ROAD-GRAPH.md` per the behavior list (concise — one page).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>node routing/scripts/measure-route-cpu.mjs 2>&1 | tail -3 && node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- measure-route-cpu.mjs prints "p95" + a millisecond number and exits 0 (p95 ≤ 8 ms), or the SUMMARY records which A1 mitigation was applied and the new p95
|
||||
- upload-road-graph.mjs exists with the maintainer shell; if the bucket exists the round-trip get + --check proves the upload
|
||||
- docs/ROAD-GRAPH.md exists covering: bucket create, upload command, ODbL attribution, envelope law, CPU gate
|
||||
- the R2 bucket state (created / needs-user) is recorded in the SUMMARY
|
||||
</acceptance_criteria>
|
||||
<done>Graph hosting is fork-ready ($0, scripted, documented), the binary is measured against the free-plan CPU ceiling with a printed verdict, and A1's risk is retired or explicitly escalated</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Internet → /api/route | Untrusted callers would love a free routing oracle; identity via Bearer claim only |
|
||||
| Request body → A* | Origin coordinates are untrusted input reaching a compute-heavy path |
|
||||
| R2 object → decodeGraph | Fork-uploaded binary (covered by 06-01's header validation — decodeGraph null on any mismatch) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-01 | DoS / Elevation of Privilege | /api/route | mitigate | Bearer-gated exactly like /api/location (401 class tested); the 10 ms CPU cap is the natural per-request rate limiter; body cap 4096 (413). RP1 re-pinned to fail any unauthenticated/proxy-shaped routing arm. |
|
||||
| T-06-01b | Denial of Service (compute) | handleRoute | mitigate | V5: origin lat/lng type+range-validated against graph.bbox BEFORE snap/A* (400 class); unknown destinationId short-circuits before any graph work; alternatives generation prohibited on this path (single best route — RESEARCH anti-pattern). |
|
||||
| T-06-03 | Tampering | loadGraph → decodeGraph | mitigate | decodeGraph's magic+version+size check (06-01) → null → designed router-pending (D-03); upload script is maintainer-surface only; corrupt binary in R2 degrades honest, never crashes the Worker. |
|
||||
| T-06-04 | Information Disclosure | handleRoute logs | mitigate | Category-only logging (`[route] rejected: <category>`) — never coordinates, ids, or payloads (the webhook law extended). |
|
||||
| — | Information Disclosure (fork) | docs/ROAD-GRAPH.md | accept | The graph is public OSM-derived data (ODbL-attributed) — hosting it is not a privacy surface; documented licensing keeps forks honest. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm run routing:test && npm test -w bot` green; both typechecks clean
|
||||
- Real graph: `--check` green, size inside the 30–80 MB envelope, stats printed
|
||||
- CPU gate: p95 ≤ 8 ms printed (A1 retired) or escalation recorded
|
||||
- RP1/RP2 re-pinned green; GE5 (one KV namespace) still green
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Engine A routes real positions for real: build → upload → Bearer-gated `/api/route` → client `createHttpRoutingSource` — all $0, fork-friendly, honestly degrading when the graph is absent, and measured against the free-plan CPU ceiling. GUID-02's "recalculates from the current position" now has an engine behind it.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md`
|
||||
</output>
|
||||
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/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
|
||||
|
||||
<interfaces>
|
||||
<!-- Builder law to copy (web/src/route/route-card.ts): createElement + textContent ONLY —
|
||||
"a name-derived string never reaches innerHTML" (RS5). Theme classes only, never local
|
||||
restyle. aria attributes on interactive groups. Instrument formats composed in code
|
||||
(fillTemplate), never locale strings. -->
|
||||
|
||||
<!-- State machine contract consumed (web/src/guidance/guidance.ts, plan 06-02):
|
||||
GuidanceState = "idle" | "on_route" | "off_route" | "rerouting" | "resuming" | "no_reroute"
|
||||
Effects carry haptic cues "approach" | "maneuver" | "arrive". -->
|
||||
|
||||
<!-- webapp.ts gains OPTIONAL members only (the contract-stays-the-contract law):
|
||||
HapticFeedback?: { impactOccurred(style: "light"|"medium"|"heavy"|"rigid"|"soft"): unknown;
|
||||
notificationOccurred(type: "error"|"success"|"warning"): unknown;
|
||||
selectionChanged(): unknown }
|
||||
disableVerticalSwipes?: () => void; // Bot API 7.7+
|
||||
enableVerticalSwipes?: () => void;
|
||||
enableClosingConfirmation?: () => void; // Bot API 6.2+
|
||||
disableClosingConfirmation?: () => void;
|
||||
isActive?: boolean; // Bot API 8.0+
|
||||
onEvent?(event: string, cb: () => void): void; // accepts "activated" | "deactivated" -->
|
||||
|
||||
<!-- EXACT COPY (06-UI-SPEC copywriting contract — values verbatim, en + ms):
|
||||
guideRegister GUIDE/GUIDE · guideStreetUnnamed "Unnamed road"/"Jalan tanpa nama"
|
||||
guideReroutingLine "Finding a new route from where you are."/"Mencari laluan baharu dari kedudukan anda."
|
||||
guideResumingLine "Resuming from your last known progress."/"Sambung semula dari kemajuan terakhir anda."
|
||||
guideNoRerouteLine "Rerouting unavailable on this server."/"Pengiraan semula tiada pada pelayan ini."
|
||||
guideNoRerouteBody "No road graph here — follow the drawn route; recalculation is unavailable."/"Tiada graf jalan di sini — ikut laluan yang dilukis; pengiraan semula tidak tersedia."
|
||||
ctaBannerList LIST/SENARAI · ctaBannerClose CLOSE/TUTUP · ctaEndTrip "END TRIP"/"TAMAT PERJALANAN"
|
||||
guideConfirmHeadline "Abandon route?"/"Tinggalkan laluan?"
|
||||
guideConfirmBody "Guidance stops and this trip's record is deleted. Nothing is stored."/"Panduan berhenti dan rekod perjalanan ini dipadam. Tiada apa-apa disimpan."
|
||||
ctaConfirmAbandon "YES, ABANDON"/"YA, TINGGALKAN" · ctaConfirmContinue "CONTINUE"/"TERUSKAN"
|
||||
guideEndHeadline "Trip ended"/"Perjalanan tamat"
|
||||
guideEndBody "Route guidance stopped and the trip record is deleted — nothing about this drive remains."/"Panduan laluan dihentikan dan rekod perjalanan dipadam — tiada apa-apa tentang pemanduan ini kekal."
|
||||
ariaManeuverList "Full maneuver list"/"Senarai penuh manuver" · ariaBannerToggle "Next maneuver"/"Manuver seterusnya" · ariaTripProgress "Trip progress"/"Kemajuan perjalanan"
|
||||
Registers ("GUIDE // NEXT", "GUIDE // REROUTING", "GUIDE // RESUMING", "GUIDE // NO REROUTE",
|
||||
"GUIDE // CONFIRM", "GUIDE // END") are instrument constants composed in code — en under both locales. -->
|
||||
|
||||
<!-- UI-SPEC geometry (machined literals, the sanctioned exception classes):
|
||||
banner min-height 96px, padding space-sm space-md, arrow plate 48×48 (glyph 32×32),
|
||||
compact strip min-height 24px padding-block space-xs, banner→strip separator 1px
|
||||
chassis-subtle, list panel max-width 320px max-height 480px, row min-height 48px
|
||||
glyph 24×24, next-row lamp 8×8, progress bar 4px. Ink hierarchy: register chassis-subtle,
|
||||
street soft-beige, distance pure-white, arrow/lamps/bar-fill signal-orange. NO red anywhere
|
||||
in guidance chrome (abandoning is deliberate, not an error). Past-maneuver rows: outline ink. -->
|
||||
|
||||
<!-- Gates this plan bumps (PATTERNS cautions 3–5): RS10 floor 16 → 24 in route-surface.test.mjs;
|
||||
BA9 input list += web/src/guidance/*.ts in build-artifact.test.mjs (budget ≤ 204800 gz, printed);
|
||||
copy-gate.test.mjs extends if it enumerates modules/keys. RS6 is NOT touched here (plan 06 owns it). -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: The 8-glyph committed set + glyphs.ts + strings keys</name>
|
||||
<files>web/src/guidance/glyphs/*.svg, web/src/guidance/glyphs.ts, web/src/i18n/strings.ts, web/test/copy-gate.test.mjs</files>
|
||||
<read_first>
|
||||
- .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)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- glyphs.ts exports GLYPHS: Record<GlyphKind, string> 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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 `<rect fill="currentColor" stroke="none">`. 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<GlyphKind, string>`, and `export function svgHost(kind: GlyphKind): SVGSVGElement` — a `<template>` whose innerHTML is set from the STATIC `GLYPHS[kind]` constant (the ONE sanctioned innerHTML site: a static import value, never a name-derived or runtime string — the surface gate whitelists exactly this line), returning the parsed `<svg>` element for sizing by the caller (banner 32×32, list 24×24).
|
||||
3. `web/src/i18n/strings.ts`: append the 18 keys from the context table to `ShellStrings` + both locale records, values VERBATIM, doc comments naming D-06/D-09/D-14/UI-SPEC. Extend `web/test/copy-gate.test.mjs` in the same change if it enumerates expected keys/modules so it stays green AND enforces the new keys' presence.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web && npm run web:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- all 8 SVG files exist under web/src/guidance/glyphs/; each contains `viewBox="0 0 32 32"` and `stroke-width="3"` and `stroke-linecap="square"`
|
||||
- glyphs.ts exports GLYPHS with exactly 8 kinds (test asserts GLYPH_KINDS.length === 8 and each raw string non-empty)
|
||||
- grep "Unnamed road" and "Tinggalkan laluan\?" and "YA, TINGGALKAN" all match web/src/i18n/strings.ts (en+ms pairs)
|
||||
- copy-gate.test.mjs green and asserting the new keys exist in both locales
|
||||
- typecheck clean (the 18 keys are non-optional interface members — a missing locale entry fails the build)
|
||||
</acceptance_criteria>
|
||||
<done>The committed glyph set exists once (SVG source of truth, PNGs come later from the same files), and every guidance word the app will ever show is in the strings module in both locales</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: Banner + list + guidance/confirm/trip-end card builders + shell.css</name>
|
||||
<files>web/src/guidance/banner.ts, web/src/shell.css, web/test/guidance-surface.test.mjs</files>
|
||||
<read_first>
|
||||
- web/src/route/route-card.ts (THE builder law: actions interface first, createElement+textContent only, aria on interactive groups, instrument formats composed in code)
|
||||
- web/src/ui/strip.ts + the strip section of web/src/shell.css (what [data-guidance="active"] compacts — layout only, strip.ts logic untouched)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md sections: Shell Layout, Maneuver Banner, Maneuver List, Guidance Card, Abandon-Route Confirm Card, Trip-Ended Card, State Coexistence
|
||||
- web/test/route-surface.test.mjs RS5/RS9 (the structural-gate style guidance-surface mirrors)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- buildGuidanceBanner renders all five slots for EVERY state (never blanks mid-swap): register line (GUIDE // NEXT | GUIDE // REROUTING | GUIDE // RESUMING | GUIDE // NO REROUTE), 48×48 arrow plate with the glyph, distance (telemetry-num + label unit; "—" when unknown during REROUTING/RESUMING/NO REROUTE), street line (uppercase, ellipsis; streetUnnamed fallback), toggle word LIST/CLOSE with aria-expanded
|
||||
- the banner root is a <button> (full-width, the list toggle) with aria-controls → the list panel's id
|
||||
- buildManeuverList: card panel (max-width 320px, role="region", aria-label=ariaManeuverList) + transparent tap-out layer; rows in trip order with glyph 24×24 + street + right-aligned distance; NEXT row = orange glyph + 8×8 orange lamp; FUTURE = matte-black glyph; PAST = outline ink glyph + outline text; rows are real text, glyphs aria-hidden
|
||||
- startGantryBlink(el)/stopGantryBlink(el): JS-timed two-state toggle ~500 ms (the boot-card precedent — an indicator duty cycle, never CSS animation)
|
||||
- buildGuidanceCard: GUIDE register, destination headline, telemetry row (minutes telemetry-num + MIN + {km} KM + ETA HH:MM), 4px progress bar (role="progressbar", aria-valuemin 0 / max 100 / now pct, aria-label ariaTripProgress), state line ONLY in no_reroute (guideNoRerouteBody), actions = ONE .btn-secondary END TRIP — no .btn-primary anywhere in guidance chrome except the confirm card
|
||||
- buildConfirmCard: GUIDE // CONFIRM + exact en/ms headline/body + .btn-primary "YES, ABANDON" + .btn-secondary "CONTINUE"; bottom register placement class
|
||||
- buildTripEndCard: GUIDE // END + exact headline/body, no actions
|
||||
- distance/ETA formatting instrument constants in code: "{n} M" under 1000 (integer), "{n.n} KM" at/over (one decimal), 24-hour ETA clock — identical under en/ms
|
||||
- structural: zero "transition"/"animation" CSS in the new shell.css block; zero hex literals (token vars only); zero border-radius; zero box-shadow
|
||||
</behavior>
|
||||
<action>
|
||||
1. `web/src/guidance/banner.ts` — five exported builders, each `(locale, view, actions) => HTMLElement` with a view interface per the behavior list (copy route-card.ts's actions-interface-first structure):
|
||||
- `buildGuidanceBanner(locale, view: { state: GuidanceState; glyph: GlyphKind; distanceM: number | null; street: string | null; listOpen: boolean }, actions: { onToggle(): void })`
|
||||
- `buildManeuverList(locale, rows: Array<{ kind: GlyphKind; street: string; distanceM: number; rowState: "next" | "future" | "past" }>, actions: { onDismiss(): void })` (the tap-out layer dismisses)
|
||||
- `startGantryBlink(el: HTMLElement): void` / `stopGantryBlink(el: HTMLElement): void` (setInterval 500 ms toggling a data attribute; one interval per element, stop clears)
|
||||
- `buildGuidanceCard(locale, view: { destinationName: string; minutesRemaining: number; kmRemaining: number; etaClock: string; progressPct: number; showNoReroute: boolean }, actions: { onEndTrip(): void })`
|
||||
- `buildConfirmCard(locale, actions: { onAbandon(): void; onContinue(): void })` and `buildTripEndCard(locale): HTMLElement`
|
||||
All copy via `strings[locale]`; registers composed in code (`\`${strings[locale].guideRegister} // NEXT\`` style instrument constants); formats via a local `fillTemplate`-style composer (route-card precedent).
|
||||
2. `web/src/shell.css` — append the guidance block, token vars ONLY: banner band (matte-black chassis, min-height 96px, padding space-sm space-md, full-bleed, below safe-top + space-sm), 1px chassis-subtle banner→strip separator, `[data-guidance="active"]` strip compaction (min-height 24px, padding-block space-xs, telemetry cluster display:none — layout only), list panel + row anatomy + separators + lamp, guidance-card telemetry row + 4px progress bar (chassis-subtle track, 1px matte-black border, signal-orange fill, width set by inline style from the pct), ink colors per the hierarchy (register chassis-subtle, street soft-beige, distance pure-white). NO transition/animation/box-shadow/border-radius/hex anywhere in the block.
|
||||
3. RED→GREEN `web/test/guidance-surface.test.mjs` (the RS style: Node-24 dynamic .ts imports, minimal document fake — reuse the route-surface DOM-stub approach): behavioral tests per the behavior list + structural gates — innerHTML ban with the svgHost whitelist (exactly one sanctioned site), inline-copy ban (RS9-analog grep of banner.ts for English/Malay literals outside strings imports — allowed: the instrument register words NEXT/REROUTING/RESUMING/NO REROUTE/CONFIRM/END and unit formats), CSS block scan (no transition|animation|box-shadow|border-radius|#[0-9a-f]{3,6} in the appended guidance section), aria assertions (banner button aria-expanded/controls, list region label, progressbar valueattrs).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- guidance-surface.test.mjs green with: five-slot-every-state banner test, list row-states test, gantry blink start/stop test, confirm/trip-end copy tests asserting the exact strings-module keys, END TRIP is btn-secondary and the only card action, progressbar aria assertions
|
||||
- structural: banner.ts innerHTML appears ONLY in svgHost against GLYPHS[kind]; grep "btn-primary" in banner.ts matches ONLY the confirm card builder
|
||||
- shell.css guidance block: grep `transition|animation|box-shadow|border-radius` → 0 matches in the appended section; grep `#[0-9a-fA-F]{3,6}` → 0 matches (token vars only)
|
||||
- strip.ts untouched (grep confirms no changes; compaction is pure CSS under [data-guidance="active"])
|
||||
</acceptance_criteria>
|
||||
<done>The cockpit renders: banner five-slot discipline with calm states, honest collapsible list, evolved guidance card with progress instrument, guarded confirm surface, and trip-end card — all token-pure and Node-tested</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 3: Capability guards — haptics + close protection + webapp members + gate bumps</name>
|
||||
<files>web/src/guidance/haptics.ts, web/src/guidance/close-protection.ts, web/src/telegram/webapp.ts, web/test/guidance-surface.test.mjs, web/test/route-surface.test.mjs, web/test/build-artifact.test.mjs</files>
|
||||
<read_first>
|
||||
- web/src/telegram/webapp.ts lines 31–65 (the contract: optional members only — grow it, never break existing fakes)
|
||||
- web/src/telegram/safe-area.ts lines 35–50 (the isVersionAtLeast-gate shape to copy)
|
||||
- web/test/build-artifact.test.mjs lines 185–230 (BA9's input list + printed budget — extend in the same change)
|
||||
- web/test/route-surface.test.mjs RS10 (the floor bump — PATTERNS caution 4)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- fireHaptic(wa, "approach") → wa.HapticFeedback.selectionChanged(); "maneuver" → impactOccurred("medium"); "arrive" → notificationOccurred("success")
|
||||
- fireHaptic on a wa WITHOUT HapticFeedback → no-op, no throw (D-08 graceful)
|
||||
- engageCloseProtection(wa): calls disableVerticalSwipes() only when isVersionAtLeast("7.7"), enableClosingConfirmation() only when isVersionAtLeast("6.2"); releaseCloseProtection inverts both; capability-missing → silent no-op (Pitfall 4's layered defense: body-swipe off + native confirm net; the header swipe cannot be disabled — documented in the module header)
|
||||
- webapp.ts fakes in EXISTING tests still typecheck/run (optional members only)
|
||||
- BA9: input list includes the guidance modules, budget printed, ≤ 204800 gz bytes
|
||||
- RS10: floor raised to 24 src files
|
||||
</behavior>
|
||||
<action>
|
||||
1. `web/src/telegram/webapp.ts`: add the optional members from the context box to TelegramWebAppLike (each with the Bot-API-version doc comment). No existing member changes shape.
|
||||
2. `web/src/guidance/haptics.ts`: `fireHaptic(wa: TelegramWebAppLike | null, cue: "approach" | "maneuver" | "arrive"): void` — `?.` chains only; module header cites D-08 + the no-haptic-on-reroute/confirm law (UI-SPEC: a reroute haptic would announce blame).
|
||||
3. `web/src/guidance/close-protection.ts`: `engageCloseProtection(wa)` / `releaseCloseProtection(wa)` — version gates `typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")` / `("6.2")` before each call, `?.` on the member; header documents the Pitfall-4 layering (the Rams card is the designed surface; the native dialog is the net; header swipe undisableable per official docs).
|
||||
4. RED→GREEN in `web/test/guidance-surface.test.mjs`: recording-fake tests for both modules (calls recorded, version gates respected, no-throw on capability-less fakes).
|
||||
5. Gate bumps in the same change: `web/test/route-surface.test.mjs` RS10 floor `>= 16` → `>= 24`; `web/test/build-artifact.test.mjs` BA9 input list += the web/src/guidance .ts modules (glyphs, banner, haptics, close-protection, guidance, eta, watch — camera lands in plan 06 and rides the same list then). Run the full web suite; if the bundle budget busts, the guidance modules are DOM strings — investigate before touching the budget (the gate is not negotiable without a recorded call).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web && npm run web:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- webapp.ts gains ONLY optional members (existing interface members unchanged; existing suites green)
|
||||
- guidance-surface.test.mjs asserts all haptic/close-protection behaviors incl. version gates (7.7 / 6.2) and no-throw no-ops
|
||||
- route-surface.test.mjs RS10 asserts >= 24 files and passes
|
||||
- build-artifact.test.mjs BA9 lists the guidance modules, prints its measurement, passes ≤ 204800
|
||||
- grep `isVersionAtLeast("7.7")` and `isVersionAtLeast("6.2")` match web/src/guidance/close-protection.ts
|
||||
- typecheck clean
|
||||
</acceptance_criteria>
|
||||
<done>The eyes-free channel and the three-layer close protection exist behind version guards that degrade to honest no-ops; the storage and bundle gates grew with the family and stayed green</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Telegram client → WebApp APIs | Client capabilities vary across Bot API 6.1–8.0+; every new call is capability-guarded |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-06 | Tampering (crash/doS on old clients) | haptics.ts, close-protection.ts | mitigate | Version gates BEFORE every call + `?.` optional-member chains + no-throw fakes proven in tests (RESEARCH Pitfall 5). Graceful degradation is the D-08 law. |
|
||||
| — | Spoofing (UI overlay confusion) | confirm card | accept | The confirm card is plain DOM in the app's own register band; no privileged surface to spoof; Telegram chrome is out of scope. |
|
||||
| — | Information Disclosure | banner/list/card | mitigate-by-design | Builders render only route-model data the user is already looking at; no coordinates are ever logged (no console in builders — structural gate). |
|
||||
| — | DoS | gantry blink interval | mitigate | startGantryBlink stores one interval per element and stopGantryBlink clears it; state swaps always stop the previous blink (wired in plan 06; the API makes a leak visible in review). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w web && npm run web:typecheck` green (existing suites + guidance-surface + copy gate)
|
||||
- RS10 (≥24) and BA9 (budget printed, ≤204800) green with the guidance family counted
|
||||
- Zero inline copy, zero off-token CSS values, zero red in guidance chrome
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
The guidance cockpit exists as pure, tested builders + guards: banner five-slot discipline with the calm REROUTING/RESUMING/NO REROUTE states, collapsible honest list, evolved guidance card with progress bar, ONE confirm surface with the exact privacy copy, trip-end card, capability-safe haptics and close protection — every word in strings.ts, every pixel from tokens.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-05-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,253 @@
|
||||
---
|
||||
phase: 06-turn-by-turn-guidance-live-driving
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [06-02, 06-04, 06-05]
|
||||
files_modified:
|
||||
- web/src/telegram/backbutton.ts
|
||||
- web/src/map/follow.ts
|
||||
- web/src/guidance/camera.ts
|
||||
- web/src/route/route.ts
|
||||
- web/src/main.ts
|
||||
- web/test/route-surface.test.mjs
|
||||
autonomous: true
|
||||
requirements: [GUID-02, GUID-05, GUID-06, PX-01, PX-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Guidance activates when the route is drawn and the first accepted fix is ≥ GUIDANCE_START_ALONG_M along it — never without a route (PX-03's precondition)"
|
||||
- "The Telegram BackButton walks search → routes → guidance → (confirm) → routes → search and never dead-ends; close() fires only at the true root (PX-01)"
|
||||
- "BackButton from guidance opens the abandon confirm card; back while the confirm is open means CONTINUE (D-09)"
|
||||
- "The camera rides the SNAPPED line (locked follow, padding {top:320,bottom:64,left:64,right:64}, pitch 35, north-up); user pan unlocks → the recenter control returns (D-10)"
|
||||
- "A missed turn flows to a silent /api/route reroute and the new line snaps in — banner calm, no blame (GUID-02 wired)"
|
||||
- "Minimize POSTs the envelope (keepalive, best-effort D-13); re-open DELETEs it (cron pauses — structural); a fresh boot with ?g=<id> resumes at the progressed position (GUID-05/GUID-06)"
|
||||
- "Arrival AND abandon both: delete the envelope (idempotent), stop the watch, release close protection, show the trip-ended card (D-14)"
|
||||
artifacts:
|
||||
- path: "web/src/guidance/camera.ts"
|
||||
provides: "locked-follow guidance camera over the snapped line"
|
||||
exports: ["GUIDANCE_CAMERA_PADDING", "GUIDANCE_ZOOM", "GUIDANCE_EASE_MS", "createGuidanceCamera"]
|
||||
- path: "web/src/route/route.ts"
|
||||
provides: "the guidance controller wiring: pump, effects dispatch, chrome updates, envelope flows"
|
||||
- path: "web/src/telegram/backbutton.ts"
|
||||
provides: "the context-aware full-state-graph wire (predicate + handler as one change)"
|
||||
key_links:
|
||||
- from: "web/src/route/route.ts"
|
||||
to: "web/src/guidance/guidance.ts"
|
||||
via: "watch → shouldAcceptFix → stepGuidance → effects"
|
||||
pattern: "stepGuidance"
|
||||
- from: "web/src/route/route.ts"
|
||||
to: "web/src/guidance/banner.ts"
|
||||
via: "chrome re-render per accepted fix"
|
||||
pattern: "buildGuidanceBanner"
|
||||
- from: "web/src/route/route.ts"
|
||||
to: "/api/guidance"
|
||||
via: "POST on deactivated (keepalive), DELETE on activated + trip end"
|
||||
pattern: "/api/guidance"
|
||||
- from: "web/src/main.ts"
|
||||
to: "routing/src/http-source.ts"
|
||||
via: "engine-a first, fixture fallback — the composed ladder, code-constructed"
|
||||
pattern: "createHttpRoutingSource"
|
||||
- from: "web/test/route-surface.test.mjs"
|
||||
to: "web/src/route/route.ts"
|
||||
via: "RS6 re-pinned (guidance IMPLEMENTATION lives in web/src/guidance/) + the PX-01 full-walk test"
|
||||
pattern: "RS6|walkBack"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the guidance brain (06-02) into the live shell through the chrome (06-05) and the server (06-04/03): the full PX-01 BackButton state graph, the D-10 locked-follow camera, the fix pump dispatching banner/list/card/haptics/reroute, and the minimize/resume/trip-end envelope flows — completing GUID-02/05/06's client half and D-14's in-app half.
|
||||
|
||||
Purpose: this is where navigation becomes behavior. The BackButton walk (PX-01) only exists now that the full state graph does; the envelope lifecycle (D-11's structural foreground gate) only works when the app POSTs on minimize and DELETEs on return.
|
||||
Output: camera + pump + BackButton graph + envelope flows wired in route.ts/main.ts, RS6 re-pinned, PX-01 walk proven with a recording fake.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/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
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- From 06-02 (web/src/guidance/): createGuidanceSession/stepGuidance (events fix |
|
||||
reroute-started | reroute-failed | reroute-applied; effects haptic | reroute-requested |
|
||||
arrival), projectAlongRoute, watch.ts {shouldAcceptFix, createGuidanceWatch},
|
||||
eta.ts {updateEta, composeEtaClock}, constants GUIDANCE_START_ALONG_M etc. -->
|
||||
|
||||
<!-- From 06-05 (web/src/guidance/): buildGuidanceBanner / buildManeuverList / buildGuidanceCard /
|
||||
buildConfirmCard / buildTripEndCard / startGantryBlink / stopGantryBlink / GLYPHS;
|
||||
fireHaptic(wa, cue); engageCloseProtection(wa) / releaseCloseProtection(wa);
|
||||
webapp.ts now has HapticFeedback?, disableVerticalSwipes?, enableClosingConfirmation?,
|
||||
isActive?, onEvent("activated"|"deactivated"). -->
|
||||
|
||||
<!-- From 06-04: createHttpRoutingSource({ endpoint, bearer, fetchImpl? }) — backend "engine-a",
|
||||
fail-honest nulls. From 06-03: POST /api/guidance → {id}; GET/DELETE /api/guidance?id=.
|
||||
Envelope snapshot: {destinationId, destinationName, distanceM, durationS, geometry
|
||||
(polyline6), startedAtSec}. Session token: web/src/session/store.ts getSession(). -->
|
||||
|
||||
<!-- Existing seams (PATTERNS): wireBackButton(wa, hasOverlay, walkBack) — predicate + handler
|
||||
as ONE change; createFollowController(map, onModeChange) with the movestart originalEvent
|
||||
unlock law; createRouteController(deps) — all DOM/map concerns injected (RS gates run in
|
||||
Node); recenter control data-attribute states; console law: ARAHARAH-GUIDE state=<state>
|
||||
tokens only, never coordinates (the RS8 family). -->
|
||||
|
||||
<!-- UI-SPEC camera table (binding): guidance start → easeTo snapped, padding {top:320,
|
||||
bottom:64,left:64,right:64}, pitch 35, zoom 16 (GUIDANCE_ZOOM), 400 ms (GUIDANCE_EASE_MS);
|
||||
per accepted fix → easeTo the SNAPPED point; user pan (originalEvent) unlocks; recenter
|
||||
re-locks; reroute snap-in → NO camera jump (setData only); trip end → camera holds.
|
||||
Bearing NEVER rotates (north-up, recorded call). -->
|
||||
|
||||
<!-- UI-SPEC BackButton contract (binding): guidance Back → abandon confirm card; confirm open
|
||||
+ Back → CONTINUE (dismiss, stay); YES, ABANDON → teardown → route card state; trip-ended
|
||||
card Back → search root; route-state Back → Phase 5 walk. -->
|
||||
</interfaces>
|
||||
|
||||
**RS6 re-pin (PATTERNS caution 3):** the gate currently bans `progress`/`maneuver`/`btn-primary` tokens in web/src/route/. This plan's route.ts gains guidance REFERENCES — re-pin RS6 in the same change: the ban becomes "guidance IMPLEMENTATION lives in web/src/guidance/ — route/ files may import from ../guidance/ and call it, but must not define maneuver/progress rendering inline" (structural: the tokens are allowed only on `import … from "../guidance/…"` lines and direct call sites).
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 1: Guidance camera (locked follow) + BackButton full state graph</name>
|
||||
<files>web/src/guidance/camera.ts, web/src/map/follow.ts, web/src/telegram/backbutton.ts, web/test/route-surface.test.mjs</files>
|
||||
<read_first>
|
||||
- web/src/map/follow.ts (the mode machine + programmatic-move lock + CAMERA_PADDING_PX — extend, don't reinvent)
|
||||
- web/src/telegram/backbutton.ts (wireBackButton — the predicate + handler-as-one-change law; the Phase 5 header comment reserved this extension)
|
||||
- web/src/ui/recenter.ts (data-attribute state swaps; the control returns unlocked on pan)
|
||||
- web/test/route-surface.test.mjs RS7 (the recording fake-wa walk test — extend for the full graph)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md Camera Behavior + BackButton Contract tables (BINDING parameters)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- createGuidanceCamera.easeToSnapped(point): fakeMap.easeTo recorded with center=snapped point, padding {top:320,bottom:64,left:64,right:64}, pitch 35, zoom 16, duration 400 — bearing NEVER passed (north-up)
|
||||
- programmatic moves (originalEvent undefined) do NOT unlock follow; a movestart WITH originalEvent unlocks → recenter control's follow notch clears (Phase 3 semantics)
|
||||
- follow.ts's existing follow(fix) behavior is unchanged when called without opts (Phase 3/5 routes keep their camera law)
|
||||
- PX-01 walk with the recording fake: root → hidden; route state → shown, click → Phase 5 walk back; guidance → shown, click → confirm card opens (NOT teardown); confirm open → click → confirm dismissed (CONTINUE), guidance alive; YES ABANDON → chrome clears → route card state → click → Phase 5 walk → destination card (root) → hidden; trip-ended card → click → search root, hidden; close() recorded ZERO times anywhere except an explicit root close path
|
||||
</behavior>
|
||||
<action>
|
||||
1. `web/src/map/follow.ts`: extend `follow(fix, opts?: { padding?: {top,bottom,left,right}; pitch?: number; zoom?: number; durationMs?: number })` — opts spread over the CURRENT defaults so existing callers are byte-identical in behavior. Keep the lock/originalEvent law untouched.
|
||||
2. `web/src/guidance/camera.ts`: named exports `GUIDANCE_CAMERA_PADDING = { top: 320, bottom: 64, left: 64, right: 64 }`, `GUIDANCE_ZOOM = 16`, `GUIDANCE_EASE_MS = 400`, `GUIDANCE_CAMERA_PITCH = 35`; `createGuidanceCamera(map, followController)` returning `{ easeToSnapped(point: RoutePoint): void; onRerouteApplied(): void }` — easeToSnapped calls followController.follow(point, { padding: GUIDANCE_CAMERA_PADDING, pitch: GUIDANCE_CAMERA_PITCH, zoom: GUIDANCE_ZOOM, durationMs: GUIDANCE_EASE_MS }); onRerouteApplied is a documented no-op camera-wise (setData happens in the route layer — UI-SPEC: no camera jump on reroute).
|
||||
3. `web/src/telegram/backbutton.ts`: grow the wire's doc-header to the 3+ state graph and extend the predicate/handler so `walkBack` is CONTEXT-AWARE via an injected `resolveAction(): BackAction` where `BackAction = { kind: "hidden" } | { kind: "close" } | { kind: "callback"; fn: () => void }` — keep the old two-argument call shape working (hasOverlay + walkBack compose into the resolver internally) so Phase 5 call sites and RS7 stay valid; the visible-iff-pushed-state and never-close-from-pushed laws hold for every new state.
|
||||
4. RED→GREEN in `web/test/route-surface.test.mjs`: extend RS7's recording-fake block with the full-walk scenario per the behavior list (the confirm-card steps assert the CALLBACKS the fake receives — the confirm surface itself is 06-05's, wired in task 2), plus camera tests with the route-surface fakeMap pattern (easeTo args recorded; unlock law).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web && npm run web:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- camera.ts exports the three constants with EXACT UI-SPEC values (test pins them, RS4 style)
|
||||
- fakeMap easeTo assertions: padding/pitch/zoom/duration exact; no bearing key ever present
|
||||
- follow.ts without opts behaves byte-identically (existing follow tests untouched-green)
|
||||
- RS7 extended: full walk recorded, close() count === 0 across all pushed-state clicks
|
||||
- typecheck clean
|
||||
</acceptance_criteria>
|
||||
<done>The camera rides the snapped line at the UI-SPEC pose and unlocks only on user gesture; the BackButton resolver walks every state with zero dead ends and zero premature closes</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: The guidance pump in route.ts — start, effects, chrome, reroute</name>
|
||||
<files>web/src/route/route.ts, web/test/route-surface.test.mjs</files>
|
||||
<read_first>
|
||||
- web/src/route/route.ts IN FULL (the controller you are evolving: injected deps, setState funnel, fail-honest catch, console law, D-10 reserved note)
|
||||
- web/src/guidance/guidance.ts + watch.ts + eta.ts (06-02 contract — pump against it, never re-implement it)
|
||||
- web/src/guidance/banner.ts + haptics.ts + close-protection.ts (06-05 chrome + guards)
|
||||
- web/src/route/route-card.ts (the card the guidance card evolves from)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md State Coexistence table (BINDING: OFF_ROUTE shows no premature signal; REROUTING holds last honest numbers; NO REROUTE state line; list re-render on reroute-apply)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- route drawn + fix < GUIDANCE_START_ALONG_M along → still Phase 5 route state (no guidance chrome)
|
||||
- route drawn + fix ≥ 30 m along → guidance activates: engageCloseProtection, disableVerticalSwipes path via engage, [data-guidance="active"] on the shell root, banner + guidance card + collapsed list mount, camera easeToSnapped, ARAHARAH-GUIDE state=on_route token logged (category-only)
|
||||
- accepted fixes pump stepGuidance: banner distance/street update, ETA via updateEta + composeEtaClock, progress bar pct = alongM/totalM, haptic effects → fireHaptic, OFF_ROUTE → banner UNCHANGED (no premature signal), REROUTING → register swap + reroute copy + distance "—" + gantry blink start
|
||||
- reroute-requested effect → source.route({origin: fix, destinationId}) (the composed ladder from task 3); success → reroute-applied event → new line setData, list re-render (scroll top), gantry stop, camera NO jump; failure/throw → reroute-failed → NO REROUTE banner state + card state line (fail-honest, calm)
|
||||
- arrival effect → trip-end flow (task 3's teardown: DELETE envelope idempotent, stop watch, release protection, trip-ended card, chrome clears)
|
||||
- END TRIP tap → confirm card (bottom); YES, ABANDON → same teardown; CONTINUE → dismiss
|
||||
</behavior>
|
||||
<action>
|
||||
1. Extend `RouteControllerDeps` with injected guidance concerns: `wa` (TelegramWebAppLike | null), `shellRoot` (the element receiving data-guidance), `register` (the register shower), banner/card/list mount points, `console` stays the module's own (state tokens only: `ARAHARAH-GUIDE state=<state>` — RS8 family; never coordinates).
|
||||
2. Implement the guidance controller INSIDE route.ts's injected-deps shape: a `startGuidance(model)` / `pumpFix(fix)` / `teardownGuidance(reason: "arrival" | "abandon")` triple held in the controller closure, using ONLY 06-02's machine + 06-05's builders (zero maneuver math inline — RS6's re-pinned ban enforces it). Pump: `shouldAcceptFix` gate → `stepGuidance` → effects dispatch (haptics via fireHaptic; reroute via the injected source; arrival → teardown) → chrome updates (banner five slots, ETA via updateEta/composeEtaClock, progress pct, list rows via the model's steps + projectAlongRoute). REROUTING: startGantryBlink on mount, stopGantryBlink on resolve. The confirm card + trip-end card mount into the injected bottom/center registers per UI-SPEC placement.
|
||||
3. RED→GREEN in `web/test/route-surface.test.mjs` with the fakeDeps pattern: guidance activation gate (stationary vs moving first fix), pump updates (banner distance from a known fix, ETA state, progress pct), OFF_ROUTE no-premature-signal, REROUTING calm state + exactly one source.route call, reroute failure → NO REROUTE + card state line, reroute success → list re-rendered + no camera jump, END TRIP → confirm → YES ABANDON → teardown effects recorded (close-protection release + watch stop + DELETE fetch recorded via stubFetch), console tokens category-only.
|
||||
4. RE-PIN RS6 in the same change: tokens `progress`/`maneuver`/`btn-primary` banned in web/src/route/ EXCEPT on `from "../guidance/…"` import lines and direct call expressions of imported names (structural scan per the context note).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web && npm run web:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- route-surface.test.mjs green including: activation gate, pump updates, no-premature-signal, exactly-one-reroute-call, NO REROUTE degradation, teardown-on-abandon (release + stop + DELETE recorded), ARAHARAH-GUIDE tokens category-only
|
||||
- RS6 re-pinned and green (guidance implementation lives in web/src/guidance/)
|
||||
- grep `ARAHARAH-GUIDE state=` matches web/src/route/route.ts; grep `console\.` in route.ts shows only state-token lines (no `${lat` / coordinate interpolation)
|
||||
- typecheck clean
|
||||
</acceptance_criteria>
|
||||
<done>Driving works end-to-end in Node fakes: motion starts guidance, fixes drive the cockpit, excursions self-heal silently through the real /api/route, and both exit paths tear down cleanly</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 3: Envelope flows — minimize POST, resume GET, trip-end DELETE + composed ladder</name>
|
||||
<files>web/src/main.ts, web/src/route/route.ts, web/test/route-surface.test.mjs</files>
|
||||
<read_first>
|
||||
- web/src/main.ts lines 40–70 and 340–470 (the composition root: fixture source construction, route controller creation, session/boot choreography, BackButton wiring)
|
||||
- web/src/session/store.ts (getSession — the Bearer getter for http-source)
|
||||
- bot/src/kv/guidance-sessions.ts (the frozen snapshot fields the POST body must carry EXACTLY)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 5 (envelope lifecycle as structural foreground gate; write-at-minimize + earliest-signal belt) + Pitfall 2 (the minimize race — fetch keepalive)
|
||||
- web/test/destination-preload.test.mjs (the ?param reader pattern — ?g= follows it)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- deactivated event during guidance → POST /api/guidance with the frozen snapshot shape (geometry re-encoded via encodePolyline6 from the live model; startedAtSec = trip start) using fetch(…, { keepalive: true }) — recorded by stubFetch even when the harness simulates immediate freeze
|
||||
- activated event during guidance → DELETE /api/guidance?id=<stored id> (cron pauses — structural); the id is retained for trip-end idempotent DELETE
|
||||
- fresh boot with ?g=<uuid> → GET /api/guidance?id= → envelope → model rebuilt (decodePolyline6 + deriveCumulativeDistanceM + destination fields) → RESUMING state (projected numbers from startedAtSec + avg speed; GUIDE // RESUMING banner + gantry) → first accepted fix → live ON_ROUTE; a failed/foreign GET → honest destination-less boot (Phase 4 flow), never a fake route
|
||||
- composed ladder in main.ts: engine-a first, fixture fallback on null — `route: (req) => (await http.route(req)) ?? (await fixture.route(req))` code-constructed (no env reads)
|
||||
- trip end (arrival or abandon) → DELETE envelope (404-tolerant), watch stop, close-protection release, data-guidance removed, trip-ended card; BackButton from trip-end walks to search root
|
||||
</behavior>
|
||||
<action>
|
||||
1. In `main.ts`: build the composed RoutingSource ladder beside the existing fixture construction (code-constructed per the types.ts no-registry law): `const httpSource = createHttpRoutingSource({ endpoint: "/api/route", bearer: () => getSession() });` then the wrapper `{ name: "ladder", route: async (req) => (await httpSource.route(req)) ?? (await fixtureSource.route(req)) }` handed to createRouteController. Keep the golden-demo path: with no graph/no token, engine-a nulls fall through to the fixture corpus (captures stay stable).
|
||||
2. Envelope lifecycle in the controller closure (exposed to main.ts via the controller): `onDeactivated` → POST (keepalive fetch, body = frozen snapshot only — id/chat_id/created_at are SERVER-minted; store the returned id); `onActivated` → DELETE ?id; `teardownGuidance` → DELETE ?id (tolerate 404) + watch stop + release + trip-end card. Wire `wa.onEvent?.("deactivated"|"activated", …)` with capability guards.
|
||||
3. `?g=` resume in main.ts's boot choreography (destination-preload pattern): read `new URLSearchParams(location.search).get("g")`, UUID-validate, GET envelope, rebuild the model, create the guidance session in RESUMING state (projection: `remainingM = distanceM × (1 − min(1, (nowSec − startedAtSec) × avgSpeed / distanceM))`, avgSpeed = distanceM/durationS), render RESUMING chrome (banner register + gantry + projected ETA card numbers), flip to live on first accepted fix.
|
||||
4. RED→GREEN in `web/test/route-surface.test.mjs`: stubFetch records POST (keepalive true, snapshot fields exactly the frozen six), DELETE on activated + on teardown (idempotent second DELETE tolerated), ?g= boot → RESUMING → first fix → ON_ROUTE (fake wa events + URL stub), failed GET → Phase 4 boot flow (no guidance chrome).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w web && npm run web:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- stubFetch assertions: POST /api/guidance body has EXACTLY destinationId/destinationName/distanceM/durationS/geometry/startedAtSec (no id/chat_id/created_at — server-minted), keepalive true; DELETE called with the stored id on activated AND teardown
|
||||
- ?g= resume test: RESUMING state rendered (GUIDE // RESUMING register + projected numbers), first fix flips to live ON_ROUTE
|
||||
- main.ts grep `createHttpRoutingSource` and `?? (await` (the composed ladder) present; grep for env reads in the backend selection → 0 (code-constructed law)
|
||||
- full web suite green; typecheck clean
|
||||
</acceptance_criteria>
|
||||
<done>The envelope lifecycle is structural (no key, no push), the minimize race is belt-and-braces (keepalive + earliest signal), resume works from a killed webview via ?g=, and both trip ends execute the deletion + honest messaging</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Minimize/freeze → envelope POST | A backgrounding webview may freeze mid-fetch (Pitfall 2) — best-effort honesty, not a security boundary |
|
||||
| ?g= deep link → resume GET | The guidance id is an opaque UUID traveling in the URL; ownership enforced server-side by the claim check |
|
||||
| User gestures → camera | Only originalEvent-bearing moves unlock follow — synthetic moves cannot hijack the driving camera |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-05 | Information Disclosure | envelope POST + reroute calls | mitigate-by-design | Exactly three positions ever leave the device: reroute origins (real excursions only), the minimize envelope (user-initiated handoff), nothing else. On-route fixes stay local (06-02's structural gate). The envelope carries the frozen route snapshot only — no live coordinate stream. |
|
||||
| T-06-04 | Information Disclosure | console + state tokens | mitigate | ARAHARAH-GUIDE state tokens only — tested to contain no coordinates (RS8 family); no payload/claim values in any log line. |
|
||||
| T-06-06 | Tampering (old-client crash) | onEvent("deactivated"/"activated") wiring | mitigate | `wa.onEvent?.()` optional chains; event names ignored by older clients by contract; capability guards proven in 06-05's fakes. |
|
||||
| — | Spoofing (?g= probe) | resume GET | mitigate (server, 06-03) | Foreign/absent id → 404 → honest Phase 4 boot; the client renders no guidance chrome without a validated envelope. |
|
||||
| — | DoS (self) | reroute requests | accept | The D-04 storm guard (20 s) bounds reroute rate client-side; replay tests prove exactly-one-call under excursion storms. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w web && npm run web:typecheck` green (RS6 re-pinned; PX-01 full walk; envelope flows; camera laws)
|
||||
- Zero dead ends in the BackButton graph (RS7 extended walk, close() only at true root)
|
||||
- Console carries only ARAHARAH-GUIDE/ARAHARAH-ROUTE state tokens
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Guidance is ALIVE in the shell: motion activates it, fixes drive the cockpit, missed turns self-heal through the real router with a calm banner, minimizing hands off to the cron (envelope live), re-opening resumes at the progressed position, the BackButton walks the full graph through the abandon confirmation, and both trip ends delete the record and say so. PX-01/PX-03's observable behaviors are claimed and tested.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-06-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,269 @@
|
||||
---
|
||||
phase: 06-turn-by-turn-guidance-live-driving
|
||||
plan: 07
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [06-03, 06-04, 06-05]
|
||||
files_modified:
|
||||
- bot/src/guidance/cron.ts
|
||||
- bot/src/webhook.ts
|
||||
- bot/wrangler.jsonc
|
||||
- bot/src/i18n/strings.ts
|
||||
- bot/test/guidance-cron.test.mjs
|
||||
- bot/test/commands.test.mjs
|
||||
- routing/scripts/build-glyphs.mjs
|
||||
- routing/package.json
|
||||
- web/public/glyphs/depart.png
|
||||
- web/public/glyphs/straight.png
|
||||
- web/public/glyphs/left.png
|
||||
- web/public/glyphs/right.png
|
||||
- web/public/glyphs/slight-left.png
|
||||
- web/public/glyphs/slight-right.png
|
||||
- web/public/glyphs/uturn.png
|
||||
- web/public/glyphs/arrive.png
|
||||
autonomous: true
|
||||
requirements: [GUID-05]
|
||||
must_haves:
|
||||
truths:
|
||||
- "A Worker Cron Trigger scans guidance:* envelopes every 60 s, projects progress along the snapshot, and pushes a glyph+text turn-card on its 3-minute cadence (D-11/D-12)"
|
||||
- "Cards push ONLY to envelope.chat_id read from KV — no client-supplied chat id exists anywhere in the cron path (T-06-02)"
|
||||
- "The cron performs ZERO KV writes — the stateless cadence holds the 1,000-writes/day free budget (Pitfall 3)"
|
||||
- "Card copy never promises live tracking — 'best-effort projection' is the strongest claim (D-13)"
|
||||
- "The 8 committed glyph PNGs exist, token-colored, built from the SAME committed SVG sources the app renders (D-12 single source)"
|
||||
- "The card's OPEN button deep-links ${MINI_APP_URL}/?g=<id> so a killed webview resumes from the envelope (GUID-06 bridge)"
|
||||
artifacts:
|
||||
- path: "bot/src/guidance/cron.ts"
|
||||
provides: "PURE projectTurnCards + the scheduled() projection/push logic"
|
||||
exports: ["projectTurnCards", "CARD_CADENCE_S"]
|
||||
- path: "bot/src/webhook.ts"
|
||||
provides: "scheduled() handler with KV list pagination"
|
||||
- path: "bot/wrangler.jsonc"
|
||||
provides: "triggers.crons */1 * * * * (60 s scan; the card decision is cadence-gated in code)"
|
||||
- path: "web/public/glyphs/*.png"
|
||||
provides: "8 committed 256×256 turn-card PNGs served by the Worker's static assets"
|
||||
key_links:
|
||||
- from: "bot/src/guidance/cron.ts"
|
||||
to: "routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM)"
|
||||
via: "projection over the snapshot geometry — one turn authority shared with engine A"
|
||||
pattern: "classifyTurn"
|
||||
- from: "bot/src/webhook.ts scheduled()"
|
||||
to: "bot/src/guidance/cron.ts"
|
||||
via: "KV list prefix guidance: → projectTurnCards → maybe sendPhoto"
|
||||
pattern: "projectTurnCards"
|
||||
- from: "routing/scripts/build-glyphs.mjs"
|
||||
to: "web/src/guidance/glyphs/*.svg"
|
||||
via: "the committed SVGs are the single source; PNGs are their rasterization"
|
||||
pattern: "glyphs"
|
||||
---
|
||||
|
||||
<objective>
|
||||
GUID-05's backgrounded half: the repo's first Cron Trigger scanning live guidance envelopes, projecting progress along the frozen snapshot geometry, and pushing honest glyph+text turn-cards to the user's own chat — plus the committed 8-glyph PNG set built from the app's SVG sources.
|
||||
|
||||
Purpose: navigation survives the app being minimized, best-effort and honestly framed (D-13). The envelope lifecycle is structural (plan 06): no key, no push.
|
||||
Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with KV-budget pins + 8 committed PNGs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/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-RESEARCH.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
<!-- FROZEN envelope (bot/src/kv/guidance-sessions.ts — read-only here; GE2 means NO steps field):
|
||||
{ id, chat_id, route_snapshot: { destinationId, destinationName, distanceM, durationS,
|
||||
geometry (polyline6), startedAtSec }, created_at } -->
|
||||
|
||||
<!-- From 06-01 (routing workspace — bot already depends on it): classifyTurn(prevDeg, nextDeg),
|
||||
decodePolyline6(s), deriveCumulativeDistanceM(points), snapToGraph? (NOT needed — see the
|
||||
recorded call below), haversineM. Subpath-with-extension imports only. -->
|
||||
|
||||
<!-- RECORDED CALL (UI-SPEC deviation, honesty-law rationale): the turn-card's maneuver line is
|
||||
composed STREET-LESS — "{dist} — {action}" — because the FROZEN envelope carries geometry
|
||||
only, no step names (GE2 field freeze stays; adding a 7th snapshot field is a user
|
||||
decision, not a plan's). Snapping turn vertices to the graph to guess street names was
|
||||
REJECTED: a wrong street name in a card is a lie; a street-less card is honest. The
|
||||
UI-SPEC's "{action} onto {street}" form remains the app-side banner's job, where the
|
||||
route model DOES carry steps. Record this call in the phase fidelity artifact. -->
|
||||
|
||||
<!-- Caption (UI-SPEC Turn-Card section, HTML, ≤1024 chars — ~120 used):
|
||||
GUIDE // T-{nn} ← instrument constant, code-composed
|
||||
<b>{dist} — {cardActionBare[action]}</b> ← uppercase, street-less per the recorded call
|
||||
ETA {HH:MM} · {km} KM LEFT ← cardEtaTemplate
|
||||
Best-effort projection — updates while the app is minimized. ← cardHonesty
|
||||
Inline keyboard: [{ text: cardOpenButton, web_app: { url: "<MINI_APP_URL>/?g=<id>" } }]
|
||||
(web_app buttons carry ONLY a url — the 04-03 mechanism-correction precedent — so the
|
||||
guidance id travels as ?g= in the URL, read by plan 06's boot reader.) -->
|
||||
|
||||
<!-- Cadence (D-11 discretion, Open Q 3): cron scans every 60 s; a card is DUE on the first
|
||||
tick of each 180 s window — `CARD_CADENCE_S = 180`, `due = nowSec % 180 < 60` (cron fires
|
||||
at second ~0 of each minute). Stateless: zero cursor keys, zero KV writes. -->
|
||||
|
||||
<!-- Bot copy keys (bot/src/i18n/strings.ts, en + ms, UI-SPEC pinned):
|
||||
cardActionDepart "DEPART"/"MULA" · cardActionStraight "CONTINUE ON"/"TERUS KE" ·
|
||||
cardActionLeft "TURN LEFT ONTO"/"BELOK KIRI KE" · cardActionRight "TURN RIGHT ONTO"/"BELOK KANAN KE" ·
|
||||
cardActionSlightLeft "KEEP LEFT ONTO"/"SEDAKIT KIRI KE" · cardActionSlightRight "KEEP RIGHT ONTO"/"SEDAKIT KANAN KE" ·
|
||||
cardActionUturn "MAKE A U-TURN ONTO"/"BUAT PUSARAN KE" · cardActionArrive "ARRIVE AT"/"TIBA DI"
|
||||
(kept verbatim for the copy gate + future street-bearing use)
|
||||
cardActionBareDepart "DEPART"/"MULA" · cardActionBareStraight "CONTINUE"/"TERUS" ·
|
||||
cardActionBareLeft "TURN LEFT"/"BELOK KIRI" · cardActionBareRight "TURN RIGHT"/"BELOK KANAN" ·
|
||||
cardActionBareSlightLeft "KEEP LEFT"/"SEDAKIT KIRI" · cardActionBareSlightRight "KEEP RIGHT"/"SEDAKIT KANAN" ·
|
||||
cardActionBareUturn "MAKE A U-TURN"/"BUAT PUSARAN" · cardActionBareArrive "ARRIVE"/"TIBA"
|
||||
cardEtaTemplate "ETA {time} · {km} KM LEFT"/"ETA {masa} · {km} KM LAGI"
|
||||
cardHonesty "Best-effort projection — updates while the app is minimized."/"Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan."
|
||||
cardOpenButton "OPEN GUIDANCE"/"BUKA PANDUAN" -->
|
||||
|
||||
<!-- createBot(env, { baseFetch }) (bot/src/bot/bot.ts) — the cron-side send:
|
||||
bot.api.sendPhoto(chat_id, "<MINI_APP_URL>/glyphs/<kind>.png", { caption, parse_mode: "HTML", reply_markup });
|
||||
scheduled() copies webhook.ts's ExportedHandler extension (RESEARCH code example):
|
||||
KV list({ prefix: "guidance:", cursor }) + list_complete pagination, category-only logs
|
||||
([guidance-cron] …), fail-closed when BOT_TOKEN/binding missing. -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 1: projectTurnCards (pure) + bot copy keys</name>
|
||||
<files>bot/src/guidance/cron.ts, bot/src/i18n/strings.ts, bot/test/guidance-cron.test.mjs, bot/test/commands.test.mjs</files>
|
||||
<read_first>
|
||||
- bot/src/i18n/strings.ts (the BotStrings interface + copy-gate law — append keys, never inline)
|
||||
- bot/test/commands.test.mjs (the bot copy gate — extend for the new keys in the same change)
|
||||
- routing/src/astar.ts classifyTurn + routing/src/polyline.ts + routing/src/geo.ts (the projection's building blocks — import, never re-implement)
|
||||
- bot/src/kv/guidance-sessions.ts (the frozen snapshot this projects — geometry + startedAtSec + distanceM/durationS)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Turn-card send" + UI-SPEC Turn-Card Chat Message section (caption anatomy)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- progress projection: given envelope with known geometry (a synthetic 3-leg polyline with bearing changes at known points), distanceM 10000, durationS 900, startedAtSec = T: projectTurnCards(env, T+300) → progressM ≈ 10000×(300/900), remainingM ≈ 6667, etaClock = now + 600 s rounded to HH:MM
|
||||
- next-turn detection: the FIRST classified turn (classifyTurn over consecutive-leg bearings) whose along-distance ≥ progressM → kind + zero-padded index in the register (GUIDE // T-03)
|
||||
- remaining distance to that turn = its along-distance − progressM, formatted "{n} M" / "{n.n} KM"
|
||||
- cadence: nowSec % 180 < 60 → due true (e.g. nowSec = 3600 → true; 3660 → false); CARD_CADENCE_S = 180 exported
|
||||
- progress ≥ distanceM (trip would have ended) → due false, no card (the cron never cards a dead trip)
|
||||
- caption: exactly 4 lines per the anatomy, HTML parse mode, ≤ 1024 chars, contains the honesty line verbatim, contains NO street names (street-less recorded call), register zero-padded
|
||||
- copy law: caption contains none of "live", "real-time", "guaranteed" (case-insensitive) — D-13's strongest-claim gate
|
||||
</behavior>
|
||||
<action>
|
||||
1. Bot copy: append the 22 keys from the context box to `BotStrings` + both locale records (verbatim values); extend the copy gate in `bot/test/commands.test.mjs` to assert them in en + ms.
|
||||
2. RED: `bot/test/guidance-cron.test.mjs` — build a synthetic envelope (geometry via encodePolyline6 of a known 3-leg path with 90° and 45° turns), assert every behavior above with fixed nowSec values.
|
||||
3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards is PURE: injected nowSec, no KV, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number): { due: boolean; glyph: GlyphKindName | null; caption: string | null; maneuverIdx: number | null }` — decode geometry → cumulative → progressM (clamped to distanceM) → walk bearings (bearing changes between segments; skip degenerate sub-m points) → classifyTurn → first turn ≥ progressM (none → "straight" glyph, index 00) → compose caption via strings + the exact anatomy (HTML escape NOT needed: all parts are code-composed from numbers + string-table constants — assert no envelope-derived string like destinationName enters the caption in v1). GlyphKindName is cron.ts's local union mirroring the 8 PNG names. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- guidance-cron.test.mjs green: projection math, next-turn kind + zero-padded index, cadence table, dead-trip no-card, 4-line caption ≤ 1024, honesty-line present, no-street-name assertion, D-13 banned-words scan
|
||||
- bot/src/guidance/cron.ts imports classifyTurn from @araharah/routing (grep) — no local turn classifier
|
||||
- bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green
|
||||
- grep `console\.` → 0 matches in bot/src/guidance/cron.ts (pure module)
|
||||
</acceptance_criteria>
|
||||
<done>Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption are all Node-testable with zero infrastructure</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>task 2: scheduled() handler + crons config + offline send tests + KV-budget pin</name>
|
||||
<files>bot/src/webhook.ts, bot/wrangler.jsonc, bot/test/guidance-cron.test.mjs</files>
|
||||
<read_first>
|
||||
- bot/src/webhook.ts (the ExportedHandler fetch skeleton — scheduled() extends the SAME object; the fail-closed secret posture lines 58–71)
|
||||
- bot/src/bot/bot.ts createBot(env, { baseFetch }) (the injectable-fetch factory the offline tests capture sends through)
|
||||
- bot/src/config.ts (MINI_APP_URL var — the glyph URL + deep link base)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Cron config + scheduled handler" code example + Pitfall 3 (KV write budget) + local-test endpoint (`/cdn-cgi/local/scheduled`)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- scheduled() lists KV prefix "guidance:" with cursor pagination (list_complete respected), gets each envelope via getGuidanceSession (malformed → skip + `[guidance-cron] skipped: malformed` category log), projects, and for due cards calls bot.api.sendPhoto with chat_id FROM THE ENVELOPE ONLY
|
||||
- offline send capture: createBot(env, { baseFetch: recording fake }) — the recorded sendPhoto args show url `${MINI_APP_URL}/glyphs/<kind>.png`, caption matching the task-1 anatomy, parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=<envelope id>`
|
||||
- non-due tick → zero sends; absent/failed envelope → skip, never throw (one bad key cannot kill the tick)
|
||||
- KV-BUDGET PIN (Pitfall 3): structural scan — bot/src/guidance/cron.ts + the scheduled() arm contain ZERO `.put(` calls (stateless law; the gate lives in guidance-cron.test.mjs)
|
||||
- wrangler.jsonc gains `"triggers": { "crons": ["*/1 * * * *"] }`; kv_namespaces stays exactly ONE entry (GE5)
|
||||
- fail-closed: missing BOT_TOKEN → scheduled() logs `[guidance-cron] skipped: unconfigured` and returns (the webhook fail-closed posture)
|
||||
</behavior>
|
||||
<action>
|
||||
1. `bot/wrangler.jsonc`: add the triggers block with a law comment ("60 s scan; card cadence decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window; local test: curl \"http://localhost:8787/cdn-cgi/local/scheduled\"").
|
||||
2. RED: extend `bot/test/guidance-cron.test.mjs` with the scheduled() block — invoke the exported `scheduled(controller, env, ctx)` directly (controller = `{ cron: "*/1 * * * *" } as ScheduledController`), env = KV Map-fake seeded with 3 envelopes (one due, one not-due, one malformed string) + a valid BOT_TOKEN, baseFetch recording; assert the behavior list.
|
||||
3. GREEN in `bot/src/webhook.ts`: add `async scheduled(controller, env, ctx)` to the ExportedHandler — fail-closed config check (BOT_TOKEN + chat_sessions present, else category log + return); paginated KV list; per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec)` → due ? `createBot(env).api.sendPhoto(...)` inside try/catch (delivery variance is documented best-effort, D-13 — a failed send logs `[guidance-cron] send failed` and continues). ctx.waitUntil wraps the whole body. Category-only logs throughout.
|
||||
4. Keep `npm run bot:typecheck` green (ScheduledController/ExportedHandler types from @cloudflare/workers-types).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -w bot && npm run bot:typecheck</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- guidance-cron.test.mjs scheduled() block green: due-card send recorded with envelope chat_id + glyph URL + ?g= deep link; non-due → 0 sends; malformed key skipped; one-bad-key tick survives
|
||||
- structural KV-budget pin: grep `\.put\(` → 0 matches in bot/src/guidance/cron.ts AND in the scheduled() arm of webhook.ts (assert in-test, PATTERNS PG style)
|
||||
- bot/wrangler.jsonc has triggers.crons ["*/1 * * * *"]; kv_namespaces length still 1 (GE5 green)
|
||||
- D-13 copy law holds in the wired path (caption from projectTurnCards, unchanged)
|
||||
- bot:typecheck clean
|
||||
</acceptance_criteria>
|
||||
<done>The cron is live in dev: scan → project → push, envelope-chat-only, zero KV writes, honest failure posture, and locally testable via the /cdn-cgi/local/scheduled endpoint</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 3: build-glyphs.mjs — the committed 8-glyph PNG set (sharp)</name>
|
||||
<files>routing/scripts/build-glyphs.mjs, routing/package.json, web/public/glyphs/*.png</files>
|
||||
<read_first>
|
||||
- web/src/guidance/glyphs/*.svg (06-05's committed sources — THE single source these PNGs rasterize)
|
||||
- routing/scripts/fetch-route-fixtures.mjs (the maintainer-tool shell: header, Usage, Exit codes, collect-then-write)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md "The 8-glyph set" production paragraph (256×256, #EB8008 on #1A1A1A full-bleed, committed beside the static assets)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `npm run glyphs` (script added to routing/package.json: "glyphs": "node scripts/build-glyphs.mjs") produces web/public/glyphs/{depart,straight,left,right,slight-left,slight-right,uturn,arrive}.png
|
||||
- each PNG is 256×256, non-trivial size (> 500 bytes), visually the token-colored glyph on the matte-black plate
|
||||
- the script recolors currentColor → #EB8008 and injects a full-bleed #1A1A1A plate — the SOURCE SVGs stay untouched (currentColor law for the app's theme-following rendering)
|
||||
- any missing/invalid source SVG → exit 1, NO partial writes (collect-then-write)
|
||||
</behavior>
|
||||
<action>
|
||||
1. `npm install -D -w routing sharp` (research Installation — devDependency only, zero runtime deps added; verify `npm ls sharp` shows it under routing).
|
||||
2. Write `routing/scripts/build-glyphs.mjs` in the maintainer shell: header names the D-12 boundary + the single-source law; named constants `GLYPH_KINDS` (the 8), `STROKE = "#EB8008"`, `PLATE = "#1A1A1A"`, `SIZE = 256` (hex literals allowed HERE — build constants copied verbatim from tokens.css, the DSGN-01 precedent; the script is not runtime CSS); read each SVG, string-substitute `currentColor` → STROKE and inject `<rect width="32" height="32" fill="#1A1A1A"/>` as the first child of `<svg …>` plus width/height 256 attributes; render via `sharp(Buffer.from(svg), { density: 72 * (256 / 32) }).png().toBuffer()` — collect all 8 buffers, then write all files; print the 8 output paths + sizes; exit codes per the shell.
|
||||
3. Run it; commit the 8 PNGs (committed artifacts per D-12 — "committed once, reused everywhere"; web/public/* rides the Vite build into web/dist, served by the Worker's static handler, fetched by sendPhoto).
|
||||
4. Sanity: `git status` shows the SVG sources UNCHANGED (the recolor is in-memory only).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run glyphs && node -e "const fs=require('fs');const kinds=['depart','straight','left','right','slight-left','slight-right','uturn','arrive'];for(const k of kinds){const s=fs.statSync('web/public/glyphs/'+k+'.png');if(s.size<500)process.exit(1)};console.log('8 PNGs ok')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- all 8 web/public/glyphs/*.png exist, each > 500 bytes, committed
|
||||
- routing/package.json has sharp in devDependencies and the "glyphs" script
|
||||
- the SVG sources are unmodified after a run (git status clean for web/src/guidance/glyphs/)
|
||||
- the script carries the maintainer header + named color constants (#EB8008 / #1A1A1A verbatim from tokens.css)
|
||||
</acceptance_criteria>
|
||||
<done>The chat card's visual language is committed: 8 crisp token-colored glyphs rasterized once from the same sources the banner renders — no renderer dependency, no runtime cost</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Cron tick → Telegram API | The Worker pushes only to chat ids read from defensively-validated KV envelopes |
|
||||
| KV envelopes → cards | A malformed record must never produce a card (or a crash) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-02 | Spoofing / Information Disclosure (cross-chat push) | scheduled() → sendPhoto | mitigate | chat_id comes ONLY from getGuidanceSession's validated envelope (06-03's defensive read); no request path can influence the recipient; the offline test asserts the recorded send's chat_id equals the seeded envelope's. |
|
||||
| T-06-04 | Information Disclosure (logs + card copy) | cron logging + caption | mitigate | Category-only logs (`[guidance-cron] skipped: malformed`) — never chat ids, coordinates, or payloads; the card carries ETA/distance/turn-kind to the USER'S OWN chat only; the D-13 copy gate bans "live/real-time/guaranteed". |
|
||||
| — | DoS (KV budget) | scheduled() | mitigate | STATELESS: zero KV writes per tick (structural pin); reads are 1 list + N gets per minute — comfortably inside 100 k reads/day at v1 scale (A8). |
|
||||
| T-06-03 | Tampering (glyph assets) | web/public/glyphs/*.png | accept | Committed repo artifacts built by a maintainer script from committed sources; the Worker serves its own repo's assets — no untrusted upload path. |
|
||||
| — | Repudiation | — | accept | Cards are transactional messages to the owning chat (the Out-of-Scope table bans engagement pushes); nothing persistent beyond the TTL'd envelope. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -w bot && npm run bot:typecheck` green (projection, scheduled wiring, copy gate, KV-budget pin)
|
||||
- `npm run glyphs` green; 8 committed PNGs; SVG sources untouched
|
||||
- wrangler.jsonc: crons configured, exactly one KV namespace (GE5)
|
||||
- Local cron smoke (dev): `npx wrangler dev` + `curl "http://localhost:8787/cdn-cgi/local/scheduled"` documented in the SUMMARY (requires a seeded local envelope — optional manual smoke)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Minimized sessions get honest turn-cards: the cron scans every minute, projects along the frozen snapshot, pushes token-colored glyph cards on the 3-minute cadence to the owner's chat with an OPEN deep-link that resumes a killed webview — zero KV writes, zero dishonest claims, zero renderer dependencies.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
phase: 06-turn-by-turn-guidance-live-driving
|
||||
plan: 08
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: [06-06, 06-07]
|
||||
files_modified:
|
||||
- web/src/mock/telegram-mock.ts
|
||||
- web/scripts/capture.mjs
|
||||
autonomous: false
|
||||
requirements: [GUID-01, GUID-02, GUID-03, GUID-04, GUID-05, GUID-06, PX-01, PX-03]
|
||||
must_haves:
|
||||
truths:
|
||||
- "The capture harness replays a committed drive fixture through the LIVE shell via ?drive=<name> (D-16's browser leg)"
|
||||
- "All seven guidance capture legs exist and assert ARAHARAH-GUIDE state tokens (never coordinates in console)"
|
||||
- "The DSGN-02 guidance gallery compares the captures against active_route_guidance/screen.png"
|
||||
- "ONE REAL DRIVE demonstrates: banner legibility, silent reroute self-heal, minimize → turn-cards, resume, trip-end deletion messaging (D-16 device gate)"
|
||||
artifacts:
|
||||
- path: "web/src/mock/telegram-mock.ts"
|
||||
provides: "?drive= fixture-replay fix source with pause/resume controls"
|
||||
- path: "web/scripts/capture.mjs"
|
||||
provides: "the 7 guidance legs + gallery composite"
|
||||
key_links:
|
||||
- from: "web/src/mock/telegram-mock.ts"
|
||||
to: "web/test/fixtures/drives/*.json"
|
||||
via: "import.meta.glob fixture loading; the mock's fix source replays the drive"
|
||||
pattern: "fixtures/drives"
|
||||
- from: "web/scripts/capture.mjs"
|
||||
to: "web/src/mock/telegram-mock.ts"
|
||||
via: "?drive= profiles + scripted taps drive the legs"
|
||||
pattern: "drive="
|
||||
---
|
||||
|
||||
<objective>
|
||||
The phase's verification close (D-16): extend the Mock-Telegram harness with the ?drive= fixture-replay knob, capture the seven guidance legs + DSGN-02 gallery, and run the ONE REAL DRIVE device gate — banner legibility, silent self-heal, minimize/turn-cards, resume, trip-end deletion.
|
||||
|
||||
Purpose: replays proved the brain in Node (06-02); captures prove the SHELL renders it; the real drive proves the ROAD behaves. This is the phase's declared protocol (the 03/04 precedent) and its exit gate.
|
||||
Output: harness knob + 7 captures + gallery artifact + a recorded real-drive verdict.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/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-VALIDATION.md
|
||||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Capture profiles table (06-UI-SPEC — BINDING names + asserts):
|
||||
harness knob ?drive=<fixture>: the mock's Telegram fix source replays the D-16 JSON stream
|
||||
through the live shell; pairs with ?dest= + ?fixpair= carriers; asserts ARAHARAH-GUIDE
|
||||
state=<state> tokens (state tokens only — no coordinates in console, D-15 hygiene)
|
||||
shell-guidance-active.png mock=android&dest=<golden-id>&fixpair=<pair>&drive=<corridor> light
|
||||
→ banner (GUIDE // NEXT, arrow, distance, street) + compact strip + guidance card + progress bar + locked pose
|
||||
shell-guidance-list.png same, after a scripted banner tap → list expanded, NEXT row lamp (collapsed-default proof = the active leg's complement)
|
||||
shell-guidance-rerouting.png drive=<missed-turn> captured INSIDE the REROUTING window → calm banner (reroute copy, — distance, gantry), NO red anywhere
|
||||
shell-guidance-confirm.png scripted END TRIP tap → confirm card bottom register, both CTAs, no red
|
||||
shell-guidance-tripend.png scripted YES, ABANDON → trip-ended card (center), guidance chrome cleared
|
||||
shell-guidance-dark.png active profile, scheme=dark → PX-02 chrome re-slots, banner Rams-constant
|
||||
guidance-gallery-compare.png DSGN-02 side-by-side vs stitch_minimalist_route_navigator/active_route_guidance/screen.png -->
|
||||
|
||||
<!-- Mock harness (web/src/mock/telegram-mock.ts) + capture script (web/scripts/capture.mjs):
|
||||
follow their EXISTING leg/profile/carrier patterns exactly (phase 3/4/5 legs stay
|
||||
unchanged); fixture loading via import.meta.glob over ../test/fixtures/drives/*.json;
|
||||
expose window.__ARAHARAH_DRIVE = { pause(), resume() } so the capture script can freeze
|
||||
the replay mid-stream for the REROUTING leg's timing window and drive taps between fixes. -->
|
||||
|
||||
<!-- The real-drive checklist (06-VALIDATION manual-only row + D-16):
|
||||
start a route on a real device → banner + haptic at approach/maneuver → miss a turn
|
||||
deliberately → silent recalculation (no dialog, no blame) → minimize mid-drive →
|
||||
turn-cards arrive in chat → re-open (or tap OPEN GUIDANCE) → guidance resumes at the
|
||||
progressed position → END TRIP → "trip ended" messaging → (optional KV spot-check:
|
||||
guidance:* key gone). Record the verdict + any defects verbatim. -->
|
||||
</interfaces>
|
||||
|
||||
**The captures assert STATE TOKENS, never coordinates** (the Phase 5 capture law). The gallery is DSGN-02's phase artifact — fidelity review rides the same blocking checkpoint as the drive.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>task 1: ?drive= replay knob + the seven guidance capture legs + gallery</name>
|
||||
<files>web/src/mock/telegram-mock.ts, web/scripts/capture.mjs</files>
|
||||
<read_first>
|
||||
- web/src/mock/telegram-mock.ts IN FULL (the mock fix-source architecture — ?drive= extends its source selection; the existing ?fixpair= carrier pattern)
|
||||
- web/scripts/capture.mjs IN FULL (leg/profile structure, theme carrier, composite/gallery pattern from Phase 5's route legs, console-capture assert style)
|
||||
- web/test/fixtures/drives/ (the four committed fixtures — ?drive= names map to these files)
|
||||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md Capture Profiles table (BINDING: names, profiles, asserts)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `mock=android&dest=<golden-id>&fixpair=<pair>&drive=corridor-follow` boots into the route state and, past the 30 m activation gate, plays the fixture's fixes on their recorded schedule through the LIVE shell (real pump, real builders, real camera — not the Node fakes)
|
||||
- window.__ARAHARAH_DRIVE.pause()/resume() freezes/resumes the replay clock (the REROUTING leg captures inside the window; the confirm/tripend legs run after resume)
|
||||
- each leg's console capture shows the asserted ARAHARAH-GUIDE state tokens and ZERO coordinate-bearing log lines
|
||||
- all seven PNGs exist under the capture output dir; the gallery composite places them beside the mockup screen.png
|
||||
- existing legs (boot, denied, route, captures from phases 3–5) still pass unchanged
|
||||
</behavior>
|
||||
<action>
|
||||
1. `web/src/mock/telegram-mock.ts`: add the ?drive= knob — read `drive` from location.search (the existing carrier style), validate against the import.meta.glob record of `../test/fixtures/drives/*.json` (unknown name → honest console token + fall back to the default source, never a crash); the drive source emits each Fix at `t0 + (fix.ts − fixes[0].ts)` on a replay clock that __ARAHARAH_DRIVE.pause()/resume() controls (paused clock stops emission, resume continues mid-stream — the REROUTING leg needs the freeze); console emits nothing but the state-token family.
|
||||
2. `web/scripts/capture.mjs`: add the seven legs EXACTLY per the UI-SPEC table (names, profiles, asserts): active (light), list (scripted banner tap — page.click on the banner button), rerouting (drive=missed-turn-excursion + pause inside the REROUTING window — poll the console for the `ARAHARAH-GUIDE state=rerouting` token then shoot), confirm (scripted END TRIP tap), tripend (scripted YES, ABANDON tap), dark (scheme=dark), and the gallery-compare composite (the existing Phase 5 composite pattern with active_route_guidance/screen.png). Each leg asserts its state tokens via the existing console-capture helper + the DOM text checks the table names (GUIDE // NEXT, register words); add a NO-RED assertion to the rerouting/confirm legs (scan the captured banner/confirm element's computed colors or the absence of the functional-red token class — the cheapest honest check: assert the elements' class lists contain no red-token class).
|
||||
3. Run the full capture suite; commit the seven PNGs + gallery; record the guidance gallery path in the SUMMARY for the DSGN-02 review.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run web:capture 2>&1 | tail -20 && node -e "const fs=require('fs');const legs=['shell-guidance-active','shell-guidance-list','shell-guidance-rerouting','shell-guidance-confirm','shell-guidance-tripend','shell-guidance-dark','guidance-gallery-compare'];for(const l of legs){if(!fs.existsSync('web/captures/'+l+'.png'))process.exit(1)};console.log('7 guidance legs ok')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- all 7 PNGs exist under web/captures/ (adjust the path prefix to capture.mjs's actual output dir — verify by reading the script first)
|
||||
- telegram-mock.ts contains `import.meta.glob` over fixtures/drives and the __ARAHARAH_DRIVE pause/resume surface
|
||||
- capture.mjs contains the 7 leg names from the UI-SPEC table and asserts ARAHARAH-GUIDE tokens
|
||||
- prior capture legs unbroken (the suite's existing legs green)
|
||||
- captures/summary output shows zero coordinate-bearing console assertions
|
||||
</acceptance_criteria>
|
||||
<done>The shell is proven on film: seven legs covering active, list, calm rerouting, confirm, trip-end, dark, and the mockup comparison — driven by real committed drive fixtures</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>task 2: The ONE REAL DRIVE — the phase's device gate (D-16) + DSGN-02 gallery review</name>
|
||||
<what-built>
|
||||
Live turn-by-turn guidance end-to-end: Rams maneuver banner + collapsible list + guidance card with honest ETA, haptic cues, silent engine-A rerouting, minimize → chat turn-cards via the Worker cron, seamless resume, BackButton full state graph with the abandon confirmation, and trip-end deletion messaging. Seven capture legs + the DSGN-02 guidance gallery are committed for review beside stitch_minimalist_route_navigator/active_route_guidance/screen.png.
|
||||
</what-built>
|
||||
<action>
|
||||
Blocking human checkpoint (automation-first: task 1's captures + all three workspace suites + the CPU gate are green BEFORE this runs). The user performs Part A (gallery fidelity review) and Part B (the one real drive with the 7-step checklist below). No OpenCode automatable step remains: physical GPS, real road geometry, and human legibility/haptic judgment are the declared device-gate protocol (D-16, phases 3–5 precedent). The executor records the per-step verdict verbatim in the SUMMARY; a FAIL holds the phase open (the Phase 3 UNVERIFIED precedent) rather than silently closing it.
|
||||
</action>
|
||||
<how-to-verify>
|
||||
Part A — gallery (5 min): open the guidance gallery composite (path in 06-08's SUMMARY). Confirm fidelity: banner ink hierarchy (white distance, beige street, orange arrow), no red in guidance chrome, radius-0, token-pure. Approve or list deviations (tokens.css wins over mockup; the spec records calls).
|
||||
Part B — the drive (one real drive, phone mounted, safe location to start):
|
||||
1. Deploy fresh (`npm run deploy -w bot` with the graph uploaded — 06-04) and open the Mini App from the bot.
|
||||
2. Pick a golden destination, start driving. VERIFY: banner shows distance + arrow + street; compact strip; guidance card ETA progresses without whiplash; haptic at ~300 m approach and at the maneuver.
|
||||
3. Miss a turn DELIBERATELY. VERIFY: no dialog, no blame — the banner calmly shows the rerouting state, then the new route snaps in.
|
||||
4. Minimize the app mid-drive. VERIFY: within ~3–4 minutes a turn-card arrives in chat (glyph + ETA + distance + OPEN GUIDANCE).
|
||||
5. Re-open via the card's OPEN GUIDANCE. VERIFY: guidance resumes at the progressed position (resuming state, then live).
|
||||
6. Tap END TRIP → confirm card → YES, ABANDON. VERIFY: "trip ended" messaging; chat cards stop.
|
||||
7. (Optional, executor-assisted) KV spot-check: `npx wrangler kv key list --namespace-id=<chat_sessions id> --prefix "guidance:"` shows no stale keys.
|
||||
Record PASS/FAIL per step + anything that felt wrong (legibility in sunlight, haptic feel, card lag).
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" (drive + gallery pass) or list the failing steps/deviations</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Capture harness → live shell | The replay knob feeds the REAL pump with committed fixtures — no test-only bypasses of validation |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-06-04 | Information Disclosure (capture hygiene) | mock + capture console asserts | mitigate | Legs assert STATE TOKENS and assert the ABSENCE of coordinate-bearing log lines (the D-15 hygiene law carried into captures); no new logging is introduced by this plan. |
|
||||
| — | Privacy (the drive itself) | real-drive protocol | mitigate | The drive uses the product's own privacy posture: no location history exists to leak; the KV spot-check proves the post-trip deletion promise (D-14) on real infrastructure. |
|
||||
| — | Tampering (fixture/kernel mismatch) | ?drive= knob | mitigate | Unknown drive names fail honest (token + default source) rather than replaying arbitrary data; fixtures are committed and attributed. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm run web:capture` green with the 7 new legs; prior legs unbroken
|
||||
- Gallery composite committed (DSGN-02 artifact for the phase)
|
||||
- The real-drive checklist recorded with a per-step verdict (the phase's exit gate — a FAIL holds the phase open, per the Phase 3 UNVERIFIED precedent)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
D-16 is satisfied on both legs: the recorded drives prove the machine, the captures prove the shell, and ONE REAL DRIVE proves the road — banner, self-heal, turn-cards, resume, deletion — with the gallery review closing DSGN-02 for the phase.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-08-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user