#!/usr/bin/env bash
# prune-logs.sh  -  delete per-task project logs under the multi-agent log root.
#
# Every run persists a task log dir at
#   $HOME/.claude/logs/multi-agent/<project>/<task>/
# holding agent-state.json, agent-log.md, tracker-state.json, otel-spans.jsonl.
# These accumulate forever. This prunes whole task dirs, with optional age /
# project / task filters.
#
# PRESERVED ALWAYS: the audit trail (audit.jsonl + rotations), the metrics
# corpus (metrics.jsonl, metrics-summary.md), and .counter files (per-project
# task numbering). They live outside task dirs, and as a second line of
# defense any directory carrying one of those reserved names is skipped even
# if it looks like a task dir.
#
# SAFE BY DEFAULT:
#   - dry-run: lists what WOULD be removed + space freed; deletes nothing
#     until you pass --yes.
#   - root guard: the log root must be an existing directory and is refused
#     when it resolves to /, $HOME, or a git work tree (a repo checkout is
#     never a log root).
#   - active-task grace: task dirs with files modified in the last
#     PRUNE_GRACE_MIN minutes (default 10) are spared, so pruning while a
#     run is still writing its log dir cannot break resume. An explicit
#     --task=ID bypasses the grace; PRUNE_GRACE_MIN=0 disables it.
#
# Usage:
#   prune-logs.sh                       # dry-run: every idle task dir
#   prune-logs.sh --yes                 # delete every idle task dir
#   prune-logs.sh --older-than=30       # only task dirs older than 30 days
#   prune-logs.sh --project=my-ios-app  # only one project's task dirs
#   prune-logs.sh --task=PROJ-123       # only one task dir
#   prune-logs.sh --older-than=30 --yes
#
# Env:
#   PRUNE_LOG_ROOT   override the log root (default $HOME/.claude/logs/multi-agent)
#   PRUNE_GRACE_MIN  active-task grace window in minutes (default 10, 0 = off)
#
# Exit: 0 on success, 2 on usage error or refused log root.

set -uo pipefail

LOG_ROOT="${PRUNE_LOG_ROOT:-$HOME/.claude/logs/multi-agent}"
GRACE_MIN="${PRUNE_GRACE_MIN:-10}"
DELETE=0
OLDER_DAYS=0
PROJECT=""
TASK=""

usage() {
  grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
}

# --project / --task values are compared against path components; only plain
# names are meaningful. Rejects empty, ".", "..", anything with a slash, and
# anything starting with "." or "-".
valid_id() {
  printf '%s' "$1" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._-]*$'
}

for arg in "$@"; do
  case "$arg" in
    --yes | --force) DELETE=1 ;;
    --older-than=*)
      OLDER_DAYS="${arg#*=}"
      if ! printf '%s' "$OLDER_DAYS" | grep -qE '^[0-9]+$'; then
        echo "prune-logs: --older-than needs a whole number of days, got: $OLDER_DAYS" >&2
        exit 2
      fi
      ;;
    --project=*)
      PROJECT="${arg#*=}"
      if ! valid_id "$PROJECT"; then
        echo "prune-logs: --project needs a plain directory name (letters/digits then . _ -), got: '$PROJECT'" >&2
        exit 2
      fi
      ;;
    --task=*)
      TASK="${arg#*=}"
      if ! valid_id "$TASK"; then
        echo "prune-logs: --task needs a plain directory name (letters/digits then . _ -), got: '$TASK'" >&2
        exit 2
      fi
      ;;
    -h | --help)
      usage
      exit 0
      ;;
    *)
      echo "prune-logs: unknown argument: $arg" >&2
      exit 2
      ;;
  esac
done

if ! printf '%s' "$GRACE_MIN" | grep -qE '^[0-9]+$'; then
  echo "prune-logs: PRUNE_GRACE_MIN needs a whole number of minutes, got: $GRACE_MIN" >&2
  exit 2
fi

if [ ! -d "$LOG_ROOT" ]; then
  echo "prune-logs: no log root at $LOG_ROOT  -  nothing to do"
  exit 0
fi

# Root guard: resolve symlinks, then refuse roots that can never be a log
# root. Everything removed below sits strictly inside this resolved path.
LOG_ROOT="$(cd "$LOG_ROOT" 2>/dev/null && pwd -P)"
if [ -z "$LOG_ROOT" ]; then
  echo "prune-logs: cannot resolve log root" >&2
  exit 2
fi
HOME_REAL="$(cd "$HOME" 2>/dev/null && pwd -P || printf '%s' "$HOME")"
if [ "$LOG_ROOT" = "/" ] || [ "$LOG_ROOT" = "$HOME_REAL" ] || [ -e "$LOG_ROOT/.git" ]; then
  echo "prune-logs: refusing log root $LOG_ROOT (/, \$HOME, or a git work tree is never a log root)" >&2
  exit 2
fi

# A task dir is any dir (depth 1-2 under the root) carrying a run marker.
is_task_dir() {
  local d="$1"
  [ -f "$d/agent-state.json" ] || [ -f "$d/tracker-state.json" ] ||
    [ -f "$d/agent-log.md" ] || [ -f "$d/otel-spans.jsonl" ]
}

# Names promised to survive pruning. A directory carrying one of these names
# is never a legitimate task dir, so it is skipped outright.
is_reserved_name() {
  case "$1" in
    audit.jsonl | audit.*.jsonl* | audit.jsonl.* | metrics.jsonl | metrics-summary.md | .counter | *.counter) return 0 ;;
  esac
  return 1
}

MTIME_ARGS=()
if [ "$OLDER_DAYS" -gt 0 ]; then
  MTIME_ARGS=(-mtime "+$OLDER_DAYS")
fi

matches=()
skipped_active=0
while IFS= read -r -d '' d; do
  base="$(basename "$d")"
  if is_reserved_name "$base"; then continue; fi
  is_task_dir "$d" || continue
  # --task: dir name must equal the task id
  if [ -n "$TASK" ] && [ "$base" != "$TASK" ]; then continue; fi
  # --project: the immediate parent dir name must equal the project
  if [ -n "$PROJECT" ] && [ "$(basename "$(dirname "$d")")" != "$PROJECT" ]; then continue; fi
  # Active-task grace: spare dirs a run touched in the last GRACE_MIN minutes
  # so a concurrent run keeps a resumable log dir. --task is an explicit,
  # deliberate target and bypasses the grace.
  if [ "$GRACE_MIN" -gt 0 ] && [ -z "$TASK" ] &&
    find "$d" -mmin "-$GRACE_MIN" -print 2>/dev/null | head -1 | grep -q .; then
    skipped_active=$((skipped_active + 1))
    continue
  fi
  matches+=("$d")
done < <(find "$LOG_ROOT" -mindepth 1 -maxdepth 2 -type d ${MTIME_ARGS[@]+"${MTIME_ARGS[@]}"} -print0 2>/dev/null)

if [ "${#matches[@]}" -eq 0 ]; then
  if [ "$skipped_active" -gt 0 ]; then
    echo "prune-logs: only active task dirs matched (touched in the last ${GRACE_MIN}m)  -  spared, nothing to do"
  else
    echo "prune-logs: no task logs matched under $LOG_ROOT  -  nothing to do"
  fi
  exit 0
fi

total_kb=0
for d in "${matches[@]}"; do
  kb=$(du -sk "$d" 2>/dev/null | awk '{print $1}')
  total_kb=$((total_kb + ${kb:-0}))
done
human="${total_kb} KB"
[ "$total_kb" -ge 1024 ] && human="$((total_kb / 1024)) MB"

if [ "$DELETE" -eq 0 ]; then
  echo "DRY-RUN  -  would remove ${#matches[@]} task log dir(s) (frees ~${human}):"
  for d in "${matches[@]}"; do echo "  ${d#"$LOG_ROOT"/}"; done
  [ "$skipped_active" -gt 0 ] && echo "Spared $skipped_active active dir(s) touched in the last ${GRACE_MIN}m."
  echo "Audit trail + metrics corpus are preserved. Re-run with --yes to delete."
  exit 0
fi

removed=0
for d in "${matches[@]}"; do
  # Containment guard: resolve the target and require it strictly inside the
  # resolved log root before rm -rf.
  target="$(cd "$d" 2>/dev/null && pwd -P)" || continue
  case "$target" in
    "$LOG_ROOT"/*) ;;
    *)
      echo "prune-logs: skipping path outside log root: $d" >&2
      continue
      ;;
  esac
  rm -rf "$target" 2>/dev/null && removed=$((removed + 1))
done
spared_note=""
[ "$skipped_active" -gt 0 ] && spared_note=", spared $skipped_active active"
echo "══ prune-logs: removed ${removed}/${#matches[@]} task log dir(s)${spared_note}, freed ~${human} (audit + metrics preserved) ══"
exit 0
