diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 30eea63..e29a9ea 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,7 +43,9 @@ production, data-loss. Detail: EXTENDED + `.claude/OLLAMA.md`. - **SSH:** system OpenSSH (`C:\Windows\System32\OpenSSH\ssh.exe`), never Git-for-Windows SSH. - **Data integrity:** never placeholder/fake data — check vault, wiki, or ask. - **Hard-to-reverse or outward-facing actions:** confirm first (per-action, per-session). -- **Error logging:** when a task errors during execution (failed command, skill, or tool call), append a brief entry to `errorlog.md` (repo root) — date, machine (from `identity.json`), the command/skill that failed, and the error. One line or two; just enough to spot patterns. These feed skill + harness improvements. Don't log expected/handled conditions, only genuine failures. +- **Error logging (mandatory, all skills):** when a task or skill hits a GENUINE functional error during execution (failed command, API/auth failure, unexpected API response, tool call), record it to `errorlog.md` (repo root) via the canonical helper — never hand-format: `bash .claude/scripts/log-skill-error.sh "" "" [--context "k=v ..."]`. It stamps UTC date + machine (from `identity.json`) and inserts in the standard `YYYY-MM-DD | MACHINE | skill | error` format (newest on top) for later skill **linting**, and soft-fails so it never breaks the caller. **Every skill MUST call it at its failure branches**; you (main loop) call it after any skill/command genuinely fails. Do NOT log expected/handled conditions (no search matches, no unread messages, a user declining a prompt) — only real failures worth spotting a pattern. Python skills shell out to the same helper. +- **Log user corrections too (`--correction`):** when the user CORRECTS an improper assumption or wrong approach you took (e.g. "don't use INKY unless onboarded", "EXO already has Mail.Send", "I don't need an exact match"), log it: `bash .claude/scripts/log-skill-error.sh "" "assumed X; correct is Y" --correction`. These are the highest-value entries — they surface recurring bad assumptions so we can train them out of the system. If the correction is a durable preference, ALSO save it as a `feedback` memory (the two are complementary: memory fixes future behavior, errorlog tracks the pattern for linting). +- **Log preventable friction too (`--friction`):** any time you waste tokens on a preventable, repeatable self-inflicted error — harness/env/tool misuse (Git-Bash `/tmp` path mismatch, shell env not persisting between Bash calls, passing huge args on the command line, PowerShell var case-collisions, etc.) — log it: `bash .claude/scripts/log-skill-error.sh "" "what wasted tokens + the fix" --friction [--context "ref="]`. **If it repeats something already in memory or CLAUDE.md, that's the highest-value entry** — it means a rule/memory isn't working; cite the ref. This log is the corpus we lint to build better CLAUDE.md rules and to clean stale/misleading memory. Goal: stop paying twice for the same mistake. - **Windows:** ensure `bash` resolves to Git-for-Windows MSYS bash, not the WSL stub; write `.claude/current-mode` with a relative/forward-slash path only (never a backslash Windows path). Detail + fixes: EXTENDED. diff --git a/.claude/scripts/discord-dm.sh b/.claude/scripts/discord-dm.sh index aa38181..ea48133 100644 --- a/.claude/scripts/discord-dm.sh +++ b/.claude/scripts/discord-dm.sh @@ -78,7 +78,11 @@ if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then ENV_FILE="$ROOT/projects/discord-bot/.env" [ -f "$ENV_FILE" ] && TOKEN="$(grep -iE '^[[:space:]]*DISCORD_TOKEN[[:space:]]*=' "$ENV_FILE" | head -1 | sed -E 's/^[^=]*=[[:space:]]*//; s/^["'"'"']//; s/["'"'"'][[:space:]]*$//')" fi -if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then echo "[ERROR] no bot token (vault + .env both empty)" >&2; exit 2; fi +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "[ERROR] no bot token (vault + .env both empty)" >&2 + bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "no Discord bot token (vault projects/discord-bot/bot-token + .env both empty)" >/dev/null 2>&1 + exit 2 +fi auth=(-H "Authorization: Bot ${TOKEN}" -H "Content-Type: application/json" -H "User-Agent: ${UA}") @@ -87,7 +91,11 @@ if [ "$MODE" = "dm" ]; then DM="$(printf '%s' "$(jq -nc --arg r "$TARGET" '{recipient_id:$r}')" | \ curl -s -m 15 "${auth[@]}" -X POST "$API/users/@me/channels" --data-binary @-)" CHID="$(printf '%s' "$DM" | jq -r '.id // empty' 2>/dev/null)" - if [ -z "$CHID" ]; then echo "[ERROR] could not open DM channel for $LABEL: $DM" >&2; exit 3; fi + if [ -z "$CHID" ]; then + echo "[ERROR] could not open DM channel for $LABEL: $DM" >&2 + bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "failed to open DM channel for $LABEL" --context "resp=${DM:0:80}" >/dev/null 2>&1 + exit 3 + fi TARGET="$CHID" fi @@ -103,4 +111,5 @@ if [ "$HTTP" = "200" ]; then exit 0 fi echo "[ERROR] discord-dm: Discord returned ${HTTP:-no-response} — ${BODY}" >&2 +bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "Discord send to $LABEL failed" --context "http=${HTTP:-none} resp=${BODY:0:80}" >/dev/null 2>&1 exit 3 diff --git a/.claude/scripts/log-skill-error.sh b/.claude/scripts/log-skill-error.sh new file mode 100644 index 0000000..01810af --- /dev/null +++ b/.claude/scripts/log-skill-error.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# log-skill-error.sh — append an entry to errorlog.md in the canonical format, +# for later linting that feeds skill fixes, CLAUDE.md rules, and memory cleanup. +# +# Despite the name this is the GENERAL agent error/correction/friction log — it +# captures three things (see --type below): +# 1. skill/command FUNCTIONAL failures (API/auth/unexpected-response/bad-exit) +# 2. user CORRECTIONS of an improper assumption I made (--correction) +# 3. preventable self-inflicted FRICTION that wasted tokens (--friction) — +# harness/env/tool misuse, ESPECIALLY a repeat of an already-documented +# gotcha (that means a rule or memory isn't working and needs strengthening) +# +# Do NOT call it for expected/handled conditions (a search with no matches, a +# "no unread messages", a user declining a prompt) — only real, preventable, +# pattern-worthy events. +# +# Usage: +# bash log-skill-error.sh "" +# echo "" | bash log-skill-error.sh +# bash log-skill-error.sh "" --context "op=send id=123 http=403" +# bash log-skill-error.sh "" --correction +# +# Categories (all feed the lint that improves skills, CLAUDE.md, and memory): +# (default) execution failure — API/auth failure, unexpected response, bad exit. +# --correction — the USER corrected an improper assumption/approach I made. +# --friction — preventable self-inflicted error that wasted tokens (harness/env/ +# tool misuse). If it repeats a documented gotcha, note it in +# --context (e.g. ref=feedback_tmp_path_windows) — that's the signal +# a rule/memory needs strengthening. +# (--type also supported; tags the error column as [].) +# bash log-skill-error.sh "" --friction --context "ref=" +# +# Writes: YYYY-MM-DD | MACHINE | | [] [ctx: ] +# (newest entry inserted at the top, just under the append marker). +# +# Soft-fail by design: this NEVER breaks the caller. Missing log, missing jq, +# empty message -> prints a [WARN] to stderr and exits 0. +set -u +ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" + +SKILL="${1:-unknown}"; shift || true +CONTEXT="" +ETYPE="" # "" / exec = execution failure; "correction" = user corrected a bad assumption +ARGS=() +while [ $# -gt 0 ]; do + case "$1" in + --context) CONTEXT="${2:-}"; shift 2;; + --type) ETYPE="${2:-}"; shift 2;; + --correction) ETYPE="correction"; shift;; + --friction) ETYPE="friction"; shift;; + *) ARGS+=("$1"); shift;; + esac +done +MSG="${ARGS[*]:-}" +if [ -z "$MSG" ] && [ ! -t 0 ]; then MSG="$(cat)"; fi +if [ -z "$MSG" ]; then echo "[WARN] log-skill-error: empty message, nothing logged" >&2; exit 0; fi + +LOG="$ROOT/errorlog.md" +if [ ! -f "$LOG" ]; then echo "[WARN] log-skill-error: $LOG not found" >&2; exit 0; fi + +DATE="$(date -u +%F)" +IDF="$ROOT/.claude/identity.json" +MACHINE="" +if command -v jq >/dev/null 2>&1 && [ -f "$IDF" ]; then + MACHINE="$(jq -r '.machine_name // .hostname // empty' "$IDF" 2>/dev/null)" +fi +[ -z "$MACHINE" ] && MACHINE="$(hostname 2>/dev/null || echo unknown)" + +# normalize whitespace/newlines so each entry is one line +MSG="$(printf '%s' "$MSG" | tr '\n' ' ' | sed 's/[[:space:]]\{1,\}/ /g; s/^ //; s/ $//')" +[ -n "$CONTEXT" ] && MSG="$MSG [ctx: $CONTEXT]" +# Tag non-execution categories at the start of the error column for easy linting +# (e.g. grep "\[correction\]" errorlog.md to surface improper-assumption patterns). +if [ -n "$ETYPE" ] && [ "$ETYPE" != "exec" ]; then MSG="[$ETYPE] $MSG"; fi +ENTRY="$DATE | $MACHINE | $SKILL | $MSG" + +MARK="" +TMP="$LOG.tmp.$$" +if awk -v entry="$ENTRY" -v mark="$MARK" ' + { print } + ($0==mark && !done) { print ""; print entry; done=1 } + END { if (!done) { print ""; print entry } } # marker missing -> append at end +' "$LOG" > "$TMP" 2>/dev/null && mv "$TMP" "$LOG" 2>/dev/null; then + echo "[OK] logged skill error to errorlog.md ($SKILL)" +else + rm -f "$TMP" 2>/dev/null + echo "[WARN] log-skill-error: could not write $LOG" >&2 +fi +exit 0 diff --git a/.claude/scripts/post-bot-alert.sh b/.claude/scripts/post-bot-alert.sh index 9b7dacf..2197a36 100644 --- a/.claude/scripts/post-bot-alert.sh +++ b/.claude/scripts/post-bot-alert.sh @@ -86,4 +86,8 @@ if [ "$HTTP" = "200" ]; then fi echo "[WARNING] post-bot-alert: Discord returned ${HTTP:-no-response} — ${BODY}" >&2 +# Log the Discord POST failure (non-200 / unreachable) once. Do NOT route this +# through post-bot-alert itself — that would recurse; log-skill-error.sh only +# writes to errorlog.md. Soft-fail preserved: this never changes the exit 0. +bash "$ROOT/.claude/scripts/log-skill-error.sh" "post-bot-alert" "Discord POST failed (non-200/unreachable)" --context "channel=${CHANNEL_NAME} http=${HTTP:-none} resp=${BODY:0:80}" >/dev/null 2>&1 || true exit 0 diff --git a/.claude/scripts/rmm-auth.sh b/.claude/scripts/rmm-auth.sh index ffbb165..a3f8074 100755 --- a/.claude/scripts/rmm-auth.sh +++ b/.claude/scripts/rmm-auth.sh @@ -11,19 +11,27 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" IDENTITY_FILE="$REPO_ROOT/.claude/identity.json" +# Functional-error logger. MUST stay silent on stdout (this script's stdout is +# eval'd by the caller) — log-skill-error.sh prints only to stderr, and we +# redirect everything to /dev/null to be safe. +_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "rmm-auth" "$@" >/dev/null 2>&1 || true; } + if [ ! -f "$IDENTITY_FILE" ]; then + _logerr "identity.json not found; RMM auth cannot resolve vault" --context "path=$IDENTITY_FILE" echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] identity.json not found' >&2" exit 1 fi VAULT_PATH=$(jq -r '.vault_path // empty' "$IDENTITY_FILE") if [ -z "$VAULT_PATH" ]; then + _logerr "vault_path not in identity.json; RMM auth failed" --context "path=$IDENTITY_FILE" echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] vault_path not in identity.json' >&2" exit 1 fi VAULT_SH="$VAULT_PATH/scripts/vault.sh" if [ ! -f "$VAULT_SH" ]; then + _logerr "vault.sh not found at resolved vault_path; RMM auth failed" --context "path=$VAULT_SH" echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] vault.sh not found at $VAULT_SH' >&2" exit 1 fi @@ -35,6 +43,7 @@ RMM_EMAIL=$(bash "$VAULT_SH" get-field infrastructure/gururmm-server.sops.yaml c RMM_PASS=$(bash "$VAULT_SH" get-field infrastructure/gururmm-server.sops.yaml credentials.gururmm-api.admin-password 2>/dev/null) if [ -z "$RMM_EMAIL" ] || [ -z "$RMM_PASS" ]; then + _logerr "vault read of GuruRMM API credentials failed (empty email/password)" --context "entry=infrastructure/gururmm-server.sops.yaml" echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] Failed to get RMM credentials from vault' >&2" exit 1 fi @@ -45,6 +54,7 @@ JWT=$(curl -s -X POST "$RMM_URL/api/auth/login" -H "Content-Type: application/js TOKEN=$(echo "$JWT" | jq -r '.token // empty') if [ -z "$TOKEN" ]; then + _logerr "RMM login failed (no token returned from /api/auth/login)" --context "url=$RMM_URL resp=${JWT:0:80}" echo "export TOKEN=''; export RMM=''; export REPO_ROOT=''; echo '[ERROR] RMM login failed: $JWT' >&2" exit 1 fi diff --git a/.claude/scripts/rmm-search.sh b/.claude/scripts/rmm-search.sh index ae9fd47..ba14b3f 100644 --- a/.claude/scripts/rmm-search.sh +++ b/.claude/scripts/rmm-search.sh @@ -37,9 +37,17 @@ if [ -z "$QUERY" ] && [ -z "$CLIENT" ] && [ "$LISTC" -eq 0 ]; then fi eval "$(bash "$ROOT/.claude/scripts/rmm-auth.sh" 2>/dev/null)" >/dev/null -if [ -z "${TOKEN:-}" ] || [ -z "${RMM:-}" ]; then echo "[ERROR] RMM auth failed (see rmm-auth.sh)" >&2; exit 1; fi +if [ -z "${TOKEN:-}" ] || [ -z "${RMM:-}" ]; then + echo "[ERROR] RMM auth failed (see rmm-auth.sh)" >&2 + bash "$ROOT/.claude/scripts/log-skill-error.sh" "rmm-search" "RMM auth failed via rmm-auth.sh (no TOKEN/RMM)" >/dev/null 2>&1 + exit 1 +fi AGENTS=$(curl -s "$RMM/api/agents" -H "Authorization: Bearer $TOKEN") -if [ -z "$AGENTS" ] || [ "${AGENTS:0:1}" != "[" ]; then echo "[ERROR] could not fetch agents: ${AGENTS:0:160}" >&2; exit 1; fi +if [ -z "$AGENTS" ] || [ "${AGENTS:0:1}" != "[" ]; then + echo "[ERROR] could not fetch agents: ${AGENTS:0:160}" >&2 + bash "$ROOT/.claude/scripts/log-skill-error.sh" "rmm-search" "GET /api/agents returned non-array/empty" --context "resp=${AGENTS:0:80}" >/dev/null 2>&1 + exit 1 +fi # Pipe agents on stdin (payload too large for argv on Windows); flags via env. printf '%s' "$AGENTS" | QUERY="$QUERY" CLIENT="$CLIENT" ONLINE="$ONLINE" JSON="$JSON" LISTC="$LISTC" LIMIT="$LIMIT" \ diff --git a/.claude/scripts/run-onboarding-diagnostic.sh b/.claude/scripts/run-onboarding-diagnostic.sh index c000ba2..ada1b1e 100644 --- a/.claude/scripts/run-onboarding-diagnostic.sh +++ b/.claude/scripts/run-onboarding-diagnostic.sh @@ -37,6 +37,11 @@ PROBE="$SCRIPT_DIR/onboarding-diagnostic.ps1" ALERT="$REPO_ROOT/.claude/scripts/post-bot-alert.sh" RMM="http://172.16.3.30:3001" +# Functional-error logger (skill name "rmm-diagnose"). Logs genuine operational +# failures (auth, vault, dispatch) — NOT the RED/AMBER/GREEN diagnostic grade, +# which is a normal by-design result. Soft-fails; never breaks the run. +_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "rmm-diagnose" "$@" >/dev/null 2>&1 || true; } + if [ ! -f "$PROBE" ]; then echo "[ERROR] Probe script not found: $PROBE" >&2 exit 1 @@ -65,6 +70,7 @@ RMM_PASS="$(bash "$VAULT" get-field infrastructure/gururmm-server.sops.yaml cred if [ -z "$RMM_EMAIL" ] || [ -z "$RMM_PASS" ] || [ "$RMM_EMAIL" = "null" ]; then echo "[ERROR] Could not read GuruRMM credentials from vault (infrastructure/gururmm-server.sops.yaml)" >&2 + _logerr "vault read of GuruRMM credentials failed (empty/null)" --context "entry=infrastructure/gururmm-server.sops.yaml" exit 1 fi @@ -75,6 +81,7 @@ TOKEN="$(curl -s -m 30 -X POST "$RMM/api/auth/login" \ if [ -z "$TOKEN" ]; then echo "[ERROR] RMM login failed (no token returned)" >&2 + _logerr "RMM login failed (no token returned from /api/auth/login)" --context "url=$RMM" exit 1 fi echo "[OK] Authenticated to GuruRMM" @@ -85,6 +92,7 @@ echo "[OK] Authenticated to GuruRMM" AGENTS="$(curl -s -m 30 "$RMM/api/agents" -H "Authorization: Bearer $TOKEN")" if [ -z "$AGENTS" ] || ! echo "$AGENTS" | jq -e 'type=="array"' >/dev/null 2>&1; then echo "[ERROR] Could not retrieve agent list" >&2 + _logerr "GET /api/agents returned non-array/empty" --context "resp=${AGENTS:0:80}" exit 1 fi @@ -263,6 +271,7 @@ PS CH_STATUS="$(echo "$CH_RESULT" | jq -r '.status')" if [ "$CH_STATUS" != "completed" ]; then echo "[ERROR] Chunk $IDX upload failed: status=$CH_STATUS stderr=$(echo "$CH_RESULT" | jq -r '.stderr' | head -c 200)" >&2 + _logerr "probe chunk upload failed" --context "host=$AGENT_HOST idx=$IDX/$N_CHUNKS status=$CH_STATUS" exit 1 fi echo "[OK] Uploaded chunk $IDX/$N_CHUNKS" @@ -288,7 +297,7 @@ try { } PS -RESULT="$(dispatch_one "$RUN_SCRIPT" "$EXEC_TIMEOUT")" || { echo "[ERROR] Probe execution dispatch failed" >&2; exit 1; } +RESULT="$(dispatch_one "$RUN_SCRIPT" "$EXEC_TIMEOUT")" || { echo "[ERROR] Probe execution dispatch failed" >&2; _logerr "probe execution dispatch failed" --context "host=$AGENT_HOST agent=$AGENT_ID"; exit 1; } CMD_ID="$(cat "$WORK_DIR/last_cmd_id" 2>/dev/null || echo unknown)" FINAL_STATUS="$(echo "$RESULT" | jq -r '.status // empty')" @@ -317,6 +326,7 @@ if [ -z "$DIAG_JSON" ] || ! echo "$DIAG_JSON" | jq -e '.host' >/dev/null 2>&1; t fi echo "--- stdout (first 60 lines) ---" >&2 printf '%s\n' "$STDOUT" | head -60 >&2 + _logerr "could not extract valid diagnostic JSON from probe output" --context "host=$AGENT_HOST status=$FINAL_STATUS exit=$EXIT_CODE" exit 1 fi diff --git a/.claude/scripts/vault.sh b/.claude/scripts/vault.sh index 51359bc..395a693 100755 --- a/.claude/scripts/vault.sh +++ b/.claude/scripts/vault.sh @@ -16,9 +16,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CLAUDETOOLS_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json" +_logerr() { bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "vault" "$@" >/dev/null 2>&1 || true; } + if [[ ! -f "$IDENTITY_FILE" ]]; then echo "[ERROR] .claude/identity.json not found at $IDENTITY_FILE" >&2 echo " Run onboarding to create it, or add vault_path manually." >&2 + _logerr "identity.json not found; vault read cannot resolve vault_path" --context "path=$IDENTITY_FILE" exit 1 fi @@ -40,6 +43,7 @@ fi if [[ -z "$VAULT_ROOT" ]]; then echo "[ERROR] vault_path not set in $IDENTITY_FILE" >&2 echo " Add: \"vault_path\": \"/path/to/vault\"" >&2 + _logerr "vault_path not set in identity.json; vault read failed" --context "path=$IDENTITY_FILE" exit 1 fi @@ -48,6 +52,7 @@ REAL_VAULT_SH="$VAULT_ROOT/scripts/vault.sh" if [[ ! -f "$REAL_VAULT_SH" ]]; then echo "[ERROR] vault.sh not found at $REAL_VAULT_SH" >&2 echo " Check vault_path in $IDENTITY_FILE" >&2 + _logerr "real vault.sh not found at resolved vault_path; vault read failed" --context "path=$REAL_VAULT_SH" exit 1 fi diff --git a/.claude/skills/1password/scripts/env_from_op.sh b/.claude/skills/1password/scripts/env_from_op.sh index b74a905..103dd4b 100755 --- a/.claude/skills/1password/scripts/env_from_op.sh +++ b/.claude/skills/1password/scripts/env_from_op.sh @@ -14,6 +14,10 @@ set -euo pipefail +# Functional-error logger (skill name "1password"); 4 levels up to the ClaudeTools repo. +CLAUDETOOLS_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +_logerr() { bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "1password" "$@" >/dev/null 2>&1 || true; } + VAULT="" ITEM="" OUTPUT=".env" @@ -35,6 +39,7 @@ done # Check op is available if ! command -v op &>/dev/null; then echo "❌ 1Password CLI (op) not found. Install: https://developer.1password.com/docs/cli/get-started/" + _logerr "op CLI not found on PATH" exit 1 fi diff --git a/.claude/skills/1password/scripts/store-mcp-credentials.sh b/.claude/skills/1password/scripts/store-mcp-credentials.sh index 7ed5557..99918b9 100755 --- a/.claude/skills/1password/scripts/store-mcp-credentials.sh +++ b/.claude/skills/1password/scripts/store-mcp-credentials.sh @@ -23,6 +23,10 @@ set -euo pipefail +# Functional-error logger (skill name "1password"); 4 levels up to the ClaudeTools repo. +CLAUDETOOLS_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +_logerr() { bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "1password" "$@" >/dev/null 2>&1 || true; } + VAULT="Dev" ITEM="" UPDATE=false @@ -88,14 +92,16 @@ ALL_FIELDS=("${OP_FIELDS[@]+"${OP_FIELDS[@]}"}" "${SECRET_VALUES[@]+"${SECRET_VA echo "Saving to 1Password..." if $UPDATE; then - op item edit "$ITEM" --vault "$VAULT" "${ALL_FIELDS[@]}" + op item edit "$ITEM" --vault "$VAULT" "${ALL_FIELDS[@]}" \ + || { rc=$?; _logerr "op item edit failed (update MCP creds)" --context "item=$ITEM vault=$VAULT rc=$rc"; exit $rc; } echo "" echo "✅ Updated '$ITEM' in vault '$VAULT'" else # Try create, fall back to update if already exists if op item get "$ITEM" --vault "$VAULT" &>/dev/null 2>&1; then echo " Item already exists — updating instead..." - op item edit "$ITEM" --vault "$VAULT" "${ALL_FIELDS[@]}" + op item edit "$ITEM" --vault "$VAULT" "${ALL_FIELDS[@]}" \ + || { rc=$?; _logerr "op item edit failed (update MCP creds)" --context "item=$ITEM vault=$VAULT rc=$rc"; exit $rc; } echo "" echo "✅ Updated '$ITEM' in vault '$VAULT'" else @@ -103,7 +109,8 @@ else --category API_CREDENTIAL \ --title "$ITEM" \ --vault "$VAULT" \ - "${ALL_FIELDS[@]}" + "${ALL_FIELDS[@]}" \ + || { rc=$?; _logerr "op item create failed (MCP creds)" --context "item=$ITEM vault=$VAULT rc=$rc"; exit $rc; } echo "" echo "✅ Created '$ITEM' in vault '$VAULT'" fi diff --git a/.claude/skills/1password/scripts/store_secret.sh b/.claude/skills/1password/scripts/store_secret.sh index d96eeb6..47c459f 100755 --- a/.claude/skills/1password/scripts/store_secret.sh +++ b/.claude/skills/1password/scripts/store_secret.sh @@ -9,6 +9,10 @@ set -euo pipefail +# Functional-error logger (skill name "1password"); 4 levels up to the ClaudeTools repo. +CLAUDETOOLS_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +_logerr() { bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "1password" "$@" >/dev/null 2>&1 || true; } + TITLE="" FIELD="credential" VALUE="" @@ -67,7 +71,8 @@ VAULT_FLAG="" if $UPDATE; then echo "Updating '${FIELD}' in '${TITLE}'..." - op item edit "$TITLE" $VAULT_FLAG "${FIELD}[password]=${VALUE}" + op item edit "$TITLE" $VAULT_FLAG "${FIELD}[password]=${VALUE}" \ + || { rc=$?; _logerr "op item edit failed (update secret)" --context "item=$TITLE field=$FIELD vault=${VAULT:-default} rc=$rc"; exit $rc; } echo "✅ Updated '${FIELD}' in '${TITLE}'" else echo "Creating '${TITLE}' in 1Password..." @@ -76,7 +81,8 @@ else --title "$TITLE" \ $VAULT_FLAG \ "${FIELD}[password]=${VALUE}" \ - --format=json) + --format=json) \ + || { rc=$?; _logerr "op item create failed" --context "item=$TITLE category=$CATEGORY vault=${VAULT:-default} rc=$rc"; exit $rc; } ITEM_ID=$(echo "$RESULT" | jq -r '.id') VAULT_NAME=$(echo "$RESULT" | jq -r '.vault.name') diff --git a/.claude/skills/agy/scripts/ask-gemini.sh b/.claude/skills/agy/scripts/ask-gemini.sh index 079cdee..80b23c0 100644 --- a/.claude/skills/agy/scripts/ask-gemini.sh +++ b/.claude/skills/agy/scripts/ask-gemini.sh @@ -104,6 +104,8 @@ MODE="${1:-}"; shift 2>/dev/null || true TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT PF="$TMP/prompt.txt"; OUT="$TMP/out.txt"; ERR="$TMP/err.txt" REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}" +# Functional-error logger (skill name "agy"); soft-fails, never breaks the caller. +_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "agy" "$@" >/dev/null 2>&1 || true; } # gtimeout on macOS (brew coreutils), timeout elsewhere. TIMEOUT_CMD="timeout" @@ -191,6 +193,7 @@ emit_or_fail() { # print .response, or retry once on a transient empty turn, el # Auth failures won't be fixed by a retry — report immediately. if auth_failed; then echo "[$SELF] Gemini auth error — run 'gemini' interactively and choose 'Login with Google', then retry." >&2 + _logerr "gemini auth/login failure" --context "mode=$MODE" exit 1 fi # Gemini occasionally returns an empty turn (or absorbs a 429 backoff into the @@ -202,11 +205,13 @@ emit_or_fail() { # print .response, or retry once on a transient empty turn, el if [ -n "$txt" ]; then printf '%s\n' "$txt"; return 0; fi if auth_failed; then echo "[$SELF] Gemini auth error — run 'gemini' interactively and choose 'Login with Google', then retry." >&2 + _logerr "gemini auth/login failure (after retry)" --context "mode=$MODE" exit 1 fi fi echo "[$SELF] no response from gemini. stderr tail:" >&2 tail -3 "$ERR" >&2 2>/dev/null || true + _logerr "gemini returned no response (empty after retry)" --context "mode=$MODE err=$(tail -1 "$ERR" 2>/dev/null | tr -d '\n' | cut -c1-80)" exit 1 } diff --git a/.claude/skills/b2/scripts/b2.py b/.claude/skills/b2/scripts/b2.py index 26514b7..86cd698 100644 --- a/.claude/skills/b2/scripts/b2.py +++ b/.claude/skills/b2/scripts/b2.py @@ -32,12 +32,32 @@ from __future__ import annotations import argparse import json +import os +import subprocess import sys from typing import Optional from b2_client import B2Client, B2Error, RATE_PER_GB_USD, BYTES_PER_GB, BYTES_PER_GIB +def _log_skill_error(skill, msg, context=""): + """Soft-fail: append a functional-error entry to errorlog.md (never throws).""" + try: + root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") + if not os.path.exists(h): + return + a = ["bash", h, skill, msg] + if context: + a += ["--context", context] + subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10) + except Exception: + pass + + def _emit(obj, as_json: bool, table_fn=None) -> None: if as_json or table_fn is None: print(json.dumps(obj, indent=2, default=str)) @@ -757,6 +777,10 @@ def main(argv=None) -> int: return rc if isinstance(rc, int) else 0 except B2Error as exc: print(f"[ERROR] {exc}", file=sys.stderr) + _log_skill_error("b2", f"{exc}", + context=f"cmd={getattr(args, 'command', '?')}" + + (f" http={exc.status}" if exc.status else "") + + (f" code={exc.code}" if exc.code else "")) return 1 except KeyboardInterrupt: return 130 diff --git a/.claude/skills/bitdefender/scripts/gz.py b/.claude/skills/bitdefender/scripts/gz.py index be18a01..6b8e23a 100644 --- a/.claude/skills/bitdefender/scripts/gz.py +++ b/.claude/skills/bitdefender/scripts/gz.py @@ -37,11 +37,31 @@ from __future__ import annotations import argparse import dataclasses import json +import os +import subprocess import sys from gz_client import GravityZoneClient, GravityZoneError, GZEndpointSummary +def _log_skill_error(skill, msg, context=""): + """Soft-fail: append a functional-error entry to errorlog.md (never throws).""" + try: + root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") + if not os.path.exists(h): + return + a = ["bash", h, skill, msg] + if context: + a += ["--context", context] + subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10) + except Exception: + pass + + def _emit(obj, as_json: bool, table_fn=None) -> None: if as_json or table_fn is None: print(json.dumps(obj, indent=2, default=_json_default)) @@ -554,6 +574,8 @@ def main(argv=None) -> int: return rc if isinstance(rc, int) else 0 except GravityZoneError as exc: print(f"[ERROR] {exc}", file=sys.stderr) + _log_skill_error("bitdefender", f"{exc}", + context=f"cmd={getattr(args, 'command', '?')}") return 1 except KeyboardInterrupt: return 130 diff --git a/.claude/skills/coord/scripts/coord.py b/.claude/skills/coord/scripts/coord.py index d607480..fd2a2d5 100644 --- a/.claude/skills/coord/scripts/coord.py +++ b/.claude/skills/coord/scripts/coord.py @@ -21,7 +21,25 @@ Usage: coord.py lock release coord.py lock list [--project KEY] """ -import sys, os, json, argparse, urllib.request, urllib.error, urllib.parse +import sys, os, json, argparse, subprocess, urllib.request, urllib.error, urllib.parse + + +def _log_skill_error(skill, msg, context=""): + """Soft-fail: append a functional-error entry to errorlog.md (never throws).""" + try: + root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") + if not os.path.exists(h): + return + a = ["bash", h, skill, msg] + if context: + a += ["--context", context] + subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10) + except Exception: + pass def find_identity(): @@ -73,6 +91,10 @@ def call(method, path, body=None, query=None): def die(st, resp, ok=(200, 201)): if st not in ok: print(f"[coord] ERROR HTTP {st}: {json.dumps(resp)[:500]}", file=sys.stderr) + _log_skill_error( + "coord", f"coord API call failed (HTTP {st})", + context=f"http={st} cmd={' '.join(sys.argv[1:3])} resp={json.dumps(resp)[:80]}", + ) sys.exit(1) diff --git a/.claude/skills/grok/scripts/ask-grok.sh b/.claude/skills/grok/scripts/ask-grok.sh index cd68149..2f101f5 100644 --- a/.claude/skills/grok/scripts/ask-grok.sh +++ b/.claude/skills/grok/scripts/ask-grok.sh @@ -83,6 +83,8 @@ WORK="$TMP/work"; mkdir -p "$WORK" PF="$TMP/prompt.txt"; OUT="$TMP/out.json" RUN_CWD="$WORK" # grok's working dir; the 'review' mode overrides to the repo so read_file can reach repo files REPO_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)}" +# Functional-error logger (skill name "grok"); soft-fails, never breaks the caller. +_logerr() { bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "grok" "$@" >/dev/null 2>&1 || true; } # run grok headless. $1=timeout secs; rest=extra flags. Reads $PF -> $OUT. # Never fails the script on grok's exit code (Cancelled is expected; we read artifacts). @@ -170,7 +172,7 @@ case "$MODE" in run_grok 180 --disable-web-search --max-turns 3 txt="$(jfield text)" if [ -n "$txt" ]; then printf '%s\n' "$txt"; else - echo "[$SELF] no text (stopReason=$(jfield stopReason)); raw: $OUT" >&2; exit 1; fi + echo "[$SELF] no text (stopReason=$(jfield stopReason)); raw: $OUT" >&2; _logerr "grok returned no text" --context "mode=$MODE stopReason=$(jfield stopReason)"; exit 1; fi ;; image) [ -z "${1:-}" ] && { echo "usage: $SELF image \"\" [out.png]" >&2; exit 2; } @@ -180,7 +182,7 @@ case "$MODE" in sid="$(jfield sessionId)"; art="$(find_artifact "$sid" images)" if [ -n "$art" ] && [ -f "$art" ]; then cp -f "$art" "$out" echo "[$SELF] image OK -> $out (session $sid)" - else echo "[$SELF] no image artifact (session=$sid, stopReason=$(jfield stopReason))" >&2; exit 1; fi + else echo "[$SELF] no image artifact (session=$sid, stopReason=$(jfield stopReason))" >&2; _logerr "grok produced no image artifact" --context "session=$sid stopReason=$(jfield stopReason)"; exit 1; fi ;; video) [ -z "${1:-}" ] || [ -z "${2:-}" ] && { echo "usage: $SELF video \"\" [out.mp4]" >&2; exit 2; } @@ -192,7 +194,7 @@ case "$MODE" in sid="$(jfield sessionId)"; art="$(find_artifact "$sid" videos)" if [ -n "$art" ] && [ -f "$art" ]; then cp -f "$art" "$out" echo "[$SELF] video OK -> $out (session $sid)" - else echo "[$SELF] no video artifact (session=$sid, stopReason=$(jfield stopReason))" >&2; exit 1; fi + else echo "[$SELF] no video artifact (session=$sid, stopReason=$(jfield stopReason))" >&2; _logerr "grok produced no video artifact" --context "session=$sid stopReason=$(jfield stopReason)"; exit 1; fi ;; xsearch) [ -z "${1:-}" ] && { echo "usage: $SELF xsearch \"\"" >&2; exit 2; } @@ -200,7 +202,7 @@ case "$MODE" in run_grok 150 --max-turns 6 txt="$(jfield text)" if [ -n "$txt" ]; then printf '%s\n' "$txt"; else - echo "[$SELF] no result (stopReason=$(jfield stopReason))" >&2; exit 1; fi + echo "[$SELF] no result (stopReason=$(jfield stopReason))" >&2; _logerr "grok xsearch returned no result" --context "mode=xsearch stopReason=$(jfield stopReason)"; exit 1; fi ;; review|file) [ -z "${1:-}" ] && { echo "usage: $SELF review [instructions]" >&2; exit 2; } @@ -233,7 +235,7 @@ case "$MODE" in fi fi if [ -n "$txt" ]; then printf '%s\n' "$txt"; else - echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi + echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; _logerr "grok review returned no result" --context "mode=$MODE session=$(jfield sessionId) stopReason=$(jfield stopReason)"; exit 1; fi ;; review-files) # review-files [-i "instructions"] [file ...] @@ -283,7 +285,7 @@ case "$MODE" in fi fi if [ -n "$txt" ]; then printf '%s\n' "$txt"; else - echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi + echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; _logerr "grok review returned no result" --context "mode=$MODE session=$(jfield sessionId) stopReason=$(jfield stopReason)"; exit 1; fi ;; review-diff) # review-diff [-C ] [-i "instructions"] [-- ] @@ -328,7 +330,7 @@ case "$MODE" in fi fi if [ -n "$txt" ]; then printf '%s\n' "$txt"; else - echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; exit 1; fi + echo "[$SELF] no result (session=$(jfield sessionId), stopReason=$(jfield stopReason))" >&2; _logerr "grok review returned no result" --context "mode=$MODE session=$(jfield sessionId) stopReason=$(jfield stopReason)"; exit 1; fi ;; raw) "$GROK" "$@" diff --git a/.claude/skills/mailprotector/scripts/mp.py b/.claude/skills/mailprotector/scripts/mp.py index 495d08e..68fa2cf 100644 --- a/.claude/skills/mailprotector/scripts/mp.py +++ b/.claude/skills/mailprotector/scripts/mp.py @@ -37,11 +37,31 @@ from __future__ import annotations import argparse import json +import os +import subprocess import sys from mp_client import MailprotectorClient, MailprotectorError, VALID_SCOPES +def _log_skill_error(skill, msg, context=""): + """Soft-fail: append a functional-error entry to errorlog.md (never throws).""" + try: + root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") + if not os.path.exists(h): + return + a = ["bash", h, skill, msg] + if context: + a += ["--context", context] + subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10) + except Exception: + pass + + def _emit(obj) -> None: print(json.dumps(obj, indent=2, ensure_ascii=False, default=str)) @@ -314,6 +334,8 @@ def main(argv=None) -> int: p.error(f"unknown command {args.cmd}") except MailprotectorError as exc: print(f"[ERROR] {exc}", file=sys.stderr) + _log_skill_error("mailprotector", f"{exc}", + context=f"cmd={getattr(args, 'cmd', '?')}") return 1 return 0 diff --git a/.claude/skills/onboard365/scripts/onboard365.sh b/.claude/skills/onboard365/scripts/onboard365.sh index cefdcb8..a3ed07e 100644 --- a/.claude/skills/onboard365/scripts/onboard365.sh +++ b/.claude/skills/onboard365/scripts/onboard365.sh @@ -20,6 +20,7 @@ set -euo pipefail TENANT_ADMIN_APPID="709e6eed-0711-4875-9c44-2d3518c47063" CONSENT_BASE="https://login.microsoftonline.com" CONSENT_REDIRECT="https://azcomputerguru.com" +__ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" # ── Locate the reused remediation-tool scripts ──────────────────────────────── # Prefer the applied global copy (stable path on every fleet machine); fall back @@ -43,6 +44,7 @@ RT="$(find_rtool)" || { echo "[ERROR] remediation-tool scripts not found." >&2 echo " Expected: \$HOME/.claude/skills/remediation-tool/scripts/onboard-tenant.sh" >&2 echo " Run a repo sync, or check identity.json.claudetools_root." >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "onboard365" "onboard365: remediation-tool scripts not found (onboard-tenant.sh missing on this machine)" >/dev/null 2>&1 || true exit 3 } diff --git a/.claude/skills/packetdial/scripts/ns.py b/.claude/skills/packetdial/scripts/ns.py index f3431bb..3f5f2e5 100644 --- a/.claude/skills/packetdial/scripts/ns.py +++ b/.claude/skills/packetdial/scripts/ns.py @@ -34,11 +34,31 @@ from __future__ import annotations import argparse import json +import os +import subprocess import sys from ns_client import NetSapiensClient, PacketDialError +def _log_skill_error(skill, msg, context=""): + """Soft-fail: append a functional-error entry to errorlog.md (never throws).""" + try: + root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") + if not os.path.exists(h): + return + a = ["bash", h, skill, msg] + if context: + a += ["--context", context] + subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10) + except Exception: + pass + + def _emit(obj) -> None: print(json.dumps(obj, indent=2, ensure_ascii=False, default=str)) @@ -161,6 +181,8 @@ def main(argv=None) -> int: p.error(f"unknown command {args.cmd}") except PacketDialError as exc: print(f"[ERROR] {exc}", file=sys.stderr) + _log_skill_error("packetdial", f"{exc}", + context=f"cmd={getattr(args, 'cmd', '?')}") return 1 return 0 diff --git a/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh b/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh index df2ea72..4b4c4a6 100644 --- a/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh +++ b/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh @@ -86,8 +86,10 @@ process_one() { case "$rc" in 201) echo "ASSIGNED (Exchange Admin -> Exchange Operator SP)" ;; 400) if echo "$body" | grep -qiE 'conflicting object|already (exist|present)'; then echo "OK (already assigned)" - else echo "ERROR (HTTP 400: $(echo "$body" | jqr '.error.message // .' | head -c 120))"; fi ;; - *) echo "ERROR (HTTP $rc: $(echo "$body" | jqr '.error.message // .' | head -c 120))" ;; + else echo "ERROR (HTTP 400: $(echo "$body" | jqr '.error.message // .' | head -c 120))" + bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "assign-exchange-role: role assignment POST failed" --context "tenant=$tgt http=400 msg=$(echo "$body" | jqr '.error.message // .' | head -c 80)" >/dev/null 2>&1 || true; fi ;; + *) echo "ERROR (HTTP $rc: $(echo "$body" | jqr '.error.message // .' | head -c 120))" + bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "assign-exchange-role: role assignment POST failed" --context "tenant=$tgt http=$rc msg=$(echo "$body" | jqr '.error.message // .' | head -c 80)" >/dev/null 2>&1 || true ;; esac } @@ -95,7 +97,7 @@ echo "=== assign-exchange-role [mode=$MODE] ===" echo "Role: Exchange Administrator ($EXCH_ADMIN_TEMPLATE) -> SP: Exchange Operator ($EXCHANGE_OP_APPID)" echo "------------------------------------------------------------------------" if [ "$TARGET" = "--all" ]; then - [ -f "$TENANTS_MD" ] || { echo "[ERROR] tenants.md not found: $TENANTS_MD" >&2; exit 66; } + [ -f "$TENANTS_MD" ] || { echo "[ERROR] tenants.md not found: $TENANTS_MD" >&2; bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "assign-exchange-role: --all run but references/tenants.md not found" --context "path=$TENANTS_MD" >/dev/null 2>&1 || true; exit 66; } # extract tenant GUIDs from the markdown table (column 3) grep -oE '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' "$TENANTS_MD" \ | sort -u | while read -r tid; do process_one "$tid"; done diff --git a/.claude/skills/remediation-tool/scripts/get-token.sh b/.claude/skills/remediation-tool/scripts/get-token.sh index 70e734c..d0a31df 100755 --- a/.claude/skills/remediation-tool/scripts/get-token.sh +++ b/.claude/skills/remediation-tool/scripts/get-token.sh @@ -239,6 +239,7 @@ case "$AUTH_OVERRIDE" in if [[ -z "$CERT_X5T" || -z "$CERT_KEY_B64" ]]; then echo "ERROR: REMEDIATION_AUTH=cert but cert fields missing in vault ($VAULT_PATH)" >&2 echo " Required fields under credentials: cert_thumbprint_b64url, cert_private_key_pem_b64" >&2 + bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "get-token: cert auth forced but cert fields missing in vault" --context "tier=$TIER vault=$VAULT_PATH" >/dev/null 2>&1 || true exit 4 fi AUTH_METHOD="cert" @@ -251,6 +252,7 @@ case "$AUTH_OVERRIDE" in if [[ -z "$CLIENT_SECRET" ]]; then echo "ERROR: REMEDIATION_AUTH=secret but client_secret missing in vault ($VAULT_PATH)" >&2 echo " Check field: credentials.client_secret (or credentials.credential for older entries)" >&2 + bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "get-token: secret auth forced but client_secret missing in vault" --context "tier=$TIER vault=$VAULT_PATH" >/dev/null 2>&1 || true exit 4 fi AUTH_METHOD="secret" @@ -269,6 +271,7 @@ case "$AUTH_OVERRIDE" in echo "ERROR: no usable credential found in $VAULT_PATH" >&2 echo " Need either credentials.cert_thumbprint_b64url + credentials.cert_private_key_pem_b64," >&2 echo " or credentials.client_secret (or legacy credentials.credential)." >&2 + bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "get-token: no usable credential (cert or client_secret) in vault" --context "tier=$TIER vault=$VAULT_PATH" >/dev/null 2>&1 || true exit 4 fi AUTH_METHOD="secret" @@ -336,6 +339,7 @@ PY if [[ $ASSERT_RC -ne 0 || -z "$CLIENT_ASSERTION" ]]; then echo "ERROR: failed to build client_assertion JWT" >&2 [[ -n "$CLIENT_ASSERTION" ]] && echo "$CLIENT_ASSERTION" >&2 + bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "get-token: failed to build client_assertion JWT (cert auth)" --context "tier=$TIER rc=$ASSERT_RC" >/dev/null 2>&1 || true exit 4 fi @@ -371,11 +375,13 @@ if [[ -z "$TOKEN" ]]; then echo " After the admin accepts, run onboard-tenant.sh to assign required directory roles:" >&2 SCRIPT_DIR_ERR="$(dirname "${BASH_SOURCE[0]}")" echo " bash ${SCRIPT_DIR_ERR}/onboard-tenant.sh ${TARGET}" >&2 + bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "get-token: AADSTS7000229 — app not consented in tenant" --context "tenant=$TENANT_ID tier=$TIER auth=$AUTH_METHOD" >/dev/null 2>&1 || true exit 5 fi echo "ERROR: token request failed (tenant=$TENANT_ID tier=$TIER auth=$AUTH_METHOD)" >&2 echo "$RESP" >&2 + bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "get-token: token request failed (no access_token)" --context "tenant=$TENANT_ID tier=$TIER auth=$AUTH_METHOD err=${ERROR_CODE:-none}" >/dev/null 2>&1 || true exit 5 fi diff --git a/.claude/skills/remediation-tool/scripts/onboard-tenant.sh b/.claude/skills/remediation-tool/scripts/onboard-tenant.sh index 589684d..de54f42 100755 --- a/.claude/skills/remediation-tool/scripts/onboard-tenant.sh +++ b/.claude/skills/remediation-tool/scripts/onboard-tenant.sh @@ -23,6 +23,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." && pwd)}" TARGET="${1:?Usage: onboard-tenant.sh [--dry-run]}" DRY_RUN=false @@ -182,6 +183,7 @@ create_sp_if_missing() { return 0 fi echo " [ERROR] Failed to create SP for $app_name: $(echo "$resp" | jq -r '.error.message // empty')" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "onboard-tenant: failed to create service principal" --context "app=$app_name appId=$app_id msg=$(echo "$resp" | jq -r '.error.message // empty' | head -c 80)" >/dev/null 2>&1 || true return 1 fi @@ -239,6 +241,7 @@ grant_app_role() { return 0 fi echo " [ERROR] grant_app_role failed for $role_id: $(echo "$resp" | jq -r '.error.message // "unknown"')" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "onboard-tenant: grant_app_role appRoleAssignment failed" --context "role=$role_id msg=$(echo "$resp" | jq -r '.error.message // "unknown"' | head -c 80)" >/dev/null 2>&1 || true return 1 fi } @@ -380,6 +383,7 @@ assign_role() { fi echo " [ERROR] Failed to assign $role_name" >&2 echo " Response: $resp" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "onboard-tenant: failed to assign directory role" --context "role=$role_name sp=$sp_oid msg=$(echo "$resp" | jq -r '.error.message // empty' | head -c 80)" >/dev/null 2>&1 || true return 1 fi echo " [OK] $role_name assigned (assignment id=$assigned_id)" @@ -390,6 +394,7 @@ echo "[INFO] Resolving tenant: $TARGET" TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TARGET") if [[ -z "$TENANT_ID" ]]; then echo "[ERROR] Could not resolve tenant ID for: $TARGET" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "onboard-tenant: could not resolve tenant ID" --context "target=$TARGET" >/dev/null 2>&1 || true exit 1 fi @@ -412,6 +417,7 @@ if [[ $GET_TOKEN_EXIT -ne 0 ]]; then fi echo "[ERROR] Failed to acquire Tenant Admin token (exit $GET_TOKEN_EXIT)" >&2 echo "$TOKEN_ERR" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "onboard-tenant: failed to acquire Tenant Admin token" --context "tenant=$TENANT_ID exit=$GET_TOKEN_EXIT" >/dev/null 2>&1 || true exit 5 fi TENANT_ADMIN_TOKEN="$TENANT_ADMIN_TOKEN_OUT" @@ -440,6 +446,7 @@ DEFENDER_SP_OID=$(get_sp_oid "$TENANT_ADMIN_TOKEN" "$DEFENDER_APP_ID") if [[ -z "$GRAPH_SP_OID" ]]; then echo "[ERROR] Microsoft Graph SP missing — cannot grant app permissions" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "onboard-tenant: Microsoft Graph SP missing in tenant — cannot grant app permissions" --context "tenant=$TENANT_ID" >/dev/null 2>&1 || true exit 1 fi diff --git a/.claude/skills/remediation-tool/scripts/patch-tenant-admin-manifest.sh b/.claude/skills/remediation-tool/scripts/patch-tenant-admin-manifest.sh index a85ff35..5adba01 100755 --- a/.claude/skills/remediation-tool/scripts/patch-tenant-admin-manifest.sh +++ b/.claude/skills/remediation-tool/scripts/patch-tenant-admin-manifest.sh @@ -20,6 +20,8 @@ ROLE_MGMT_PERMISSION_ID="9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CLAUDETOOLS_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json" +# Repo root for the functional-error logger (4 levels up from this scripts dir). +__ELOG_ROOT="${CLAUDETOOLS_ROOT_ENV:-$(cd "$SCRIPT_DIR/../../../.." && pwd)}" VAULT_ROOT="${VAULT_PATH:-}" if [[ -z "$VAULT_ROOT" && -f "$IDENTITY_FILE" ]]; then @@ -39,7 +41,7 @@ CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$MANAGEMENT_VAULT if [[ -z "$CLIENT_SECRET" ]]; then CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$MANAGEMENT_VAULT_PATH" credentials.credential 2>/dev/null | tr -d '\r\n' || true) fi -[[ -z "$CLIENT_SECRET" ]] && { echo "[ERROR] Could not read secret from $MANAGEMENT_VAULT_PATH" >&2; exit 4; } +[[ -z "$CLIENT_SECRET" ]] && { echo "[ERROR] Could not read secret from $MANAGEMENT_VAULT_PATH" >&2; bash "$__ELOG_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "patch-tenant-admin-manifest: could not read Management app secret from vault" --context "vault=$MANAGEMENT_VAULT_PATH" >/dev/null 2>&1 || true; exit 4; } echo "[OK] Management app secret retrieved" # ── Step 2: Get Management app token (home tenant) ─────────────────────────── @@ -55,6 +57,7 @@ MGMT_TOKEN=$(echo "$TOKEN_RESP" | jq -r '.access_token // empty') if [[ -z "$MGMT_TOKEN" ]]; then echo "[ERROR] Failed to acquire Management app token" >&2 echo "$TOKEN_RESP" >&2 + bash "$__ELOG_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "patch-tenant-admin-manifest: failed to acquire Management app token (home tenant)" --context "err=$(echo "$TOKEN_RESP" | jq -r '.error // empty' 2>/dev/null)" >/dev/null 2>&1 || true exit 5 fi echo "[OK] Management app token acquired" @@ -73,6 +76,7 @@ APP_DISPLAY=$(echo "$APP_RESP" | jq -r '.value[0].displayName // empty') if [[ -z "$APP_OBJ_ID" ]]; then echo "[ERROR] Tenant Admin application not found (appId=$TENANT_ADMIN_APP_ID)" >&2 echo "Response: $APP_RESP" >&2 + bash "$__ELOG_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "patch-tenant-admin-manifest: Tenant Admin application registration not found" --context "appId=$TENANT_ADMIN_APP_ID" >/dev/null 2>&1 || true exit 6 fi echo "[OK] Found app: $APP_DISPLAY (objectId=$APP_OBJ_ID)" @@ -108,6 +112,7 @@ else echo "[OK] App manifest patched (HTTP 204)" else echo "[ERROR] PATCH returned HTTP $PATCH_RESP" >&2 + bash "$__ELOG_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "patch-tenant-admin-manifest: app manifest PATCH failed" --context "appObjId=$APP_OBJ_ID http=$PATCH_RESP" >/dev/null 2>&1 || true exit 7 fi fi @@ -121,7 +126,7 @@ TA_SP_RESP=$(curl -s --max-time 15 \ --data-urlencode "\$select=id,displayName" \ "https://graph.microsoft.com/v1.0/servicePrincipals") TA_SP_OID=$(echo "$TA_SP_RESP" | jq -r '.value[0].id // empty') -[[ -z "$TA_SP_OID" ]] && { echo "[ERROR] Tenant Admin SP not found in home tenant" >&2; exit 8; } +[[ -z "$TA_SP_OID" ]] && { echo "[ERROR] Tenant Admin SP not found in home tenant" >&2; bash "$__ELOG_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "patch-tenant-admin-manifest: Tenant Admin SP not found in home tenant" --context "appId=$TENANT_ADMIN_APP_ID" >/dev/null 2>&1 || true; exit 8; } echo "[OK] Tenant Admin SP: $TA_SP_OID" echo "[INFO] Locating Microsoft Graph SP in home tenant..." @@ -132,7 +137,7 @@ GRAPH_SP_RESP=$(curl -s --max-time 15 \ --data-urlencode "\$select=id" \ "https://graph.microsoft.com/v1.0/servicePrincipals") GRAPH_SP_OID=$(echo "$GRAPH_SP_RESP" | jq -r '.value[0].id // empty') -[[ -z "$GRAPH_SP_OID" ]] && { echo "[ERROR] Microsoft Graph SP not found in home tenant" >&2; exit 8; } +[[ -z "$GRAPH_SP_OID" ]] && { echo "[ERROR] Microsoft Graph SP not found in home tenant" >&2; bash "$__ELOG_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "patch-tenant-admin-manifest: Microsoft Graph SP not found in home tenant" >/dev/null 2>&1 || true; exit 8; } echo "[OK] Microsoft Graph SP: $GRAPH_SP_OID" # ── Step 6: Check if appRoleAssignment already granted ──────────────────────── @@ -165,6 +170,7 @@ else if [[ -z "$GRANT_ID" ]]; then echo "[ERROR] Failed to grant appRoleAssignment" >&2 echo "$GRANT_RESP" >&2 + bash "$__ELOG_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "patch-tenant-admin-manifest: failed to grant RoleManagement.ReadWrite.Directory appRoleAssignment" --context "msg=$(echo "$GRANT_RESP" | jq -r '.error.message // empty' 2>/dev/null | head -c 80)" >/dev/null 2>&1 || true exit 9 fi echo "[OK] appRoleAssignment granted (id=$GRANT_ID)" diff --git a/.claude/skills/remediation-tool/scripts/reset-password.sh b/.claude/skills/remediation-tool/scripts/reset-password.sh index 4207e96..ea5a2d2 100644 --- a/.claude/skills/remediation-tool/scripts/reset-password.sh +++ b/.claude/skills/remediation-tool/scripts/reset-password.sh @@ -23,6 +23,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." && pwd)}" TENANT_INPUT="${1:?usage: reset-password.sh [--force-change]}" UPN="${2:?usage: reset-password.sh [--force-change]}" @@ -43,7 +44,7 @@ G="https://graph.microsoft.com/v1.0" # --- resolve target user object id --- UID_=$(curl -s "${GH[@]}" "$G/users/${UPN}?\$select=id" | tr -d '\000-\037' \ | python -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) -[[ -z "$UID_" ]] && { echo "[ERROR] user not found: $UPN" >&2; exit 1; } +[[ -z "$UID_" ]] && { echo "[ERROR] user not found: $UPN" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: target user not found / Graph returned no id" --context "tenant=$TENANT_ID upn=$UPN" >/dev/null 2>&1 || true; exit 1; } echo "[info] tenant=$TENANT_ID target=$UPN id=$UID_ force_change=$FORCE_CHANGE" # --- build payload (single-quoted heredoc would block $NEWPW; use python to emit JSON safely) --- @@ -61,6 +62,7 @@ fi if [[ "$CODE" != "403" ]]; then echo "[ERROR] unexpected HTTP $CODE on password PATCH" >&2 curl -s -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD" | tr -d '\000-\037' >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: unexpected HTTP on password PATCH" --context "tenant=$TENANT_ID upn=$UPN http=$CODE" >/dev/null 2>&1 || true exit 1 fi @@ -69,7 +71,7 @@ echo "[info] 403 on direct reset (target likely holds an admin role) -> JIT elev # --- resolve tenant-admin SP object id --- SPID=$(curl -s "${GH[@]}" "$G/servicePrincipals(appId='$TENANT_ADMIN_APPID')?\$select=id" | tr -d '\000-\037' \ | python -c "import sys,json;print(json.load(sys.stdin).get('id',''))") -[[ -z "$SPID" ]] && { echo "[ERROR] could not resolve Tenant Admin service principal" >&2; exit 1; } +[[ -z "$SPID" ]] && { echo "[ERROR] could not resolve Tenant Admin service principal" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: could not resolve Tenant Admin SP for JIT elevation" --context "tenant=$TENANT_ID" >/dev/null 2>&1 || true; exit 1; } # --- does the SP already hold Privileged Authentication Administrator? --- EXISTING=$(curl -s "${GH[@]}" "$G/roleManagement/directory/roleAssignments?\$filter=principalId+eq+'$SPID'+and+roleDefinitionId+eq+'$PAA_ROLE_ID'" \ @@ -82,7 +84,7 @@ else ASSIGN_BODY=$(SPID="$SPID" RID="$PAA_ROLE_ID" python -c "import os,json;print(json.dumps({'principalId':os.environ['SPID'],'roleDefinitionId':os.environ['RID'],'directoryScopeId':'/'}))") CREATED_ASSIGNMENT=$(curl -s -X POST "${GH[@]}" "$G/roleManagement/directory/roleAssignments" --data-binary "$ASSIGN_BODY" \ | tr -d '\000-\037' | python -c "import sys,json;d=json.load(sys.stdin);print(d.get('id',''))" 2>/dev/null || true) - [[ -z "$CREATED_ASSIGNMENT" ]] && { echo "[ERROR] failed to assign Privileged Authentication Administrator to SP" >&2; exit 1; } + [[ -z "$CREATED_ASSIGNMENT" ]] && { echo "[ERROR] failed to assign Privileged Authentication Administrator to SP" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: failed to assign Privileged Authentication Administrator to SP (JIT elevation)" --context "tenant=$TENANT_ID sp=$SPID" >/dev/null 2>&1 || true; exit 1; } echo "[info] assigned Privileged Authentication Administrator to SP (assignment $CREATED_ASSIGNMENT)" fi @@ -90,7 +92,7 @@ fi cleanup() { if [[ -n "$CREATED_ASSIGNMENT" ]]; then DC=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "${GH[@]}" "$G/roleManagement/directory/roleAssignments/$CREATED_ASSIGNMENT") - if [[ "$DC" == "204" ]]; then echo "[info] removed JIT role assignment (de-elevated)"; else echo "[WARNING] failed to remove JIT role assignment $CREATED_ASSIGNMENT (HTTP $DC) - REMOVE MANUALLY" >&2; fi + if [[ "$DC" == "204" ]]; then echo "[info] removed JIT role assignment (de-elevated)"; else echo "[WARNING] failed to remove JIT role assignment $CREATED_ASSIGNMENT (HTTP $DC) - REMOVE MANUALLY" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: failed to remove JIT Privileged Auth Admin role - standing privilege left behind, REMOVE MANUALLY" --context "tenant=$TENANT_ID assignment=$CREATED_ASSIGNMENT http=$DC" >/dev/null 2>&1 || true; fi fi } trap cleanup EXIT @@ -108,4 +110,5 @@ done echo "[ERROR] password reset still failing after elevation (last HTTP $CODE)" >&2 curl -s -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD" | tr -d '\000-\037' >&2 +bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: reset still failing after JIT elevation + retries" --context "tenant=$TENANT_ID upn=$UPN http=$CODE" >/dev/null 2>&1 || true exit 1 diff --git a/.claude/skills/remediation-tool/scripts/resolve-tenant.sh b/.claude/skills/remediation-tool/scripts/resolve-tenant.sh index b88cc47..86b7ae1 100755 --- a/.claude/skills/remediation-tool/scripts/resolve-tenant.sh +++ b/.claude/skills/remediation-tool/scripts/resolve-tenant.sh @@ -4,6 +4,8 @@ # Output (stdout): tenant GUID. Exit 0 on success, 1 on failure. set -euo pipefail +__ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" + INPUT="${1:?usage: resolve-tenant.sh }" # If it looks like a GUID already, pass through. @@ -31,6 +33,7 @@ TENANT_ID=$(echo "$RESP" | jq -r '.issuer // empty' | sed -E 's|^https://login\. if [[ -z "$TENANT_ID" ]] || [[ ! "$TENANT_ID" =~ ^[0-9a-fA-F]{8}- ]]; then echo "ERROR: could not resolve tenant for domain: $DOMAIN" >&2 echo "Response: $RESP" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "resolve-tenant: OpenID discovery did not return a tenant GUID" --context "domain=$DOMAIN" >/dev/null 2>&1 || true exit 1 fi diff --git a/.claude/skills/remediation-tool/scripts/user-breach-check.sh b/.claude/skills/remediation-tool/scripts/user-breach-check.sh index bfdfeee..359dab9 100755 --- a/.claude/skills/remediation-tool/scripts/user-breach-check.sh +++ b/.claude/skills/remediation-tool/scripts/user-breach-check.sh @@ -6,6 +6,7 @@ set -euo pipefail SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +__ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" TENANT_INPUT="${1:?usage: user-breach-check.sh }" UPN="${2:?usage: user-breach-check.sh }" @@ -28,6 +29,7 @@ UID_=$(jq -r '.id // empty' "$OUT/00_user.json") if [[ -z "$UID_" ]]; then echo "ERROR: user not found or Graph returned error" >&2 cat "$OUT/00_user.json" >&2 + bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "user-breach-check: user not found or Graph returned error resolving user object" --context "tenant=$TENANT_ID upn=$UPN err=$(jq -r '.error.code // empty' "$OUT/00_user.json" 2>/dev/null)" >/dev/null 2>&1 || true exit 1 fi echo "[info] object id: $UID_" diff --git a/.claude/skills/skill-creator/SKILL.md b/.claude/skills/skill-creator/SKILL.md index e0f9d35..45b3482 100644 --- a/.claude/skills/skill-creator/SKILL.md +++ b/.claude/skills/skill-creator/SKILL.md @@ -98,6 +98,23 @@ After creating the files: - Commands: Tell them to use `/{name}` or `/{name} arguments` 3. Remind them to update CLAUDE.md's Commands & Skills table if they want it documented there +## Mandatory: functional error logging + +**Every skill MUST report genuine functional errors to `errorlog.md`** via the canonical +helper, so failures can be linted and fed back into skill improvements (CLAUDE.md core rule). +Bake this into the skill from the start: + +- In each skill **script's failure branches** (API/auth failure, unexpected response, + validation error, unexpected non-zero exit), call: + ```bash + bash "$ROOT/.claude/scripts/log-skill-error.sh" "" "" --context "op=... http=..." + ``` + It stamps date+machine, inserts in the standard `YYYY-MM-DD | MACHINE | skill | error` + format, and soft-fails (never breaks the caller). Python skills shell out to the same helper. +- In the **SKILL.md**, add a line under workflow/guidelines: "On a functional error, log it via + `log-skill-error.sh` before surfacing it." +- Do NOT log expected/handled conditions (no results, no unread, user-declined) — only real failures. + ## Quality Checklist Before finalizing, verify: @@ -107,6 +124,7 @@ Before finalizing, verify: - [ ] File is in the correct location (`.claude/skills/` or `.claude/commands/`) - [ ] Name uses kebab-case and is concise - [ ] For skills with auto-triggers: triggers are specific enough to avoid false positives +- [ ] **Functional errors are logged** via `log-skill-error.sh` in the script's failure branches ## Tips for Good Skills/Commands diff --git a/.claude/skills/vault/scripts/vault-helper.sh b/.claude/skills/vault/scripts/vault-helper.sh index d890ba5..65d0d9c 100644 --- a/.claude/skills/vault/scripts/vault-helper.sh +++ b/.claude/skills/vault/scripts/vault-helper.sh @@ -25,6 +25,12 @@ # Anything you put OUTSIDE those keys is committed in PLAINTEXT — never do that. set -euo pipefail +# ── Functional-error logger ─────────────────────────────────────────────────── +# errorlog.md + log-skill-error.sh live in the ClaudeTools repo (4 levels up from +# .claude/skills/vault/scripts/), NOT the SOPS vault repo (VAULT_DIR below). +CLAUDETOOLS_ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +_logerr() { bash "$CLAUDETOOLS_ROOT/.claude/scripts/log-skill-error.sh" "vault" "$@" >/dev/null 2>&1 || true; } + # ── Resolve vault root (the #1 thing sessions get wrong) ────────────────────── # Order: $VAULT_PATH override → the repo we're standing in (correct identity) → # $HOME identity vault_path → $HOME identity claudetools_root → that repo's identity. @@ -84,7 +90,7 @@ cmd_verify() { local f; f=$(abspath "${1:?usage: verify }") [[ -f "$f" ]] || { echo "[ERROR] not found: $f" >&2; exit 1; } if ! _is_encrypted "$f"; then echo "[FAIL] $1 — NO encrypted values found (plaintext?)"; exit 1; fi - if ! ( cd "$VAULT_DIR" && sops -d "$f" >/dev/null 2>&1 ); then echo "[FAIL] $1 — encrypted but does not decrypt (key mismatch?)"; exit 1; fi + if ! ( cd "$VAULT_DIR" && sops -d "$f" >/dev/null 2>&1 ); then echo "[FAIL] $1 — encrypted but does not decrypt (key mismatch?)"; _logerr "sops decrypt failed (key mismatch?)" --context "op=verify path=$1"; exit 1; fi echo "[OK] $1 — encrypted and decrypts cleanly" } @@ -129,7 +135,7 @@ doc["notes"]="" with open(f,"w",encoding="utf-8",newline="\n") as fh: yaml.safe_dump(doc,fh,default_flow_style=False,sort_keys=False,allow_unicode=True) PY - ( cd "$VAULT_DIR" && sops --encrypt --in-place "$f" ) || { echo "[ERROR] sops encrypt failed; removing plaintext" >&2; rm -f "$f"; exit 1; } + ( cd "$VAULT_DIR" && sops --encrypt --in-place "$f" ) || { echo "[ERROR] sops encrypt failed; removing plaintext" >&2; _logerr "sops encrypt failed (new entry)" --context "op=new path=$path"; rm -f "$f"; exit 1; } cmd_verify "$path" echo "[INFO] Created ${f#$VAULT_DIR/}. Publish with: bash .claude/scripts/sync.sh (Phase 6 commits+pushes the vault)" } @@ -142,7 +148,7 @@ cmd_set() { local f; f=$(abspath "$path") [[ -f "$f" ]] || { echo "[ERROR] not found: ${f#$VAULT_DIR/} (use 'new' to create)" >&2; exit 1; } local tmp; tmp=$(mktemp) - ( cd "$VAULT_DIR" && sops -d "$f" ) > "$tmp" 2>/dev/null || { echo "[ERROR] decrypt failed" >&2; rm -f "$tmp"; exit 1; } + ( cd "$VAULT_DIR" && sops -d "$f" ) > "$tmp" 2>/dev/null || { echo "[ERROR] decrypt failed" >&2; _logerr "sops decrypt failed (set)" --context "op=set path=$path"; rm -f "$tmp"; exit 1; } SETS="$(printf '%s\n' "${sets[@]}")" "$PY" - "$tmp" <<'PY' import os,sys,yaml f=sys.argv[1] @@ -154,7 +160,7 @@ for kv in os.environ["SETS"].splitlines(): yaml.safe_dump(doc,open(f,"w",encoding="utf-8",newline="\n"),default_flow_style=False,sort_keys=False,allow_unicode=True) PY cp "$tmp" "$f"; rm -f "$tmp" - ( cd "$VAULT_DIR" && sops --encrypt --in-place "$f" ) || { echo "[ERROR] re-encrypt failed" >&2; exit 1; } + ( cd "$VAULT_DIR" && sops --encrypt --in-place "$f" ) || { echo "[ERROR] re-encrypt failed" >&2; _logerr "sops re-encrypt failed (set)" --context "op=set path=$path"; exit 1; } cmd_verify "$path" echo "[INFO] Updated ${f#$VAULT_DIR/}. Publish with: bash .claude/scripts/sync.sh" } diff --git a/errorlog.md b/errorlog.md index 56108cc..f1bee4a 100644 --- a/errorlog.md +++ b/errorlog.md @@ -1,14 +1,37 @@ # Error Log -Brief records of task-execution errors across the fleet, used to improve skills and the -command harness. Append newest entries at the top. Keep each entry to 1-2 lines. +Brief records of preventable, pattern-worthy events across the fleet — used to improve +skills, write better CLAUDE.md rules, and clean stale/misleading memory. The aim: never +pay tokens twice for the same avoidable mistake. Append newest at the top; keep entries to +1-2 lines. **Always write via the helper, never by hand:** +`bash .claude/scripts/log-skill-error.sh "" "" [--correction|--friction] [--context "k=v"]` -Format: `YYYY-MM-DD | MACHINE | command/skill | error (brief)` +Format: `YYYY-MM-DD | MACHINE | command/skill/context | [type] error (brief) [ctx: ...]` + +Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +`[correction]` = user corrected an improper assumption I made · +`[friction]` = preventable self-inflicted token-waste (harness/env/tool misuse; cite a +`ref=` in ctx when it repeats a documented gotcha — that flags a rule/memory to strengthen). --- +2026-06-15 | GURU-5070 | powershell/var-case | [friction] PowerShell vars are case-INSENSITIVE: $gUid silently overwrote $guid (GPO id), Set-ADObject hit a bad DN and left GPT.ini/AD versionNumber inconsistent until fixed. Never rely on case to distinguish PS variables + +2026-06-15 | GURU-5070 | python/argv-limit | [friction] passed full /api/agents JSON (248 agents) as a python CLI arg -> 'Argument list too long' on Windows. Pipe large payloads via stdin, not argv + +2026-06-15 | GURU-5070 | bash/env-persist | [friction] re-derived RMM token every call after $TOKEN/$RMM vanished between Bash tool calls - shell env does NOT persist across calls; must re-eval auth (or chain) in the same command + +2026-06-15 | GURU-5070 | bash/tmp-path | [friction] wrote curl -o /tmp/x.json then jq read it back and failed (No such file) - Git-Bash vs Write/tool /tmp resolve differently. Pipe directly or use repo-relative paths. REPEAT of documented gotcha [ctx: ref=feedback_tmp_path_windows] + +2026-06-15 | GURU-5070 | DMARC / DNS | [correction] assumed ACG's own INKY rua convention (reports-sg.inkydmarc.com) applied to a client domain; only use the INKY rua if THAT client is onboarded to INKY - otherwise plain p=none or a real mailbox + +2026-06-15 | GURU-5070 | remediation-tool (sendMail) | [correction] assumed none of the consented apps could send mail and started granting Graph Mail.Send; the Exchange Operator app ALREADY had Graph Mail.Send - I was decoding the EXO-audience token, not a Graph-audience token. Mint a Graph token for the app before concluding a permission is missing + +2026-06-15 | GURU-5070 | rmm-search | [correction] assumed the CLI search must replicate the UI Omnibox scoreMatch exactly; user wants a FLEXIBLE forgiving multi-field search optimized for first-try correctness, not UI parity + + 2026-06-15 | GURU-BEAST-ROG | /syncro (comment edit) | Syncro API does not expose a comment-edit or comment-delete endpoint — once posted, comments can only be modified via the GUI. Bot posted an internal resolution note with an unwanted "Performed by: ClaudeTools Discord Bot" line and could not remove it programmatically. Remediation needed: either suppress bot-attribution lines from internal notes by default, or add a GUI-edit step to the workflow when the note needs correction. 2026-06-14 | GURU-5070 | mailbox skill (Graph token) | FABB app `fabb3421` (Claude-MSP-Access / "Cloud MSP Access") token request returned AADSTS700016 — app/SP no longer present in azcomputerguru.com tenant (deleted; gotchas.md already marked it deprecated). Blocks /mailbox + the M365 contacts task. Verified the remediation suite (live, ACG tenant) carries NO Mail.Send/Mail.ReadWrite/Contacts scopes (investigator has Mail.Read only) — so a straight repoint can't restore mailbox-send/contacts. Pending Mike decision: stand up a single-tenant ACG-internal mailbox app vs. add scopes to a suite tier. [2026-06-15] Docs hardened — gotchas.md now marks fabb3421 DELETED with the Mail/Contacts-scope blast radius + flags the 3 legacy "old app only" tenants (Valleywide/Dataforth/Cascades) as now having NO working remediation app (migration URGENT); mailbox.md carries a BLOCKED/AADSTS700016 banner. DECISION 2026-06-15 (Mike): Mail.Send goes into the suite (Exchange Operator tier) since its real use is IR victim-notification during mailbox takeovers; add Mail.Send to the exchange-op manifest + consent, repoint mailbox.md to exchange-op. Implementation not yet executed (production app change, needs go).