198 lines
8.2 KiB
Bash
Executable File
198 lines
8.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# reflect-stop-hook.sh — Stop hook (agent reflection loop, durable kernel)
|
|
#
|
|
# At end-of-run, capture the doer's end-state as a structured `reflection.v1`
|
|
# sidecar: the mechanical diff risk-floor plus any self-report the agent left
|
|
# behind. This is the passive capture half of the design (§10 step 1). It does
|
|
# NOT route, score, or gate — it only writes the sidecar; pickup is future work.
|
|
#
|
|
# FAIL-CLOSED: if REFLECTION_MODE is unset or "off", this is a strict no-op.
|
|
# Global registration is therefore safe; the feature only activates when a
|
|
# launcher/profile explicitly sets REFLECTION_MODE=solo|orchestrated.
|
|
#
|
|
# NON-BLOCKING: Stop hooks are observational. This script NEVER emits a
|
|
# `decision` field and ALWAYS exits 0 — it can never fail or stall a session.
|
|
#
|
|
# Environment contract:
|
|
# REFLECTION_MODE off|solo|orchestrated (default: off → no-op)
|
|
# REFLECTION_DIR output dir (default: <repo>/.mosaic/reflections)
|
|
# REFLECTION_INPUT self-report JSON (default: <repo>/.mosaic/reflection-input.json)
|
|
# REFLECTION_TASK_REF canonical task ref (default: <repo>#<branch>)
|
|
# REFLECTION_AGENT persona/runtime id (default: unknown)
|
|
# REFLECTION_RISK_THRESHOLD review cutoff [0,1] (default: 0.5)
|
|
#
|
|
# Risk-floor surface table is kept in sync with the authoritative TS
|
|
# implementation at packages/macp/src/risk-floor.ts (evaluateRiskFloor).
|
|
#
|
|
# Exit codes: always 0 (observational hook).
|
|
|
|
set -euo pipefail
|
|
|
|
# ---- fail-closed gate -------------------------------------------------------
|
|
MODE="${REFLECTION_MODE:-off}"
|
|
if [[ "$MODE" != "solo" && "$MODE" != "orchestrated" ]]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Read the Stop payload (best-effort; never required).
|
|
INPUT="$(cat || true)"
|
|
|
|
# Sentinel lock path (global so the EXIT trap can clean it after main returns).
|
|
LOCKFILE=""
|
|
trap 'rm -f "${LOCKFILE:-}" 2>/dev/null || true' EXIT
|
|
|
|
main() {
|
|
command -v jq >/dev/null 2>&1 || return 0 # no jq → silently no-op
|
|
|
|
local session_id payload_cwd repo_dir repo_name branch task_ref agent
|
|
session_id="$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null || echo unknown)"
|
|
# Sanitize: session_id is interpolated into file/lock paths — allow safe
|
|
# filename chars only (defends against ../ or / in the payload).
|
|
session_id="${session_id//[^a-zA-Z0-9_-]/}"
|
|
session_id="${session_id:-unknown}"
|
|
payload_cwd="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"
|
|
|
|
# Resolve repo root: prefer git toplevel from the payload cwd, else PWD.
|
|
local start_dir="${payload_cwd:-$PWD}"
|
|
repo_dir="$(git -C "$start_dir" rev-parse --show-toplevel 2>/dev/null || echo "$start_dir")"
|
|
repo_name="$(basename "$repo_dir")"
|
|
branch="$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)"
|
|
|
|
task_ref="${REFLECTION_TASK_REF:-${repo_name}#${branch}}"
|
|
agent="${REFLECTION_AGENT:-unknown}"
|
|
|
|
# ---- sentinel guard: avoid re-fire loops --------------------------------
|
|
local out_dir lock
|
|
out_dir="${REFLECTION_DIR:-${repo_dir}/.mosaic/reflections}"
|
|
mkdir -p "$out_dir" 2>/dev/null || return 0
|
|
lock="${out_dir}/.${session_id}.lock"
|
|
if [[ -e "$lock" ]]; then
|
|
return 0
|
|
fi
|
|
: > "$lock" 2>/dev/null || true
|
|
LOCKFILE="$lock"
|
|
|
|
# ---- mechanical: changed files ------------------------------------------
|
|
# Union of committed-vs-HEAD~ is out of scope; capture the working surface:
|
|
# staged + unstaged + untracked, best-effort.
|
|
# Exclude .mosaic/ (agent scratch: reflections, locks, self-report input) —
|
|
# it is tooling state, not part of the diff under review.
|
|
local files
|
|
files="$(
|
|
{
|
|
git -C "$repo_dir" diff --name-only HEAD 2>/dev/null || true
|
|
git -C "$repo_dir" diff --name-only --staged 2>/dev/null || true
|
|
git -C "$repo_dir" ls-files --others --exclude-standard 2>/dev/null || true
|
|
} | sed '/^$/d' | grep -v '^\.mosaic/' | sort -u || true
|
|
)"
|
|
|
|
# ---- mechanical: risk-floor (inline port of evaluateRiskFloor) ----------
|
|
local threshold="${REFLECTION_RISK_THRESHOLD:-0.5}"
|
|
local top_surface="none" top_weight="0.0" tripping=""
|
|
local f surface weight
|
|
while IFS= read -r f; do
|
|
[[ -z "$f" ]] && continue
|
|
surface="$(classify_surface "$f")"
|
|
weight="$(surface_weight "$surface")"
|
|
if awk "BEGIN{exit !($weight > $top_weight)}"; then
|
|
top_weight="$weight"; top_surface="$surface"; tripping="$f"
|
|
elif [[ "$surface" == "$top_surface" && "$surface" != "none" ]] && awk "BEGIN{exit !($weight == $top_weight)}"; then
|
|
tripping="${tripping:+$tripping, }$f"
|
|
fi
|
|
done <<< "$files"
|
|
|
|
local needs_review reason file_count
|
|
file_count="$(printf '%s\n' "$files" | sed '/^$/d' | wc -l | tr -d ' ')"
|
|
if awk "BEGIN{exit !($top_weight >= $threshold)}"; then needs_review=true; else needs_review=false; fi
|
|
if [[ "$top_surface" == "none" ]]; then
|
|
if [[ "$file_count" -eq 0 ]]; then reason="no files changed"; else reason="no sensitive surface in ${file_count} changed file(s)"; fi
|
|
else
|
|
reason="${top_surface} surface (weight ${top_weight}) in: ${tripping}"
|
|
fi
|
|
|
|
# ---- self-report merge (optional) ---------------------------------------
|
|
local input_file degraded self_json
|
|
input_file="${REFLECTION_INPUT:-${repo_dir}/.mosaic/reflection-input.json}"
|
|
degraded=true
|
|
self_json='{"confidence":null,"most_likely_wrong":null,"known_not_in_diff":null}'
|
|
if [[ -r "$input_file" ]] && jq -e . "$input_file" >/dev/null 2>&1; then
|
|
self_json="$(jq '{
|
|
confidence: (.confidence // null),
|
|
most_likely_wrong: (.most_likely_wrong // null),
|
|
known_not_in_diff: (.known_not_in_diff // null)
|
|
}' "$input_file" 2>/dev/null || echo "$self_json")"
|
|
degraded=false
|
|
fi
|
|
|
|
# ---- assemble + atomic write --------------------------------------------
|
|
local ts files_json record tmp final
|
|
ts="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
|
|
files_json="$(printf '%s\n' "$files" | jq -R . | jq -s 'map(select(length>0))')"
|
|
|
|
record="$(jq -n \
|
|
--arg task_ref "$task_ref" \
|
|
--arg agent "$agent" \
|
|
--arg session_id "$session_id" \
|
|
--arg ts "$ts" \
|
|
--arg repo "$repo_name" \
|
|
--argjson needs_review "$needs_review" \
|
|
--argjson score "$top_weight" \
|
|
--arg surface "$top_surface" \
|
|
--arg reason "$reason" \
|
|
--argjson files "$files_json" \
|
|
--argjson self "$self_json" \
|
|
--argjson degraded "$degraded" \
|
|
--arg mode "$MODE" \
|
|
'{
|
|
schema: "reflection.v1",
|
|
task_ref: $task_ref,
|
|
agent: $agent,
|
|
session_id: $session_id,
|
|
timestamp: $ts,
|
|
repo: $repo,
|
|
confidence: $self.confidence,
|
|
most_likely_wrong: $self.most_likely_wrong,
|
|
known_not_in_diff: $self.known_not_in_diff,
|
|
risk: { needs_review: $needs_review, score: $score, surface: $surface, reason: $reason },
|
|
files_changed: $files,
|
|
provenance: { source: "stop-hook", reflection_attempt: 1, degraded: $degraded, reflection_mode: $mode }
|
|
}' 2>/dev/null || true)"
|
|
|
|
[[ -z "$record" ]] && return 0
|
|
|
|
final="${out_dir}/${session_id}-${ts//[:]/}.reflection.json"
|
|
tmp="${final}.tmp"
|
|
printf '%s\n' "$record" > "$tmp" 2>/dev/null || return 0
|
|
mv -f "$tmp" "$final" 2>/dev/null || true
|
|
}
|
|
|
|
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
|
|
classify_surface() {
|
|
local p="$1"
|
|
if printf '%s' "$p" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi
|
|
if printf '%s' "$p" | grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed'; then echo data; return; fi
|
|
if printf '%s' "$p" | grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi
|
|
if printf '%s' "$p" | grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite'; then echo build; return; fi
|
|
if printf '%s' "$p" | grep -qE '\.tsx|\.css|components/|apps/web/'; then echo ui; return; fi
|
|
if printf '%s' "$p" | grep -qE '\.spec\.|\.test\.|__tests__/'; then echo test; return; fi
|
|
if printf '%s' "$p" | grep -qE '\.md$|docs/'; then echo docs; return; fi
|
|
echo none
|
|
}
|
|
|
|
# surface_weight SURFACE → numeric weight (mirrors TS SURFACE_RULES)
|
|
surface_weight() {
|
|
case "$1" in
|
|
auth) echo 1.0 ;;
|
|
data) echo 0.9 ;;
|
|
infra) echo 0.85 ;;
|
|
build) echo 0.6 ;;
|
|
ui) echo 0.4 ;;
|
|
test) echo 0.2 ;;
|
|
docs) echo 0.1 ;;
|
|
*) echo 0.0 ;;
|
|
esac
|
|
}
|
|
|
|
main || true
|
|
exit 0
|