#!/bin/bash
# onboarding-ledger-write-gate.sh — schema + state-machine integrity gate
#                                   for writes to the onboarding status
#                                   ledger.
#
# Hook    : PreToolUse:Write|Edit
# Mode    : DENY (blocks the write before it lands on disk)
# State   : validates shape via hooks/lib/validator.bundle.mjs (the
#           onboarding-status schema is inlined in the bundle at build
#           time; source schemas/onboarding-status.schema.json), plus the
#           pre-existing tests/e2e/docs/onboarding-status.json
#           (when present) to validate the state-machine transition.
# Env     : none
#
# Why
# ---
# The ledger is the single source of truth for the pipeline state. A
# corrupted ledger silently degrades every downstream gate
# (onboarding-ledger-gate, workflow-reviewer briefings). This hook is the
# guard at the only mutation point.
#
# What it gates
# -------------
# 1. **Shape validation.** The proposed contents must validate against
#    the onboarding-status schema inlined in hooks/lib/validator.bundle.mjs
#    (source: schemas/onboarding-status.schema.json). When node or the
#    bundle is unavailable, the schema portion is skipped but the
#    non-node checks (state-machine, actor-identity, mode-auth, per-phase
#    deliverables) still fire and jq parseability is enforced.
# 2. **No phase-skip transitions.** A write that bumps `currentPhase` from
#    N to N+2 with N+1 still `pending` is denied — every phase must
#    progress through `pending → in-progress → completed` in order
#    (skip-deviations are recorded by setting status: skipped + populating
#    `approvedDeviations[]`).
# 3. **No reviewerVerdict: approved without a handoverEnvelope.** A phase
#    cannot be approved unless the closing subagent's handover envelope
#    is captured in the same record.
# 4. **Actor-identity on approval transitions.** Any write that
#    transitions a phase's `reviewerVerdict` from non-approved to
#    `approved` MUST come from a registered approver subagent context.
#    Orchestrator-direct writes that approve a phase are denied — only
#    `workflow-reviewer-*` / `phase-validator-*` dispatches (tracked by
#    workflow-approver-registry.sh) can record approvals. This is the
#    separation-of-duties gate: the orchestrator does the work, an
#    approver subagent records the verdict.
# 5. **Mode authorisation.** Any write that sets or changes `runMode`
#    (the coverage-expansion mode — `standard` vs `depth`) MUST also
#    include a non-empty `modeAuthorizer` field capturing the user's
#    explicit choice (verbatim quote). The schema permits `runMode` to
#    be persisted; this gate forces it to be persisted with an audit
#    trail of who chose it. Prevents the orchestrator from silently
#    defaulting to a mode without asking.
# 6. **Silent-allow for non-ledger writes.** Only files whose path ends
#    with `tests/e2e/docs/onboarding-status.json` are gated.
# 7. **Silent-allow when the file is missing AND the write has no
#    approvals.** A fresh-run ledger init with all phases pending has
#    no actor-identity check (nothing is being approved).
#
# Canonical reference
# -------------------
# schemas/onboarding-status.schema.json
# skills/onboarding/SKILL.md §"Status ledger + workflow reviewer"
# hooks/lib/validator.bundle.mjs (generated by `npm run build:validator`)
#
# Failure → action
# ----------------
# Shape invalid                    → DENY with schema-path + bad field
# Phase-skip without approval      → DENY naming the missing in-between phase
# reviewerVerdict approved w/o handover → DENY naming the empty field

set -uo pipefail

JQ="$(dirname "${BASH_SOURCE[0]}")/bin/jq"
[ -x "$JQ" ] || JQ="$(command -v jq || true)"
if [ -z "$JQ" ]; then
  echo "[$(basename "${BASH_SOURCE[0]}")] FATAL: jq not found at \$HOOK_DIR/bin/jq nor on PATH." >&2
  exit 1
fi

INPUT=$(cat)

# Session-scope gate: this hook applies only to achilles-activated
# sessions; plain dev sessions silent-allow (lib/achilles-activation.sh).
. "$(dirname "${BASH_SOURCE[0]}")/lib/achilles-activation.sh"
achilles_require_active "$INPUT"
TOOL_NAME=$(echo "$INPUT" | "$JQ" -r '.tool_name // empty' 2>/dev/null || echo "")

# Only act on Write and Edit.
case "$TOOL_NAME" in
  Write|Edit) ;;
  *) exit 0 ;;
esac

FILE_PATH=$(echo "$INPUT" | "$JQ" -r '.tool_input.file_path // empty' 2>/dev/null || echo "")

# Rule 4: silent-allow when this isn't a ledger write. Match the path
# suffix against a leading-slash-normalised form so a BARE RELATIVE path
# (tests/e2e/docs/onboarding-status.json) matches the same pattern as an
# absolute one — otherwise a relative-path write would slip the gate.
NORM_PATH="/${FILE_PATH#/}"
case "$NORM_PATH" in
  */tests/e2e/docs/onboarding-status.json) ;;
  *) exit 0 ;;
esac

# shellcheck disable=SC1091
. "$(dirname "${BASH_SOURCE[0]}")/lib/pipeline-gate.sh"
PIPELINE_LEDGER="$FILE_PATH"
PIPELINE_SIDECAR="$(dirname "$FILE_PATH")/.ledger-integrity.json"
PIPELINE_SCHEMA_NAME="onboarding-status"
PIPELINE_MSG_LEDGER_NAME='onboarding-status.json'
PIPELINE_MSG_SIDECAR_REL='tests/e2e/docs/.ledger-integrity.json'
PIPELINE_MSG_LEDGER_REL='tests/e2e/docs/onboarding-status.json'
PIPELINE_MSG_REVIEWER_LABEL='workflow-reviewer-phase'
PIPELINE_MSG_SKILL_REF='skills/onboarding/SKILL.md'
PIPELINE_MSG_SCHEMA_REF='schemas/onboarding-status.schema.json'
PIPELINE_MSG_REVIEWER_SKILL='skills/workflow-reviewer/SKILL.md'

emit_deny() {
  local reason="$1"
  "$JQ" -n --arg r "$reason$(achilles_scope_notice)" '{
    "hookSpecificOutput": {
      "hookEventName": "PreToolUse",
      "permissionDecision": "deny",
      "permissionDecisionReason": $r
    }
  }'
}

# Extract the proposed contents. For Write the field is `content`; for
# Edit we synthesise by applying the patch to the existing file (via the
# validator bundle's `replace` subcommand — LITERAL string replacement
# matching the Edit tool's semantics, including uniqueness and
# replace_all). For Edit without an existing file, the operation will
# fail downstream — silent allow here.
# NB: command substitution strips a trailing newline vs the actual on-disk
# write — benign for JSON (parse/validation insensitive), would matter for
# whitespace-sensitive formats.
PROPOSED_CONTENT=""
case "$TOOL_NAME" in
  Write)
    PROPOSED_CONTENT=$(echo "$INPUT" | "$JQ" -r '.tool_input.content // empty' 2>/dev/null || echo "")
    ;;
  Edit)
    OLD_STRING=$(echo "$INPUT" | "$JQ" -r '.tool_input.old_string // empty' 2>/dev/null || echo "")
    NEW_STRING=$(echo "$INPUT" | "$JQ" -r '.tool_input.new_string // ""' 2>/dev/null || echo "")
    REPLACE_ALL=$(echo "$INPUT" | "$JQ" -r '.tool_input.replace_all // false' 2>/dev/null || echo "false")
    if [ -f "$FILE_PATH" ] && [ -n "$OLD_STRING" ]; then
      NODE_BIN="$(command -v node 2>/dev/null || true)"
      VALIDATOR="$(dirname "${BASH_SOURCE[0]}")/lib/validator.bundle.mjs"
      if [ -z "$NODE_BIN" ] || [ ! -f "$VALIDATOR" ]; then
        emit_deny "[BLOCKED] Cannot synthesise the proposed ledger content for an Edit (node or the validator bundle is unavailable), so the gate cannot validate the transition.

File: ${FILE_PATH}

Fix: re-issue this change as a full Write of the complete ledger JSON
(the Write path validates without content synthesis), or restore node /
reinstall @civitas-cerebrum/achilles to get hooks/lib/validator.bundle.mjs."
        exit 0
      fi
      TMP_OLD=$(mktemp /tmp/ledger-old-XXXXXX) ; TMP_NEW=$(mktemp /tmp/ledger-new-XXXXXX)
      printf '%s' "$OLD_STRING" > "$TMP_OLD"
      printf '%s' "$NEW_STRING" > "$TMP_NEW"
      ALL_FLAG=""
      [ "$REPLACE_ALL" = "true" ] && ALL_FLAG="--all"
      SYNTH_EXIT=0
      SYNTH_ERR_FILE=$(mktemp /tmp/ledger-synth-err-XXXXXX)
      PROPOSED_CONTENT=$("$NODE_BIN" "$VALIDATOR" replace "$FILE_PATH" "$TMP_OLD" "$TMP_NEW" $ALL_FLAG 2>"$SYNTH_ERR_FILE") || SYNTH_EXIT=$?
      SYNTH_ERR=$(cat "$SYNTH_ERR_FILE" 2>/dev/null || true)
      rm -f "$TMP_OLD" "$TMP_NEW" "$SYNTH_ERR_FILE"
      if [ "$SYNTH_EXIT" != "0" ]; then
        emit_deny "[BLOCKED] Edit to onboarding-status.json could not be synthesised: ${SYNTH_ERR:-unknown error}.

File: ${FILE_PATH}

The gate validates the post-edit content before allowing the write. An
old_string that is missing or not unique would also fail the Edit tool
itself. Fix the old_string (or use replace_all) and re-issue."
        exit 0
      fi
    fi
    ;;
esac

# Silent-allow when we couldn't extract content. This only covers a Write
# with empty/missing content and an Edit against a missing file — Edit
# synthesis failures are denied above, not silently allowed.
[ -n "$PROPOSED_CONTENT" ] || exit 0

# Write the proposed content to a tempfile and validate it against the
# onboarding-status schema via the dependency-free validator bundle
# (hooks/lib/validator.bundle.mjs — the schema is inlined at build time,
# so no schema-file lookup and no node_modules are needed at the install
# location). When node or the bundle is unavailable, the schema portion
# is skipped — but the shell-side checks below (state-machine,
# actor-identity, mode-auth, per-phase deliverables) still fire, and jq
# parseability is enforced. Silent-allowing the whole hook on a missing
# node binary would let an orchestrator with no node on $PATH bypass the
# entire gate.
TMP_PROPOSED=$(mktemp /tmp/onboarding-ledger-XXXXXX.json)
trap 'rm -f "$TMP_PROPOSED"' EXIT
printf '%s' "$PROPOSED_CONTENT" > "$TMP_PROPOSED"

NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}"
VALIDATOR="$(dirname "${BASH_SOURCE[0]}")/lib/validator.bundle.mjs"

pipeline_schema_validate "$TMP_PROPOSED" "$FILE_PATH"
_psv_ret=$?
[ "$_psv_ret" -eq 0 ] && exit 0

# When schema validation was skipped (no node / no ajv), we still need
# the proposed content parseable as JSON for the downstream jq queries
# to be meaningful. Fail-closed: deny on malformed JSON regardless of
# whether ajv was available.
if [ "${PIPELINE_SCHEMA_VALIDATION_SKIPPED:-0}" = "1" ]; then
  if ! "$JQ" -e . "$TMP_PROPOSED" >/dev/null 2>&1; then
    emit_deny "[BLOCKED] Proposed onboarding-status.json is not parseable JSON (schema validation was skipped because node/ajv is unavailable, but jq parsing failed).

File: ${FILE_PATH}

Fix: re-author the JSON, run \`jq . <<< '<contents>'\` locally to confirm it parses, then re-issue the write."
    exit 0
  fi
fi

# ---------------------------------------------------------------------------
# State-machine transition check (lib call) — phase-skip, approved-requires-
# handover, reviewerCycles+1 on verdict-change, 3rd-reject-must-escalate.
# ---------------------------------------------------------------------------
pipeline_validate_transition "$TMP_PROPOSED" "$FILE_PATH" && exit 0

# ---------------------------------------------------------------------------
# Actor-identity check on approval transitions — separation of duties (lib call).
# ---------------------------------------------------------------------------
AGENT_ID=$(echo "$INPUT" | "$JQ" -r '.agent_id // empty' 2>/dev/null || echo "")
pipeline_check_sod "$TMP_PROPOSED" "$FILE_PATH" "$AGENT_ID" && exit 0

# ---------------------------------------------------------------------------
# Mode-authorisation check (lib call) — runMode/modeAuthorizer co-location.
# ---------------------------------------------------------------------------
pipeline_check_mode_authorizer "$TMP_PROPOSED" "$FILE_PATH" && exit 0

# ---------------------------------------------------------------------------
# Per-phase positive-deliverable checks (Phase-N → completed transitions).
# When a phase's status flips from non-completed to "completed" in the
# proposed write, the canonical deliverables for that phase must already
# exist on disk. This catches "orchestrator marked the phase done without
# actually producing the deliverables" — the failure mode that
# markdown-text contract enforcement alone could not stop.
#
# Per-phase manifests (minimum required files / sentinel checks):
#
#   Phase 4 (Journey-mapping):
#     - tests/e2e/docs/journey-map.md exists AND line 1 == the sentinel
#       `<!-- journey-mapping:generated -->`.
#     - tests/e2e/docs/.phase4-cycle-state.json exists AND contains at
#       minimum cycles."1" + cycles."2" entries (cycle 1 discovery +
#       cycle 2 edge-probe — non-negotiable per journey-mapping/SKILL.md
#       §"Iterative discovery cycles").
#
#   Phase 5 (Coverage-expansion):
#     - tests/e2e/docs/coverage-expansion-state.json exists AND contains
#       at minimum passes."1" (the strict-per-journey first pass).
#
#   Phase 6 (Bug-discovery):
#     - tests/e2e/docs/adversarial-findings.md exists.
#
#   Phase 7 (Secrets-sweep):
#     - .env.example exists at the project root.
#
#   Phase 8 (Report):
#     - qa-summary-deck.html AND qa-summary-deck.pdf exist at the
#       project root.
#
# Phases 1-3 are not enforced here — their deliverables (config files,
# fixtures, happy-path specs) don't have unforgeable signatures the
# harness can verify cheaply. The ledger's `phases[N].deliverables[]`
# array is the audit trail for those phases; the orchestrator-to-
# reviewer brief gate ensures the reviewer reads them.
# ---------------------------------------------------------------------------

# PROJECT_ROOT is the directory containing tests/e2e/docs/. The ledger
# path is .../tests/e2e/docs/onboarding-status.json — strip the tail.
PROJECT_ROOT="${FILE_PATH%/tests/e2e/docs/onboarding-status.json}"

# Build the set of phase IDs whose status is transitioning to "completed"
# in this write. Compare proposed[N].status vs prior[N].status (treat
# "prior" as "pending" when the file doesn't yet exist). Space-separated
# list to keep set -u happy on bash 3 where empty arrays expand to
# "unset variable" under "${arr[@]}".
PHASES_NEWLY_COMPLETED=""
for phase_id in 1 2 3 4 5 6 7 8; do
  idx=$((phase_id - 1))
  new_status=$("$JQ" -r ".phases[${idx}].status // empty" "$TMP_PROPOSED" 2>/dev/null || echo "")
  prior_status="pending"
  if [ -f "$FILE_PATH" ]; then
    prior_status=$("$JQ" -r ".phases[${idx}].status // \"pending\"" "$FILE_PATH" 2>/dev/null || echo "pending")
  fi
  if [ "$new_status" = "completed" ] && [ "$prior_status" != "completed" ]; then
    PHASES_NEWLY_COMPLETED="${PHASES_NEWLY_COMPLETED} ${phase_id}"
  fi
done

# Helper: emit a deny with the standard payload structure used above.
emit_phase_deny() {
  local phase="$1"
  local missing="$2"
  local fix_hint="$3"
  local skill_ref="$4"
  emit_deny "[BLOCKED] Phase ${phase} cannot transition to status: \"completed\" — required deliverable missing.

File: ${FILE_PATH}

Missing: ${missing}

This is the per-phase positive-deliverable check. The ledger cannot
mark a phase complete unless that phase's canonical deliverables exist
on disk. The deliverables are unforgeable signatures of the correct
skill having been invoked — without them, the phase was either skipped
or shortcut.

Fix: ${fix_hint}

See: ${skill_ref}"
  exit 0
}

for phase_id in $PHASES_NEWLY_COMPLETED; do
  case "$phase_id" in
    4)
      # Phase 4 — journey-map.md + sentinel + cycle-state with cycles 1 & 2.
      MAP_PATH="$PROJECT_ROOT/tests/e2e/docs/journey-map.md"
      CYCLE_STATE_PATH="$PROJECT_ROOT/tests/e2e/docs/.phase4-cycle-state.json"

      if [ ! -f "$MAP_PATH" ]; then
        emit_phase_deny "4" \
          "tests/e2e/docs/journey-map.md does not exist." \
          "invoke the \`journey-mapping\` skill via the Skill tool. It runs the iterative discovery cycle protocol and writes the map with the line-1 sentinel." \
          "skills/onboarding/SKILL.md §\"Phase 4 — Journey mapping\" + skills/journey-mapping/SKILL.md"
      fi

      FIRST_LINE=$(head -n 1 "$MAP_PATH" 2>/dev/null || echo "")
      if [ "$FIRST_LINE" != "<!-- journey-mapping:generated -->" ]; then
        emit_phase_deny "4" \
          "tests/e2e/docs/journey-map.md is missing the line-1 sentinel \`<!-- journey-mapping:generated -->\`. Got: \"${FIRST_LINE:0:80}\"" \
          "regenerate the map via the journey-mapping skill. The sentinel is its authorship marker — without it the map is forged." \
          "skills/journey-mapping/SKILL.md §\"Recognizing a previously-generated journey map\""
      fi

      if [ ! -f "$CYCLE_STATE_PATH" ]; then
        emit_phase_deny "4" \
          "tests/e2e/docs/.phase4-cycle-state.json does not exist." \
          "the journey-mapping skill writes the cycle state as it dispatches per-section subagents. Absence ⇒ no cycle ever ran." \
          "skills/journey-mapping/SKILL.md §\"Cycle protocol\""
      fi

      # Cycle 1 + Cycle 2 are non-negotiable per the iterative-discovery
      # protocol (≥1 discovery cycle + exactly 1 edge-probe cycle).
      HAS_CYCLE_1=$("$JQ" -r '.cycles["1"] != null' "$CYCLE_STATE_PATH" 2>/dev/null || echo "false")
      HAS_CYCLE_2=$("$JQ" -r '.cycles["2"] != null' "$CYCLE_STATE_PATH" 2>/dev/null || echo "false")
      if [ "$HAS_CYCLE_1" != "true" ] || [ "$HAS_CYCLE_2" != "true" ]; then
        emit_phase_deny "4" \
          ".phase4-cycle-state.json is missing cycle-1 and/or cycle-2 records (has-cycle-1=${HAS_CYCLE_1}, has-cycle-2=${HAS_CYCLE_2}). Both are non-negotiable: ≥1 discovery cycle + exactly 1 edge-probe cycle." \
          "complete the cycle protocol — dispatch cycle-1 section agents (strict per-section parallel), then the cycle-2 edge-probe — before closing Phase 4." \
          "skills/journey-mapping/SKILL.md §\"Iterative discovery cycles\""
      fi

      # Cycle-roster completeness: for EVERY cycle recorded, the section
      # subagents must have all returned. dispatched-sections == returned-
      # sections (set equality, not just length). Catches the "dispatched
      # 7, only 5 came back, marked the cycle done anyway" failure mode.
      for cycle_id in 1 2; do
        DISPATCHED=$("$JQ" -c ".cycles[\"${cycle_id}\"][\"dispatched-sections\"] // [] | sort" "$CYCLE_STATE_PATH" 2>/dev/null || echo "[]")
        RETURNED=$("$JQ" -c ".cycles[\"${cycle_id}\"][\"returned-sections\"] // [] | sort" "$CYCLE_STATE_PATH" 2>/dev/null || echo "[]")
        if [ "$DISPATCHED" != "$RETURNED" ]; then
          DISPATCHED_COUNT=$(echo "$DISPATCHED" | "$JQ" 'length')
          RETURNED_COUNT=$(echo "$RETURNED" | "$JQ" 'length')
          emit_phase_deny "4" \
            "Cycle ${cycle_id} dispatched-sections (${DISPATCHED_COUNT}) != returned-sections (${RETURNED_COUNT}). Some section agents did not return; the cycle is incomplete." \
            "wait for every dispatched section to return before authoring the journey map. Re-dispatch any stalled sections. The author step consumes the union of all section returns — partial returns mean partial coverage." \
            "skills/journey-mapping/SKILL.md §\"Cycle protocol\""
        fi
      done
      ;;
    5)
      # Phase 5 — coverage-expansion-state.json with at least pass-1 record.
      COV_STATE_PATH="$PROJECT_ROOT/tests/e2e/docs/coverage-expansion-state.json"
      if [ ! -f "$COV_STATE_PATH" ]; then
        emit_phase_deny "5" \
          "tests/e2e/docs/coverage-expansion-state.json does not exist." \
          "invoke the \`coverage-expansion\` skill via the Skill tool. It writes the state file as it runs the per-pass pipeline." \
          "skills/onboarding/SKILL.md §\"Phase 5 — Coverage expansion\" + skills/coverage-expansion/SKILL.md"
      fi
      HAS_PASS_1=$("$JQ" -r '.passes["1"] != null' "$COV_STATE_PATH" 2>/dev/null || echo "false")
      if [ "$HAS_PASS_1" != "true" ]; then
        emit_phase_deny "5" \
          "coverage-expansion-state.json reports no pass-1 record. Pass 1 (strict per-journey, compositional) is the foundation of every coverage-expansion mode." \
          "run at least Pass 1 of coverage-expansion before closing Phase 5." \
          "skills/coverage-expansion/SKILL.md §\"Non-negotiables\""
      fi

      # Phase-5 ordering (cross-cutting §12): a standard/depth run only
      # completes when ALL FIVE passes plus the cleanup/dedup step are
      # recorded in coverage-expansion-state.json. The cleanup commit
      # RECORDS passes 1-5 + cleanup and does NOT delete the state file;
      # the orchestrator deletes it only AFTER reviewer approval, as the
      # final post-approval act, then writes the Phase-5 ledger completion.
      # So at the moment this completion write lands, the state file must
      # still exist and carry the full record. (A breadth-mode run is the
      # documented single-pass exception — it records cleanup with a
      # `mode: breadth` marker and is exempt from the 5-pass requirement.)
      RUN_MODE_COV=$("$JQ" -r '.runMode // .mode // "standard"' "$COV_STATE_PATH" 2>/dev/null || echo "standard")
      if [ "$RUN_MODE_COV" != "breadth" ]; then
        MISSING_PASSES=""
        for pnum in 1 2 3 4 5; do
          HAS_P=$("$JQ" -r --arg p "$pnum" '.passes[$p] != null' "$COV_STATE_PATH" 2>/dev/null || echo "false")
          [ "$HAS_P" = "true" ] || MISSING_PASSES="${MISSING_PASSES} ${pnum}"
        done
        CLEANUP_RECORDED=$("$JQ" -r '
          (.cleanup != null) or (.cleanupRecorded == true) or (.passes["cleanup"] != null)
        ' "$COV_STATE_PATH" 2>/dev/null || echo "false")
        if [ -n "$MISSING_PASSES" ] || [ "$CLEANUP_RECORDED" != "true" ]; then
          emit_phase_deny "5" \
            "coverage-expansion-state.json does not record the full five-pass run + cleanup. Missing pass record(s):${MISSING_PASSES:- none}; cleanup recorded: ${CLEANUP_RECORDED}. A standard/depth Phase 5 completes only after passes 1-5 AND the cleanup/dedup step are recorded." \
            "complete all five passes (compositional 1-3 + adversarial 4-5) and the cleanup/dedup step. The ordering is: RECORD passes 1-5 + cleanup in coverage-expansion-state.json → workflow-reviewer-phase5 approval → the orchestrator DELETES the state file as the final post-approval act → write this Phase-5 ledger completion. The state file must still be present and complete at this write (deletion happens post-approval, not before)." \
            "skills/coverage-expansion/SKILL.md §\"Five passes\" + cross-cutting §12 (phase-5 state-file ordering)"
        fi
      fi

      # Coverage-completeness check: Pass 1's dispatched-journeys + any
      # deferredJourneys[] entries must together cover the journey map's
      # full roster. Catches the "dispatched 8 of 41 journeys, called
      # exit-#2, marked Phase 5 complete" failure mode. The roster is
      # derived from the journey-map.md (one entry per `^#### j-` block).
      MAP_PATH="$PROJECT_ROOT/tests/e2e/docs/journey-map.md"
      if [ -f "$MAP_PATH" ]; then
        # Roster headings are canonical `### j-<slug>: <name>`; the prior
        # `^#### j-` pattern (4 hashes) never matched the real map and left
        # the coverage-completeness check dead. Accept `### j-` or `#### j-`.
        ROSTER_COUNT=$(grep -cE '^###[#]? j-' "$MAP_PATH" 2>/dev/null; true)
        ROSTER_COUNT=${ROSTER_COUNT:-0}
        DISPATCHED_COUNT=$("$JQ" -r '.passes["1"]["dispatched-journeys"] // [] | length' "$COV_STATE_PATH" 2>/dev/null || echo "0")
        DEFERRED_COUNT=$("$JQ" -r '.passes["1"]["deferredJourneys"] // [] | length' "$COV_STATE_PATH" 2>/dev/null || echo "0")
        DISPATCHED_COUNT=${DISPATCHED_COUNT:-0}
        DEFERRED_COUNT=${DEFERRED_COUNT:-0}
        TOTAL_ACCOUNTED=$((DISPATCHED_COUNT + DEFERRED_COUNT))

        if [ "$ROSTER_COUNT" -gt 0 ] && [ "$TOTAL_ACCOUNTED" -lt "$ROSTER_COUNT" ]; then
          UNCOVERED=$((ROSTER_COUNT - TOTAL_ACCOUNTED))
          emit_phase_deny "5" \
            "Pass 1 coverage incomplete: journey-map.md lists ${ROSTER_COUNT} journeys; coverage-expansion-state.json records ${DISPATCHED_COUNT} dispatched + ${DEFERRED_COUNT} deferred = ${TOTAL_ACCOUNTED} accounted. ${UNCOVERED} journey(s) are silently missing. This is the silent-scope-compression failure mode." \
            "either (a) dispatch the remaining ${UNCOVERED} journey(s) through coverage-expansion Pass 1, OR (b) add a deferredJourneys[] entry for each missing journey with a reason (structural prefix OR an \"authorizer\" field carrying a verbatim user quote). Pre-emptive scope reduction without authorisation is denied." \
            "skills/coverage-expansion/SKILL.md §\"Two valid exits\" + §\"Deferral authorisation\""
        fi

        # Deferral authorisation: each deferredJourneys[] entry must have
        # a structural reason prefix OR an explicit authorizer quote.
        # Self-imposed reasons (budget-cap, session-length, auto-mode-stop)
        # without an authorizer field are silent scope narrowing.
        if [ "$DEFERRED_COUNT" -gt 0 ]; then
          BAD_DEFERRAL=$(
            "$JQ" -r '
              .passes["1"]["deferredJourneys"] // [] | .[] |
              select(
                ((.reason // "") | test("^(blocked-on-app-bug:|test-data-prerequisite:|user-authorised:)")) | not
              ) |
              select(((.authorizer // "") | length) == 0) |
              .journey // "<unknown>"
            ' "$COV_STATE_PATH" 2>/dev/null | head -1 || true
          )
          if [ -n "$BAD_DEFERRAL" ]; then
            emit_phase_deny "5" \
              "deferredJourneys[] entry for \"${BAD_DEFERRAL}\" carries neither a structural reason prefix (\`blocked-on-app-bug:\`, \`test-data-prerequisite:\`, \`user-authorised:\`) nor an \`authorizer\` field with a verbatim user quote. Self-imposed deferrals (budget-cap, session-length, auto-mode-stop) without authorisation are silent scope narrowing." \
              "either dispatch this journey through Pass 1, or add a reason matching one of the allowed structural prefixes, or capture the user's verbatim authorisation in an \`authorizer\` field." \
              "skills/coverage-expansion/SKILL.md §\"Deferral authorisation\""
          fi
        fi
      fi

      ;;
    6)
      # Phase 6 — adversarial-findings ledger exists AND has substance.
      ADV_PATH="$PROJECT_ROOT/tests/e2e/docs/adversarial-findings.md"
      if [ ! -f "$ADV_PATH" ]; then
        emit_phase_deny "6" \
          "tests/e2e/docs/adversarial-findings.md does not exist." \
          "invoke the \`bug-discovery\` skill (or the adversarial passes of coverage-expansion). They write the findings ledger as probes return." \
          "skills/onboarding/SKILL.md §\"Phase 6 — Bug discovery\" + skills/bug-discovery/SKILL.md"
      fi

      # Content check: the ledger must contain at least one per-journey
      # section block. The canonical schema (per
      # references/subagent-return-schema.md §3) uses `### j-<slug>` as
      # the per-journey section header. An empty ledger (just the
      # title) means no probe ever ran — the file exists but the
      # methodology was bypassed. grep -c always prints a count (even
      # 0) and exits 1 on no-match — capture stdout, ignore exit code.
      JOURNEY_BLOCKS=$(grep -c '^### j-' "$ADV_PATH" 2>/dev/null; true)
      JOURNEY_BLOCKS=${JOURNEY_BLOCKS:-0}
      if [ "$JOURNEY_BLOCKS" -lt 1 ]; then
        emit_phase_deny "6" \
          "tests/e2e/docs/adversarial-findings.md exists but contains 0 per-journey section blocks (\`### j-<slug>\`). File existence alone is not bug-discovery; the ledger must record at least one probe." \
          "dispatch the bug-discovery probe subagents per journey (or the adversarial passes of coverage-expansion). Each probe appends a \`### j-<slug>\` section to the ledger as it returns." \
          "skills/bug-discovery/SKILL.md + element-interactions/references/subagent-return-schema.md §3"
      fi
      ;;
    7)
      # Phase 7 — .env.example exists at project root.
      ENV_EXAMPLE_PATH="$PROJECT_ROOT/.env.example"
      if [ ! -f "$ENV_EXAMPLE_PATH" ]; then
        emit_phase_deny "7" \
          ".env.example does not exist at the project root." \
          "invoke the \`secrets-sweep\` skill. It writes .env.example as it extracts literals from the test suite." \
          "skills/onboarding/SKILL.md §\"Phase 7 — Secrets sweep\" + skills/secrets-sweep/SKILL.md"
      fi
      ;;
    8)
      # Phase 8 — qa-summary-deck.{html,pdf} exist at project root.
      DECK_HTML="$PROJECT_ROOT/qa-summary-deck.html"
      DECK_PDF="$PROJECT_ROOT/qa-summary-deck.pdf"
      MISSING_DECK=""
      [ -f "$DECK_HTML" ] || MISSING_DECK="qa-summary-deck.html"
      [ -f "$DECK_PDF" ]  || MISSING_DECK="${MISSING_DECK:+$MISSING_DECK + }qa-summary-deck.pdf"
      if [ -n "$MISSING_DECK" ]; then
        emit_phase_deny "8" \
          "$MISSING_DECK missing from project root." \
          "invoke the \`work-summary-deck\` skill. It writes the HTML deck and renders the PDF." \
          "skills/onboarding/SKILL.md §\"Phase 8 — Report\" + skills/work-summary-deck/SKILL.md"
      fi
      ;;
  esac
done

# All checks passed — silent allow.
exit 0
