#!/usr/bin/env bash
#
# note-session.sh  -  record what a NON-pipeline session ran into.
#
# WHY THIS EXISTS
#
# rules/outside-the-pipeline.md actively sends the user to work outside a
# pipeline run: read a ticket, use a stack skill, call an MCP tool. That is the
# right advice, and it meant every lesson learned in those sessions landed
# nowhere - none of the five durable stores ever saw them, because all five are
# written by pipeline phases.
#
# So this reads the session's own transcript and keeps the mechanical facts:
# which repo was touched, which commands failed, which tool calls the user
# refused. No model, no interpretation.
#
# WHAT IS DELIBERATELY NOT KEPT
#
# Not the prose. Not command arguments, not file contents, not tool output. A
# transcript is the least redacted artefact on the machine - it holds whatever
# the session read, tokens and customer data included - so what survives here is
# the shape of an event and nothing that carries a payload: a command's first
# word, a tool's name, an exit code, a count. That is enough for
# learn-from-transcripts.mjs to correlate later, and it cannot leak a secret
# because it never copies a value.
#
# Usage:
#   ./note-session.sh [--transcript <path>] [--json] [--dry-run]
#
# Exit 0 always. It runs from SessionEnd; a hook that fails a session over
# bookkeeping is worse than the bookkeeping it protects.

set -uo pipefail

TRANSCRIPT=""
JSON=0
DRY=0
while [ "$#" -gt 0 ]; do
  case "$1" in
    --transcript) TRANSCRIPT="${2:-}"; shift 2 || shift ;;
    --json)       JSON=1; shift ;;
    --dry-run)    DRY=1; shift ;;
    -h|--help)    echo "usage: $0 [--transcript <path>] [--json] [--dry-run]" >&2; exit 0 ;;
    *)            shift ;;
  esac
done

# Claude Code exports the active transcript to the hook environment; falling back
# to "newest under the project's own transcript dir" keeps the script runnable by
# hand and in a gate.
if [ -z "$TRANSCRIPT" ]; then
  TRANSCRIPT="${CLAUDE_TRANSCRIPT_PATH:-}"
fi
if [ -z "$TRANSCRIPT" ]; then
  SLUG_DIR="$HOME/.claude/projects/$(pwd | sed 's/[^A-Za-z0-9]/-/g')"
  TRANSCRIPT=$(ls -t "$SLUG_DIR"/*.jsonl 2>/dev/null | head -1)
fi

if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
  [ "$JSON" -eq 1 ] && printf '{"status":"noop","reason":"no-transcript"}\n'
  exit 0
fi

REPO_SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")
OUT_DIR="$HOME/.claude/memory/multi-agent/$REPO_SLUG"
OUT="$OUT_DIR/session-notes.jsonl"

TRANSCRIPT="$TRANSCRIPT" REPO_SLUG="$REPO_SLUG" OUT="$OUT" DRY="$DRY" JSON="$JSON" \
python3 - <<'PY'
import json, os, re, sys
from collections import Counter
from datetime import datetime, timezone

path = os.environ["TRANSCRIPT"]

# Only the head word of a command survives, and only when it looks like a plain
# program name. `rm -rf /Users/<name>/secret` becomes `rm`; a command that starts
# with a path or a variable becomes nothing at all.
SAFE_HEAD = re.compile(r"^[a-z][a-z0-9_.-]{0,31}$")

def head_word(cmd):
    """The first real program in a command, or None.

    `cd` swallows its argument: nearly every command in this codebase opens with
    `cd <repo>`, and a version that skipped only the `cd` then hit the path and
    redacted the whole line - so every failure was recorded with no command at
    all. The path is what must not survive; the program after it is the signal.
    """
    if not isinstance(cmd, str):
        return None
    toks = cmd.strip().split()
    i = 0
    while i < len(toks):
        tok = toks[i]
        if tok in ("sudo", "env", "time", "command", "exec"):
            i += 1
            continue
        if tok in ("cd", "pushd"):
            i += 2          # drop the directory with it
            continue
        if tok in ("&&", ";", "|", "\\"):
            i += 1
            continue
        if tok.startswith(("-", "/", "$", "(", "{", '"', "'", "!")):
            return None
        base = tok.split("/")[-1]
        return base if SAFE_HEAD.match(base) else None
    return None

def result_text(block):
    c = block.get("content")
    if isinstance(c, list):
        return " ".join(x.get("text", "") for x in c if isinstance(x, dict))
    return c if isinstance(c, str) else ""

pending = {}          # tool_use_id -> (tool name, command head)
failed = Counter()    # command head -> failures
denied = Counter()    # tool name -> refusals
tools = Counter()     # tool name -> calls
errors = 0

try:
    fh = open(path, encoding="utf-8")
except Exception:
    print(json.dumps({"status": "noop", "reason": "unreadable-transcript"}))
    sys.exit(0)

with fh:
    for line in fh:
        try:
            d = json.loads(line)
        except Exception:
            continue
        content = (d.get("message") or {}).get("content")
        if not isinstance(content, list):
            continue
        for b in content:
            if not isinstance(b, dict):
                continue
            if b.get("type") == "tool_use":
                name = b.get("name") or "?"
                tools[name] += 1
                cmd = (b.get("input") or {}).get("command") if isinstance(b.get("input"), dict) else None
                pending[b.get("id")] = (name, head_word(cmd))
            elif b.get("type") == "tool_result":
                name, cmd_head = pending.pop(b.get("tool_use_id"), ("?", None))
                text = result_text(b)
                # A refusal is not an error: the tool never ran. It says what the
                # user does not want done, which is the more durable signal.
                if "has been denied" in text:
                    denied[name] += 1
                elif b.get("is_error"):
                    errors += 1
                    if cmd_head:
                        failed[cmd_head] += 1

row = {
    "v": "1.0.0",
    "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    "repo": os.environ["REPO_SLUG"],
    "transcript": os.path.basename(path),
    "source": "outside-pipeline",
    "tool_calls": sum(tools.values()),
    "tools": dict(tools.most_common(8)),
    "errors": errors,
    "failed_commands": dict(failed.most_common(8)),
    "denied_tools": dict(denied),
}

# Nothing happened worth a row. An empty session should not grow the file.
if row["tool_calls"] == 0 and errors == 0 and not denied:
    print(json.dumps({"status": "noop", "reason": "nothing-observed"}))
    sys.exit(0)

if os.environ.get("DRY") == "1":
    print(json.dumps({"status": "dry-run", "row": row}))
    sys.exit(0)

out = os.environ["OUT"]
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "a", encoding="utf-8") as fh:
    fh.write(json.dumps(row, ensure_ascii=False) + "\n")

print(json.dumps({"status": "written", "path": out, "errors": errors,
                  "denied": sum(denied.values()), "toolCalls": row["tool_calls"]}))
PY
exit 0
