diff --git a/.claude/hooks/honest-eta.sh b/.claude/hooks/honest-eta.sh index 56cbefc..9fad912 100755 --- a/.claude/hooks/honest-eta.sh +++ b/.claude/hooks/honest-eta.sh @@ -13,6 +13,29 @@ set -euo pipefail +_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$_HOOK_DIR/../lib/packs.sh" ]; then + # shellcheck source=../lib/packs.sh + source "$_HOOK_DIR/../lib/packs.sh" +fi + +_load_or_fallback() { + local section="$1" fallback="$2" loaded="" + if declare -F load_locale_section >/dev/null 2>&1; then + loaded="$(load_locale_section "$section" 2>/dev/null)" + fi + if [ -z "$loaded" ]; then + printf '%s' "$fallback" + else + printf '%s' "$loaded" + fi +} + +ETA_CONTEXT_RE="$(_load_or_fallback eta_context '\b([0-9]+([-.][0-9]+)?)[[:space:]]*(min|minute|hour|hr|day|week|wk|month|mo|year|yr|sprint)s?\b|\bETA[: ]|\bestimated time|\btime to (deliver|ship|complete|implement|finish|land)|\bshould take (about|around|roughly|approximately)?[[:space:]]?[0-9]|\bwill take (about|around|roughly|approximately)?[[:space:]]?[0-9]|\bcompletion in [0-9]|\bready in (about|around)?[[:space:]]?[0-9]')" +LINEAR_SCALING_RE="$(_load_or_fallback eta_linear_scaling '\b[0-9]+x[[:space:]]+(faster|speedup|speed-?up)|with[[:space:]]+[0-9]+[[:space:]]+(agents|lanes|workers).*[0-9]+x|linear(ly)?[[:space:]]+scal(es|ing|able)|divid(ed|ing)[[:space:]]+by[[:space:]]+(lane|agent)[[:space:]]+count|per[- ]lane[[:space:]]+speedup|N[[:space:]]+agents[[:space:]]*=[[:space:]]*N x')" +AGENT_NATIVE_RE="$(_load_or_fallback eta_agent_native 'agent[[:space:]_]wall[[:space:]_]clock|agent[[:space:]_]hours|human[[:space:]_]touch[[:space:]_]time|calendar[[:space:]_]blockers|critical[[:space:]_]path|estimate[[:space:]_]type[: ]+(agent-native|human-equivalent|blocked|unknown)|optimistic[[:space:],/]+(.*)?[[:space:]]?likely[[:space:],/]+(.*)?[[:space:]]?pessimistic|confidence[: ]+(high|medium|low|unknown)([[:space:],]+with[[:space:]]+downgrade)?|insufficient_data')" +HEDGE_RANGE_RE="$(_load_or_fallback eta_hedge_range '\b(optimistic|likely|pessimistic|worst[- ]case|best[- ]case|p50|p90|range)[: ]+|approximately[[:space:]]+[0-9]+[[:space:]]*-[[:space:]]*[0-9]+|\bsomewhere between[[:space:]]+[0-9]+|\bcould be anywhere from')" + INPUT="$(cat)" if ! command -v jq >/dev/null 2>&1; then @@ -24,6 +47,47 @@ if ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then exit 0 fi +# Rust path: prefer agentcloseout-physics when available. +if command -v agentcloseout-physics >/dev/null 2>&1; then + RULES_DIR="${LLM_DARK_PATTERNS_RULES_DIR:-}" + if [ -z "$RULES_DIR" ]; then + for candidate in \ + "$(dirname "$0")/../../agent-closeout-bench/rules/closeout" \ + "/home/fer/Documents/agent-closeout-bench/rules/closeout" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/agentcloseout-physics/rules/closeout"; do + if [ -d "$candidate" ]; then RULES_DIR="$candidate"; break; fi + done + fi + if [ -n "$RULES_DIR" ] && [ -d "$RULES_DIR" ] && [ -f "$RULES_DIR/honest_eta.yaml" ]; then + TMP_INPUT="$(mktemp)"; printf '%s' "$INPUT" > "$TMP_INPUT" + VERDICT_JSON="$(agentcloseout-physics scan --category honest_eta --rules "$RULES_DIR" --input "$TMP_INPUT" 2>/dev/null || true)" + rm -f "$TMP_INPUT" + if [ -n "$VERDICT_JSON" ]; then + DECISION="$(printf '%s' "$VERDICT_JSON" | jq -r '.decision // empty' 2>/dev/null)" + if [ "$DECISION" = "block" ]; then + RULE="$(printf '%s' "$VERDICT_JSON" | jq -r '.matched_rules[0].rule_id // "honest_eta"' 2>/dev/null)" + EVIDENCE="$(printf '%s' "$VERDICT_JSON" | jq -r '.redacted_evidence[0] // ""' 2>/dev/null)" + if [ "$RULE" = "honest_eta.linear_scaling_claim" ]; then + echo "BLOCKED: linear-scaling claim in time estimate — agents don't divide work by lane count." >&2 + else + echo "BLOCKED: time estimate without Agent-Native Estimate shape or honest hedge range." >&2 + fi + echo "Matched rule: $RULE" >&2 + [ -n "$EVIDENCE" ] && echo "Evidence: $EVIDENCE" >&2 + echo "" >&2 + echo "Repair guidance:" >&2 + echo "- Use the Agent-Native Estimate shape: estimate type, agent_wall_clock optimistic/likely/pessimistic, critical_path, confidence." >&2 + echo "- Or an honest hedge range (optimistic/likely/pessimistic, or 'somewhere between X and Y')." >&2 + echo "- If capacity is genuinely unknown, say 'estimate type: blocked/unknown' instead of inventing a number." >&2 + exit 2 + fi + if [ "$DECISION" = "pass" ]; then + exit 0 + fi + fi + fi +fi + json_get() { local filter="$1" printf '%s' "$INPUT" | jq -r "$filter // empty" 2>/dev/null || true @@ -57,7 +121,8 @@ if [ -z "$message" ]; then fi # Step 1 — does the message contain a time-estimate claim? -ETA_CONTEXT='(\b([0-9]+([-.][0-9]+)?)[[:space:]]*(min|minute|hour|hr|day|week|wk|month|mo|year|yr|sprint)s?\b|\bETA[: ]|\bestimated time|\btime to (deliver|ship|complete|implement|finish|land)|\bshould take (about|around|roughly|approximately)?[[:space:]]?[0-9]|\bwill take (about|around|roughly|approximately)?[[:space:]]?[0-9]|\bcompletion in [0-9]|\bready in (about|around)?[[:space:]]?[0-9])' +# Vocab loaded from packs/locale/.txt section [eta_context]. +ETA_CONTEXT="(${ETA_CONTEXT_RE})" HAS_ETA=$(printf '%s\n' "$message" | grep -Eic "$ETA_CONTEXT" || true) @@ -66,7 +131,8 @@ if [ "$HAS_ETA" -eq 0 ]; then fi # Step 2 — linear-scaling claims are always bad, regardless of context. -LINEAR_SCALING='(\b[0-9]+x[[:space:]]+(faster|speedup|speed-?up)|with[[:space:]]+[0-9]+[[:space:]]+(agents|lanes|workers).*[0-9]+x|linear(ly)?[[:space:]]+scal(es|ing|able)|divid(ed|ing)[[:space:]]+by[[:space:]]+(lane|agent)[[:space:]]+count|per[- ]lane[[:space:]]+speedup|N[[:space:]]+agents[[:space:]]*=[[:space:]]*N x)' +# Vocab loaded from packs/locale/.txt section [eta_linear_scaling]. +LINEAR_SCALING="(${LINEAR_SCALING_RE})" if printf '%s\n' "$message" | grep -Eiq "$LINEAR_SCALING"; then block "linear-scaling claim in time estimate — agents don't divide work by lane count." \ @@ -79,11 +145,13 @@ if printf '%s\n' "$message" | grep -Eiq "$LINEAR_SCALING"; then fi # Step 3 — redemption: Agent-Native Estimate structured fields. -AGENT_NATIVE='(agent[[:space:]_]wall[[:space:]_]clock|agent[[:space:]_]hours|human[[:space:]_]touch[[:space:]_]time|calendar[[:space:]_]blockers|critical[[:space:]_]path|estimate[[:space:]_]type[: ]+(agent-native|human-equivalent|blocked|unknown)|optimistic[[:space:],/]+(.*)?[[:space:]]?likely[[:space:],/]+(.*)?[[:space:]]?pessimistic|confidence[: ]+(high|medium|low|unknown)([[:space:],]+with[[:space:]]+downgrade)?|insufficient_data)' +# Vocab loaded from packs/locale/.txt section [eta_agent_native]. +AGENT_NATIVE="(${AGENT_NATIVE_RE})" # Step 4 — also accept honest hedge ranges as partial redemption when an # operator only asked for a rough number. -HEDGE_RANGE='(\b(optimistic|likely|pessimistic|worst[- ]case|best[- ]case|p50|p90|range)[: ]+|approximately[[:space:]]+[0-9]+[[:space:]]*-[[:space:]]*[0-9]+|\bsomewhere between[[:space:]]+[0-9]+|\bcould be anywhere from)' +# Vocab loaded from packs/locale/.txt section [eta_hedge_range]. +HEDGE_RANGE="(${HEDGE_RANGE_RE})" HAS_AGENT_NATIVE=$(printf '%s\n' "$message" | grep -Eic "$AGENT_NATIVE" || true) HAS_HEDGE=$(printf '%s\n' "$message" | grep -Eic "$HEDGE_RANGE" || true) diff --git a/.claude/hooks/no-curfew.sh b/.claude/hooks/no-curfew.sh index 0eb50ad..3663b60 100755 --- a/.claude/hooks/no-curfew.sh +++ b/.claude/hooks/no-curfew.sh @@ -2,9 +2,33 @@ # Claude Code hook: block unsolicited rest/wellness paternalism in agent-mode sessions. # Aligned with Anthropic's stated design intent: "various forms of paternalism and # moralizing are disrespectful." (Claude's Constitution) +# +# Vocabulary loaded from packs/locale/.txt sections [paternalism] and +# [paternalism_allow]. Inline English fallback preserves pre-pack behavior. set -euo pipefail +_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$_HOOK_DIR/../lib/packs.sh" ]; then + # shellcheck source=../lib/packs.sh + source "$_HOOK_DIR/../lib/packs.sh" +fi + +_load_or_fallback() { + local section="$1" fallback="$2" loaded="" + if declare -F load_locale_section >/dev/null 2>&1; then + loaded="$(load_locale_section "$section" 2>/dev/null)" + fi + if [ -z "$loaded" ]; then + printf '%s' "$fallback" + else + printf '%s' "$loaded" + fi +} + +PATERNALISM_RE="$(_load_or_fallback paternalism 'go to sleep|get some sleep|get some rest|take a break|save it for tomorrow|tomorrow morning|call it a night|rest up|step away|log off|wind down|your wellbeing|your well-being|take care of yourself|burnout|burned out|you'\''ve earned (a |some )?(rest|break)|it'\''s (late|getting late)|the work can wait|come back fresh|recharge|self-care|mental health break|you should stop|sleep matters|sleep is important|grab some sleep|give yourself a break|easy on yourself|don'\''t push yourself')" +PATERNALISM_ALLOW_RE="$(_load_or_fallback paternalism_allow 'you (asked|requested) (for|about) (a break|rest|sleep|self-care)|since you (mentioned|said) you (are|were|feel) tired|as you requested .*break')" + INPUT="$(cat)" if ! command -v jq >/dev/null 2>&1; then @@ -16,6 +40,43 @@ if ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then exit 0 fi +# Rust path: prefer agentcloseout-physics when available. +if command -v agentcloseout-physics >/dev/null 2>&1; then + RULES_DIR="${LLM_DARK_PATTERNS_RULES_DIR:-}" + if [ -z "$RULES_DIR" ]; then + for candidate in \ + "$(dirname "$0")/../../agent-closeout-bench/rules/closeout" \ + "/home/fer/Documents/agent-closeout-bench/rules/closeout" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/agentcloseout-physics/rules/closeout"; do + if [ -d "$candidate" ]; then RULES_DIR="$candidate"; break; fi + done + fi + if [ -n "$RULES_DIR" ] && [ -d "$RULES_DIR" ] && [ -f "$RULES_DIR/no_curfew.yaml" ]; then + TMP_INPUT="$(mktemp)"; printf '%s' "$INPUT" > "$TMP_INPUT" + VERDICT_JSON="$(agentcloseout-physics scan --category no_curfew --rules "$RULES_DIR" --input "$TMP_INPUT" 2>/dev/null || true)" + rm -f "$TMP_INPUT" + if [ -n "$VERDICT_JSON" ]; then + DECISION="$(printf '%s' "$VERDICT_JSON" | jq -r '.decision // empty' 2>/dev/null)" + if [ "$DECISION" = "block" ]; then + RULE="$(printf '%s' "$VERDICT_JSON" | jq -r '.matched_rules[0].rule_id // "no_curfew"' 2>/dev/null)" + EVIDENCE="$(printf '%s' "$VERDICT_JSON" | jq -r '.redacted_evidence[0] // ""' 2>/dev/null)" + echo "BLOCKED: unsolicited rest/wellness paternalism in agent-mode session." >&2 + echo "Matched rule: $RULE" >&2 + [ -n "$EVIDENCE" ] && echo "Evidence: $EVIDENCE" >&2 + echo "" >&2 + echo "Repair guidance:" >&2 + echo "- Drop the human-rest framing. Continue with the next concrete piece of work." >&2 + echo "- If the operator did request rest advice, restate the operator's request so the allow-clause matches." >&2 + echo "- Anthropic's Constitution: paternalism and moralizing are disrespectful when unsolicited." >&2 + exit 2 + fi + if [ "$DECISION" = "pass" ]; then + exit 0 + fi + fi + fi +fi + json_get() { local filter="$1" printf '%s' "$INPUT" | jq -r "$filter // empty" 2>/dev/null || true @@ -48,15 +109,16 @@ if [ -z "$message" ]; then exit 0 fi -# If the operator explicitly asked for rest/break advice this turn, the model is -# allowed to provide it. We detect this by looking for a marker phrase the model -# tends to repeat back when it is genuinely responding to a wellness ask. -if printf '%s\n' "$message" | grep -Eiq '(you (asked|requested) (for|about) (a break|rest|sleep|self-care)|since you (mentioned|said) you (are|were|feel) tired|as you requested .*break)'; then +# If the operator explicitly asked for rest/break advice this turn, the +# model is allowed to provide it. The allow vocab is loaded from +# packs/locale/.txt section [paternalism_allow]. +if printf '%s\n' "$message" | grep -Eiq "(${PATERNALISM_ALLOW_RE})"; then exit 0 fi -# Trigger: unsolicited paternalism vocabulary. -PATERNALISM='(^|[^[:alpha:]])(go to sleep|get some sleep|get some rest|take a break|save it for tomorrow|tomorrow morning|call it a night|rest up|step away|log off|wind down|your wellbeing|your well-being|take care of yourself|burnout|burned out|you'\''ve earned (a |some )?(rest|break)|it'\''s (late|getting late)|the work can wait|come back fresh|recharge|self-care|mental health break|you should stop|sleep matters|sleep is important|get some sleep|grab some sleep|give yourself a break|easy on yourself|don'\''t push yourself)([^[:alpha:]]|$)' +# Trigger: unsolicited paternalism vocabulary loaded from +# packs/locale/.txt section [paternalism]. +PATERNALISM="(^|[^[:alpha:]])(${PATERNALISM_RE})([^[:alpha:]]|$)" if printf '%s\n' "$message" | grep -Eiq "$PATERNALISM"; then block "unsolicited rest/wellness paternalism in agent-mode session." \ diff --git a/.claude/hooks/no-disclaimer-spam.sh b/.claude/hooks/no-disclaimer-spam.sh index d805ce3..23be91a 100755 --- a/.claude/hooks/no-disclaimer-spam.sh +++ b/.claude/hooks/no-disclaimer-spam.sh @@ -9,6 +9,42 @@ INPUT="$(cat)" if ! command -v jq >/dev/null 2>&1; then exit 0; fi if ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then exit 0; fi +# Rust path: prefer agentcloseout-physics when available. +if command -v agentcloseout-physics >/dev/null 2>&1; then + RULES_DIR="${LLM_DARK_PATTERNS_RULES_DIR:-}" + if [ -z "$RULES_DIR" ]; then + for candidate in \ + "$(dirname "$0")/../../agent-closeout-bench/rules/closeout" \ + "/home/fer/Documents/agent-closeout-bench/rules/closeout" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/agentcloseout-physics/rules/closeout"; do + if [ -d "$candidate" ]; then RULES_DIR="$candidate"; break; fi + done + fi + if [ -n "$RULES_DIR" ] && [ -d "$RULES_DIR" ] && [ -f "$RULES_DIR/no_disclaimer_spam.yaml" ]; then + TMP_INPUT="$(mktemp)"; printf '%s' "$INPUT" > "$TMP_INPUT" + VERDICT_JSON="$(agentcloseout-physics scan --category no_disclaimer_spam --rules "$RULES_DIR" --input "$TMP_INPUT" 2>/dev/null || true)" + rm -f "$TMP_INPUT" + if [ -n "$VERDICT_JSON" ]; then + DECISION="$(printf '%s' "$VERDICT_JSON" | jq -r '.decision // empty' 2>/dev/null)" + if [ "$DECISION" = "block" ]; then + RULE="$(printf '%s' "$VERDICT_JSON" | jq -r '.matched_rules[0].rule_id // "no_disclaimer_spam"' 2>/dev/null)" + EVIDENCE="$(printf '%s' "$VERDICT_JSON" | jq -r '.redacted_evidence[0] // ""' 2>/dev/null)" + echo "BLOCKED: disclaimer spam: defensive padding ('Please note', 'It's important to mention', etc.)." >&2 + echo "Matched rule: $RULE" >&2 + [ -n "$EVIDENCE" ] && echo "Evidence: $EVIDENCE" >&2 + echo "" >&2 + echo "Repair guidance:" >&2 + echo "- If the noted thing is important, state it directly. Don't preamble with a meta-frame." >&2 + echo "- If it's not important enough to state directly, drop it." >&2 + exit 2 + fi + if [ "$DECISION" = "pass" ]; then + exit 0 + fi + fi + fi +fi + json_get() { printf '%s' "$INPUT" | jq -r "$1 // empty" 2>/dev/null || true; } block() { echo "BLOCKED: $1" >&2 diff --git a/.claude/hooks/no-emoji-spam.sh b/.claude/hooks/no-emoji-spam.sh index 26b3bb5..91089c4 100755 --- a/.claude/hooks/no-emoji-spam.sh +++ b/.claude/hooks/no-emoji-spam.sh @@ -10,6 +10,44 @@ INPUT="$(cat)" if ! command -v jq >/dev/null 2>&1; then exit 0; fi if ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then exit 0; fi +# Rust path: prefer agentcloseout-physics when available. +# Note: Rust hardcodes threshold > 3; LLM_DARK_PATTERNS_EMOJI_THRESHOLD only +# honored in the bash fallback below. +if command -v agentcloseout-physics >/dev/null 2>&1 && [ -z "${LLM_DARK_PATTERNS_EMOJI_THRESHOLD:-}" ]; then + RULES_DIR="${LLM_DARK_PATTERNS_RULES_DIR:-}" + if [ -z "$RULES_DIR" ]; then + for candidate in \ + "$(dirname "$0")/../../agent-closeout-bench/rules/closeout" \ + "/home/fer/Documents/agent-closeout-bench/rules/closeout" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/agentcloseout-physics/rules/closeout"; do + if [ -d "$candidate" ]; then RULES_DIR="$candidate"; break; fi + done + fi + if [ -n "$RULES_DIR" ] && [ -d "$RULES_DIR" ] && [ -f "$RULES_DIR/no_emoji_spam.yaml" ]; then + TMP_INPUT="$(mktemp)"; printf '%s' "$INPUT" > "$TMP_INPUT" + VERDICT_JSON="$(agentcloseout-physics scan --category no_emoji_spam --rules "$RULES_DIR" --input "$TMP_INPUT" 2>/dev/null || true)" + rm -f "$TMP_INPUT" + if [ -n "$VERDICT_JSON" ]; then + DECISION="$(printf '%s' "$VERDICT_JSON" | jq -r '.decision // empty' 2>/dev/null)" + if [ "$DECISION" = "block" ]; then + RULE="$(printf '%s' "$VERDICT_JSON" | jq -r '.matched_rules[0].rule_id // "no_emoji_spam"' 2>/dev/null)" + echo "BLOCKED: emoji spam: > 3 emoji codepoints (default threshold)." >&2 + echo "Matched rule: $RULE" >&2 + echo "" >&2 + echo "Repair guidance:" >&2 + echo "- Drop the emoji and use plain text. Reserve emoji for information that prose alone cannot carry." >&2 + echo "- Operator can customize the threshold via env (bash path only):" >&2 + echo " LLM_DARK_PATTERNS_EMOJI_THRESHOLD=10 (permissive)" >&2 + echo " LLM_DARK_PATTERNS_EMOJI_THRESHOLD=0 (zero tolerance)" >&2 + exit 2 + fi + if [ "$DECISION" = "pass" ]; then + exit 0 + fi + fi + fi +fi + json_get() { printf '%s' "$INPUT" | jq -r "$1 // empty" 2>/dev/null || true; } block() { echo "BLOCKED: $1" >&2 diff --git a/.claude/hooks/no-tldr-bait.sh b/.claude/hooks/no-tldr-bait.sh index 0ffbf96..04d675a 100755 --- a/.claude/hooks/no-tldr-bait.sh +++ b/.claude/hooks/no-tldr-bait.sh @@ -9,6 +9,40 @@ INPUT="$(cat)" if ! command -v jq >/dev/null 2>&1; then exit 0; fi if ! printf '%s' "$INPUT" | jq -e . >/dev/null 2>&1; then exit 0; fi +# Rust path: prefer agentcloseout-physics when available. +if command -v agentcloseout-physics >/dev/null 2>&1; then + RULES_DIR="${LLM_DARK_PATTERNS_RULES_DIR:-}" + if [ -z "$RULES_DIR" ]; then + for candidate in \ + "$(dirname "$0")/../../agent-closeout-bench/rules/closeout" \ + "/home/fer/Documents/agent-closeout-bench/rules/closeout" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/agentcloseout-physics/rules/closeout"; do + if [ -d "$candidate" ]; then RULES_DIR="$candidate"; break; fi + done + fi + if [ -n "$RULES_DIR" ] && [ -d "$RULES_DIR" ] && [ -f "$RULES_DIR/no_tldr_bait.yaml" ]; then + TMP_INPUT="$(mktemp)"; printf '%s' "$INPUT" > "$TMP_INPUT" + VERDICT_JSON="$(agentcloseout-physics scan --category no_tldr_bait --rules "$RULES_DIR" --input "$TMP_INPUT" 2>/dev/null || true)" + rm -f "$TMP_INPUT" + if [ -n "$VERDICT_JSON" ]; then + DECISION="$(printf '%s' "$VERDICT_JSON" | jq -r '.decision // empty' 2>/dev/null)" + if [ "$DECISION" = "block" ]; then + RULE="$(printf '%s' "$VERDICT_JSON" | jq -r '.matched_rules[0].rule_id // "no_tldr_bait"' 2>/dev/null)" + echo "BLOCKED: TL;DR/summary bait at message end." >&2 + echo "Matched rule: $RULE" >&2 + echo "" >&2 + echo "Repair guidance:" >&2 + echo "- Drop the TL;DR / In summary / Bottom line trailer; power users already read the body." >&2 + echo "- If the message is long enough to need a summary, lead with it; do not tail it." >&2 + exit 2 + fi + if [ "$DECISION" = "pass" ]; then + exit 0 + fi + fi + fi +fi + json_get() { printf '%s' "$INPUT" | jq -r "$1 // empty" 2>/dev/null || true; } block() { echo "BLOCKED: $1" >&2