From 662fc1de0430f6cae59bd7ef3293ad86edecc70a Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:34:41 +0200 Subject: [PATCH 1/5] chore(zellij): drop the hardcoded project-tab layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every login ran `zellij -l projects`, which loaded a hand-maintained layout naming nine project tabs. That list is a copy of state that already lives in ~/dev, so it drifted: two of its nine tabs pointed at directories that no longer exist (openclaw, cockpit), and none pointed at the repos actually being worked on. Startup reliably produced tabs nobody wanted. The layout's only other content was a default_tab_template that is byte-for-byte zellij's compiled-in default (tab-bar / pane / status-bar, verified via `zellij setup --dump-layout default`). So the file contributed nothing but the stale list, and deleting it is the whole fix — a bare `zellij` yields one tab whose cwd is inherited from the launching terminal rather than hardcoded anywhere. Fixes the class, not the instance: there is no longer a project list to go stale. Open project tabs on demand instead. --- .bashrc | 2 +- .config/zellij/layouts/projects.kdl | 44 ----------------------------- install.sh | 1 - 3 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 .config/zellij/layouts/projects.kdl diff --git a/.bashrc b/.bashrc index 22dd0dd..11fb317 100644 --- a/.bashrc +++ b/.bashrc @@ -304,7 +304,7 @@ if [ -z "${ZELLIJ:-}" ] && [ -z "${BASH_EXECUTION_STRING:-}" ] && command -v zel if zellij list-sessions -ns 2>/dev/null | grep -q .; then zellij attach else - zellij -l projects + zellij fi fi diff --git a/.config/zellij/layouts/projects.kdl b/.config/zellij/layouts/projects.kdl deleted file mode 100644 index 8dfa9d1..0000000 --- a/.config/zellij/layouts/projects.kdl +++ /dev/null @@ -1,44 +0,0 @@ -// Project workspace layout — shell panes that survive restart. -// Each tab opens a shell in the project directory. -// Type 'codex' or 'claude' to start an agent in any project tab. -// Type 'cockpit' in the Cockpit tab to start the web UI. - -layout { - default_tab_template { - pane size=1 borderless=true { - plugin location="zellij:tab-bar" - } - children - pane size=1 borderless=true { - plugin location="zellij:status-bar" - } - } - - tab name="Revamp-Info" { - pane cwd="/home/g/dev/revamp-info" - } - tab name="evig" { - pane cwd="/home/g/dev/evig" - } - tab name="AOZ" { - pane cwd="/home/g/dev/aoz-housing" - } - tab name="OC" { - pane cwd="/home/g/dev/orangecat" - } - tab name="Kivvi" { - pane cwd="/home/g/dev/kivvi" - } - tab name="OpenClaw" { - pane cwd="/home/g/dev/openclaw" - } - tab name="Cockpit" { - pane cwd="/home/g/dev/cockpit" - } - tab name="Dotfiles" { - pane cwd="/home/g/dev/dotfiles" - } - tab name="Scratch" focus=true { - pane cwd="/home/g" - } -} diff --git a/install.sh b/install.sh index c5d6eda..9aff895 100755 --- a/install.sh +++ b/install.sh @@ -45,7 +45,6 @@ link .bashrc link .config/kitty/kitty.conf link .config/starship.toml link .config/zellij/config.kdl -link .config/zellij/layouts/projects.kdl # Project mapping link .config/claude-projects.conf From 81941e07ff302528ed3196165020938c1cb7a214 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:50:19 +0200 Subject: [PATCH 2/5] feat(fleet): flag work that has aged, not work that is merely dirty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo already had a fleet health check. It did not prevent the thing it looks like it prevents: on 2026-08-24 orangecat held 118 uncommitted files and fleetcrown 88, both untouched for ten days — a finished EntityType rename and a finished publishing workstream, each existing on exactly one disk. `git-health` had been reporting them accurately that entire time. The design error is worth naming, because it is easy to repeat: DIRTY IS NOT A DEFECT. Every tree is dirty while someone works in it, so a count that is equally true at minute five and at day ten carries no signal. It gets read as noise, and then it stops being read. Age is what separates work in progress from work that is going nowhere. So stranded-work.sh reports only what has aged past a threshold (default 3d) and prints NOTHING when the fleet is healthy. The silence is the mechanism, not manners: a check that speaks on every shell gets muted, and a muted check is an absent one. It covers both uncommitted files and commits that were made but never pushed — the second is how four of fleetcrown's commits hid, since a tree can be clean and still be holding everything back. It cannot run in CI: the work it looks for has never left the machine, so GitHub cannot see it by construction. It runs from .bashrc instead, printing the last scan from cache and refreshing in the background, so it never costs a prompt. `fleet-stranded` runs it on demand. The deciding half takes TSV on stdin and touches no git, no filesystem and no clock, so the tests drive the shipped rule rather than a restatement of it — the drift the existing ratchet test comments already worry about. Both failure modes are tested, because either one kills it: it must go red on aged work, and it must stay silent on a fresh tree. Writing the tests first paid immediately — the rename case failed, because the porcelain prefix was being stripped after the "old -> new" arrow was resolved, which ate three characters of every renamed path. orangecat's rename touched 18 deletions and a rename; the guard would have misread it. 17 tests, wired into CI. --- .bashrc | 17 +++ .github/workflows/ci.yml | 8 ++ scripts/fleet/stranded-work.sh | 195 ++++++++++++++++++++++++++++ scripts/fleet/test-stranded-work.sh | 167 ++++++++++++++++++++++++ 4 files changed, 387 insertions(+) create mode 100755 scripts/fleet/stranded-work.sh create mode 100755 scripts/fleet/test-stranded-work.sh diff --git a/.bashrc b/.bashrc index 11fb317..8de91f1 100644 --- a/.bashrc +++ b/.bashrc @@ -291,6 +291,23 @@ git-health() { echo " $clean clean, $dirty dirty, $ahead with unpushed commits" } +# ═══════════════════════════════════════════════════ +# Stranded work — finished work that is going nowhere +# ═══════════════════════════════════════════════════ +# git-health above answers "what is dirty", which is every repo someone is +# working in. That number was 118 for orangecat every day for ten days and +# changed nobody's behaviour, because a count that is equally true at minute +# five and at day ten reads as noise. +# +# This one only speaks when work has AGED past a threshold, and says nothing +# otherwise. It prints from cache so it never costs a prompt, and refreshes in +# the background when that cache goes stale. +fleet-stranded() { bash "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" "${1:-}"; } + +if [ -z "${BASH_EXECUTION_STRING:-}" ] && [ -f "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" ]; then + bash "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" --shell 2>/dev/null +fi + # ═══════════════════════════════════════════════════ # Zellij Auto-Attach # ═══════════════════════════════════════════════════ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13d6708..e6f857e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,14 @@ jobs: - name: The duplication ratchet can still go red run: bash scripts/ci/test-shared-inventory.sh + # The stranded-work guard replaces a check that failed by being ignorable: + # git-health reported orangecat's 118 dirty files every day for ten days + # and changed nothing. This one keys on AGE and stays silent when healthy, + # so both failure modes are gates — it must go red on aged work, and it + # must stay quiet on a fresh tree, or it gets muted and is then absent. + - name: The stranded-work guard can go red, and stays quiet when it should + run: bash scripts/fleet/test-stranded-work.sh + # The sweep decides what ships in every repo that calls it, so it is the # last script here that should be untested — and until now it was. These # run the REAL script against a fake `gh`, exercising shipped control flow diff --git a/scripts/fleet/stranded-work.sh b/scripts/fleet/stranded-work.sh new file mode 100755 index 0000000..fe9f908 --- /dev/null +++ b/scripts/fleet/stranded-work.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# Which repos are holding finished work that is going nowhere? +# +# This fleet already had `git-health` (in .bashrc), and it did not prevent the +# thing it looks like it prevents. On 2026-08-24 orangecat held 118 uncommitted +# files and fleetcrown 88, both untouched for ten days — a completed EntityType +# rename and a completed publishing workstream, existing on exactly one disk. +# `git-health` had been reporting "118 dirty" that whole time. +# +# That is the design error worth naming: DIRTY IS NOT A DEFECT. Every tree is +# dirty while someone is working in it. A number that is equally true at minute +# five and at day ten carries no signal, so it gets read as background noise and +# then not read at all. +# +# AGE is the signal. Dirty for an hour is work. Dirty for a week is stranded. +# +# So this reports only what has aged past a threshold, and says NOTHING when the +# fleet is healthy. Silence-when-clean is not politeness, it is the mechanism: +# a check that prints on every shell gets muted, and a muted check is absent. +# +# It is also why this cannot be a CI job. The work it looks for has never left +# the machine — GitHub cannot see it by definition. It has to run where the +# uncommitted files are. +# +# Usage: +# stranded-work.sh # report (silent when clean) +# stranded-work.sh --check # exit 1 if anything is stranded +# stranded-work.sh --shell # print cache, refresh in background; never blocks +# stranded-work.sh --scan # emit raw TSV, no verdict (feeds the tests) +# +# Env: FLEET_ROOT (default ~/dev), STRANDED_DAYS (default 3), STRANDED_CACHE + +set -uo pipefail + +ROOT="${FLEET_ROOT:-$HOME/dev}" +DAYS="${STRANDED_DAYS:-3}" +CACHE="${STRANDED_CACHE:-$HOME/.cache/fleet-stranded}" +CACHE_TTL_SEC="${STRANDED_CACHE_TTL:-14400}" # 4h + +MODE=report +case "${1:-}" in + --check) MODE=check ;; + --shell) MODE=shell ;; + --scan) MODE=scan ;; + "") MODE=report ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; +esac + +now_epoch() { date +%s; } + +# --------------------------------------------------------------------------- +# Scanning half — needs git and a filesystem. +# Emits TSV: name \t dirty_count \t dirty_age_days \t unpushed_count \t unpushed_age_days \t branch +# An age of -1 means "nothing of that kind", so the deciding half never has to +# distinguish absent from zero. +# --------------------------------------------------------------------------- +scan_repo() { + local dir="$1" name branch now + name="$(basename "$dir")" + now="$(now_epoch)" + + branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" + + local dirty_count=0 oldest_mtime="" dirty_age=-1 + local line path + while IFS= read -r line; do + [ -n "$line" ] || continue + dirty_count=$((dirty_count + 1)) + # Porcelain is "XY path". Strip the 2-char status and its space FIRST, then + # resolve renames ("R old -> new") to the path that still exists. Doing it + # the other way round eats three characters of the new filename. + path="${line:3}" + path="${path##* -> }" + [ -e "$dir/$path" ] || continue # deletions have no mtime to read + local m + m="$(stat -c %Y "$dir/$path" 2>/dev/null)" || continue + if [ -z "$oldest_mtime" ] || [ "$m" -lt "$oldest_mtime" ]; then oldest_mtime="$m"; fi + done < <(git -C "$dir" status --porcelain 2>/dev/null) + + if [ -n "$oldest_mtime" ]; then + dirty_age=$(( (now - oldest_mtime) / 86400 )) + fi + + # Unpushed: measured against the upstream when there is one, else against the + # remote default branch. A branch that was never pushed is the worse case, not + # an exempt one — that is where four of fleetcrown's commits were hiding. + local base unpushed_count=0 unpushed_age=-1 oldest_commit + if git -C "$dir" rev-parse --verify -q '@{u}' >/dev/null 2>&1; then + base='@{u}' + elif git -C "$dir" rev-parse --verify -q origin/main >/dev/null 2>&1; then + base='origin/main' + elif git -C "$dir" rev-parse --verify -q origin/master >/dev/null 2>&1; then + base='origin/master' + else + base='' + fi + + if [ -n "$base" ]; then + unpushed_count="$(git -C "$dir" rev-list --count "$base"..HEAD 2>/dev/null || echo 0)" + if [ "${unpushed_count:-0}" -gt 0 ]; then + oldest_commit="$(git -C "$dir" log "$base"..HEAD --format=%ct 2>/dev/null | tail -1)" + [ -n "$oldest_commit" ] && unpushed_age=$(( (now - oldest_commit) / 86400 )) + fi + fi + + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$name" "$dirty_count" "$dirty_age" "${unpushed_count:-0}" "$unpushed_age" "$branch" +} + +scan_all() { + local d + for d in "$ROOT"/*/; do + [ -d "$d/.git" ] || continue + scan_repo "${d%/}" + done +} + +# --------------------------------------------------------------------------- +# Deciding half — pure text. No git, no filesystem, no clock. +# +# Kept separate for the same reason shared-inventory.sh splits counting from +# ratcheting: a rule you can only exercise by aging a real repo three days is a +# rule nobody re-tests after editing it. +# +# Reads TSV on stdin. Prints one line per stranded repo. Returns 1 if any. +# --------------------------------------------------------------------------- +decide() { + local threshold="$1" found=0 + local name dirty dirty_age unpushed unpushed_age branch + while IFS=$'\t' read -r name dirty dirty_age unpushed unpushed_age branch; do + [ -n "$name" ] || continue + local why="" + if [ "$dirty" -gt 0 ] && [ "$dirty_age" -ge "$threshold" ]; then + why="${dirty} uncommitted, oldest ${dirty_age}d" + fi + if [ "$unpushed" -gt 0 ] && [ "$unpushed_age" -ge "$threshold" ]; then + [ -n "$why" ] && why="${why}; " + why="${why}${unpushed} unpushed, oldest ${unpushed_age}d" + fi + [ -n "$why" ] || continue + printf ' %-20s %s (%s)\n' "$name" "$why" "$branch" + found=1 + done + return $((found == 0 ? 0 : 1)) +} + +render() { + local body + body="$(scan_all | decide "$DAYS")" + local rc=$? + if [ $rc -ne 0 ]; then + printf 'Stranded work — finished but going nowhere (>%sd):\n%s\n' "$DAYS" "$body" + fi + return $rc +} + +# The tests source this file to drive decide() and scan_repo() directly, so what +# they exercise is the shipped rule rather than a restatement of it that can +# quietly drift away from the one actually running. +if [ -n "${STRANDED_LIB_ONLY:-}" ]; then + return 0 2>/dev/null || exit 0 +fi + +case "$MODE" in + scan) + scan_all + ;; + report) + render + exit 0 + ;; + check) + render + exit $? + ;; + shell) + # Never block a shell. Print what we knew as of the last scan, then refresh + # in the background if that knowledge is stale. A guard that costs three + # seconds of every prompt is a guard that gets deleted. + mkdir -p "$(dirname "$CACHE")" + [ -s "$CACHE" ] && cat "$CACHE" + stale=1 + if [ -f "$CACHE" ]; then + age=$(( $(now_epoch) - $(stat -c %Y "$CACHE" 2>/dev/null || echo 0) )) + [ "$age" -lt "$CACHE_TTL_SEC" ] && stale=0 + fi + if [ "$stale" -eq 1 ]; then + ( render > "$CACHE.tmp" 2>/dev/null; mv -f "$CACHE.tmp" "$CACHE" 2>/dev/null ) \ + >/dev/null 2>&1 & + disown 2>/dev/null || true + fi + exit 0 + ;; +esac diff --git a/scripts/fleet/test-stranded-work.sh b/scripts/fleet/test-stranded-work.sh new file mode 100755 index 0000000..fab527d --- /dev/null +++ b/scripts/fleet/test-stranded-work.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# +# Negative tests for the stranded-work guard. +# +# The guard's entire value is that it can go RED, and its entire usability is +# that it stays SILENT otherwise. Both halves are failure modes: +# +# - If it never fires, it certifies the thing it was built to catch. That is +# how 206 finished files sat on one disk for ten days while `git-health` +# cheerfully reported them every single day. +# - If it fires on healthy work, it gets muted, and a muted check is absent. +# This is the more likely death, so "young dirty tree says nothing" is +# tested as carefully as "old dirty tree speaks up". +# +# The deciding half is pure text and is tested against fixtures. The scanning +# half is tested against REAL git repos built in a tmpdir with backdated files, +# because the thing most likely to be wrong is the mtime and rename parsing, +# and no fixture would exercise that. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/stranded-work.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); } +no() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); } + +export STRANDED_LIB_ONLY=1 +# shellcheck source=/dev/null +source "$SCRIPT" "" +unset STRANDED_LIB_ONLY + +# name dirty dirty_age unpushed unpushed_age branch +row() { printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$@"; } + +echo "deciding half — fixtures, no git:" + +out="$(row clean 0 -1 0 -1 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "a clean repo says nothing and exits 0" \ + || no "a clean repo should be silent (rc=$rc, out='$out')" + +out="$(row busy 42 0 0 -1 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "42 files dirty since today is work, not a finding" \ + || no "a fresh dirty tree must stay silent (rc=$rc, out='$out')" + +out="$(row busy 42 2 0 -1 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "dirty for 2d is below a 3d threshold and stays silent" \ + || no "2d < 3d must stay silent (rc=$rc)" + +out="$(row oc 118 10 0 -1 chore/x | decide 3)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"118 uncommitted"* ]] && [[ "$out" == *"10d"* ]] \ + && ok "the real orangecat case goes red and names the age" \ + || no "118 files dirty 10d must go red (rc=$rc, out='$out')" + +out="$(row edge 1 3 0 -1 main | decide 3)"; rc=$? +[ $rc -ne 0 ] \ + && ok "exactly at the threshold counts as stranded (>=, not >)" \ + || no "age == threshold must fire" + +out="$(row fc 0 -1 4 11 fix/y | decide 3)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"4 unpushed"* ]] \ + && ok "committed-but-never-pushed is caught too (fleetcrown's 4)" \ + || no "unpushed commits must go red (rc=$rc, out='$out')" + +out="$(row both 9 5 2 6 main | decide 3)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"uncommitted"* ]] && [[ "$out" == *"unpushed"* ]] \ + && ok "a repo stranded both ways reports both reasons" \ + || no "both reasons should appear (out='$out')" + +out="$(row young 0 -1 3 0 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "commits pushed-pending since today stay silent" \ + || no "fresh unpushed commits must stay silent (rc=$rc)" + +out="$(printf '%s%s%s' "$(row a 0 -1 0 -1 main)"$'\n' "$(row b 7 9 0 -1 main)"$'\n' "$(row c 0 -1 0 -1 main)"$'\n' | decide 3)"; rc=$? +[ $rc -ne 0 ] && [ "$(printf '%s\n' "$out" | grep -c .)" -eq 1 ] \ + && ok "only the stranded repo is listed, not the healthy ones" \ + || no "exactly one line expected (out='$out')" + +echo +echo "scanning half — real git repos with backdated files:" + +mkrepo() { + local d="$TMP/$1"; mkdir -p "$d"; git -C "$d" init -q + git -C "$d" config user.email t@t; git -C "$d" config user.name t + echo seed > "$d/seed.txt"; git -C "$d" add -A + git -C "$d" commit -qm seed --no-verify + printf '%s' "$d" +} + +d="$(mkrepo fresh)" +line="$(scan_repo "$d")" +[ "$(printf '%s' "$line" | cut -f2)" = "0" ] \ + && ok "a committed repo scans as 0 dirty" \ + || no "expected 0 dirty, got '$line'" + +d="$(mkrepo aged)" +echo change >> "$d/seed.txt" +echo new > "$d/untracked.txt" +touch -d '9 days ago' "$d/seed.txt" "$d/untracked.txt" +line="$(scan_repo "$d")" +n="$(printf '%s' "$line" | cut -f2)"; age="$(printf '%s' "$line" | cut -f3)" +[ "$n" = "2" ] && [ "$age" -ge 8 ] \ + && ok "modified + untracked both counted, age read from mtime (${age}d)" \ + || no "expected 2 files aged ~9d, got n=$n age=$age" + +# A deleted file has no mtime to stat. It must still count, and must not abort +# the scan or poison the age — this is the case that silently breaks naive +# implementations, and orangecat's rename deleted 18 files. +d="$(mkrepo deleted)" +echo x > "$d/gone.txt"; git -C "$d" add -A; git -C "$d" commit -qm two --no-verify +rm "$d/gone.txt" +line="$(scan_repo "$d")" +n="$(printf '%s' "$line" | cut -f2)" +[ "$n" = "1" ] \ + && ok "a deletion counts as dirty without breaking the mtime scan" \ + || no "expected 1 dirty for a deletion, got '$line'" + +d="$(mkrepo renamed)" +git -C "$d" mv seed.txt moved.txt +touch -d '7 days ago' "$d/moved.txt" +line="$(scan_repo "$d")" +n="$(printf '%s' "$line" | cut -f2)"; age="$(printf '%s' "$line" | cut -f3)" +[ "$n" = "1" ] && [ "$age" -ge 6 ] \ + && ok "a rename resolves to its new path, not the vanished old one (${age}d)" \ + || no "expected the renamed path to be statted, got n=$n age=$age" + +d="$(mkrepo unpushed)" +echo more >> "$d/seed.txt"; git -C "$d" add -A +GIT_COMMITTER_DATE="$(date -d '12 days ago' -Iseconds)" \ + git -C "$d" -c user.email=t@t -c user.name=t commit -qm "old work" --no-verify \ + --date="$(date -d '12 days ago' -Iseconds)" +line="$(scan_repo "$d")" +[ "$(printf '%s' "$line" | cut -f2)" = "0" ] \ + && ok "a repo with no remote scans without erroring" \ + || no "no-remote repo should scan cleanly, got '$line'" + +echo +echo "end to end:" + +out="$(FLEET_ROOT="$TMP" STRANDED_DAYS=3 bash "$SCRIPT" --check 2>&1)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"Stranded work"* ]] \ + && ok "--check exits non-zero and prints a header when the fleet is stranded" \ + || no "--check should have gone red (rc=$rc)" + +clean_root="$TMP/onlyclean"; mkdir -p "$clean_root" +cp -r "$TMP/fresh" "$clean_root/fresh" +out="$(FLEET_ROOT="$clean_root" STRANDED_DAYS=3 bash "$SCRIPT" --check 2>&1)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "--check is silent and exits 0 on a healthy fleet" \ + || no "healthy fleet must print nothing (rc=$rc, out='$out')" + +out="$(FLEET_ROOT="$TMP" bash "$SCRIPT" --nonsense 2>&1)"; rc=$? +[ $rc -eq 2 ] \ + && ok "an unknown argument fails loudly rather than defaulting to a pass" \ + || no "bad args should exit 2, got $rc" + +echo +echo " $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] From 23c123e9b81485f896781b70909198f0fc5c299f Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:13:57 +0200 Subject: [PATCH 3/5] chore: ignore per-session agent worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _claude_autoworktree_enter creates .claude/worktrees/ per session, so every concurrent session leaves a permanently-untracked directory behind. That is scratch, not source, and it makes `git status` read dirty forever — the noise that let 206 genuinely stranded files hide across this fleet for ten days. Scoped to worktrees/ rather than all of .claude/, so tracked settings and CLAUDE.md are unaffected. Matches what threadkit already does. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b636136..6313533 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ __pycache__/ *.pyc .claude/hooks/dangerous-commands.log .claude/hooks/fleetcrown-capture.sh + +# Per-session agent worktrees — scratch, never part of the repo. +.claude/worktrees/ From b3e9ac1abc914dc6e3c8fff60264c93959e820b4 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:37:10 +0200 Subject: [PATCH 4/5] chore(git): ignore agent browser scratch globally, not in 18 repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.playwright-mcp/` is written by the Playwright MCP server whenever an agent drives a browser. It had appeared in 9 repos and left 2 permanently dirty. The first fix was a .gitignore line in all 18 — 18 diffs, 18 reviews, and a 19th repo that gets missed next month. But no contributor without this setup ever sees the path, so it was never the project's business. One line in the global ignore covers every repo, including ones not created yet. Matters because a permanently dirty tree is the noise that lets genuinely stranded work hide — the thing scripts/fleet/stranded-work.sh exists to catch. Co-Authored-By: Claude Opus 5 --- .config/git/ignore | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.config/git/ignore b/.config/git/ignore index 3865b18..3f86ce2 100644 --- a/.config/git/ignore +++ b/.config/git/ignore @@ -1,2 +1,13 @@ +# Global ignores — for scratch that MY tooling creates in every repo I touch. +# +# The test for belonging here rather than in a project's .gitignore: would a +# different contributor, on a machine without my setup, ever see this path? If +# not, it is not the project's business. Putting it in 18 repos' .gitignore +# instead means 18 diffs, 18 reviews, and a 19th repo that gets missed. **/.claude/settings.local.json .envrc + +# Playwright MCP writes here whenever an agent drives a browser. It appeared in +# 9 repos this way and left 2 of them permanently dirty — and a permanently +# dirty tree is what lets genuinely stranded work hide in the noise. +.playwright-mcp/ From cbed302db7ee2b93f12ae689857195d94ef416e7 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:31:23 +0200 Subject: [PATCH 5/5] fix(fleet): the stranded-work guard could not see worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped this morning to find work that never leaves the machine. It scanned $FLEET_ROOT/*/ only — so it missed the place this fleet does most of its work. Every agent session runs in a `git worktree` under .claude/worktrees/, and those hold real branches. Measured tonight, hours after the guard went in: 26 unpushed commits across 25 locations, oldest 25 days ...while `fleet-stranded` reported two dirty files in dotfiles and nothing else. fleetcrown alone held 9 branches, none of them pushed to origin. The guard was not merely incomplete; it was certifying a fleet that had almost a month of work sitting on one disk. That is the failure mode named in this file's own header — a check that gets read as "all clear" when it means "I did not look there" — and I built it into the fix for it. A blind spot shaped like the dominant workflow is worse than no guard at all. Worktrees now report as `repo/worktree-dir` with their OWN branch, and the main checkout is not double-counted as one of its own worktrees. Three tests pin it, including that last one; all three go red with the scan removed. Found while auditing fleetcrown for code debt — the audit's biggest finding turned out to be in the auditor. --- scripts/fleet/stranded-work.sh | 33 +++++++++++++++++++-- scripts/fleet/test-stranded-work.sh | 45 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/scripts/fleet/stranded-work.sh b/scripts/fleet/stranded-work.sh index fe9f908..7d812f6 100755 --- a/scripts/fleet/stranded-work.sh +++ b/scripts/fleet/stranded-work.sh @@ -57,7 +57,10 @@ now_epoch() { date +%s; } # --------------------------------------------------------------------------- scan_repo() { local dir="$1" name branch now - name="$(basename "$dir")" + # Optional $2 overrides the display name, so a worktree can report as + # "repo/branch-dir" instead of a bare directory name that says nothing about + # which repo it belongs to. + name="${2:-$(basename "$dir")}" now="$(now_epoch)" branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" @@ -108,11 +111,35 @@ scan_repo() { "$name" "$dirty_count" "$dirty_age" "${unpushed_count:-0}" "$unpushed_age" "$branch" } +# Worktrees hold real branches, and this guard could not see them. +# +# Measured 2026-08-25 in fleetcrown alone: 9 agent worktrees under +# .claude/worktrees/ held 13 commits across 9 branches, NONE of them pushed, +# the oldest 3 weeks old — while `fleet-stranded` reported the fleet clean. +# The loop below only ever looked at $ROOT/*/, so every branch created by +# `_claude_autoworktree_enter` was invisible to the one check built to find +# exactly this. A guard with a blind spot shaped like the fleet's dominant +# workflow is worse than none: it certifies the thing it cannot see. +# +# `git worktree list --porcelain` reports the main checkout first; skip it, +# since the loop already scanned it. +scan_worktrees() { + local repo="$1" repo_name="$2" first=1 wt + while IFS= read -r wt; do + if [ "$first" -eq 1 ]; then first=0; continue; fi # main checkout + [ -e "$wt" ] || continue + scan_repo "$wt" "${repo_name}/$(basename "$wt")" + done < <(git -C "$repo" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}') +} + scan_all() { - local d + local d name for d in "$ROOT"/*/; do [ -d "$d/.git" ] || continue - scan_repo "${d%/}" + d="${d%/}" + name="$(basename "$d")" + scan_repo "$d" "$name" + scan_worktrees "$d" "$name" done } diff --git a/scripts/fleet/test-stranded-work.sh b/scripts/fleet/test-stranded-work.sh index fab527d..f4c7a85 100755 --- a/scripts/fleet/test-stranded-work.sh +++ b/scripts/fleet/test-stranded-work.sh @@ -87,6 +87,14 @@ out="$(printf '%s%s%s' "$(row a 0 -1 0 -1 main)"$'\n' "$(row b 7 9 0 -1 main)"$' echo echo "scanning half — real git repos with backdated files:" +mkrepo_at() { + local d="$1/$2"; mkdir -p "$d"; git -C "$d" init -q + git -C "$d" config user.email t@t; git -C "$d" config user.name t + echo seed > "$d/seed.txt"; git -C "$d" add -A + git -C "$d" commit -qm seed --no-verify + printf '%s' "$d" +} + mkrepo() { local d="$TMP/$1"; mkdir -p "$d"; git -C "$d" init -q git -C "$d" config user.email t@t; git -C "$d" config user.name t @@ -142,6 +150,43 @@ line="$(scan_repo "$d")" && ok "a repo with no remote scans without erroring" \ || no "no-remote repo should scan cleanly, got '$line'" +echo +echo "worktrees — the blind spot that made this guard certify a lie:" + +# The fleet works almost entirely in `git worktree` checkouts created per agent +# session. Scanning only $FLEET_ROOT/*/ therefore missed the majority of real +# work: 26 unpushed commits across the fleet on 2026-08-25, oldest 25 days, +# while the guard printed nothing. These pin the fix. +wt_root="$TMP/wtfleet"; mkdir -p "$wt_root" +d="$(mkrepo_at "$wt_root" withwt)" +# A real remote, because "unpushed" is meaningless without one — scan_repo +# measures against @{u} or origin/main and correctly reports nothing when +# neither exists. +git init -q --bare "$TMP/withwt.git" +git -C "$d" remote add origin "$TMP/withwt.git" +git -C "$d" branch -M main +git -C "$d" push -q -u origin main 2>/dev/null +git -C "$d" worktree add -q "$d/.claude/worktrees/feature" -b feat/x 2>/dev/null +echo work > "$d/.claude/worktrees/feature/file.txt" +git -C "$d/.claude/worktrees/feature" add -A +GIT_COMMITTER_DATE="$(date -d '9 days ago' -Iseconds)" \ + git -C "$d/.claude/worktrees/feature" -c user.email=t@t -c user.name=t \ + commit -qm "stranded in a worktree" --no-verify --date="$(date -d '9 days ago' -Iseconds)" + +out="$(FLEET_ROOT="$wt_root" STRANDED_DAYS=3 bash "$SCRIPT" --check 2>&1)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"withwt/feature"* ]] \ + && ok "an aged commit inside a worktree is found, and named repo/worktree" \ + || no "worktree work must be reported (rc=$rc, out='$out')" + +[[ "$out" == *"feat/x"* ]] \ + && ok "...and reports the worktree's own branch, not the parent's" \ + || no "expected the worktree branch feat/x (out='$out')" + +# The main checkout must not be double-counted as one of its own worktrees. +[ "$(printf '%s\n' "$out" | grep -c 'withwt')" -eq 1 ] \ + && ok "the main checkout is not re-scanned as its own worktree" \ + || no "expected exactly one withwt line (out='$out')" + echo echo "end to end:"