304 lines
28 KiB
Markdown
304 lines
28 KiB
Markdown
---
|
||
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>
|