#!/usr/bin/env bash
#
# capture-flush.sh  -  write what a run has already learned into the durable
# stores, without needing the run to reach its end.
#
# WHY THIS EXISTS
#
# Every persistent write used to live in Phase 7: triage ingest, the learnings
# ledger distill, the knowledge-base append, the code-graph refresh. And Phase 7
# is, by the pipeline's own admission in features/code-graph.md, the phase a run
# is LEAST likely to reach. A run killed in Phase 3, a session that hits its
# context ceiling, a crash after review - each one threw away everything it had
# established, and the next run on the same repo rediscovered it from scratch.
#
# So the writes move here, and Phase 7 becomes the LAST flush rather than the
# only one. Phase boundaries call this, and so does SessionEnd. Nothing about
# the trigger depends on a model noticing that a moment qualifies: it hangs on
# a phase transition and on process exit, both objectively visible without any
# agent's cooperation.
#
# What it does NOT do: call a model. Everything here is derived from artefacts
# already on disk (triage-output.json) plus agent-state.json. The parts of
# Phase 7 that genuinely need a model - the knowledge-base extraction, the
# per-repo memory synthesis - stay in Phase 7, because a hook cannot think.
#
# Usage:
#   ./capture-flush.sh [--state <agent-state.json>] [--if-stale] [--json] [--quiet]
#
#   --state     the run to flush. Default: resolved from the newest task dir
#               under $HOME/.claude/logs/multi-agent (see resolve_state).
#   --if-stale  flush only when the run did NOT complete Phase 7 - the
#               SessionEnd case. A finished run has already flushed.
#   --json      machine-readable result for a caller that wants to count rows.
#   --quiet     no stdout. Exit status still distinguishes the outcomes.
#
# Exit codes:
#   0  flushed, or nothing to flush (both are fine outcomes)
#   1  bad usage
#
# It never exits non-zero because a store rejected a row. This runs from a hook,
# and a hook that fails a session over a bookkeeping write is worse than the
# bookkeeping it protects. Every failure is reported and swallowed.

set -uo pipefail

STATE=""
IF_STALE=0
JSON=0
QUIET=0

while [ "$#" -gt 0 ]; do
  case "$1" in
    --state)    STATE="${2:-}"; shift 2 || shift ;;
    --if-stale) IF_STALE=1; shift ;;
    --json)     JSON=1; shift ;;
    --quiet)    QUIET=1; shift ;;
    -h|--help)
      echo "usage: $0 [--state <agent-state.json>] [--if-stale] [--json] [--quiet]" >&2
      exit 1 ;;
    *)
      echo "ERR: unexpected arg $1" >&2; exit 1 ;;
  esac
done

LOGS="$HOME/.claude/logs/multi-agent"
SCRIPTS="$HOME/.claude/scripts"
[ -d "$SCRIPTS" ] || SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"

say() { [ "$QUIET" -eq 1 ] && return 0; printf '%s\n' "$1"; }

# Resolve the run to flush.
#
# Newest by mtime of agent-state.json, not by directory name: task ids do not
# sort chronologically (PROJ-1002 can run before PROJ-1001 does) and a
# name-sorted pick would flush the wrong run.
resolve_state() {
  [ -d "$LOGS" ] || return 0
  find "$LOGS" -name agent-state.json -type f -maxdepth 4 -print0 2>/dev/null \
    | xargs -0 ls -t 2>/dev/null | head -1
}

if [ -z "$STATE" ]; then
  STATE="$(resolve_state)"
fi

if [ -z "$STATE" ] || [ ! -f "$STATE" ]; then
  say "capture-flush: no run state found - nothing to flush"
  [ "$JSON" -eq 1 ] && printf '{"status":"noop","reason":"no-state"}\n'
  exit 0
fi

if ! command -v jq >/dev/null 2>&1; then
  say "capture-flush: jq unavailable - skipped"
  [ "$JSON" -eq 1 ] && printf '{"status":"skipped","reason":"no-jq"}\n'
  exit 0
fi

TASK_ID=$(jq -r '.taskId // .jiraId // empty' "$STATE" 2>/dev/null)
ARTIFACTS=$(jq -r '.artifactsPath // empty' "$STATE" 2>/dev/null)
WORKTREE=$(jq -r '.worktreePath // empty' "$STATE" 2>/dev/null)
PHASE7=$(jq -r '[.phases[]? | select((.id // "") == "7") | .status] | first // ""' "$STATE" 2>/dev/null)

if [ "$IF_STALE" -eq 1 ] && [ "$PHASE7" = "completed" ]; then
  say "capture-flush: ${TASK_ID:-run} already completed Phase 7 - nothing stale"
  [ "$JSON" -eq 1 ] && printf '{"status":"noop","reason":"already-flushed","taskId":"%s"}\n' "$TASK_ID"
  exit 0
fi

# The triage artefact is the only input either store needs, and it has two homes:
# Phase 6 removes the worktree once the PR is open, so the salvaged copy under
# artifactsPath is tried FIRST. Reading the worktree path first would degrade
# silently for exactly the runs this script exists to rescue.
TRIAGE=""
for cand in "${ARTIFACTS:+$ARTIFACTS/triage-output.json}" "${WORKTREE:+$WORKTREE/triage-output.json}"; do
  [ -n "$cand" ] && [ -f "$cand" ] && { TRIAGE="$cand"; break; }
done

INGESTED="skipped"
DISTILLED="skipped"

# Both writes are idempotent by contract, which is what makes calling this at
# every phase boundary safe: the second call through writes 0 rows.
#
# And 0 rows is a SUCCESS here. Both tools exit 2 on "nothing new", which is the
# normal outcome of every flush after the first - so exit 2 is folded into ok
# rather than reported as failure. Treating it as failure would have made the
# steady state of this script look broken.
run_store() {
  local label="$1"; shift
  local rc=0
  "$@" >/dev/null 2>&1 || rc=$?
  case "$rc" in
    0) printf 'ok' ;;
    2) printf 'ok-nothing-new' ;;
    *) printf 'failed(%s)' "$rc" ;;
  esac
}

if [ -n "$TRIAGE" ]; then
  INGESTED=$(run_store ingest \
    node "$SCRIPTS/triage-memory.mjs" ingest --triage "$TRIAGE" --state "$STATE")
  if [ -n "$TASK_ID" ]; then
    DISTILLED=$(run_store distill \
      node "$SCRIPTS/learnings-ledger.mjs" from-triage --triage "$TRIAGE" --task "$TASK_ID")
  fi
fi

if [ "$JSON" -eq 1 ]; then
  printf '{"status":"flushed","taskId":"%s","triage":"%s","ingest":"%s","distill":"%s"}\n' \
    "$TASK_ID" "${TRIAGE:-none}" "$INGESTED" "$DISTILLED"
fi

if [ -z "$TRIAGE" ]; then
  say "capture-flush: ${TASK_ID:-run} has no triage output yet - nothing to flush"
else
  say "capture-flush: ${TASK_ID:-run} triage=$INGESTED ledger=$DISTILLED"
fi
exit 0
