chore(claude): history-driven agent-tooling hardening (#6195)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-10 09:42:25 -05:00
committed by GitHub
parent 7115c176d4
commit 57d11e3255
8 changed files with 192 additions and 1 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 <path> 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)

50
.claude/hooks/subagent-audit.sh Executable file
View File

@@ -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

View File

@@ -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?)"
}
]
}

View File

@@ -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.

View File

@@ -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/<owner>/<repo>/<full-commit-sha>/<path/to/img.png>
```
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 <target> --title "<type>: <summary>" --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.

9
.mcp.json Normal file
View File

@@ -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"
}
}
}