User generiert 4-stelligen Code in der App, setzt ihn manuell als
Screen Time Passcode â ReBreak speichert ihn auf dem Backend.
Damit kann niemand Screen Time deaktivieren â deny-removal bleibt
aktiv â App nicht deinstallierbar ohne den Passcode.
Backend:
- Profile.screentimePasscode Feld (Migration add_screentime_passcode)
- POST /api/protection/screentime-passcode â Code speichern
- GET /api/protection/screentime-passcode â Code abrufen (nach Cooldown)
iOS UI (blocker.tsx):
- ScreentimePasscodeCard erscheint wenn Layer 1 + 2 aktiv (iOS only)
- Code-Generierung â Einmal-Anzeige â Deep-Link zu Settings â Screen Time
- BestÀtigung speichert Code auf Backend, Card zeigt Confirmed-State
Locales: DE/EN/FR/AR screentime_* Keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
iCloud-Sign-In Pattern: wenn ein neues GerÀt versucht sich anzumelden
und das Plan-Limit erreicht ist, kann der User auf einem bereits
angemeldeten GerĂ€t bestĂ€tigen â Code wird auf BEIDEN GerĂ€ten gezeigt
fĂŒr visuellen Vergleich (verhindert Code-Forwarding-Attacken).
Backend:
- New table device_approval_requests + supabase_realtime + RLS
- POST /api/devices/approvals â create (new device)
- GET /api/devices/approvals â list pending (existing devices)
- GET /api/devices/approvals/:id â status poll (new device)
- POST /api/devices/approvals/:id/approve â approve + atomic evict
- POST /api/devices/approvals/:id/reject â reject
- POST /api/devices/approvals/:id/email â trigger email fallback
- POST /api/devices/approvals/email/:token â magic-link approve (no auth)
- Email-Template via Resend (lyra-neutral, security-formal)
- 10min TTL, 6-digit numeric codes (crypto-random)
Frontend (rebreak-native):
- DeviceApprovalIncomingSheet â existing devices: code + device-picker + Allow/Reject
- DeviceApprovalPendingSheet â new device: code + spinner + 'Send via email'
- useDeviceApprovalRealtime â postgres_changes subscription
- DeviceLimitReachedSheet â neues CTA 'Auf anderem GerĂ€t bestĂ€tigen'
- i18n DE/EN/FR/AR
Migration lÀuft automatisch via prisma migrate deploy bei push.
Without explicit language param, nova-2-general falls back to multilingual
auto-detect and often misdetects arabic audio as english (phonetic transcript
'salam alaikum' instead of 'ۧÙŰłÙŰ§Ù ŰčÙÙÙÙ '). detectLang() then sees only
latin chars and answers in english.
Confirmed via Deepgram docs: nova-2-general accepts language=ar and language=tr
(only nova-3 rejects them with HTTP 400).
LLM-Prompt (message.post + sos-stream):
- LANG_INSTRUCTIONS Map raus, ersetzt durch dynamische Instruktion
'Reply in {detectedFromUser} ... fallback: {appLang}'
- Lyra matcht jetzt die Sprache der letzten User-Message (per
detectLang Unicode-Detection); App-Locale ist nur noch Fallback
- Instruktion doppelt eingehÀngt (Anfang + Ende des System-Prompts)
gegen recency bias bei langen deutschen Prompts
TTS (speak dispatcher + speak-cartesia + speak-elevenlabs):
- Kein 'de'-Default mehr fĂŒr language. detectLang(text, locale) leitet
Sprache primÀr aus dem Antwort-Text ab (Arabic/Cyrillic/CJK/Turkish-
Letters), Locale als Fallback
- Cartesia + ElevenLabs: language/language_code nur senden wenn
ableitbar, sonst Provider auto-detect statt erzwungenem 'de'
- speak-cartesia: sonic-2 â sonic-3 (Multi-Lang, war beim Dispatcher-
Fix gestern vergessen worden)
- Google: en-US neutraler Fallback statt de-DE-Bias
Neu: server/utils/detect-lang.ts
TTS-Sprache war provider-ĂŒbergreifend hart auf "de" verdrahtet, locale aus dem
Request wurde ignoriert â arabischer Text wurde deutsch-phonetisch gesprochen.
- locale aus Body auslesen â Basis-Sprachcode an alle Provider
- Pro: Cartesia sonic-2 â sonic-3 (sonic-2 kann kein Arabisch; sonic-3 = 42 Sprachen)
- Legend: ElevenLabs language_code gesetzt (turbo_v2_5 multilingual, ar dabei)
- Google-Fallback: BCP-47-Map (arâar-XA etc.), de-Voice nur noch fĂŒr de
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nova-2 unterstĂŒtzt kein ar/tr â Deepgram 400 "No such model/language/tier
combination" â leeres Transcript ("kein Text nach Speech"). nova-3 deckt alle
gelisteten Sprachen als diskrete Codes ab (de/en/tr/ar/fr/es/pt/it), ohne
Regression. Verifiziert gg. Deepgram models-languages-overview.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 1 â Junk-Folder Fix:
- noopTimer (alle 2min) ruft jetzt triggerScan(conn) fire-and-forget auf
- Outlook/Hotmail-Mails die direkt in "Junk Email" landen werden damit
innerhalb von max. 2min erfasst (IDLE hört nur INBOX, kein exists-Event)
- Consent-Guard analog exists-Event: nur wenn conn.consentAt gesetzt
Task 2 â Layer 2.6 global Display-Name-Patterns:
- getMailDisplayNamePatterns(userId) neu in db/domains.ts:
lÀdt aus global_mail_display_names (admin-curated, pending Migration)
+ user_custom_domains type=mail_display_name (backward-compat)
mit try/catch-Fallback bis Schema-Migration deployed ist
- getCustomMailDisplayNames() als @deprecated markiert (bleibt fĂŒr Ăbergangszeitraum)
- scan-internal.post.ts: Import auf getMailDisplayNamePatterns umgestellt
- mail-classifier.ts: Layer 2.6 Kommentar von "dead code" auf "live v1.1" aktualisiert
Schema-Migration (global_mail_display_names) ist Aufgabe von rebreak-backend.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MDM-VPN-Pivot (Phase F.2 done):
- ops/mdm/profiles/rebreak-iphone-protection.mobileconfig auf v5 mit
com.apple.vpn.managed Payload + OnDemandUserOverrideDisabled. iPhone-User
kann ReBreak-VPN-Profile nicht entfernen und "Bedarf verbinden"-Toggle
ist disabled. allowEnablingRestrictions empirisch widerlegt fĂŒr FC-Toggle-
Lock â out.
- DEV-removable Variante als Test-Profile dazu.
- Bootstrap-Tool (rebreak-supervise.sh) + Supervision-Identity-Setup-Doc.
- PHASES.md updated mit empirischen Befunden.
App-side MDM-Detect (Pfad-a Banner-Logic):
- modules/rebreak-protection: getDeviceState() returnt mdmManaged via
Heuristik NETunnelProviderManager.count > 1 (App selbst kann nur einen
eigenen erstellen, MDM-Push fĂŒgt einen zweiten hinzu).
- DeviceLayers.mdmManaged?: boolean Type.
- blocker.tsx: lockedIn-Bedingung erweitert um mdmManaged. Bei MDM-managed
iPhones wird der App-Lock-Card (FC-Authorization-Toggle UI) ausgeblendet
weil der per-App FC-Toggle nicht lockbar ist und durch den MDM-VPN-Layer
redundant.
Layer-2-Country-Curated-Pivot:
- backend: vip-swap.post.ts raus, suggest.post.ts rein. Curated-domains
durch admin (separate Tabelle/Pfad), getrennt von User-Custom-Domains.
- Admin-APIs fĂŒr curated-domain Pflege (index.get + [id].patch).
- seed-country-blocklists Script fĂŒr initiale Curated-Domain-Liste.
- protection/webcontent-domains.get refactored fĂŒr Country-Curated-Pfad.
- Migration drop_vip_swap_fields.sql + schema.prisma adjusted.
- docs/concepts/layer2-country-pivot.md mit Architektur + Decision-Trail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
â ïž TEMP â damit der VIP-Swap-Dialog schon ab der 4. Web-Domain triggert
statt erst ab 31. NACH DEM TEST auf 30 zurĂŒcksetzen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wenn die VIP-Liste (Layer 2) voll ist (>30 eigene Web-Domains) und der
User eine neue Custom-Domain hinzufĂŒgt, ersetzt er bewusst eine
bestehende â der Tausch greift in der VIP erst nach 24h Cooldown.
- Schema: UserCustomDomain.vipDeferUntil + vipEvictAt
(Migration 20260522_add_vip_swap_fields, additiv + nullable)
- getWebCustomDomains: filtert deferred (noch nicht in VIP) + evicted
(Cooldown durch â raus) â lazy ausgewertet, kein Cron
- POST /api/custom-domains: neue Web-Domain ĂŒber dem 30er-Cap â wird
zurĂŒckgestellt (vipDeferUntil gesetzt), Response-Flag vipFull
- POST /api/custom-domains/vip-swap: setzt effectiveAt = jetzt+24h auf
neue + ersetzte Domain
- Layer 1 bleibt unberĂŒhrt â die neue Domain ist dort sofort aktiv
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Custom-Domain-Slots sind jetzt EIN gemeinsamer Pool fĂŒr web + mail
(Pro 10 / Legend 20) statt getrennter web/mail-Buckets. Free-Tier ist
entfallen â PLAN_LIMITS hat nur noch pro + legend, getPlanLimits
defaultet auf pro.
Backend:
- plan-features: customDomains ist eine Zahl (CustomDomainLimits weg)
- index.post: Slot-Check gegen Gesamt-Count, Fehler einheitlich LIMIT_REACHED
- index.get: liefert { items, count, limit }
- change-preview + coach/message an die neue Form angepasst
Frontend:
- useCustomDomains: count/limit (Zahlen) statt countsByType/limits
- AddDomainSheet: ein generischer Limit-Hinweis (error_limit_reached)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Blocker-UI:
- FilterTile: Trash-Button â status-aware Freigabe-Button (Freigeben/Erneut/
in-PrĂŒfung); RemoveDomainSheet entfernt â kein Domain-Entfernen mehr in der UI
- VIP-Liste landabhÀngig: zeigt die komponierte Endpoint-Liste statt nur
eigener Customs; Land ĂŒber GerĂ€te-Region (expo-localization)
- VIP-Realtime: refetch bei Domain-Add/Approve/Reject, pulsierender Ring
fĂŒr neue/active/submitted Chips
VIP-Komposition (webcontent-domains):
- Hybrid: Customs auf 30 gekappt, 20 Slots fest fĂŒr die kuratierte Top-Liste
reserviert â Customs können die Top-Gambling-Domains nicht verdrĂ€ngen
Add-Check (custom-domains POST), fĂŒr web reaktiviert â 3 FĂ€lle gegen
Layer 1 (global) + Layer 2 (kuratierte VIP):
- weder global noch kuratiert â normaler active-Eintrag
- global + kuratiert â alreadyProtected, kein Slot
- global, nicht kuratiert â inGlobalNotVip; per addToVip als status=approved
speicherbar (kein Slot, nur VIP-Liste)
DE-Gambling-Liste 30â36, nach Relevanz sortiert (erste 20 = reservierte PlĂ€tze)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der alreadyGlobal-Pre-Check (Domain schon in der 208k-Layer-1-Blocklist â
kein Custom-Slot) gilt jetzt nur noch fuer Mail-Typen. Fuer type='web'
uebersprungen: Web-Custom-Domains speisen die Layer-2-VIP-Liste, eine separate
Schicht â eine global (Layer 1) gelistete Domain muss in die VIP koennen,
gerade weil Layer 2 das Netz ist, wenn Layer 1 aus ist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Endpoint nutzte getWebCustomDomains ohne Import â ReferenceError â HTTP 500.
server/db/ ist nicht auto-importiert (nur server/utils/), daher expliziter
Import wie in allen anderen 15+ db/domains-Konsumenten.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ersetzt die statische v1 des Endpoints durch die per-User-VIP-Komposition:
eigene Web-Custom-Domains zuerst + globale Auffuellung â dedup â Cap 50.
v1-Reste entfernt (backend/data/, serverAssets-Eintrag) â eine Datenquelle
(backend/server/data/gambling-domains.json, direkter JSON-Import).
pnpm build:backend gruen verifiziert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neuer Nitro-Endpoint serviert die kuratierte Gambling-Domain-Liste pro Land
(DE/GB/FR) aus backend/data/gambling-domains.json. Auth wie alle
/api/protection/*-Routen (requireUser). 50-Domain-Cap pro Land serverseitig
erzwungen. Liste pflegen = JSON editieren + _meta.version hochzaehlen + deploy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: nach Cooldown-Ablauf reaktivierte sich der Schutz automatisch.
Root-Cause: Race zwischen zwei Endpoints die abgelaufene Cooldowns auflösen.
/api/cooldown/status setzt korrekt profile.protectionDisabledAt. /api/
protection/state â alle 5s wĂ€hrend Cooldown gepollt â machte beim Ablauf
NUR resolveCooldown(), ohne protectionDisabledAt. state.get gewann das Race
fast immer â protectionDisabledAt blieb null â protectionShouldBeActive=true
â Frontend-Bypass-Detection reaktivierte den Schutz.
Fix: state.get.ts setzt im expired-Branch ebenfalls protectionDisabledAt +
cooldownJustResolved-Flag.
Same pattern as touchLastSeen: getLastSeenBatch, setPresenceVisible,
getFollowingIds wurden im db/profile.ts implementiert aber nicht in den
Endpoints importiert. Alle 3 warfen 500 ReferenceError â grĂŒner Dot
zeigte sich nie + Toggle silently failed.
Nitro's auto-import covered nur defineEventHandler/getQuery etc., NICHT
unsere eigenen db-layer helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Endpoint warf 500 jedem Heartbeat-Call (ReferenceError) â Floodete
pm2-Logs. Import war im DB-Layer-File implementiert aber nicht vom
Endpoint importiert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Insta-style Online-Status mit Following-Filter + User-opt-out:
- Profile.lastSeenAt + Profile.presenceVisible (default true)
- GET /api/presence/last-seen?userIds=... batch, server-side filter
durch Follow-Relation + presenceVisible
- GET /api/me/following â User-IDs fĂŒr client-side Channel-Filter
(Supabase Realtime Presence hat keine server-side Filter)
- POST /api/me/presence-visibility Toggle
- POST /api/me/last-seen Heartbeat (Phase-1-Fallback bis Edge-Function)
- /api/auth/me extended um presenceVisible fĂŒr Settings-Initial-State
DB-Layer nutzt raw SQL bis Migration auf staging gelaufen ist
(Prisma-Client refresh erst nach CI generate).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foreign-Profile-Page rendert User-Posts jetzt via PostCard mit dem
gleichen Daten-Shape wie Index. `?userId=<uuid>` filtert auf Posts
dieses Users â kombinierbar mit `category=...`, Bot-Kategorie-Mapping
wird ĂŒbersteuert wenn explizite userId mitgegeben.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Frontend Foreign-Profile-Page rendert 3 Stat-Cards (Posts/Followers/
Approved) â counter fĂŒr approved DomainSubmissions hat im Endpoint
gefehlt â undefined im UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User-Bug: Admin-getriggerter Lyra-Post (Topic 'motivation' o.Ă€.) zeigte
trotz FR-locale im Frontend immer Deutsch. Root: LYRA_SYSTEM_PROMPT hatte
hartcodiert 'Auf Deutsch' â Groq output war immer DE.
Selbe Multi-Locale-Pattern wie approve.post.ts:
- 4 parallele Groq-Calls (Promise.allSettled) fĂŒr de/en/fr/ar
- System-Prompt nimmt Lang-Parameter: 'Antwort AUSSCHLIESSLICH in <lang>'
- Content als JSON {de,en,fr,ar} gespeichert
- customContent-Path (Admin tippt selbst Text) bleibt plain â Admin schreibt
in seiner gewÀhlten Sprache, PostCard zeigt allen Usern denselben Text
Frontend (PostCard.tsx) parsed JSON bereits richtig via
resolveLocalizedJsonContent (vorheriger Commit 44a3348).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: User mit FR-locale sahen Lyra-Confirmation-Posts trotzdem auf Deutsch
(Banner/Tabs richtig FR). Root: approve.post.ts generierte den Text via
Groq mit hartcodiertem 'auf Deutsch'-Prompt, speicherte als plain content.
Server (approve.post.ts):
- 4 parallele Groq-Calls (Promise.allSettled) â de + en + fr + ar
- Per-Locale-PROMPT_CFG mit subject/action/statsLine/thanksSegment-Texten
- Locale-aware Number-Format (toLocaleString('de-DE'|'en-US'|'fr-FR'|'ar-EG'))
- Content als JSON {de:'...',en:'...',fr:'...',ar:'...'} gespeichert
- Mindestens DE muss gelingen, sonst kein Post (Sicherheit gegen halbe Posts)
- ~4x Groq-cost pro Post (sehr gĂŒnstig bei Llama-3.3-70b, parallel-latency
bleibt Àhnlich)
Frontend (PostCard.tsx):
- resolveLocalizedJsonContent() â try-parsed JSON content
- Wenn JSON-Object mit Locale-Keys â pickt i18n.language, fĂ€llt auf DE â EN
- Sonst plain content (Legacy-Posts, Comments, User-Posts unverÀndert)
- Quick-Reject auf '{' first-char vermeidet JSON.parse-Overhead fĂŒr 99.9%
der Text-Posts
Legacy-Posts in DB bleiben DE-only (kein retroaktiver Multi-Locale-Rewrite).
Neue Posts ab Deploy haben alle 4 Sprachen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug-context: user reports nach Cooldown-Disable auf v0.2.1 Android-Build
reactiviert sich Schutz auto â a11y-Settings bleibt blockiert â keine Screenshots
möglich. v0.3.0 hat den Backend-protectionDisabledAt-Guard der das verhindert,
aber Test-Devices brauchen ein direktes Reset-Tool fĂŒr Multi-Locale-Screenshots.
Backend:
- POST /api/protection/dev-force-disabled â sets protectionDisabledAt=NOW()
ohne Cooldown-Vorlauf. Production-Guard (rebreak.org-non-staging â 403).
Frontend:
- /debug Android-Section refactored: "Force Reset + Settings öffnen" Button
- Bundle aus 3 Steps:
1. native forceDisable (VPN stop + tamper disarm + filter_enabled=false)
2. backend dev-force-disabled (Anti-Auto-Reactivation-Mark)
3. Settings â Bedienungshilfen öffnen
- Danach: User toggled ReBreak-Service in Android-Settings manuell off
â frischer a11y-deep-link-Trigger fĂŒr nĂ€chste Screenshot-Iteration
Also: fix /onboarding/welcome â /onboarding (Duo-Rewrite hat den alten Pfad
gelöscht). Route 404 auf Android sichtbar wenn User in debug-toggle 'welcome'
oder 'nickname' tappt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
## Backend: Anti-Auto-Reactivation nach Cooldown
Bug: nach Cooldown-Ablauf wurde der URL-Filter automatisch wieder
reaktiviert (enforceProtection-Loop fÀngt 'recoveringFromBypass'-Phase ab).
Damit war der Cooldown-Schritt entwertet â User konnte nicht wirklich
abschalten, weil die App den Schutz sofort wieder hochfuhr.
Fix: Profile.protectionDisabledAt (DateTime nullable). Wird in
/api/cooldown/status auf cooldown-auto-resolve gesetzt. /api/protection/state
gibt dann protectionShouldBeActive=false zurĂŒck â Frontend macht KEINE
Auto-Reactivation. User muss explizit re-aktivieren (CTA in der App).
- Migration 20260517_protection_disabled_at
- Schema: Profile.protectionDisabledAt
- /api/cooldown/status: setzt das Feld auf expired+resolve
- /api/protection/state: includes profile.protectionDisabledAt in shouldBeActive-Berechnung
- /api/protection/mark-active (POST, NEU): clears das Feld, vom Frontend
auto-aufgerufen nach erfolgreichem activateUrlFilter
Bypass-Recovery durch externe iOS-Settings-Disable (nicht cooldown-bezogen)
funktioniert weiter â protectionDisabledAt ist dann null, alte Logik greift.
## Frontend: ProtectionOffSheet (Custom-Sheet statt Alert.alert)
Bisheriges native Alert mit OK+Reactivate-Buttons hat keine visuelle
Hierarchy (iOS macht beide gleich). Ersetzt mit FormSheet:
- GroĂer blauer Primary "Schutz wieder einschalten"
- Ghost-Link "SpÀter"
- Swipe-down / Backdrop-Tap zum SchlieĂen
## Frontend: ProtectionSlide mit Pre-Explainer (Screenshot + Pulse-Marker)
User-Request: vor dem iOS-Permission-Dialog ein ErklÀrungs-Screen zeigen
damit der User weiĂ wo er tappen muss (Apple's "Don't Allow" ist groĂ+
blau = Trap, "Allow" ist der unscheinbare Button unten).
- components/onboarding/ScreenshotPointer.tsx â Reanimated pulsing red
ring, positionierbar via {xPercent, yPercent}
- lib/onboardingAssets.ts â locale-aware require()-Map fĂŒr Screenshot-
Assets mit de-Fallback
- assets/onboarding/de/ â 4 iOS-Screenshots vom User (url_filter +
screen_time permission dialogs + 2 confirm screens)
- ProtectionSlide refactored: internal phase state preexplain_url â
preexplain_lock â done. Jede Phase zeigt Screenshot + Pulse-Marker auf
korrekten Button + Lyra-Bubble + activate-CTA.
## Locale-Keys
- onboarding.lyra.protection_url.body, onboarding.lyra.protection_lock.body
- onboarding.protection.url_title, .lock_title, .tap_marker_hint
- onboarding.protection.applock_failed_*, applock_skip
- blocker.protection_off_later, reactivate_btn (refined)
## Bugfix: de.json JSON-syntax
Smart-quote-typo: schlieĂendes "" nach âErlauben" und âFortfahren" war
ein plain ASCII " (U+0022) statt U+201D, was den JSON-String frĂŒh
terminiert hat. Metro+Hermes warfen "unrecognized Unicode â".
Fix: escapte \" verwendet â JSON-safe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DiGA-Pfad-Foundation: User mit Rezept-Code löst im Onboarding ein, wird auf
plan='legend' hochgestuft (Default), Onboarding-Step springt auf 'done',
diga_code_redeemed_at als Audit-Trail. Trial-Modell wird ĂŒbersprungen.
- Prisma model DigaCode (code unique, expires_at, used_at, used_by_profile_id,
grants_plan, notes, label)
- Profile.digaCodeRedeemedAt fĂŒr Reverse-Audit
- Migration 20260517_add_diga_codes mit Table + FK + Index
- Seed: REBREAK-TEST-001..010 (single-use, reset via SQL fĂŒr erneutes Testen)
- POST /api/onboarding/redeem-diga-code â atomare Transaction, klare 400-Errors
(not_found | already_used | expired | invalid_input)
Frontend (Duo-Onboarding) dockt spĂ€ter an â diese Backend-Foundation steht.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>