diff --git a/.claude/agents/gradle-runner.md b/.claude/agents/gradle-runner.md index 71abe56a4..7dba9afe5 100644 --- a/.claude/agents/gradle-runner.md +++ b/.claude/agents/gradle-runner.md @@ -14,6 +14,14 @@ cd "$(git rev-parse --show-toplevel)" && pwd && export ANDROID_HOME="${ANDROID_H ``` If a build complains `local.properties` is missing (Google-flavor tasks), `cp secrets.defaults.properties local.properties` first — it's git-ignored. Do not `cd` elsewhere mid-command. +## Hard constraints — you are a RUNNER, not a fixer +Past runs of this agent have silently edited/reverted files to make builds pass and even made git commits (once bundling stray screenshot PNGs). Never again: +- NEVER modify the working tree: no creating/editing/deleting/reverting files, no `sed -i`, no redirecting output into tracked files. +- NEVER run git write commands: no `commit`, `add`, `checkout --`, `restore`, `stash`, `clean`, `reset`. Read-only git (`status`, `diff`, `log`) is fine. +- The ONLY permitted writes are bootstrap: `export ANDROID_HOME=...` and `cp secrets.defaults.properties local.properties` (git-ignored). +- If the build fails, REPORT it — do not attempt any fix, however trivial. +- If a Gradle task itself dirties tracked files (e.g. `allTests` regenerates `docs/assets/screenshots/*.png` on this machine), leave them dirty and say so in NOTES — do not revert. + ## How to run - Run exactly the task(s) the caller specified. Do not add `clean` unless asked. - KMP test gotcha: KMP modules use `:module:allTests`; pure-Android/JVM modules (`app`, `core:api`, `core:barcode`) use `:module:testFdroidDebugUnitTest`. If the caller's task name looks wrong for the module type, run what they asked, then note the likely correct name in your report. diff --git a/.claude/hooks/post-edit.sh b/.claude/hooks/post-edit.sh index 014480762..5c4cc766d 100755 --- a/.claude/hooks/post-edit.sh +++ b/.claude/hooks/post-edit.sh @@ -84,4 +84,43 @@ $out" ;; esac +# --- Advisory Compose-pitfall checks for Kotlin edits (warn-only, never block) --- +case "$file_path" in + *.kt) : ;; + *) exit 0 ;; +esac +case "$file_path" in + *Preview*|*commonTest*|*androidUnitTest*|*/test/*|*/androidTest/*) exit 0 ;; +esac + +notes="" + +# Lazy-list duplicate-key crash — shipped TWICE (bare telemetry.time; bare +# node.num). Flag key lambdas built on those exact fields. +risky=$(grep -nE 'key[[:space:]]*=[[:space:]]*\{[[:space:]]*([A-Za-z_][A-Za-z0-9_]*[[:space:]]*->[[:space:]]*)?[A-Za-z_][A-Za-z0-9_.]*\.(num|time)[[:space:]]*\}' "$file_path" 2>/dev/null) +if [ -n "$risky" ]; then + notes="Lazy-list key built on .num/.time — this exact pattern shipped two production dup-key crashes (bare telemetry.time, fixed with \"\${time}_\$index\"; bare node.num, fixed with distinctBy since _\$index breaks animateItem). Keys must be unique across the submitted list — dedupe the source list or compose the key: +$risky" +fi + +# Hardcoded user-facing strings — Crowdin never sees literals (caught on PR +# #6143). Main source sets only; matching surrounding hardcoded code is not +# an excuse (that's a latent bug, not a pattern). +case "$file_path" in + */commonMain/*|*/androidMain/*) + # Two shapes: inline Text("...") / Text(text = "..."), and the multiline + # form where 'text = "..."' sits on its own line inside a formatted call. + hardcoded=$(grep -nE '(Text\(|text[[:space:]]*=)[[:space:]]*"[A-Za-z]' "$file_path" 2>/dev/null) + if [ -n "$hardcoded" ]; then + [ -n "$notes" ] && notes="$notes + +" + notes="${notes}Possible hardcoded user-facing string(s) — user-facing text must use stringResource(Res.string.x) or Crowdin never sees it (PR #6143). Check .skills/compose-ui/strings-index.txt for an existing string first: +$hardcoded" + fi + ;; +esac + +[ -n "$notes" ] && emit_context "$notes" + exit 0 diff --git a/.claude/hooks/pre-bash-guard.sh b/.claude/hooks/pre-bash-guard.sh index 7e1e5e0ff..c2540ff50 100755 --- a/.claude/hooks/pre-bash-guard.sh +++ b/.claude/hooks/pre-bash-guard.sh @@ -36,6 +36,13 @@ ask() { # $1 = reason; prompt the user to confirm exit 0 } +# Gradle gates apply ONLY to this project. Without this, pushes/commits in +# OTHER repos got blocked by a failing ./gradlew (bit us: had to evade with +# `git -C push`). $1 = repo root; false -> caller should fail open. +is_this_repo() { + [ -x "$1/gradlew" ] && grep -qi meshtastic "$1/settings.gradle.kts" 2>/dev/null +} + # --- 2. Destructive-op confirmation (cheap checks first) -------------------- if printf '%s' "$cmd" | grep -q 'git push' \ && printf '%s' "$cmd" | grep -Eq -- '(--force([^-]|$)|[[:space:]]-f([[:space:]]|$))'; then @@ -51,6 +58,7 @@ if printf '%s' "$cmd" | grep -q 'git push'; then cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) [ -z "$cwd" ] && cwd="$PWD" repo_root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || exit 0 + is_this_repo "$repo_root" || exit 0 export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" # ponytail: detekt-only gate — test/allTests deliberately NOT run here (minutes # per push is too costly; baseline stays the developer's job). Ceiling: detekt @@ -76,6 +84,17 @@ cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) [ -z "$cwd" ] && cwd="$PWD" repo_root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || exit 0 [ -n "$repo_root" ] || exit 0 +is_this_repo "$repo_root" || exit 0 + +# Staged screenshot PNGs: allTests regenerates docs/assets/screenshots/*.png on +# this machine (host-render diff), and gradle-runner once auto-committed strays. +# Confirm they are intentional UI-change screenshots before they ride along. +shots=$(git -C "$repo_root" diff --cached --name-only -- 'docs/assets/screenshots/*.png' 2>/dev/null) +if [ -n "$shots" ]; then + ask "Staged screenshot PNGs detected: +$shots +allTests regenerates these on this machine — if they are NOT intentional UI-change screenshots, unstage and restore them (git restore --staged --worktree -- docs/assets/screenshots) before committing. Flagged by .claude/hooks/pre-bash-guard.sh" +fi # Staged Kotlin files (added/copied/modified/renamed). Nothing staged -> no-op. staged=$(git -C "$repo_root" diff --cached --name-only --diff-filter=ACMR -- '*.kt' '*.kts' 2>/dev/null) diff --git a/.claude/hooks/subagent-audit.sh b/.claude/hooks/subagent-audit.sh new file mode 100755 index 000000000..4cc8ad39e --- /dev/null +++ b/.claude/hooks/subagent-audit.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# PostToolUse hook (Task|Agent) for Meshtastic-Android. +# +# Tripwire for subagent side effects: gradle-runner has silently edited/reverted +# files to make builds pass AND made git commits (once bundling stray screenshot +# PNGs). After every subagent returns, surface HEAD + dirty files to the main +# loop as additionalContext — but only when there is something to see: a dirty +# tree, or a HEAD commit younger than 15 minutes (possibly made by the subagent +# that just finished). +# +# ponytail: stateless — can't diff against pre-subagent state, so your own +# in-progress edits show up too; the note says so. Snapshot-before/after if the +# noise ever matters. +# +# FAILS OPEN: any error -> exit 0 with no output. + +input=$(cat) +command -v jq >/dev/null 2>&1 || exit 0 + +cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) +[ -z "$cwd" ] && cwd="$PWD" +repo_root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || exit 0 + +dirty=$(git -C "$repo_root" status --porcelain 2>/dev/null | head -20) +head_line=$(git -C "$repo_root" log -1 --format='%h %s (%cr)' 2>/dev/null) +head_ct=$(git -C "$repo_root" log -1 --format=%ct 2>/dev/null) +[ -n "$head_ct" ] || head_ct=0 +head_age=$(( $(date +%s) - head_ct )) + +fresh_commit="" +[ "$head_age" -lt 900 ] && fresh_commit="yes" + +[ -z "$dirty" ] && [ -z "$fresh_commit" ] && exit 0 + +note="Subagent-audit (.claude/hooks/subagent-audit.sh) — post-subagent tree check: +HEAD: $head_line" +if [ -n "$fresh_commit" ]; then + note="$note +^ HEAD is under 15 min old. If YOU did not make this commit, the subagent did (gradle-runner has done this before) — inspect with 'git show --stat' before building on it." +fi +if [ -n "$dirty" ]; then + note="$note +Dirty files (first 20): +$dirty +Expected if these are your own in-progress edits. If the subagent was only supposed to BUILD/TEST, verify it didn't edit or revert files to force a pass (git diff)." +fi + +jq -n --arg c "$note" '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$c}}' +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 1727aea7f..1a6686ad7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -22,7 +22,18 @@ "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit.sh\"", "timeout": 60, - "statusMessage": "Post-edit checks (strings sort / metadata length / module CI filter)" + "statusMessage": "Post-edit checks (strings sort / metadata length / module CI filter / Compose pitfalls)" + } + ] + }, + { + "matcher": "Task|Agent", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-audit.sh\"", + "timeout": 15, + "statusMessage": "Subagent-audit (did the subagent touch the tree?)" } ] } diff --git a/.claude/skills/crashlytics-triage/SKILL.md b/.claude/skills/crashlytics-triage/SKILL.md new file mode 100644 index 000000000..f96a87203 --- /dev/null +++ b/.claude/skills/crashlytics-triage/SKILL.md @@ -0,0 +1,21 @@ +--- +name: crashlytics-triage +description: Triage current Meshtastic-Android crashes in Firebase Crashlytics — establish the right version filter (topVersions first, then topIssues filtered to "X.Y.Z (versionCode)"), fan out one crash-investigator subagent per top issue in parallel, and return a distilled verdict table. Use for "what's crashing", "triage Crashlytics", or "is build NNNN healthy" sweeps; for a single known issue id, dispatch crash-investigator directly instead. +disable-model-invocation: true +--- + +# crashlytics-triage + +Crashlytics sweep for the **meshutil** Firebase project (`484268767777`), prod app id `1:484268767777:android:70d9bffeca6efe05334160`. Datadog RUM is the *other* backend (high-volume logged errors — use `datadog-rum-investigator` there); Crashlytics is the low-volume real-crash signal. If Firebase MCP auth fails, repair the local Firebase MCP authentication (the configured session account has access). + +## 1. Version context first — never guess the filter string +Call `crashlytics_get_report` for **topVersions with NO filter**. This yields the exact display names — the version filter format is `"X.Y.Z (versionCode)"` and hand-built strings silently match nothing. Pick the target version(s): the argument if given, else the newest **production** version with meaningful session volume. topVersions doesn't say which track a versionCode shipped on — map candidate versionCodes to releases via `gh release list` (release names embed the versionCode; never hand-arithmetic) before picking, then use the exact topVersions display name as the filter. + +## 2. Top issues for that version +`crashlytics_get_report` topIssues filtered to the exact display name from step 1. Take the top ~5 (or the requested count) by event count. Note event counts and affected-user counts. + +## 3. Fan out — one crash-investigator per issue, in parallel +Dispatch the `crash-investigator` agent for each issue **in a single message** so they run concurrently. Give each: the issue id, the version display name, and the ask (root-cause hypothesis + fix area + whether it's already fixed/known). Historical patterns to investigate — hints, not automatic classifications: cluster-renderer lifecycle (fix shipped in 29321034), MQTT/TLS ktor write (fixed, lingering 2.7.14 users), LazyColumn dup-key (two prior instances). Each investigator must verify the crashing versionCode against the fix's release before reporting "known-fixed-residual". + +## 4. Verdict +One table: issue → crash count/users → root-cause hypothesis → status (NEW / known-fixed-residual / regression) → fix area. Flag anything that warrants a hotfix vs. next-release. No raw stack traces in the summary. diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md new file mode 100644 index 000000000..7a332c807 --- /dev/null +++ b/.claude/skills/pr/SKILL.md @@ -0,0 +1,34 @@ +--- +name: pr +description: Push the current branch and open a draft PR for Meshtastic-Android the repo way — baseline verified first, body drafted per .github/copilot-pull-request-instructions.md (WHY-first, categorized changes), screenshots embedded via commit-pinned raw URLs. Use whenever work is ready to go up as a PR. +disable-model-invocation: true +--- + +# pr + +Opens a PR the way this repo expects. The SOP lives in `.github/copilot-pull-request-instructions.md` — **read it now and follow it**; this skill only adds the steps around it. + +## 1. Pre-flight +- Target branch is `main` unless told otherwise. +- On the default branch? Create a feature branch first. +- Baseline must be green **this session** (`spotlessApply spotlessCheck detekt assembleDebug test allTests`). If it hasn't run since the last code change, run `/baseline` first — CI has failed repeatedly on skipped local checks. (The pre-push hook only gates detekt.) +- `git status`: nothing unintended staged. Never commit `.agent_memory/`. If `docs/assets/screenshots/*.png` are dirty from a test run (not an intentional UI change), restore them: `git checkout -- docs/assets/screenshots`. + +## 2. Body +Draft per the SOP file (WHY-first summary, then changes under the 🌟 Features / 🛠️ Improvements / 🐛 Bug Fixes / 🧹 Chores categories that apply, **Testing Performed** section when tests were added/changed). + +**Screenshots** (UI changes want them): commit the PNGs on the branch, push, then embed with commit-pinned raw URLs so they render in the PR body immediately: +``` +https://raw.githubusercontent.com//// +``` +Pin to the SHA that contains the image, not the branch name. + +## 3. Push and open +```bash +git push -u origin HEAD +gh pr create --draft --base --title ": " --body-file <(printf '%s' "$BODY") +``` +- `--base` is the target branch chosen in pre-flight (`main` unless told otherwise) — always pass it explicitly; omitting it silently uses the repo default. +- Draft by default; only `--ready` if explicitly asked. +- End the body with: `🤖 Generated with [Claude Code](https://claude.com/claude-code)` +- Report the PR URL as a markdown link. diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..69e851d59 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "$comment": "Team-shared MCP servers (auth-free only — anything needing tokens stays in user-level config). context7 = live library docs lookup; used constantly for Kotlin/Compose/AGP version questions.", + "mcpServers": { + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + } + } +}