#!/bin/bash
# PreToolUse hook on pib_create_action
# Blocks action creation when notes fail quality checks.
#
# Quality criteria (from field feedback analysis):
# 0. The action names a CONTAINER (projectFid). Checked FIRST after the
#    hierarchy interception, because placement outranks notes quality: an
#    unhomed action is invisible to every sanctioned view (there is no orphan
#    scope on pib_list_actions), so demanding acceptance criteria on one is
#    polishing something already lost. Measured 2026-08-14 on
#    claudeconsult-maginnis: 97 orphans, all created that month, ~33% of
#    August's intake.
# 1. Notes field is present and non-empty
# 2. Notes are not just a copy of the title (text field)
# 3. Notes are at least 100 characters (a meaningful paragraph)
# 4. Notes contain an acceptance criteria section
# 5. Notes contain a surface area section
# 6. Title does not encode its position in a hierarchy (Phase N / Lane X /
#    Child N / TRACKER: / COORDINATOR / SEQUENCE) — the measured tell that
#    several actions are really one PROJECT (plan §4/§6: 37 open actions
#    carried in-title hierarchy; nobody ever improvised a parent project).
#    The pattern must stay in step with HIERARCHY_TITLE_PATTERN in
#    pib-db-lib.mjs (the library's teach-after-create twin).
#
# These fire on ALL pib_create_action calls — whether from /plan,
# /execute, or ad-hoc. The hook doesn't care how you got here.
# Deliberately NOT matched on pib_create_project: an AREA has no outcome by
# definition, so none of these demands may ever be applied to one (§10.3) —
# the per-kind creation bar for containers lives in createProject itself.

# Claude Code delivers the hook payload as JSON on stdin; the tool input
# (the pib_create_action args) is under `tool_input` (fall back to
# top-level for older payload shapes). stdin is read ONCE — reuse $INPUT.
INPUT=$(cat)

# Fired-at-least-once telemetry (act:ff693d4c). Record that this gate's matcher
# actually matched and the hook ran. A dead matcher (the bare `pib_create_action`
# spelling that never matched the real `mcp__pib-db__pib_create_action` MCP tool
# name) leaves this file empty forever — which is what let this gate stay
# silently dead since it shipped. 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}"
mkdir -p "$(dirname "$FIRED_LOG")" 2>/dev/null \
  && printf '{"hook":"action-quality-gate","fired_at":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$FIRED_LOG" 2>/dev/null || true

NOTES=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print(ti.get('notes',''))" 2>/dev/null)
TEXT=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print(ti.get('text',''))" 2>/dev/null)

# Check: hierarchy-in-title (the middle-type interception, act:14876288).
# Python, not grep -E: \b is GNU-only and this pattern must match the lib's
# JS regex exactly. Surfaced FIRST because its fix (create a project) makes
# the notes checks moot for this call.
HIERARCHY_HIT=$(echo "$INPUT" | python3 -c "
import sys, json, re
d = json.load(sys.stdin); ti = d.get('tool_input', d)
m = re.search(r'\b(?:Phase|Lane|Child)\s+(?:[0-9]+|[A-Z][0-9]?)\b|TRACKER:|COORDINATOR|SEQUENCE', ti.get('text', ''))
print(m.group(0) if m else '')" 2>/dev/null)
if [ -n "$HIERARCHY_HIT" ]; then
  printf '{"decision":"block","reason":"The title encodes its position in a hierarchy (\\"%s\\"). If several actions are steps toward one outcome, that outcome is a PROJECT: create it (pib_create_project, kind=project, notes stating the outcome), parent each step to it via projectFid, and drop the position marker from each title — the project carries the structure, not the titles. If this is genuinely a standalone action, rephrase the title without the marker."}\n' "$HIERARCHY_HIT"
  exit 0
fi

# Check: the action has a HOME (act:5922d741 / the 2026-08-14 spring-clean).
#
# Surfaced before the notes checks because placement is more fundamental than
# notes quality: there is no point demanding acceptance criteria on an action
# that is about to become invisible. `projectFid` is OPTIONAL in createAction
# and silently becomes NULL, and NOTHING lists a null-container action --
# pib_list_actions filters by status/project/scope with no orphan scope, and
# next-actions and list-blocked are the same. So an unhomed action is not
# merely untidy, it is unreachable through every sanctioned view; the only way
# to see one is raw SQL, which you only reach for if you already suspect it.
#
# Measured on claudeconsult-maginnis 2026-08-14: 97 open actions with no
# container, EVERY ONE created that month, including 7 marked in-progress. The
# orphan rate went ~3.6% (June) -> ~3.9% (July) -> ~33% (August). While
# reparenting was impossible (updateAction did not accept projectFid until
# 2026-08), filing to null was the RATIONAL choice -- a permanent wrong home is
# worse than none. That excuse is gone: reparenting works, so a wrong home is
# now cheap to correct and no home is still invisible.
PROJECT_FID=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); ti=d.get('tool_input', d); print((ti.get('projectFid') or ti.get('project_fid') or '').strip())" 2>/dev/null)
if [ -z "$PROJECT_FID" ]; then
  # Offer the live container list so the fix is a PICK, not a recall. This is
  # best-effort: if the tracker cannot be read, we still BLOCK -- the refusal
  # never depends on the listing succeeding (fail-closed).
  # list-projects emits JSON. PARSE it -- an earlier revision regex-scraped the
  # text and matched prj: fids mentioned inside container NOTES, so the refusal
  # recommended containers that were only prose references. A refusal that
  # lists garbage is worse than one that lists nothing: it plants false
  # information at the exact moment the author is deciding where work goes.
  CONTAINERS=$(cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && node scripts/pib-db.mjs list-projects 2>/dev/null \
    | python3 -c "
import sys, json
try:
    rows = json.load(sys.stdin)
except Exception:
    sys.exit(1)
live = [r for r in rows if r.get('status') not in ('done', 'dropped')]
areas = [r for r in live if r.get('kind') == 'area']
projects = [r for r in live if r.get('kind') == 'project']
def fmt(rs):
    return ['%s %s' % (r.get('fid',''), (r.get('name') or '')[:58]) for r in rs]
parts = []
if areas:
    parts.append('AREAS: ' + '; '.join(fmt(areas)))
if projects:
    parts.append('PROJECTS: ' + '; '.join(fmt(projects)))
print(' || '.join(parts))" 2>/dev/null)
  if [ -n "$CONTAINERS" ]; then
    printf '{"decision":"block","reason":"This action names no container, so every container view, briefing and Ready list will skip it. One query can still find it -- pib_list_actions with unfiled=true (CLI: list-actions --unfiled) -- but that is a cleanup sweep somebody has to remember to run, not a place work gets done from. Pass projectFid. Live containers: %s. If it is a step toward one outcome, create that PROJECT (pib_create_project, kind=project, notes stating the outcome) and parent it there; if it is genuinely unscheduled, file it in the backlog container deliberately rather than leaving it nowhere."}\n' "$CONTAINERS"
  else
    echo '{"decision":"block","reason":"This action names no container, so every container view, briefing and Ready list will skip it. One query can still find it -- pib_list_actions with unfiled=true (CLI: list-actions --unfiled) -- but that is a cleanup sweep somebody has to remember to run, not a place work gets done from. Pass projectFid. Run pib_list_projects to see the live containers. If it is a step toward one outcome, create that PROJECT (pib_create_project, kind=project, notes stating the outcome) and parent it there; if it is genuinely unscheduled, file it in the backlog container deliberately rather than leaving it nowhere. (The live container list could not be read here -- that does not soften the refusal.)"}'
  fi
  exit 0
fi

if [ -z "$NOTES" ]; then
  echo '{"decision":"block","reason":"Action notes are empty. Every action needs notes with: implementation details, acceptance criteria (## AC or ## Acceptance Criteria), and surface area (## Surface Area with - files: entries). The bar: a cold-start developer reads ONLY these notes and can implement correctly."}'
  exit 0
fi

# Check: notes are not just the title repeated
if [ "$NOTES" = "$TEXT" ]; then
  echo '{"decision":"block","reason":"Action notes are identical to the title. Notes must contain implementation details, acceptance criteria, and surface area — not just a restated title."}'
  exit 0
fi

# Check: minimum length (100 chars)
NOTE_LEN=${#NOTES}
if [ "$NOTE_LEN" -lt 100 ]; then
  echo "{\"decision\":\"block\",\"reason\":\"Action notes are only ${NOTE_LEN} characters. Minimum is 100. Include: implementation approach, acceptance criteria (## Acceptance Criteria), and surface area (## Surface Area).\"}"
  exit 0
fi

# Check: has acceptance criteria section
if ! echo "$NOTES" | grep -qiE '(## (AC|Acceptance|Criteria)|(\*\*AC|\*\*Acceptance)|- \[[ x]\])'; then
  echo '{"decision":"block","reason":"Action notes have no acceptance criteria section. Add ## Acceptance Criteria containing testable pass/fail criteria."}'
  exit 0
fi

# Check: has surface area section
if ! echo "$NOTES" | grep -qiE '(## Surface|files:|dirs:)'; then
  echo '{"decision":"block","reason":"Action notes have no surface area section. Add ## Surface Area with - files: path/to/file entries listing files this action changes."}'
  exit 0
fi

exit 0
