Scaffold: agent starter kit (seeded from Ebb)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 20:15:40 +02:00
commit 8bef22afb2
28 changed files with 713 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
# Deletes Gradle and Kotlin build output in the main checkout and in every agent worktree (.claude/worktrees/*):
# each project's build/ (a directory named build next to a build.gradle.kts or settings.gradle.kts), .gradle/configuration-cache
# and .kotlin. Never looks into export/, libs/, models/, spike/ or "samsung sdk/", and never deeper than four levels.
# Prints what it removes and the space freed. Waits for the Gradle lock of tools/gradle-serial.sh, so it never deletes
# under a running build. The orchestrator runs it after removing a worktree or when the disk is low.
# Usage from any checkout: tools/clean-builds.sh [--dry-run]
dry=0
case "$1" in
--dry-run) dry=1 ;;
"") ;;
*) echo "usage: $0 [--dry-run]" >&2; exit 2 ;;
esac
common=$(git rev-parse --path-format=absolute --git-common-dir) || exit 1
main=$(dirname "$common")
# Candidates under one checkout root; .claude is skipped here because the worktrees are walked as roots of their own.
candidates() {
find "$1" -maxdepth 4 \
\( -path "$1/export" -o -path "$1/libs" -o -path "$1/models" -o -path "$1/spike" -o -path "$1/samsung sdk" \
-o -path "$1/.claude" -o -name .git -o -name src -o -name node_modules \) -prune \
-o -type d \( -name build -o -name .kotlin -o -path '*/.gradle/configuration-cache' \) -print -prune
}
list=$(mktemp) || exit 1
trap 'rm -f "$list"' EXIT
for root in "$main" "$main"/.claude/worktrees/*; do
[ -d "$root" ] || continue
candidates "$root" | while IFS= read -r dir; do
if [ "$(basename "$dir")" = build ]; then
parent=$(dirname "$dir")
[ -f "$parent/build.gradle.kts" ] || [ -f "$parent/settings.gradle.kts" ] || continue
fi
printf '%s\n' "$dir"
done
done > "$list"
if [ ! -s "$list" ]; then echo "Nothing to remove."; exit 0; fi
if [ "$dry" -eq 0 ]; then
lock=/tmp/kvitt-gradle.lock
while ! mkdir "$lock" 2>/dev/null; do
pid=$(cat "$lock/pid" 2>/dev/null)
if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then rm -rf "$lock"; continue; fi
sleep 5
done
echo $$ > "$lock/pid"
trap 'rm -rf "$lock"; rm -f "$list"' EXIT INT TERM HUP
fi
total=0
while IFS= read -r dir; do
kb=$(du -sk "$dir" 2>/dev/null | cut -f1)
kb=${kb:-0}
total=$((total + kb))
printf '%8d MB %s\n' $((kb / 1024)) "${dir#"$main"/}"
[ "$dry" -eq 1 ] || rm -rf "$dir"
done < "$list"
if [ "$dry" -eq 1 ]; then verb="Would free"; else verb="Freed"; fi
echo "$verb $((total / 1024)) MB in $(wc -l < "$list" | tr -d ' ') directories."
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Version drift report for gradle/libs.versions.toml (Plan 09, D-056).
Reads every [versions] entry, finds an artifact that uses it in [libraries] or [plugins], and asks Google Maven and
Maven Central for that artifact's maven-metadata.xml. Prints one table: current, latest stable, newest (only where a
pre-release is newer than the latest stable), and how far the current version is behind.
The Gradle wrapper version is compared with services.gradle.org. Nothing here runs from the build; run it by hand:
python3 tools/dependency-report/latest.py
Rule (CLAUDE.md › Build and verify): run it at the start of every plan. A component more than one stable minor
behind gets a line in that plan's report; a toolchain component (Gradle, AGP, Kotlin, JDK LTS) more than one line
behind triggers a Plan 09-shaped upgrade before the plan starts.
"""
import json
import re
import sys
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
REPOS = ["https://dl.google.com/android/maven2", "https://repo1.maven.org/maven2"]
# Catalog entries that are SDK levels, not artifacts.
NOT_ARTIFACTS = {"minSdk", "compileSdk", "targetSdk"}
# The toolchain is looked up by its Gradle plugin, not by the first library that shares its version.
PREFERRED = {
"agp": ("com.android.tools.build", "gradle"),
"kotlin": ("org.jetbrains.kotlin", "kotlin-gradle-plugin"),
"ksp": ("com.google.devtools.ksp", "symbol-processing-gradle-plugin"),
}
STABLE = re.compile(r"^\d+(\.\d+)*$")
def key(version):
"""Numeric parts first; a pre-release (anything after the first '-') sorts before its release."""
base, _, suffix = version.partition("-")
numbers = [int(p) if p.isdigit() else -1 for p in base.split(".")]
return numbers, (0, suffix) if suffix else (1, "")
def fetch(url):
try:
with urllib.request.urlopen(url, timeout=20) as response:
return response.read()
except OSError:
return None
def versions_of(group, name):
"""Versions from every repository together: some artifacts (early KSP) also sit, stale, on Google Maven."""
found = set()
for repo in REPOS:
body = fetch(f"{repo}/{group.replace('.', '/')}/{name}/maven-metadata.xml")
if body:
found.update(v.text for v in ET.fromstring(body).iter("version") if v.text)
return sorted(found)
def plugin_marker(plugin_id):
return plugin_id, f"{plugin_id}.gradle.plugin"
def read_catalog(text):
"""The subset of TOML the catalog uses (works on Python < 3.11, which has no tomllib): returns the [versions]
entries in file order and, per version name, the first library or plugin that refers to it."""
versions, artifacts, section = {}, {}, None
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line.startswith("["):
section = line.strip("[]")
continue
if section == "versions":
match = re.match(r'([\w-]+)\s*=\s*"([^"]+)"', line)
if match:
versions[match.group(1)] = match.group(2)
continue
ref = re.search(r'version\.ref\s*=\s*"([^"]+)"', line)
if not ref or ref.group(1) in artifacts:
continue
module = re.search(r'module\s*=\s*"([^"]+)"', line)
plugin = re.search(r'\bid\s*=\s*"([^"]+)"', line)
if section == "libraries" and module:
artifacts[ref.group(1)] = tuple(module.group(1).split(":"))
elif section == "plugins" and plugin:
artifacts[ref.group(1)] = plugin_marker(plugin.group(1))
return versions, artifacts
def behind(current, stable):
if not stable or not STABLE.match(current):
return ""
c, s = [int(x) for x in current.split(".")], [int(x) for x in stable.split(".")]
c += [0] * (3 - len(c))
s += [0] * (3 - len(s))
if c[0] < s[0]:
return f"{s[0] - c[0]} major"
if c[1] < s[1]:
return f"{s[1] - c[1]} minor"
if c[2] < s[2]:
return "patch"
return ""
def row(name, coordinate, current, available):
stable = sorted((v for v in available if STABLE.match(v)), key=key)
everything = sorted(available, key=key)
latest = stable[-1] if stable else "?"
newest = everything[-1] if everything else "?"
return [name, coordinate, current, latest, newest if newest not in (latest, current) else "", behind(current, latest if stable else "")]
def gradle_row():
props = (ROOT / "gradle/wrapper/gradle-wrapper.properties").read_text()
match = re.search(r"gradle-([\d.]+)-(bin|all)\.zip", props)
current = match.group(1) if match else "?"
body = fetch("https://services.gradle.org/versions/all")
available = []
if body:
available = [v["version"] for v in json.loads(body) if not v.get("snapshot") and not v.get("nightly")]
return row("gradle (wrapper)", "services.gradle.org", current, available)
def main():
versions, artifacts = read_catalog((ROOT / "gradle/libs.versions.toml").read_text())
rows = [gradle_row()]
for name, current in versions.items():
if name in NOT_ARTIFACTS:
continue
coordinate = PREFERRED.get(name) or artifacts.get(name)
if not coordinate:
rows.append([name, "(no artifact)", str(current), "", "", ""])
continue
rows.append(row(name, ":".join(coordinate), str(current), versions_of(*coordinate)))
header = ["version", "artifact", "current", "latest stable", "newer pre-release", "behind"]
widths = [max(len(str(r[i])) for r in rows + [header]) for i in range(len(header))]
print(" | ".join(h.ljust(w) for h, w in zip(header, widths)))
print("-+-".join("-" * w for w in widths))
for r in rows:
print(" | ".join(str(c).ljust(w) for c, w in zip(r, widths)))
return 0
if __name__ == "__main__":
sys.exit(main())
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Kvitt pre-commit hook: the fast subset of `./gradlew check`. Installed by tools/install-git-hooks.sh.
set -e
top=$(git rev-parse --show-toplevel)
# Secrets and personal data never reach git. Adjust the list to this project (see CLAUDE.md › Never commit).
bad=$(git -C "$top" ls-files --cached -- local.properties '*.jks' '*.keystore' '*.p12' '*.aar' 'models/' 'export/' | head -5)
if [ -n "$bad" ]; then
echo "pre-commit: a secret, keystore, vendor binary or personal-data file is staged or tracked:" >&2
echo "$bad" >&2
echo "Unstage with: git rm --cached <file>" >&2
exit 1
fi
[ -x "$top/gradlew" ] || exit 0
# The build runs on the pinned JDK (gradle.properties); the shell's default java may be newer or a macOS stub.
case "$("${JAVA_HOME:+$JAVA_HOME/bin/}java" -version 2>&1 | head -1)" in
*\"25*) ;;
*) [ -d /opt/homebrew/opt/openjdk@25 ] && export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home ;;
esac
cd "$top"
# Replace with this project's fast checks (architecture rules, the pure-module tests). Keep it under a minute.
./gradlew --quiet :core:model:test || {
echo "pre-commit: fast tests failed (see above). Run ./gradlew check for the full picture." >&2
exit 1
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Runs ./gradlew with the given arguments, one Gradle build at a time across every worktree and agent on this machine
# (CLAUDE.md › Build and verify: parallel builds exhaust memory). Waits for a running build to finish, then runs.
# Usage from any worktree root: tools/gradle-serial.sh check
lock=/tmp/kvitt-gradle.lock
top=$(git rev-parse --show-toplevel) || exit 1
while ! mkdir "$lock" 2>/dev/null; do
pid=$(cat "$lock/pid" 2>/dev/null)
if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then rm -rf "$lock"; continue; fi
sleep 5
done
echo $$ > "$lock/pid"
trap 'rm -rf "$lock"' EXIT INT TERM HUP
cd "$top" && ./gradlew "$@"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Installs the Kvitt pre-commit hook into this repository's hooks folder (shared by all worktrees).
# The hook guards itself: on branches without the app build it only runs the export/ check.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
hooks="$(git -C "$here" rev-parse --path-format=absolute --git-common-dir)/hooks"
target="$hooks/pre-commit"
if [[ -e "$target" ]] && ! cmp -s "$here/git-hooks/pre-commit" "$target"; then
echo "A different pre-commit hook already exists at $target; not overwriting. Merge them by hand." >&2
exit 1
fi
mkdir -p "$hooks"
cp "$here/git-hooks/pre-commit" "$target"
chmod +x "$target"
echo "Installed $target"