feat(agent-reflection): durable kernel — reflection.v1 capture + risk-floor + Phase-0 (#544)
Build the durable kernel of the agent reflection loop. Passive end-of-run capture of the doer's end-state as structured `reflection.v1` data, plus a deterministic diff review risk-floor. The closed calibration/skill-synthesis loop (design §7–§8) stays gated behind Phase-0 experiments P1/P2/P3. - packages/macp: evaluateRiskFloor (pure, deterministic surface classifier) + reflection.v1 JSON Schema; 15 unit tests. - packages/types: reflection.v1 zod schemas + self-report DTO; 10 unit tests. - framework: fail-closed Stop hook (reflect-stop-hook.sh) writing the sidecar, registered as hooks.Stop in runtime/claude/settings.json. Strict no-op unless REFLECTION_MODE=solo|orchestrated; never blocks or fails a session. - scripts/analysis: P1/P2/P3 experiment harnesses with pre-registered kill conditions and structured output. Mechanical fields (risk, files_changed, ids, provenance) are written by the hook; self-report fields (confidence, most_likely_wrong, known_not_in_diff) are merged from an optional $REFLECTION_INPUT, else null + provenance.degraded=true. Independent review remediations: empty/all-.mosaic diff still writes a sidecar (grep no-match no longer aborts); session_id sanitized before path use. Refs #544 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
197
packages/mosaic/framework/tools/qa/reflect-stop-hook.sh
Executable file
197
packages/mosaic/framework/tools/qa/reflect-stop-hook.sh
Executable file
@@ -0,0 +1,197 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user