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

304 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 06-turn-by-turn-guidance-live-driving
plan: 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)"
- "Card maneuver lines carry the STREET by DEFAULT — reconstructed by snapping the carded turn's vertex to the memoized R2 graph (snapToGraph + nameAt, per D-12's ETA/distance/street text); street-less bare-verb cards ONLY when the graph is absent in the isolate or the snap misses (D-03-style honest degradation)"
- "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 + PURE makeStreetAt graph→street resolver + the scheduled() projection/push logic"
exports: ["projectTurnCards", "makeStreetAt", "CARD_CADENCE_S"]
- path: "bot/src/webhook.ts"
provides: "scheduled() handler with KV list pagination + loadGraph-fed street reconstruction"
- 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, snapToGraph, nameAt)"
via: "projection + street reconstruction over shared primitives — one turn/street authority with engine A"
pattern: "classifyTurn|snapToGraph"
- from: "bot/src/webhook.ts scheduled()"
to: "bot/src/routing/worker-router.ts"
via: "loadGraph(env) — the exported isolate-memoized R2 graph feeding makeStreetAt (no second R2 loader)"
pattern: "loadGraph"
- 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(graph, point) →
{ node, distanceM } | null (grid-indexed, per-graph memoized — cheap enough for a
one-vertex snap per carded tick), nameAt(id) on RoadGraph, haversineM.
Subpath-with-extension imports only. -->
<!-- From 06-04 (bot/src/routing/worker-router.ts — EXPORTED there): loadGraph(env):
Promise<RoadGraph | null> — the module-scope promise memo (1 R2 GET per cold isolate,
shared by /api/route AND scheduled(); null on absent/corrupt graph, never throws).
The cron imports this memo — a SECOND R2 loader is forbidden (one authority, one
cold-start cost per isolate). -->
<!-- D-12 DELIVERY (street text on the card — revised per plan-checker; supersedes the
street-less recorded call): the card's maneuver line is STREET-BEARING by DEFAULT.
The FROZEN envelope carries geometry only (GE2 field freeze holds — no steps field,
no envelope extension: path (b) is user-gated and NOT implemented), so the cron
reconstructs the street itself: snap the carded turn's vertex to the memoized R2
graph (snapToGraph) → nameAt — the SAME one-authority argument the app-side banner
relies on. DEGRADATION (D-03's designed-degradation pattern): graph absent/corrupt
in the cron isolate, or a snap miss → street-less composition with the bare verbs —
honest absence, never a guessed name. CPU tradeoff (cron-side snap per due tick vs
the free-plan budget) is a NAMED REVIEW ITEM in 06-08's blocking checkpoint: the
user ratifies the default or the street-less final state (which would then be
recorded in UI-SPEC's recorded-calls fidelity list). -->
<!-- Caption (UI-SPEC Turn-Card section, HTML, ≤1024 chars — ~120 used):
GUIDE // T-{nn} ← instrument constant, code-composed
<b>{dist} — {cardAction[action]} {STREET}</b> ← DEFAULT (D-12): cardAction* verbs carry
ONTO/KE; STREET = graph-reconstructed,
uppercase; nameAt's unnamed id →
cardStreetUnnamed ("UNNAMED ROAD")
… degraded form (graph absent / snap miss):
<b>{dist} — {cardActionBare[action]}</b> ← street-less honest absence (D-03 pattern)
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):
cardAction* (8) are the DEFAULT street-bearing verbs (UI-SPEC verbatim):
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"
cardActionBare* (8) are the graph-absent DEGRADATION verbs (street-less honest absence):
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"
cardStreetUnnamed "UNNAMED ROAD"/"JALAN TANPA NAMA" ← nameAt's unnamed id, instrument voice
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 + routing/src/snap.ts (the projection's + street reconstruction's building blocks — import, never re-implement)
- routing/test/fixtures/build-mini-graph.mjs + the committed mini-graph fixture (the street-present test's graph: its named way supplies the expected street string)
- 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 + `{action} onto {street}` line)
</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, register zero-padded
- street-bearing DEFAULT (D-12): with a streetAt resolver returning a name, the maneuver line is "{dist} — {cardAction[action]} {STREET}" (the verb carries ONTO/KE; street uppercased); nameAt's unnamed id → cardStreetUnnamed ("UNNAMED ROAD"/"JALAN TANPA NAMA")
- street-less DEGRADATION: streetAt omitted or null for the carded vertex → "{dist} — {cardActionBare[action]}" — bare verbs, honest absence, same 4-line anatomy
- makeStreetAt(graph) is PURE (graph in → resolver out; snapToGraph + nameAt imported from @araharah/routing, zero I/O): against decodeGraph of the committed mini-graph fixture, a vertex on the fixture's named way resolves that way's name; a vertex off-graph → null
- 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 23 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. Street cases: (a) fake resolver `streetAt = () => "JALAN SULTAN ISMAIL"` → the ONTO line composes with that street; (b) `streetAt` omitted / returns null → the bare-verb line; (c) mini-graph integration — construct the synthetic envelope geometry ALONG the mini-graph fixture's named way so the carded turn's vertex snaps deterministically onto it, run the REAL `makeStreetAt(decodeGraph(miniGraphBuffer))`, and assert the caption contains the fixture way's name.
3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards and makeStreetAt are PURE: injected nowSec/graph, no KV, no R2, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number, streetAt?: (vertexIdx: number) => string | null): { 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: street string present → `{cardAction[action]} {street}`; null/absent resolver → `{cardActionBare[action]}`. Also export `makeStreetAt(graph: RoadGraph): (vertexIdx: number) => string | null` — snap the geometry vertex at vertexIdx (snapToGraph) → `graph.nameAt(node)`; snap miss → null; unnamed id → the cardStreetUnnamed constant. GlyphKindName is cron.ts's local union mirroring the 8 PNG names. HTML-escape surface stays ZERO: all caption parts are code-composed from numbers + string-table constants — assert no envelope-derived string (e.g. destinationName) enters the caption; street text arrives only via makeStreetAt's graph lookup. 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, street-bearing line asserted against the mini-graph fixture (real makeStreetAt), street-less bare-verb case asserted in the graph-absent test, unnamed-id → cardStreetUnnamed, D-13 banned-words scan
- bot/src/guidance/cron.ts imports classifyTurn + snapToGraph from @araharah/routing (grep) — no local turn classifier, no local snapper
- bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardStreetUnnamed + 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 — street-bearing by default (D-12), street-less only as designed degradation — 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/routing/worker-router.ts (06-04's EXPORTED loadGraph(env) memo — the ONLY R2 graph loader the cron may use)
- 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
- street reconstruction wiring (D-12 default): scheduled() awaits loadGraph(env) ONCE per tick (the worker-router memo — no second R2 loader), and when the graph resolved passes streetAt = makeStreetAt(graph) into projectTurnCards so cards carry the street; graph null (absent/corrupt R2) → streetAt omitted → the street-less bare-verb degradation card — the send still fires (degradation, not silence; D-03 pattern)
- snapping is BOUNDED: only the carded turn's vertex is snapped per carded envelope (makeStreetAt called for that index, not a walk of the whole geometry) — the cron CPU tradeoff this creates is a named review item in 06-08's blocking checkpoint
- 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 (street-bearing under the R2 mini-graph fake; bare-verb under the R2-null fake), 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 + an R2 fake (`{ get: async () => ({ arrayBuffer: async () => miniGraphBuffer }) }` — the 06-04 webhook-test shape), baseFetch recording; assert the behavior list INCLUDING both street states (mini-graph fake → caption carries the fixture street; a second run with the R2 fake returning null → bare-verb caption, send still recorded).
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; `const graph = await loadGraph(env).catch(() => null)` once per tick (import from worker-router — a corrupt/absent graph degrades to the street-less card, never fails the tick); per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec, graph ? makeStreetAt(graph) : undefined)` → 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; R2 mini-graph fake → caption carries the fixture street; R2-null fake → bare-verb street-less caption with the send still recorded; 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)
- grep `loadGraph` matches the scheduled() arm (the shared memo — no second R2 loader: grep for a second `ROAD_GRAPH.get` outside worker-router.ts → 0 matches in bot/src)
- 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 with street-bearing cards from the shared graph memo (street-less only when the graph is absent), 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/street/turn-kind to the USER'S OWN chat only; street text comes from the repo's own R2 graph (OSM-derived public data), never from the envelope; the D-13 copy gate bans "live/real-time/guaranteed". |
| — | DoS (KV budget + cron CPU) | scheduled() | mitigate / review | 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). The street reconstruction adds 1 graph memo (1 R2 GET/cold isolate) + ONE snapToGraph per carded tick — inside the free-plan CPU budget by design, and surfaced as a NAMED ratification item in 06-08's blocking checkpoint (street-less ratification path recorded there). |
| 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 + street reconstruction, scheduled wiring, copy gate, KV-budget pin)
- Street states proven: mini-graph-backed caption carries the fixture street; graph-absent case produces the bare-verb honest card (both asserted in guidance-cron.test.mjs)
- `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, and pushes token-colored glyph cards on the 3-minute cadence to the owner's chat — maneuver lines street-bearing by DEFAULT via the shared memoized graph (D-12), bare-verb street-less only as designed degradation — 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>