Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
run: |
shellcheck -s bash --severity=warning \
launch.sh dashboard.sh harvest.sh \
costs.sh progress.sh \
costs.sh coverage.sh progress.sh \
lib/*.sh lib/drivers/*.sh \
tests/*.sh

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

- **Feature: `coverage.sh` post-hoc read coverage.** Parse agent
session logs for `Read` tool calls and simple shell reads
(`sed -n`, `nl -ba | sed -n`, `cat`, `head`, `tail`), merge the
line ranges per file and agent, and report coverage as a table or
JSON. `--targets` lists never-read and partially read target
files, `--prompt-out` turns the gaps into a follow-up prompt, and
`--fail-under` gives CI an exit code. Works on containers or on
saved logs via `--logs`. Closes #65.

- **Rename: `claude-swarm` to `swarm-core`.** Update project links,
installation examples, issue-template branding, pre-push stash
labels, and test fixtures. Document URL updates for existing clones
Expand Down
38 changes: 38 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ Unit tests (no Docker or API key):
./tests/test_activity_filter.sh # Activity stream parsing.
./tests/test_config.sh # Config parsing.
./tests/test_costs.sh # Cost aggregation.
./tests/test_coverage.sh # Read coverage parsing.
./tests/test_dashboard.sh # Dashboard rendering.
./tests/test_drivers.sh # Agent driver interface.
./tests/test_format.sh # Formatting helpers.
Expand Down Expand Up @@ -684,6 +685,43 @@ Dashboard columns:
- **Tok/s** — output tokens per second of API time.
- **Time** — cumulative wall-clock duration.

## Coverage analysis

Upfront partitioning (roles, lock files, per-group prompts) says
what agents should read; `coverage.sh` reports what they actually
read, from their session logs.

```bash
./coverage.sh # Table, from containers.
./coverage.sh --json # JSON.
./coverage.sh --targets targets.txt \
--prompt-out prompts/followup.md # Gap report + prompt.
./coverage.sh --logs ./saved-logs # Offline, no Docker.
```

Read ranges come from `Read` tool calls (preferring the result's
`startLine`/`numLines` metadata) and from a deterministic subset of
shell reads: `sed -n 'A,Bp' F`, `nl -ba F | sed -n 'A,Bp'`,
`cat [-n] F`, `head -n N F`, and `tail -n N F`. Other read-like
shell commands (`grep`, `rg`, `awk`, ...) are counted as
unresolved and never guessed, so reported coverage is a lower
bound.

`--targets` takes one git pathspec per line (for example
`src/**/*.rs`); `#` starts a comment. The report then lists
targets that were never read and the unread line ranges of
partially read ones. `--prompt-out FILE` writes those gaps as a
follow-up prompt for another swarm run or an interactive session,
and `--fail-under PCT` exits `2` when any target is below `PCT`
percent read. Paths are made relative by stripping `/workspace/`
(override with `--strip`), and line counts come from `--root`
(default: the repository root).

Logs are copied from numbered agent and post-process containers,
so run it before `./launch.sh` cleanup removes them, or save
`agent_logs/` and use `--logs`. Parsing currently targets the
Claude Code `stream-json` format.

## Drivers

Agent drivers decouple the harness from any specific CLI tool.
Expand Down
229 changes: 229 additions & 0 deletions coverage.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/bin/bash
set -euo pipefail

# Post-hoc read coverage: which files and lines did agents read?
# Usage: ./coverage.sh [OPTIONS]

SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"

usage() {
cat <<HELP
Usage: $0 [OPTIONS]

Report which files and line ranges swarm agents actually read, by
parsing their session logs (Read tool calls and simple shell reads).

Options:
--logs DIR Read agent_*.log files from DIR instead of the
swarm containers. Repeatable.
--root DIR Source tree for line counts and targets
(default: repository root).
--strip PREFIX Path prefix to remove from logged paths
(default: /workspace/).
--targets FILE Git pathspecs (one per line) that should have
been read. Enables the gap report.
--prompt-out FILE Write a follow-up prompt listing unread target
code (requires --targets).
--fail-under PCT Exit 2 if any target is below PCT percent read
(requires --targets).
--json Output JSON instead of a table.
-h, --help Show this help message.

Without --logs, logs are copied from swarm containers (numbered
agents and post-process) via docker cp, like costs.sh.
HELP
}

LOG_DIRS=()
ROOT=""
STRIP="/workspace/"
TARGETS=""
PROMPT_OUT=""
FAIL_UNDER=""
JSON_MODE=false

while [ $# -gt 0 ]; do
case "$1" in
--logs) LOG_DIRS+=("$2"); shift 2 ;;
--root) ROOT="$2"; shift 2 ;;
--strip) STRIP="$2"; shift 2 ;;
--targets) TARGETS="$2"; shift 2 ;;
--prompt-out) PROMPT_OUT="$2"; shift 2 ;;
--fail-under) FAIL_UNDER="$2"; shift 2 ;;
--json) JSON_MODE=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
esac
done

source "$SWARM_DIR/lib/check-deps.sh"
check_deps git jq awk
# shellcheck source=lib/coverage.sh
source "$SWARM_DIR/lib/coverage.sh"

if { [ -n "$PROMPT_OUT" ] || [ -n "$FAIL_UNDER" ]; } \
&& [ -z "$TARGETS" ]; then
echo "ERROR: --prompt-out and --fail-under need --targets." >&2
exit 1
fi
if [ -n "$TARGETS" ] && [ ! -f "$TARGETS" ]; then
echo "ERROR: targets file not found: $TARGETS" >&2
exit 1
fi

REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
if [ -z "$ROOT" ]; then
ROOT="$REPO_ROOT"
fi

WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

# Collect logs from containers when no --logs directory is given.
if [ ${#LOG_DIRS[@]} -eq 0 ]; then
check_deps docker
# shellcheck source=lib/project.sh
source "$SWARM_DIR/lib/project.sh"
PROJECT="$(swarm_project_id "$(basename "$REPO_ROOT")")"
containers=$(docker ps -a --filter "name=${PROJECT}-agent-" \
--format '{{.Names}}' 2>/dev/null | sort -V || true)
if [ -z "$containers" ]; then
echo "No swarm containers found." >&2
exit 1
fi
for cname in $containers; do
mkdir -p "$WORK/logs/$cname"
docker cp "${cname}:/workspace/agent_logs/." \
"$WORK/logs/$cname/" >/dev/null 2>&1 || true
done
LOG_DIRS=("$WORK/logs")
fi

log_count=0
: > "$WORK/rows.tsv"
for dir in "${LOG_DIRS[@]}"; do
while IFS= read -r log; do
base="$(basename "$log")"
agent="${base#agent_}"
agent="${agent%%_*}"
coverage_extract_ranges "$log" "$agent" "$STRIP" \
>> "$WORK/rows.tsv" || true
log_count=$((log_count + 1))
done < <(find "$dir" -type f -name 'agent_*.log' | sort)
done

unresolved=$(awk -F'\t' '$5 == "unresolved"' "$WORK/rows.tsv" \
| wc -l | tr -d ' ')
reads=$(awk -F'\t' '$5 != "unresolved"' "$WORK/rows.tsv" \
| wc -l | tr -d ' ')
agents=$(awk -F'\t' '{ print $1 }' "$WORK/rows.tsv" | sort -u \
| paste -sd, -)

coverage_resolve_ranges "$ROOT" < "$WORK/rows.tsv" \
| coverage_merge > "$WORK/files.tsv"

# Target gap analysis.
: > "$WORK/targets.tsv"
if [ -n "$TARGETS" ]; then
while IFS= read -r t; do
row=$(awk -F'\t' -v p="$t" '$1 == p' "$WORK/files.tsv")
total=$(coverage_file_lines "$ROOT" "$t")
if [ -z "$row" ]; then
printf '%s\t0\t%s\t0.0\t\t\t1-%s\n' "$t" "$total" "$total" \
>> "$WORK/targets.tsv"
else
ranges=$(printf '%s' "$row" | cut -f6)
gaps=$(coverage_gaps "$ranges" "$total")
printf '%s\t%s\n' "$row" "$gaps" >> "$WORK/targets.tsv"
fi
done < <(coverage_expand_targets "$ROOT" "$TARGETS")
fi

n_targets=$(wc -l < "$WORK/targets.tsv" | tr -d ' ')
n_full=$(awk -F'\t' '$7 == ""' "$WORK/targets.tsv" \
| wc -l | tr -d ' ')
n_unread=$(awk -F'\t' '$2 == 0' "$WORK/targets.tsv" \
| wc -l | tr -d ' ')
n_partial=$((n_targets - n_full - n_unread))

if $JSON_MODE; then
jq -n \
--argjson logs "$log_count" \
--argjson reads "$reads" \
--argjson unresolved "$unresolved" \
--arg agents "$agents" \
--rawfile files "$WORK/files.tsv" \
--rawfile targets "$WORK/targets.tsv" \
--argjson has_targets "$([ -n "$TARGETS" ] && echo true \
|| echo false)" '
def rows($s): $s | split("\n") | map(select(. != "")
| split("\t"));
def num: if . == "?" then null else tonumber end;
def list: if . == "" then [] else split(",") end;
{
logs: $logs, reads: $reads, unresolved: $unresolved,
agents: ($agents | list),
files: [rows($files)[] | {
path: .[0], covered: (.[1] | tonumber), total: (.[2] | num),
percent: (.[3] | num), agents: (.[4] | list),
ranges: (.[5] | list)}]
}
+ (if $has_targets then {targets: [rows($targets)[] | {
path: .[0], covered: (.[1] | tonumber), total: (.[2] | num),
percent: (.[3] | num), agents: (.[4] | list),
unread: (.[6] | list)}]} else {} end)'
else
printf 'Coverage: %s log(s), %s read(s), %s unresolved shell' \
"$log_count" "$reads" "$unresolved"
printf ' read(s), agents: %s\n\n' "${agents:-none}"
printf '%-50s %13s %7s %s\n' "File" "Lines" "%" "Agents"
printf '%s\n' "$(printf '%.0s-' $(seq 1 79))"
while IFS=$'\t' read -r path cov tot pct ags _ranges; do
printf '%-50s %13s %7s %s\n' "$path" "${cov}/${tot}" \
"$pct" "$ags"
done < "$WORK/files.tsv"
if [ -n "$TARGETS" ]; then
printf '\nTargets: %s file(s), %s fully read, %s partial,' \
"$n_targets" "$n_full" "$n_partial"
printf ' %s never read\n' "$n_unread"
if [ "$n_unread" -gt 0 ]; then
echo "Never read:"
awk -F'\t' '$2 == 0 { print " " $1 }' "$WORK/targets.tsv"
fi
fi
fi

if [ -n "$PROMPT_OUT" ]; then
{
echo "# Coverage follow-up"
echo
echo "A previous swarm run did not read the target code below."
echo "Continue the same task, starting with files never read,"
echo "then the unread line ranges of partially read files."
echo
if [ "$n_unread" -gt 0 ]; then
echo "## Never read"
echo
awk -F'\t' '$2 == 0 { print "- `" $1 "`" }' \
"$WORK/targets.tsv"
echo
fi
if [ "$n_partial" -gt 0 ]; then
echo "## Partially read (unread lines)"
echo
awk -F'\t' '$2 > 0 && $7 != "" {
gsub(",", ", ", $7); print "- `" $1 "`: " $7 }' \
"$WORK/targets.tsv"
echo
fi
} > "$PROMPT_OUT"
fi

if [ -n "$FAIL_UNDER" ]; then
below=$(awk -F'\t' -v f="$FAIL_UNDER" \
'$4 == "?" || $4 + 0 < f + 0' "$WORK/targets.tsv" \
| wc -l | tr -d ' ')
if [ "$below" -gt 0 ]; then
exit 2
fi
fi
Loading