#!/bin/bash
# PreToolUse hook on mcp__pib-db__pib_defer_with_trigger
#
# Refuses a dependency written as PROSE when it should be an edge
# (act:d46f3b48; design in .claude/plans/work-tracker-data-model.md §12.6).
#
# The rule this teaches:
#   WHICH ACTION must finish   -> an edge   (pib_add_dependency)
#   WHAT ELSE must be true     -> a trigger (pib_defer_with_trigger)
#
# They COMPOSE on the same action; the trigger mechanism is not being replaced.
# Two live specimens are the reason the boundary is drawn here rather than
# banning one or the other:
#   "One week after act:a6bb5603's dedup corpora merge to main"
#       -> edge on act:a6bb5603, PLUS a trigger for the one-week offset
#   "Fires when Phase 2 (act:9e6cee3b) is completed AND its conclusion is that
#    the mechanism works"
#       -> edge on act:9e6cee3b, PLUS a trigger for the outcome predicate
#
# SCOPE, and why it is this narrow (all three decided by measurement):
#   * triggerCondition ONLY. pib_defer_with_trigger is the ONLY tool in the
#     server that can write the trigger_condition column — pib_create_action
#     and pib_update_action expose `text` and cannot touch it, so a gate aimed
#     at them would have been inert on arrival while looking installed.
#   * NOT titles. Every one of the 10 act: citations in open action titles is
#     PROVENANCE — "absorbs", "follow-up to", "deferred from", "CC-side of" —
#     not a blocker claim. Refusing them would block ~10 legitimate writes.
#   * NOT notes. Same shape at ten times the volume (122 open actions).
#
# Reads the payload from STDIN as .tool_input.*. Claude Code sets no
# $CLAUDE_TOOL_INPUT env var — a hook that reads one silently no-ops forever.

INPUT=$(cat)

# Fired-at-least-once telemetry (act:ff693d4c). A matcher that never matches is
# otherwise undetectable: the pib action gates shipped DEAD for months because
# the bare `pib_create_action` spelling never matched the real
# `mcp__pib-db__pib_create_action` MCP tool name, and nothing recorded the
# silence. Env-overridable for tests; fail-open so telemetry can never break
# the gate.
FIRED_LOG="${CC_HOOK_FIRED_LOG:-${CLAUDE_PROJECT_DIR:-.}/.claude/state/hooks-fired.jsonl}"
record_fired() {
  mkdir -p "$(dirname "$FIRED_LOG")" 2>/dev/null \
    && printf '{"hook":"dependency-prose-gate","fired_at":"%s","evaluated":%s}\n' \
       "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$FIRED_LOG" 2>/dev/null || true
}

# Check for a phase file override, matching work-tracker-guard.sh's convention.
PHASE_FILE=".claude/skills/hooks/phases/dependency-prose-gate.md"
if [ -f "$PHASE_FILE" ]; then
  FIRST_LINE=$(head -1 "$PHASE_FILE")
  if [ "$FIRST_LINE" = "skip: true" ]; then
    exit 0
  fi
fi

TARGET_FID=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print(ti.get('fid',''))" 2>/dev/null)
TRIGGER=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print(ti.get('triggerCondition',''))" 2>/dev/null)

if [ -z "$TRIGGER" ]; then
  record_fired false
  exit 0
fi

# PROJECTS are out of scope, and this is not a nicety: pib_defer_with_trigger
# accepts a prj: fid (deferWithTrigger picks its table from the fid, and
# --cascade is project-only), but a dependency edge is strictly action-to-action
# — addDependency REFUSES a prj: endpoint. Without this guard, deferring a
# project whose condition names an action is blocked with a refusal telling the
# caller to make an edge the library will not accept, leaving only the
# FID-IS-EXTERNAL hatch as an exit — which would write a false statement into
# the durable trigger text. Three of the three projects carrying triggers in
# this repo are reachable by that path.
case "$TARGET_FID" in
  act:*) ;;
  *) record_fired false; exit 0 ;;
esac

# The same fid vocabulary the library validates against, unanchored so it finds
# fids EMBEDDED in prose. Word boundaries keep "fact:12345678" and a 9-hex run
# from matching.
CITED=$(echo "$TRIGGER" | grep -oiE '\bact:[a-f0-9]{8}\b' | head -3 | tr '\n' ' ')

# Escape hatch, and it is not hypothetical: fids are minted per-database with
# no namespace, so an action in one tracker waiting on an action in another
# CANNOT be expressed as an edge here. One of the 11 prose dependencies
# measured in this repo is already in that state (act:4402526c cites
# act:32f04679, which does not exist in this database). A gate with no legal
# way to say that would force the caller to mangle a true statement.
#
# The hatch is PER CONDITION, not per fid — the hook has no database access and
# cannot tell which cited fid is the external one. So a condition naming both a
# local and a foreign action exempts BOTH, and the local dependency silently
# stays prose. The refusal message says so rather than leaving it as a trap. No
# condition in the live corpus cites more than one fid today.
if echo "$TRIGGER" | grep -q 'FID-IS-EXTERNAL'; then
  record_fired true
  exit 0
fi

if [ -n "$CITED" ]; then
  record_fired true
  echo "{\"decision\":\"block\",\"reason\":\"This trigger condition names another action by fid (${CITED}). A dependency on an ACTION is an edge, not prose: prose is read by nobody, an edge is read by pib_next_actions. Do this instead: (1) call pib_add_dependency with blockerFid=<the action named above> and blockedFid=<this action>; (2) if something ELSE must also be true — a date, an external event, an outcome — call pib_defer_with_trigger again with just that residual condition and no fid in it. The two compose on the same action; the trigger mechanism is not going away. To override (rare, e.g. the fid belongs to a DIFFERENT tracker and so cannot be an edge here): include the word FID-IS-EXTERNAL in the trigger text — note this exempts EVERY fid in the condition, so state a local dependency as an edge first.\"}"
  exit 0
fi

record_fired true
exit 0
