diff --git a/.claude/commands/rmm.md b/.claude/commands/rmm.md index b9826110..0ad12b36 100644 --- a/.claude/commands/rmm.md +++ b/.claude/commands/rmm.md @@ -159,6 +159,24 @@ Use `python` only when explicitly writing a Python script. Use `script` for save **VALID `command_type` values ONLY: `shell`, `powershell`, `python`, `script`, `claude_task` (plus alias `cmd` → shell = cmd.exe).** The agent deserializes `command_type` into a Rust enum; an UNKNOWN value (e.g. a made-up type) fails the agent's whole-message JSON parse and the command is **silently dropped — no ack, no result, no error** — which is indistinguishable from a network black-hole and has caused a long mis-diagnosis. On Windows: `powershell` runs powershell.exe (UTF-8 output fixed in-agent); `shell` or `cmd` runs cmd.exe. If a dispatched command sits un-acked forever, FIRST suspect an invalid `command_type` before chasing the network. (Newer agents NAK an unparseable command so it fails fast with a clear stderr instead of black-holing.) +### Quote-safe dispatch for real scripts (PREFERRED for anything with quotes/UNC/$) + +Any PowerShell payload containing embedded double-quotes, UNC `\\` paths, or `$` +that must survive literally should NOT be inlined into the JSON dispatch — the +RMM->cmd.exe layer strips/mangles them (see memory `feedback_windows_quote_stripping`). +Write the script to a file (with the Write tool — bash heredocs collapse `\\`), +then dispatch it byte-exact via `-EncodedCommand`: + +```bash +bash .claude/scripts/ps-encoded.sh rmm "$AGENT_ID" script.ps1 --timeout 120 [--user-session] +# or print a paste-safe one-liner for ScreenConnect / plink: +bash .claude/scripts/ps-encoded.sh encode script.ps1 +``` + +Size limit: the agent fails on ~7KB command bodies and encoding inflates ~2.67x, +so keep scripts under ~2KB raw (the helper warns at 4KB encoded, refuses at 6KB). +Inline dispatch below remains fine for simple quote-free commands. + ### Basic dispatch ```bash diff --git a/.claude/hooks/block-tmp-path.sh b/.claude/hooks/block-tmp-path.sh new file mode 100755 index 00000000..6cd9310b --- /dev/null +++ b/.claude/hooks/block-tmp-path.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) hook: block bash commands that WRITE files under /tmp on +# Windows (Git Bash / MSYS). +# +# Why: MSYS bash and the harness's other tools (Write, Python via py launcher) +# resolve /tmp to DIFFERENT real directories on Windows, so a file curl/tee +# writes to /tmp cannot be read back by the next tool call — the single most +# repeated tmp-path friction in errorlog.md (ref=feedback_tmp_path_windows, +# 6+ hits; also breaks run_in_background shells where TMPDIR is unset). +# +# Windows-only: exits 0 immediately on macOS/Linux where /tmp is one real dir. +# Only WRITE patterns are blocked (redirects, tee, -o/--output, cp/mv/mktemp +# targets). Reads (ls/cat/rm /tmp/...) pass. +# +# Dual-driver like block-backslash-winpath.sh: handles Claude (tool_input) and +# Grok (toolInput) event shapes; emits Grok decision JSON when denying there. + +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*) ;; # Windows Git-bash: the mismatch exists — check + *) exit 0 ;; # real /tmp elsewhere: nothing to protect +esac + +input=$(cat) +cmd=$(echo "$input" | jq -r '(.toolInput // .tool_input // {}) | .command // ""' 2>/dev/null || python -c " +import sys, json +try: + d = json.load(sys.stdin) + ti = d.get('toolInput') or d.get('tool_input') or {} + print(ti.get('command', '')) +except: + print('') +" 2>/dev/null || echo '') +is_grok=$(echo "$input" | jq -r 'if has("hookEventName") or has("toolInput") then "1" else "0" end' 2>/dev/null || echo '0') + +# Strip quoted substrings so a /tmp mention inside a string (commit message, +# grep pattern) does not false-trigger; real write targets sit outside quotes. +bare=$(printf '%s' "$cmd" | sed -E "s/'[^']*'//g; s/\"[^\"]*\"//g") + +if printf '%s' "$bare" | grep -qE '(>>?[[:space:]]*/tmp/|[[:space:]]tee[[:space:]]+(-a[[:space:]]+)?/tmp/|(^|[[:space:]])-o[[:space:]]*/tmp/|--output(=|[[:space:]]+)/tmp/|(^|[[:space:]]|;|\|)(cp|mv)[[:space:]][^|;&>]*[[:space:]]/tmp/|mktemp[[:space:]]+(-d[[:space:]]+)?/tmp/)'; then + reason="Blocked write to /tmp in bash on Windows: MSYS /tmp and the Write/Python tools' /tmp are DIFFERENT directories — the file cannot be read back by the next tool call." + echo "BLOCKED: do not write files under /tmp on Windows (Git Bash)." + echo "" + echo "MSYS bash and the harness's other tools resolve /tmp to different real" + echo "directories, so the next tool call cannot read what you just wrote" + echo "(ref: feedback_tmp_path_windows). Use one of these instead:" + echo " - repo-relative scratch: curl -o ./.x.json ... (gitignored .tmp-* also works)" + echo " - the session scratchpad dir (absolute path, shared by all tools)" + echo " - pipe directly: curl ... | jq ... (no intermediate file)" + if [ "$is_grok" = "1" ]; then + printf '{"decision":"deny","reason":"%s"}\n' "$reason" + fi + exit 2 +fi + +exit 0 diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 14ba00af..3f499775 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -82,7 +82,7 @@ - [1Password — always use service token](feedback_1password_service_token.md) — Source OP_SERVICE_ACCOUNT_TOKEN from SOPS for every `op` call. Desktop-app integration prompts are unacceptable in agent flows. - [Point vault-access teammates at SOPS path](feedback_vault_pointer_for_teammates.md) — When relaying infra/credential info to Howard or other vault-access teammates, hand over the SOPS path + key anchors; don't transcribe the entry's fields into the message. - [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl. -- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. Use single-quoted heredoc `<<'JSON'` + `--data-binary @-` for bodies; build `"` from `[char]34`; or drop the quoted part (e.g. `shutdown /c`). +- [Windows strips embedded double-quotes](feedback_windows_quote_stripping.md) — Embedded `"` in an arg gets eaten twice over: PowerShell->curl.exe (CommandLineToArgvW) AND RMM->cmd.exe. MECHANICAL FIX: deliver PS scripts via `.claude/scripts/ps-encoded.sh` (-EncodedCommand, byte-exact; author the file with the Write tool). Inline one-offs: single-quoted heredoc `<<'JSON'` + `--data-binary @-`; build `"` from `[char]34`. - [Interview the AI / read its docs before probing](feedback_interview_ai_read_docs.md) — To learn an external AI/CLI's syntax or capabilities, READ its bundled docs (Grok: `~/.grok/docs/user-guide/`, `README.md`, `grok inspect`/`models`/`--help`) or interview the model; don't guess flags or run slow trial-and-error. One run to confirm a doc-derived hypothesis, not a dozen to discover. - [Web search over blind probing](feedback_web_search_over_probing.md) — For external API/capability discovery, LEAD with web search (grok/gemini) + vendor docs; live endpoint-probing only CONFIRMS a hypothesis, never the primary discovery method (it mostly 404s, "highly suspect"). Reading a system's OWN config is fine; guessing unknown PATHS is not. Web-search bots being flaky is a must-fix (CT_THOUGHTS Thought 2). - [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details. diff --git a/.claude/memory/feedback_windows_quote_stripping.md b/.claude/memory/feedback_windows_quote_stripping.md index 85c3e02a..1cea6f21 100644 --- a/.claude/memory/feedback_windows_quote_stripping.md +++ b/.claude/memory/feedback_windows_quote_stripping.md @@ -5,6 +5,16 @@ metadata: type: feedback --- +**MECHANICAL FIX FIRST (2026-07-01): for any PowerShell payload crossing a +mangling layer (RMM dispatch, ScreenConnect command box, plink, curl.exe args), +use `.claude/scripts/ps-encoded.sh`** — it UTF-16LE-base64 encodes a script file +and delivers it via `powershell -EncodedCommand` (`encode` prints the paste-safe +one-liner; `rmm ` dispatches + polls via GuruRMM). Base64 has +no quotes/backslashes/`$` to strip, so the script arrives byte-exact (verified: +UNC `\\` survives). Author the script with the **Write tool**, not a bash heredoc +(Git-bash heredocs collapse `\\` even single-quoted). The manual rules below +remain for one-off inline args only. + On Windows, **embedded double-quotes inside a command argument get silently stripped or mangled** at two separate layers we hit repeatedly. The body of the arg survives; the `"` characters vanish, so the receiving program sees broken diff --git a/.claude/scripts/discord-dm.sh b/.claude/scripts/discord-dm.sh index ea481336..a37edcc4 100644 --- a/.claude/scripts/discord-dm.sh +++ b/.claude/scripts/discord-dm.sh @@ -99,17 +99,45 @@ if [ "$MODE" = "dm" ]; then TARGET="$CHID" fi -# --- post the message (printf | --data-binary @- — direct -d mangles multiline JSON) --- -RESP="$(printf '%s' "$(jq -nc --arg c "$MSG" '{content:$c}')" | \ - curl -s -m 15 -w $'\n%{http_code}' "${auth[@]}" \ - -X POST "$API/channels/${TARGET}/messages" --data-binary @-)" -HTTP="$(printf '%s' "$RESP" | tail -n1)" -BODY="$(printf '%s' "$RESP" | sed '$d')" +# --- chunk: Discord caps content at 2000 chars (code 50035 on overflow). +# This tool's job is delivering LONG content intact, so split into <=1900-char +# pieces (preferring newline boundaries) and send them in order — no markers +# added, so the receiver can copy-paste the sequence back together verbatim. +LIMIT=1900 +CHUNKS=() +rest="$MSG" +while [ "${#rest}" -gt "$LIMIT" ]; do + piece="${rest:0:$LIMIT}" + at_nl="${piece%$'\n'*}" # cut at the last newline in the window + if [ "${#at_nl}" -lt "${#piece}" ] && [ "${#at_nl}" -gt 0 ]; then + piece="$at_nl" + fi + CHUNKS+=("$piece") + rest="${rest:${#piece}}" + rest="${rest#$'\n'}" +done +CHUNKS+=("$rest") -if [ "$HTTP" = "200" ]; then +# --- post the message (jq | --data-binary @- — argv/-d mangles multiline + non-ASCII JSON) --- +N=${#CHUNKS[@]} +i=0 +for piece in "${CHUNKS[@]}"; do + i=$((i+1)) + RESP="$(printf '%s' "$(jq -nc --arg c "$piece" '{content:$c}')" | \ + curl -s -m 15 -w $'\n%{http_code}' "${auth[@]}" \ + -X POST "$API/channels/${TARGET}/messages" --data-binary @-)" + HTTP="$(printf '%s' "$RESP" | tail -n1)" + BODY="$(printf '%s' "$RESP" | sed '$d')" + if [ "$HTTP" != "200" ]; then + echo "[ERROR] discord-dm: Discord returned ${HTTP:-no-response} on chunk ${i}/${N} — ${BODY}" >&2 + bash "$ROOT/.claude/scripts/log-skill-error.sh" "discord-dm" "Discord send to $LABEL failed (chunk ${i}/${N})" --context "http=${HTTP:-none} resp=${BODY:0:80}" >/dev/null 2>&1 + exit 3 + fi +done + +if [ "$N" -gt 1 ]; then + echo "[OK] discord-dm: sent to ${LABEL} in ${N} chunks (message_id=$(printf '%s' "$BODY" | jq -r '.id // empty'))" +else echo "[OK] discord-dm: sent to ${LABEL} (message_id=$(printf '%s' "$BODY" | jq -r '.id // empty'))" - 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 +exit 0 diff --git a/.claude/scripts/log-skill-error.sh b/.claude/scripts/log-skill-error.sh index 01810af5..6a0d06f7 100644 --- a/.claude/scripts/log-skill-error.sh +++ b/.claude/scripts/log-skill-error.sh @@ -33,6 +33,11 @@ # Writes: YYYY-MM-DD | MACHINE | | [] [ctx: ] # (newest entry inserted at the top, just under the append marker). # +# Dedup: if an IDENTICAL entry (same date, machine, skill, message) already +# exists, no new line is added — the existing line gets a " (xN)" repeat counter +# bumped instead. Identical machine-generated failures (API retry loops) collapse +# to one line per day; a different message/context/date is still a new entry. +# # 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 @@ -62,7 +67,7 @@ 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)" + MACHINE="$(jq -r '.machine // .machine_name // .hostname // empty' "$IDF" 2>/dev/null)" fi [ -z "$MACHINE" ] && MACHINE="$(hostname 2>/dev/null || echo unknown)" @@ -76,6 +81,34 @@ ENTRY="$DATE | $MACHINE | $SKILL | $MSG" MARK="" TMP="$LOG.tmp.$$" + +# Dedup pass: an identical entry today (bare, or already counted "(xN)") gets its +# repeat counter bumped in place instead of a duplicate line. Literal string +# compares only — the message may contain regex metacharacters. +DEDUP_RC=1 +awk -v entry="$ENTRY" ' + !bumped && $0 == entry { print entry " (x2)"; bumped=1; next } + !bumped && index($0, entry " (x") == 1 { + n = substr($0, length(entry) + 4) # text after " (x" + if (n ~ /^[0-9]+\)$/) { + sub(/\)$/, "", n) + print entry " (x" n+1 ")"; bumped=1; next + } + } + { print } + END { exit bumped ? 0 : 1 } +' "$LOG" > "$TMP" 2>/dev/null && DEDUP_RC=0 +if [ "$DEDUP_RC" -eq 0 ]; then + if mv "$TMP" "$LOG" 2>/dev/null; then + echo "[OK] duplicate entry — bumped repeat counter in errorlog.md ($SKILL)" + else + rm -f "$TMP" 2>/dev/null + echo "[WARN] log-skill-error: could not write $LOG" >&2 + fi + exit 0 +fi +rm -f "$TMP" 2>/dev/null + if awk -v entry="$ENTRY" -v mark="$MARK" ' { print } ($0==mark && !done) { print ""; print entry; done=1 } diff --git a/.claude/scripts/post-bot-alert.sh b/.claude/scripts/post-bot-alert.sh index 2197a364..7b7ab17d 100644 --- a/.claude/scripts/post-bot-alert.sh +++ b/.claude/scripts/post-bot-alert.sh @@ -35,6 +35,13 @@ if [ -z "$MSG" ]; then exit 0 fi +# Discord caps message content at 2000 chars (code 50035 on overflow). Alerts +# are one-liners by contract — truncate rather than chunk. +if [ "${#MSG}" -gt 1900 ]; then + MSG="${MSG:0:1900} ...[truncated]" + echo "[WARNING] post-bot-alert: message over 1900 chars — truncated" >&2 +fi + # --- channel routing --- # Optional 2nd arg: "dev"/"bot" keyword, a raw channel id, or omit for auto. # Auto: RMM/Dev-category prefixes -> #dev-alerts (private); everything else @@ -67,14 +74,15 @@ if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then exit 0 fi -# --- post (jq builds JSON so the message is safely escaped) --- -PAYLOAD="$(jq -nc --arg c "$MSG" '{content: $c}')" -RESP="$(curl -s -m 15 -w $'\n%{http_code}' \ +# --- post (jq builds the JSON; piped to curl via STDIN, never argv — a payload +# passed as a command-line arg on Windows goes through the argv encoding layer, +# which mangles non-ASCII chars into invalid JSON -> Discord 50109) --- +RESP="$(jq -nc --arg c "$MSG" '{content: $c}' | curl -s -m 15 -w $'\n%{http_code}' \ -X POST "https://discord.com/api/v10/channels/${CHANNEL_ID}/messages" \ -H "Authorization: Bot ${TOKEN}" \ -H "Content-Type: application/json" \ -H "User-Agent: ClaudeToolsBot (claudetools, 1.0)" \ - --data-binary "$PAYLOAD" 2>/dev/null)" + --data-binary @- 2>/dev/null)" HTTP="$(printf '%s' "$RESP" | tail -n1)" BODY="$(printf '%s' "$RESP" | sed '$d')" diff --git a/.claude/scripts/ps-encoded.sh b/.claude/scripts/ps-encoded.sh new file mode 100644 index 00000000..e83345df --- /dev/null +++ b/.claude/scripts/ps-encoded.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# ps-encoded.sh — dispatch PowerShell through quote-mangling layers safely. +# +# THE point of this helper: a PS payload that traverses CommandLineToArgvW +# (curl.exe args, plink, ScreenConnect's command box, RMM->cmd.exe) gets its +# embedded double-quotes stripped and its UNC backslashes halved — the single +# most-repeated friction class in errorlog.md (ref=feedback_windows_quote_stripping, +# 9+ hits). -EncodedCommand (UTF-16LE base64) has NO quotes, backslashes, or +# dollar signs to mangle, so the script arrives byte-exact. Write the script +# to a FILE (or stdin), let this helper encode + deliver it. +# +# Usage: +# ps-encoded.sh encode print the one-liner for +# ScreenConnect / plink / any paste +# ps-encoded.sh rmm dispatch via GuruRMM + poll +# [--timeout ] agent-side timeout_seconds (default 120) +# [--user-session] context: user_session (WTS-impersonated desktop user) +# [--no-wait] dispatch only; print command id, skip polling +# [--force] override the size refusal (see below) +# +# Size guards (agent chokes on ~7KB command bodies; encoding inflates ~2.67x): +# encoded > 4000 chars -> [WARNING]; encoded > 6000 chars -> refuse unless +# --force (split the script, or stage it on the endpoint and run the file). +# +# Exit codes: 0 ok; 1 usage/encode error; 2 size refusal; 3 dispatch/poll failure. + +set -u +ROOT="${CLAUDETOOLS_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" +_logerr() { bash "$ROOT/.claude/scripts/log-skill-error.sh" "ps-encoded" "$@" >/dev/null 2>&1 || true; } + +usage() { sed -n '2,26p' "$0"; exit 1; } + +read_script() { # $1 = file path or "-" + if [ "$1" = "-" ]; then cat + elif [ -f "$1" ]; then cat "$1" + else echo "[ERROR] script file not found: $1" >&2; exit 1 + fi +} + +encode_b64() { # stdin: UTF-8 script -> stdout: UTF-16LE base64 (no BOM, no wraps) + iconv -f UTF-8 -t UTF-16LE | base64 -w0 +} + +size_gate() { # $1 = encoded string, $2 = force flag (0/1) + local n=${#1} + if [ "$n" -gt 6000 ] && [ "${2:-0}" != "1" ]; then + echo "[ERROR] encoded payload is ${n} chars (>6000); the agent fails on bodies this large." >&2 + echo " Split the script into sections, or stage it as a file on the endpoint" >&2 + echo " and dispatch 'powershell -File '. Override with --force." >&2 + return 1 + elif [ "$n" -gt 4000 ]; then + echo "[WARNING] encoded payload is ${n} chars — near the agent's body-size failure zone (~7KB)." >&2 + fi + return 0 +} + +cmd_encode() { + local src="${1:-}"; [ -z "$src" ] && usage + local b64 + b64="$(read_script "$src" | encode_b64)" + [ -z "$b64" ] && { echo "[ERROR] encoding produced nothing" >&2; _logerr "encode produced empty output" --context "src=$src"; exit 1; } + size_gate "$b64" 1 || true # encode mode: warn only, the paste target may cope + printf 'powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand %s\n' "$b64" +} + +cmd_rmm() { + local agent="${1:-}"; shift || true + local src="${1:-}"; shift || true + [ -z "$agent" ] || [ -z "$src" ] && usage + local timeout=120 context="" wait=1 force=0 + while [ $# -gt 0 ]; do + case "$1" in + --timeout) timeout="${2:?}"; shift 2 ;; + --user-session) context="user_session"; shift ;; + --no-wait) wait=0; shift ;; + --force) force=1; shift ;; + *) echo "[ERROR] unknown option: $1" >&2; usage ;; + esac + done + + local b64 + b64="$(read_script "$src" | encode_b64)" + [ -z "$b64" ] && { echo "[ERROR] encoding produced nothing" >&2; _logerr "encode produced empty output" --context "src=$src"; exit 1; } + size_gate "$b64" "$force" || exit 2 + + eval "$(bash "$ROOT/.claude/scripts/rmm-auth.sh")" + if [ -z "${TOKEN:-}" ] || [ -z "${RMM:-}" ]; then + echo "[ERROR] RMM auth failed (no TOKEN/RMM)" >&2 + _logerr "RMM auth failed via rmm-auth.sh" --context "agent=$agent" + exit 3 + fi + + # command_type=shell (cmd.exe): the base64 blob is a single quote-free token, + # so no layer between here and powershell.exe can mangle it. timeout_seconds + # is the field the agent honors — 'timeout' is silently ignored (errorlog). + local oneliner payload resp cmd_id status + oneliner="powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${b64}" + payload="$(jq -nc --arg ct shell --arg cmd "$oneliner" --argjson to "$timeout" \ + --arg cx "$context" \ + '{command_type:$ct, command:$cmd, timeout_seconds:$to} + + (if $cx != "" then {context:$cx} else {} end)')" + resp="$(printf '%s' "$payload" | curl -s -m 20 -X POST "$RMM/api/agents/$agent/command" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + --data-binary @-)" + cmd_id="$(printf '%s' "$resp" | jq -r '.command_id // empty' 2>/dev/null)" + status="$(printf '%s' "$resp" | jq -r '.status // empty' 2>/dev/null)" + if [ -z "$cmd_id" ]; then + echo "[ERROR] dispatch failed: $resp" >&2 + _logerr "EncodedCommand dispatch failed" --context "agent=$agent resp=${resp:0:80}" + exit 3 + fi + echo "[OK] dispatched command_id=$cmd_id (initial status: ${status:-unknown}, timeout_seconds=$timeout)" + [ "$wait" = "0" ] && exit 0 + + local max_polls=$(( (timeout + 30) / 5 )) count=0 result + while [ $count -lt $max_polls ]; do + result="$(curl -s -m 15 "$RMM/api/commands/$cmd_id" -H "Authorization: Bearer $TOKEN")" + status="$(printf '%s' "$result" | jq -r '.status // empty' 2>/dev/null)" + case "$status" in + completed|failed|cancelled|interrupted) + echo "--- status: $status ---" + printf '%s' "$result" | jq -r ' + "exit_code: \(.exit_code // "n/a")", + "--- stdout ---", (.stdout // .output // ""), + "--- stderr ---", (.stderr // "")' 2>/dev/null || printf '%s\n' "$result" + [ "$status" = "completed" ] && exit 0 || exit 3 ;; + running|pending) count=$((count+1)); sleep 5 ;; + *) echo "[ERROR] empty/unknown status — response: $result" >&2 + _logerr "poll returned empty status" --context "cmd=$cmd_id agent=$agent" + exit 3 ;; + esac + done + echo "[WARNING] poll timeout after $((max_polls*5))s — command $cmd_id may still be running (last: $status)" + exit 3 +} + +case "${1:-}" in + encode) shift; cmd_encode "$@" ;; + rmm) shift; cmd_rmm "$@" ;; + *) usage ;; +esac diff --git a/.claude/settings.json b/.claude/settings.json index 443bf865..bf559fd8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,6 +19,11 @@ "type": "command", "command": "bash -c 'h=\"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-backslash-winpath.sh\"; [ -f \"$h\" ] && exec bash \"$h\" || exit 0'", "timeout": 10 + }, + { + "type": "command", + "command": "bash -c 'h=\"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-tmp-path.sh\"; [ -f \"$h\" ] && exec bash \"$h\" || exit 0'", + "timeout": 10 } ] } diff --git a/.claude/skills/errorlog-dream/SKILL.md b/.claude/skills/errorlog-dream/SKILL.md new file mode 100644 index 00000000..e14dec70 --- /dev/null +++ b/.claude/skills/errorlog-dream/SKILL.md @@ -0,0 +1,107 @@ +--- +name: errorlog-dream +description: "Lint the fleet error log (errorlog.md): top failure contexts, repeat ref= citations (rules that are not sticking), cross-day noise clusters, resolved entries, machine-name drift; --apply-archive rotates old entries to errorlog-archive/. Triggers: errorlog dream, lint/analyze the error log, errorlog patterns." +--- + +# Errorlog Dream + +Sibling of `memory-dream` for `errorlog.md` — the corpus of skill failures, +user corrections, and self-inflicted friction that CLAUDE.md mandates logging. +The log exists so we "never pay tokens twice for the same avoidable mistake"; +this skill is the payoff step: it reads the corpus and surfaces what to fix. + +Read-only by default. The single mutating op (`--apply-archive`) only moves +old entries into `errorlog-archive/YYYY-MM.md`. Everything judgment-shaped +lands in a `## PROPOSED` section for the operator. + +## What it reports + +`scripts/errorlog_dream.py` parses the canonical entry format +(`YYYY-MM-DD | MACHINE | skill/context | [type] msg [ctx: ...] (xN)`; +`(xN)` is the log helper's same-day repeat counter) and produces: + +1. SUMMARY — totals by type (exec/correction/friction), machine, date span, + plus a count of unparsed legacy blocks (left untouched, never archived). +2. TOP CONTEXTS — failure volume by context group, weighted by `(xN)`. +3. REPEAT REFS — `ref=` citations appearing >=2x. **The highest-value signal:** + a friction entry citing a documented gotcha means that rule/memory is NOT + working; repeat citations mean it keeps failing. Checks whether the cited + memory file actually exists. +4. NOISE CLUSTERS — same machine + skill + normalized message (ids/numbers + collapsed) recurring across >=3 days or >=5 weighted hits. These need a + skill-side fix (expected-condition filter, backoff, health-gate) — not + more log lines. +5. RESOLVED — entries carrying a `[RESOLVED ...]` annotation; archive + candidates regardless of age. +6. MACHINE-NAME DRIFT — the same machine logged under multiple spellings + (case drift in identity.json). +7. ARCHIVE CANDIDATES — entries older than `--days` (default 60). +8. PROPOSED — `[STRENGTHEN?]` (repeat refs -> add a mechanical guard or + rewrite the memory), `[SUPPRESS?]` (noise clusters -> fix the skill), + `[ARCHIVE?]` (resolved entries). + +## Modes + +- Default — report only; prints to stdout and writes + `errorlog-archive/_reports/YYYY-MM-DD-HHMM-dream.md`. +- `--no-file` — stdout only. +- `--report-file ` — explicit report path. +- `--days ` — archive-age threshold (default 60). +- `--apply-archive` — move entries older than `--days` into + `errorlog-archive/YYYY-MM.md` (grouped by entry month, verbatim lines, + unparsed legacy blocks never moved). Idempotent. + +## Running it + +Stdlib only, no pip deps. + +```bash +# report only +bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" "$CLAUDETOOLS_ROOT/.claude/skills/errorlog-dream/scripts/errorlog_dream.py" + +# rotate out entries older than 60 days +bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" "$CLAUDETOOLS_ROOT/.claude/skills/errorlog-dream/scripts/errorlog_dream.py" --apply-archive +``` + +## The operator MUST work the PROPOSED section — that is the run's purpose + +Same posture as memory-dream: the report is not the deliverable, the fixes +are. After each run: + +1. **`[STRENGTHEN?]` repeat refs** — the cited prose rule demonstrably fails + under flow. Prefer a MECHANICAL guard over more prose: a PreToolUse hook, + a wrapper that makes the safe path the only path (e.g. `-EncodedCommand` + for quote-mangling layers), or a script-side preflight. If the cited + memory file is missing, fix or remove the dangling ref convention. +2. **`[SUPPRESS?]` noise clusters** — patch the failing skill: filter + expected/validation responses out of logging (the gz.py pattern + + `GZ_SUPPRESS_ERRORLOG`-style env flag), add a stop-after-2-identical- + failures guard, or health-gate a chronically-down backend. +3. **`[ARCHIVE?]` / archive candidates** — run `--apply-archive`; append + `[RESOLVED ]` to entries you fixed so the next run proposes them. +4. Commit + `/sync` so the trimmed log and archive reach the fleet. + +If a proposal needs a decision you can't make, leave it AND say so in your +summary — don't silently skip the section. + +## Self-test + +`scripts/selftest.py` builds a synthetic errorlog in a temp dir and asserts +every detector fires (counters, repeat refs, noise clusters, resolved, +machine drift, archive candidates) and that `--apply-archive` moves exactly +the old entries, splits by month, preserves the marker + unparsed blocks, +and is idempotent. + +```bash +bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" "$CLAUDETOOLS_ROOT/.claude/skills/errorlog-dream/scripts/selftest.py" +``` + +## Files + +``` +.claude/skills/errorlog-dream/ + SKILL.md this file + scripts/errorlog_dream.py the analyzer (report / --apply-archive) + scripts/selftest.py fixture-based self-test +errorlog-archive/ rotated months + _reports/ (created on use) +``` diff --git a/.claude/skills/errorlog-dream/scripts/errorlog_dream.py b/.claude/skills/errorlog-dream/scripts/errorlog_dream.py new file mode 100644 index 00000000..8d846de0 --- /dev/null +++ b/.claude/skills/errorlog-dream/scripts/errorlog_dream.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python +"""errorlog-dream: lint the fleet error log (errorlog.md). + +Read-only by default. Parses the canonical entry format + YYYY-MM-DD | MACHINE | skill/context | [type] message [ctx: k=v ...] (xN) +and reports the patterns the log exists to surface: which contexts generate +the most failures, which documented rules keep getting violated (repeat ref= +citations), which identical failures recur across days (noise clusters that +need a skill-side fix, not more logging), resolved entries, machine-name +drift, and entries old enough to archive. + +The single mutating mode, --apply-archive, moves entries older than --days +(default 60) into errorlog-archive/YYYY-MM.md. Everything judgment-shaped +stays in the PROPOSED section for the operator, mirroring memory-dream. +""" + +import argparse +import io +import json +import os +import re +import sys +from collections import defaultdict +from datetime import datetime, timedelta, timezone + +MARKER = "" + +ENTRY_RE = re.compile( + r"^(\d{4}-\d{2}-\d{2}) \| ([^|]+?) \| ([^|]+?) \| (.*)$" +) +TYPE_RE = re.compile(r"^\[(correction|friction|[a-z-]+)\]\s+") +CTX_RE = re.compile(r"\[ctx: ([^\]]*)\]") +REF_RE = re.compile(r"ref=([A-Za-z0-9_./#-]+)") +COUNT_RE = re.compile(r" \(x(\d+)\)\s*$") +RESOLVED_RE = re.compile(r"\[RESOLVED[^\]]*\]", re.IGNORECASE) + + +def find_root(): + env = os.environ.get("CLAUDETOOLS_ROOT") + if env and os.path.isdir(env): + return env + here = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + idf = os.path.join(here, ".claude", "identity.json") + if os.path.isfile(idf): + try: + with io.open(idf, encoding="utf-8") as fh: + root = json.load(fh).get("claudetools_root") + if root and os.path.isdir(root): + return root + except Exception: + pass + return here + + +class Entry(object): + __slots__ = ("date", "machine", "skill", "msg", "etype", "ctx", "refs", + "count", "resolved", "lines", "raw_first") + + def __init__(self, date, machine, skill, msg, lines): + self.date = date + self.machine = machine.strip() + self.skill = skill.strip() + self.lines = lines # verbatim block lines (for archiving) + self.raw_first = lines[0] + m = COUNT_RE.search(msg) + self.count = int(m.group(1)) if m else 1 + msg = COUNT_RE.sub("", msg) + t = TYPE_RE.match(msg) + self.etype = t.group(1) if t else "exec" + cm = CTX_RE.search(msg) + self.ctx = cm.group(1) if cm else "" + self.refs = REF_RE.findall(msg) + self.resolved = bool(RESOLVED_RE.search(" ".join(lines))) + self.msg = msg + + @property + def context_group(self): + return self.skill.split("/", 1)[0].strip() + + def norm_msg(self): + """Message with volatile tokens (ids, numbers, hex, paths' digits) + collapsed, for grouping recurring failures across days.""" + m = TYPE_RE.sub("", self.msg) + m = CTX_RE.sub("", m) + m = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F-]{27,}", "", m) + m = re.sub(r"\b[0-9a-fA-F]{12,}\b", "", m) + m = re.sub(r"\d+", "", m) + return re.sub(r"\s+", " ", m).strip().lower() + + +def parse_log(path): + """Return (header_lines, entries, unparsed_blocks, trailing_map). + + Blocks are runs of consecutive non-blank lines after the marker. A block + whose first line matches ENTRY_RE is an Entry (continuation lines belong + to it -- the pre-helper era wrote multi-line entries by hand); anything + else is an unparsed block, reported but never touched. + """ + with io.open(path, encoding="utf-8") as fh: + lines = fh.read().splitlines() + header, rest, seen_marker = [], [], False + for ln in lines: + (rest if seen_marker else header).append(ln) + if not seen_marker and ln.strip() == MARKER: + seen_marker = True + if not seen_marker: + rest, header = header, [] + + blocks, cur = [], [] + for ln in rest: + if ln.strip(): + cur.append(ln) + elif cur: + blocks.append(cur) + cur = [] + if cur: + blocks.append(cur) + + entries, unparsed = [], [] + for b in blocks: + m = ENTRY_RE.match(b[0]) + if m: + entries.append(Entry(m.group(1), m.group(2), m.group(3), m.group(4), b)) + else: + unparsed.append(b) + return header, entries, unparsed + + +def analyze(entries, unparsed, root, archive_days, today): + r = {} + weight = lambda es: sum(e.count for e in es) + + r["total"] = len(entries) + r["weighted"] = weight(entries) + r["unparsed"] = len(unparsed) + if entries: + r["span"] = (min(e.date for e in entries), max(e.date for e in entries)) + by_type = defaultdict(int) + for e in entries: + by_type[e.etype] += 1 + r["by_type"] = dict(by_type) + + ctxs = defaultdict(list) + for e in entries: + ctxs[e.context_group].append(e) + r["top_contexts"] = sorted( + ((c, weight(es), len(es)) for c, es in ctxs.items()), + key=lambda t: -t[1])[:15] + + # repeat ref= citations: >=2 means a documented rule/memory is not sticking + refs = defaultdict(list) + for e in entries: + for ref in e.refs: + refs[ref].append(e) + mem_dir = os.path.join(root, ".claude", "memory") + rep = [] + for ref, es in sorted(refs.items(), key=lambda kv: -len(kv[1])): + if len(es) < 2: + continue + base = ref.split("#", 1)[0].split("/")[-1] + cand = base if base.endswith(".md") else base + ".md" + exists = os.path.isfile(os.path.join(mem_dir, cand)) + rep.append((ref, len(es), exists, sorted({e.date for e in es})[-3:])) + r["repeat_refs"] = rep + + # noise clusters: same machine+skill+normalized message on >=3 distinct + # days (the helper's (xN) dedup already collapses same-day repeats) + clusters = defaultdict(list) + for e in entries: + clusters[(e.machine, e.skill, e.norm_msg())].append(e) + noise = [] + for (mach, skill, norm), es in clusters.items(): + days = sorted({e.date for e in es}) + if len(days) >= 3 or weight(es) >= 5: + noise.append((mach, skill, norm[:110], weight(es), len(days))) + r["noise"] = sorted(noise, key=lambda t: -t[3])[:15] + + r["resolved"] = [e for e in entries if e.resolved] + + machines = defaultdict(set) + for e in entries: + machines[e.machine.lower()].add(e.machine) + r["machine_drift"] = {k: sorted(v) for k, v in machines.items() if len(v) > 1} + r["by_machine"] = sorted( + ((m, weight(es)) for m, es in + ((m, [e for e in entries if e.machine.lower() == m]) for m in machines)), + key=lambda t: -t[1]) + + cutoff = (today - timedelta(days=archive_days)).strftime("%Y-%m-%d") + r["cutoff"] = cutoff + r["archive"] = [e for e in entries if e.date < cutoff] + return r + + +def render(r, archive_days): + L = [] + add = L.append + add("# errorlog-dream report") + add("") + add("## SUMMARY") + span = r.get("span") + add("- entries: %d parsed (%d weighted with (xN) counters), %d unparsed legacy block(s)" + % (r["total"], r["weighted"], r["unparsed"])) + if span: + add("- span: %s .. %s" % span) + add("- by type: " + ", ".join("%s=%d" % kv for kv in sorted(r["by_type"].items()))) + add("- by machine: " + ", ".join("%s=%d" % kv for kv in r["by_machine"])) + add("") + add("## TOP CONTEXTS (weighted)") + for c, w, n in r["top_contexts"]: + add("- %-22s %4d (%d entries)" % (c, w, n)) + add("") + add("## REPEAT REFS -- documented rules that are NOT sticking") + if r["repeat_refs"]: + for ref, n, exists, dates in r["repeat_refs"]: + add("- ref=%s cited %dx (last: %s) -- memory file %s" + % (ref, n, ", ".join(dates), "exists" if exists else "NOT FOUND")) + else: + add("- none") + add("") + add("## NOISE CLUSTERS -- identical failures recurring across days") + if r["noise"]: + for mach, skill, norm, w, days in r["noise"]: + add("- %s | %s | %dx over %d day(s): %s" % (mach, skill, w, days, norm)) + else: + add("- none") + add("") + add("## RESOLVED entries (archive candidates regardless of age)") + for e in r["resolved"]: + add("- %s | %s | %s" % (e.date, e.machine, e.skill)) + if not r["resolved"]: + add("- none") + add("") + add("## MACHINE-NAME DRIFT") + if r["machine_drift"]: + for k, variants in sorted(r["machine_drift"].items()): + add("- %s spelled %s -- normalize identity.json .machine on the odd one out" + % (k, " / ".join(variants))) + else: + add("- none") + add("") + add("## ARCHIVE CANDIDATES (older than %d days, cutoff %s)" % (archive_days, r["cutoff"])) + add("- %d entr%s -- run --apply-archive to move them to errorlog-archive/YYYY-MM.md" + % (len(r["archive"]), "y" if len(r["archive"]) == 1 else "ies")) + add("") + add("## PROPOSED (needs human approval)") + for ref, n, exists, dates in r["repeat_refs"]: + add("- [STRENGTHEN?] ref=%s keeps repeating (%dx)%s -- the prose rule failed; " + "add a mechanical guard (hook/wrapper/preflight) or rewrite the memory" + % (ref, n, "" if exists else " (and the cited memory file is MISSING)")) + for mach, skill, norm, w, days in r["noise"]: + add("- [SUPPRESS?] %s/%s fails identically %dx over %d days -- fix the skill " + "(backoff, expected-condition filter, or health-gate), don't keep logging it" + % (mach, skill, w, days)) + for e in r["resolved"]: + add("- [ARCHIVE?] resolved entry %s | %s | %s can move to the archive now" + % (e.date, e.machine, e.skill)) + if not (r["repeat_refs"] or r["noise"] or r["resolved"]): + add("- nothing to propose") + add("") + return "\n".join(L) + + +def apply_archive(log_path, root, header, entries, unparsed, cutoff_entries): + """Move cutoff_entries' blocks into errorlog-archive/YYYY-MM.md (append, + newest-first order preserved as-is) and rewrite errorlog.md without them. + Unparsed blocks are never moved.""" + arch_dir = os.path.join(root, "errorlog-archive") + if not os.path.isdir(arch_dir): + os.makedirs(arch_dir) + by_month = defaultdict(list) + for e in cutoff_entries: + by_month[e.date[:7]].append(e) + for month, es in sorted(by_month.items()): + p = os.path.join(arch_dir, "%s.md" % month) + new = not os.path.isfile(p) + with io.open(p, "a", encoding="utf-8", newline="\n") as fh: + if new: + fh.write("# Error Log archive -- %s\n\nMoved out of errorlog.md by " + "errorlog-dream --apply-archive.\n" % month) + for e in es: + fh.write("\n" + "\n".join(e.lines) + "\n") + print("[OK] archived %d entr%s -> errorlog-archive/%s.md" + % (len(es), "y" if len(es) == 1 else "ies", month)) + + keep_ids = {id(e) for e in entries} - {id(e) for e in cutoff_entries} + out = list(header) + for e in entries: + if id(e) in keep_ids: + out.append("") + out.extend(e.lines) + for b in unparsed: + out.append("") + out.extend(b) + out.append("") + with io.open(log_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write("\n".join(out)) + print("[OK] errorlog.md rewritten: %d entries kept, %d archived, %d unparsed block(s) untouched" + % (len(keep_ids), len(cutoff_entries), len(unparsed))) + + +def main(argv=None): + ap = argparse.ArgumentParser(description="lint errorlog.md") + ap.add_argument("--days", type=int, default=60, + help="archive-candidate age threshold (default 60)") + ap.add_argument("--apply-archive", action="store_true", + help="move entries older than --days to errorlog-archive/") + ap.add_argument("--no-file", action="store_true", + help="print report to stdout only") + ap.add_argument("--report-file", default=None) + ap.add_argument("--log", default=None, help="path to errorlog.md (for tests)") + ap.add_argument("--root", default=None, help="repo root override (for tests)") + args = ap.parse_args(argv) + + root = args.root or find_root() + log_path = args.log or os.path.join(root, "errorlog.md") + if not os.path.isfile(log_path): + print("[ERROR] %s not found" % log_path, file=sys.stderr) + return 2 + + header, entries, unparsed = parse_log(log_path) + today = datetime.now(timezone.utc) + r = analyze(entries, unparsed, root, args.days, today) + report = render(r, args.days) + print(report) + + if not args.no_file: + rp = args.report_file + if not rp: + rdir = os.path.join(root, "errorlog-archive", "_reports") + if not os.path.isdir(rdir): + os.makedirs(rdir) + rp = os.path.join(rdir, today.strftime("%Y-%m-%d-%H%M") + "-dream.md") + with io.open(rp, "w", encoding="utf-8", newline="\n") as fh: + fh.write(report + "\n") + print("[OK] report written: %s" % os.path.relpath(rp, root)) + + if args.apply_archive: + if r["archive"]: + apply_archive(log_path, root, header, entries, unparsed, r["archive"]) + else: + print("[OK] nothing old enough to archive (cutoff %s)" % r["cutoff"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/errorlog-dream/scripts/selftest.py b/.claude/skills/errorlog-dream/scripts/selftest.py new file mode 100644 index 00000000..764f3f1f --- /dev/null +++ b/.claude/skills/errorlog-dream/scripts/selftest.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +"""selftest for errorlog-dream: run the analyzer against a synthetic errorlog +in a temp dir and assert each detector fires, then assert --apply-archive +moves exactly the old entries and leaves the marker, recent entries, and +unparsed legacy blocks intact.""" + +import io +import os +import shutil +import sys +import tempfile +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import errorlog_dream as ed + + +def build_fixture(root, today): + d = lambda n: (today - timedelta(days=n)).strftime("%Y-%m-%d") + old1 = d(95) + old2 = d(65) + recent = d(3) + lines = [ + "# Error Log", + "", + ed.MARKER, + "", + # noise cluster: same machine+skill+shape on 3 distinct days + "%s | BOX-A | widget/api | HTTP 500 on id 12345 [ctx: cmd=list]" % d(1), + "", + "%s | BOX-A | widget/api | HTTP 500 on id 99881 [ctx: cmd=list] (x3)" % d(2), + "", + "%s | BOX-A | widget/api | HTTP 500 on id 40404 [ctx: cmd=list]" % recent, + "", + # repeat ref (2x) citing an existing memory + "%s | BOX-B | bash/env | [friction] used /tmp again [ctx: ref=fix_tmp_rule]" % d(4), + "", + "%s | BOX-B | bash/env | [friction] used /tmp AGAIN again [ctx: ref=fix_tmp_rule]" % recent, + "", + # machine-name case drift + "%s | box-b | coord | HTTP 0 talking to coord" % d(5), + "", + # resolved entry + "%s | BOX-A | sync/submodules | checkout aborted. [RESOLVED %s] fixed in sync.sh" % (d(6), d(5)), + "", + # correction + "%s | BOX-A | client/foo | [correction] assumed X; correct is Y" % d(7), + "", + # old entries -> archive candidates (two months) + "%s | BOX-A | oldskill | ancient failure one" % old1, + "", + "%s | BOX-B | oldskill | ancient failure two" % old2, + "", + # legacy multi-line unparsed block (no pipes on first line) + "Some legacy hand-written note", + "spanning two lines with no format.", + "", + ] + log = os.path.join(root, "errorlog.md") + with io.open(log, "w", encoding="utf-8", newline="\n") as fh: + fh.write("\n".join(lines)) + mem = os.path.join(root, ".claude", "memory") + os.makedirs(mem) + with io.open(os.path.join(mem, "fix_tmp_rule.md"), "w", encoding="utf-8") as fh: + fh.write("---\nname: fix_tmp_rule\n---\nrule body\n") + return log + + +def main(): + tmp = tempfile.mkdtemp(prefix="eldream-test-") + failures = [] + ok = lambda cond, name: failures.append(name) if not cond else None + try: + today = datetime.now(timezone.utc) + log = build_fixture(tmp, today) + header, entries, unparsed = ed.parse_log(log) + + ok(len(entries) == 10, "parse: 10 entries (got %d)" % len(entries)) + ok(len(unparsed) == 1, "parse: 1 unparsed block") + ok(any(e.count == 3 for e in entries), "parse: (x3) counter read") + + r = ed.analyze(entries, unparsed, tmp, 60, today) + ok(r["weighted"] == 12, "weighted count incl x3 (got %d)" % r["weighted"]) + ok(r["by_type"].get("friction") == 2 and r["by_type"].get("correction") == 1, + "type tally") + ok(any(ref == "fix_tmp_rule" and n == 2 and exists + for ref, n, exists, _ in r["repeat_refs"]), + "repeat-ref detector (existing memory)") + ok(any(skill == "widget/api" and w == 5 for _, skill, _, w, _ in r["noise"]), + "noise-cluster detector") + ok(len(r["resolved"]) == 1, "resolved detector") + ok("box-b" in r["machine_drift"], "machine-drift detector") + ok(len(r["archive"]) == 2, "archive candidates (got %d)" % len(r["archive"])) + + report = ed.render(r, 60) + for token in ("STRENGTHEN?", "SUPPRESS?", "ARCHIVE?", "MACHINE-NAME DRIFT"): + ok(token in report, "report contains %s" % token) + + # --apply-archive: moves the 2 old entries, keeps everything else + rc = ed.main(["--log", log, "--root", tmp, "--no-file", "--apply-archive"]) + ok(rc == 0, "apply-archive exit 0") + h2, e2, u2 = ed.parse_log(log) + ok(len(e2) == 8, "post-archive: 8 entries kept (got %d)" % len(e2)) + ok(len(u2) == 1, "post-archive: unparsed block untouched") + with io.open(log, encoding="utf-8") as fh: + body = fh.read() + ok(ed.MARKER in body, "post-archive: marker intact") + ok("ancient failure" not in body, "post-archive: old entries removed") + arch = os.path.join(tmp, "errorlog-archive") + months = sorted(f for f in os.listdir(arch) if f.endswith(".md")) + ok(len(months) == 2, "archive split by month (got %s)" % months) + joined = "" + for f in months: + with io.open(os.path.join(arch, f), encoding="utf-8") as fh: + joined += fh.read() + ok("ancient failure one" in joined and "ancient failure two" in joined, + "archived content present") + + # idempotent: second run archives nothing + rc = ed.main(["--log", log, "--root", tmp, "--no-file", "--apply-archive"]) + _, e3, _ = ed.parse_log(log) + ok(rc == 0 and len(e3) == 8, "second apply-archive is a no-op") + finally: + shutil.rmtree(tmp, ignore_errors=True) + + if failures: + print("[ERROR] selftest FAILED:") + for f in failures: + print(" - " + f) + return 1 + print("[OK] errorlog-dream selftest: all assertions passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/self-check/baseline/manifest.json b/.claude/skills/self-check/baseline/manifest.json index 4971ba4d..1caa708e 100644 --- a/.claude/skills/self-check/baseline/manifest.json +++ b/.claude/skills/self-check/baseline/manifest.json @@ -4,7 +4,6 @@ "derived_from": "GURU-5070", "derived_at": "2026-06-02", "note": "PROVISIONAL baseline, generated from a single known-good machine. V1 of self-check is a CENSUS tool: every machine probes itself, publishes to the coord API, and we refine this manifest from real fleet data (see baseline/README.md). Do NOT treat 'extra' or 'missing' items as authoritative until the fleet census has confirmed them across machines.", - "harness": { "min_version": "1.4.0", "version_file": ".claude/harness/VERSION", @@ -18,48 +17,104 @@ ], "guard_wired_in": ".claude/scripts/sync.sh" }, - "command_standard_links": [ { "topic": "syncro-billing", "standard": ".claude/standards/syncro/time-entry-protocol.md", "must_reference": "syncro\\.md|single source of truth", - "why": "the time-entry standard must DEFER to the /syncro command (one SSOT), not restate billing mechanics. A past drift had the standard say 'always timer' while the command said 'outlier only' — losing the pointer is the early warning of that re-drift." + "why": "the time-entry standard must DEFER to the /syncro command (one SSOT), not restate billing mechanics. A past drift had the standard say 'always timer' while the command said 'outlier only' \u2014 losing the pointer is the early warning of that re-drift." } ], "command_standard_links_note": "Deterministic half of the command-restates-standard lint: each linked standard must contain a defer-to-SSOT pointer (must_reference, a grep -iE regex). A WARN means the standard may have drifted back into restating/contradicting the command. The SEMANTIC contradiction judgement (read both files, decide if they actually conflict) is delegated to the model in SKILL.md, mirroring the memory contradiction pass.", - "required_tools": [ - { "name": "bash", "why": "hooks, scripts, sync, vault wrapper" }, - { "name": "git", "why": "repo + submodules + Gitea sync" }, - { "name": "jq", "why": "every hook and coord script parses JSON with jq" }, - { "name": "curl", "why": "coord API, vault, RMM, all HTTP calls" }, - { "name": "sops", "why": "vault decryption (SOPS)" }, - { "name": "age", "why": "SOPS age recipient/decrypt" }, - { "name": "ssh", "why": "infra access; must be system OpenSSH" } + { + "name": "bash", + "why": "hooks, scripts, sync, vault wrapper" + }, + { + "name": "git", + "why": "repo + submodules + Gitea sync" + }, + { + "name": "jq", + "why": "every hook and coord script parses JSON with jq" + }, + { + "name": "curl", + "why": "coord API, vault, RMM, all HTTP calls" + }, + { + "name": "sops", + "why": "vault decryption (SOPS)" + }, + { + "name": "age", + "why": "SOPS age recipient/decrypt" + }, + { + "name": "ssh", + "why": "infra access; must be system OpenSSH" + } ], - "required_python": { - "any_of": ["py", "python3", "python"], + "any_of": [ + "py", + "python3", + "python" + ], "why": "JSON sanitizer in check-messages.sh, identity migration, skill scripts. The resolved command is recorded in identity.json (.python.command)." }, - "capability_tools": [ - { "name": "ollama", "capability": "ollama_local", "why": "Tier-0 local inference (prose/classification)" }, - { "name": "cargo", "capability": "rust_build", "why": "GuruRMM / GuruConnect Rust builds" }, - { "name": "node", "capability": "node_build", "why": "dashboard / TS builds" }, - { "name": "gh", "capability": "github_cli", "why": "optional GitHub operations" }, - { "name": "docker", "capability": "containers", "why": "optional container workflows" }, - { "name": "op", "capability": "onepassword_cli","why": "1Password fallback credential access" } + { + "name": "ollama", + "capability": "ollama_local", + "why": "Tier-0 local inference (prose/classification)" + }, + { + "name": "cargo", + "capability": "rust_build", + "why": "GuruRMM / GuruConnect Rust builds" + }, + { + "name": "node", + "capability": "node_build", + "why": "dashboard / TS builds" + }, + { + "name": "gh", + "capability": "github_cli", + "why": "optional GitHub operations" + }, + { + "name": "docker", + "capability": "containers", + "why": "optional container workflows" + }, + { + "name": "op", + "capability": "onepassword_cli", + "why": "1Password fallback credential access" + } ], - "required_identity_fields": [ - "user", "full_name", "email", "role", "machine", - "vault_path", "claudetools_root", "platform", "architecture", - "python.command", "ollama.endpoint", "ollama.fallback", "ollama.prose_model" + "user", + "full_name", + "email", + "role", + "machine", + "vault_path", + "claudetools_root", + "platform", + "architecture", + "python.command", + "ollama.endpoint", + "ollama.fallback", + "ollama.prose_model" + ], + "optional_identity_fields": [ + "coord_api", + "last_updated" ], - "optional_identity_fields": ["coord_api", "last_updated"], - "required_scripts": [ ".claude/scripts/vault.sh", ".claude/scripts/sync.sh", @@ -70,21 +125,40 @@ "grok_recovery_scripts": [ ".claude/scripts/recover_grok_session.py" ], - "required_hook_files": [ ".claude/hooks/block-backslash-winpath.sh", + ".claude/hooks/block-tmp-path.sh", ".claude/hooks/post-commit.template" ], "grok_hook_files": [ ".grok/hooks/claudetools.json" ], - "required_settings_hooks": [ - { "event": "PreToolUse", "matcher": "Bash", "command_contains": "block-backslash-winpath.sh", "why": "blocks garbled backslash Windows-path redirects in Git Bash" }, - { "event": "UserPromptSubmit", "matcher": "", "command_contains": "check-messages.sh", "why": "injects unread coord messages + dev-mode locks each prompt" }, - { "event": "SessionStart", "matcher": "", "command_contains": "sync-memory.sh", "why": "pulls shared memory at session start" } + { + "event": "PreToolUse", + "matcher": "Bash", + "command_contains": "block-backslash-winpath.sh", + "why": "blocks garbled backslash Windows-path redirects in Git Bash" + }, + { + "event": "PreToolUse", + "matcher": "Bash", + "command_contains": "block-tmp-path.sh", + "why": "blocks /tmp file writes in Git Bash on Windows (Write/Python resolve /tmp differently - read-back fails)" + }, + { + "event": "UserPromptSubmit", + "matcher": "", + "command_contains": "check-messages.sh", + "why": "injects unread coord messages + dev-mode locks each prompt" + }, + { + "event": "SessionStart", + "matcher": "", + "command_contains": "sync-memory.sh", + "why": "pulls shared memory at session start" + } ], - "git": { "remote_host_contains": "git.azcomputerguru.com", "remote_host_internal_ip": "172.16.3.20", @@ -92,32 +166,75 @@ "post_commit_hook_expected": true, "post_commit_hook_note": "HOOKS.md mandates the dev-alerts post-commit hook in the main repo and each initialized submodule. Missing = AMBER (informational; reinstall from .claude/hooks/post-commit.template)." }, - "skills": [ - "1password", "b2", "bitdefender", "frontend-design", "gc-audit", - "impeccable", "memory-dream", "remediation-tool", "rmm-audit", - "screenconnect", "skill-creator", "stop-slop", "theme-factory", "self-check" + "1password", + "b2", + "bitdefender", + "frontend-design", + "gc-audit", + "impeccable", + "memory-dream", + "remediation-tool", + "rmm-audit", + "screenconnect", + "skill-creator", + "stop-slop", + "theme-factory", + "self-check" ], - "commands": [ - "1password", "checkpoint", "context", "create-spec", - "feature-request", "forum-post", "gc-feature-request", "import", - "inject-standards", "mailbox", "mode", "recover", "remediation-tool", - "rmm", "save", "scc", "shape-spec", "sync", "syncro-emergency-billing", - "syncro", "wiki-compile", "wiki-lint", "self-check" + "1password", + "checkpoint", + "context", + "create-spec", + "feature-request", + "forum-post", + "gc-feature-request", + "import", + "inject-standards", + "mailbox", + "mode", + "recover", + "remediation-tool", + "rmm", + "save", + "scc", + "shape-spec", + "sync", + "syncro-emergency-billing", + "syncro", + "wiki-compile", + "wiki-lint", + "self-check" ], - "capability_commands": [ - { "name": "autotask", "capability": "psa_autotask", "why": "Autotask PSA command. Syncro is the fleet-default PSA (feedback_psa_default_syncro.md), so /autotask is intentionally NOT in the shared repo and is absent on Syncro machines. Capability-gated, not required-everywhere; absence is INFO, never a FAIL. Present only on machines whose PSA is Autotask." } + { + "name": "autotask", + "capability": "psa_autotask", + "why": "Autotask PSA command. Syncro is the fleet-default PSA (feedback_psa_default_syncro.md), so /autotask is intentionally NOT in the shared repo and is absent on Syncro machines. Capability-gated, not required-everywhere; absence is INFO, never a FAIL. Present only on machines whose PSA is Autotask." + } ], "capability_commands_note": "Per-machine/per-capability slash commands that must NOT be required fleet-wide. The probe iterates only .commands[] for required conformance; entries here are documentary so a gated command is not silently dropped. Move a command here (and out of .commands[]) when it is intentionally machine-specific.", - "connectivity": [ - { "name": "coord_api", "url": "http://172.16.3.30:8001/api/coord/status", "required": true, "why": "live coordination source of truth" }, - { "name": "claudetools_api","url": "http://172.16.3.30:8001/health", "required": false, "why": "main API health" }, - { "name": "gitea_internal", "url": "http://172.16.3.20:3000", "required": false, "why": "internal Gitea (git/API on-network)" } + { + "name": "coord_api", + "url": "http://172.16.3.30:8001/api/coord/status", + "required": true, + "why": "live coordination source of truth" + }, + { + "name": "claudetools_api", + "url": "http://172.16.3.30:8001/health", + "required": false, + "why": "main API health" + }, + { + "name": "gitea_internal", + "url": "http://172.16.3.20:3000", + "required": false, + "why": "internal Gitea (git/API on-network)" + } ], - "memory": { "note": "Deterministic memory checks: MEMORY.md index exists + no orphaned memory files, plus the contradiction_patterns below. A pattern fires ONLY on machines where identity. == when_equals, so it flags a memory only where it is actually a contradiction for THIS box. Kept empty in V1 to avoid false positives; the real semantic contradiction analysis (memories vs identity.json + settings.json + this manifest) is done by the model per SKILL.md, optionally via Ollama Tier-0.", "pattern_schema": { @@ -128,7 +245,6 @@ }, "contradiction_patterns": [] }, - "capability_rules": { "ollama_local": { "tier0_engine": "local ollama (localhost:11434) for summarize/classify/extract/draft", diff --git a/errorlog.md b/errorlog.md index 37814ea0..dad37c40 100644 --- a/errorlog.md +++ b/errorlog.md @@ -7,6 +7,8 @@ pay tokens twice for the same avoidable mistake. Append newest at the top; keep `bash .claude/scripts/log-skill-error.sh "" "" [--correction|--friction] [--context "k=v"]` Format: `YYYY-MM-DD | MACHINE | command/skill/context | [type] error (brief) [ctx: ...]` +A trailing ` (xN)` is the helper's repeat counter: the same entry logged N times that +day (identical machine+skill+message collapse to one line instead of N duplicates). Categories (the `[type]` tag): _(none)_ = skill/command execution failure · `[correction]` = user corrected an improper assumption I made · @@ -17,6 +19,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · +2026-07-01 | GURU-5070 | bash/msys-pathconv | [friction] cmd.exe /c from Git-bash: MSYS converted /c to C: and opened an interactive cmd (2min timeout); use powershell.exe directly or MSYS_NO_PATHCONV=1 [ctx: ref=msys-path-conversion-family] + +2026-07-01 | GURU-5070 | bash/jq-windows | [friction] jq --rawfile with /dev/stdin fails on Windows jq (no /proc); build JSON from a shell var with jq -n --arg instead + 2026-07-01 | GURU-5070 | agy/gemini-verify | gemini verify failed: gemini-3.1-pro-preview quota exhausted, default-model fallback errored on OAuth _doSetupUser. Gemini unavailable for cross-vendor verification this session. [ctx: skill=agy] 2026-07-01 | GURU-5070 | agy | gemini returned no response (empty after 3 attempts) [ctx: mode=verify err= at async _doSetupUser (file:///C:/Users/guru/AppData/Roaming/npm/node_module] diff --git a/session-logs/2026-07/2026-07-01-mike-self-check-skill-desc-trim.md b/session-logs/2026-07/2026-07-01-mike-self-check-skill-desc-trim.md index 9f7a8833..19ef9f6c 100644 --- a/session-logs/2026-07/2026-07-01-mike-self-check-skill-desc-trim.md +++ b/session-logs/2026-07/2026-07-01-mike-self-check-skill-desc-trim.md @@ -113,3 +113,143 @@ None surfaced, created, or rotated. - Registry description totals: before 15,658 / after 9,786 (budget 10,500). - Semantic pass artifacts checked: `.claude/memory/feedback_ollama_tier0_routing.md`, `.claude/standards/syncro/time-entry-protocol.md`, `.claude/commands/syncro.md`. + +## Update: 15:48 PT — errorlog analysis + five harness improvements + +### Session Summary + +Mike asked for an analysis of the ClaudeTools harness based on usage + errorlog.md. +Full-log analysis (391 entries, 2026-06-14..07-01: 93 friction / 44 corrections / 250 exec): +40% of volume was two skills' machine-generated noise (bitdefender 111, synology 45); +top repeat refs proved two documented rules not sticking (feedback_windows_quote_stripping +9x, feedback_tmp_path_windows 6x); no linter existed for the corpus. Delivered a ranked +improvement list, then implemented five items across two rounds on Mike's go. + +Round 1: (a) log-skill-error.sh same-day DEDUP — identical date+machine+skill+message +bumps a trailing " (xN)" counter instead of appending a duplicate line; (b) fixed the +machine-name bug — helper read `.machine_name // .hostname` from identity.json but the +real field is `.machine`, so it always fell back to raw hostname (the Howard-Home vs +HOWARD-HOME case-drift cause); (c) verified gz.py (bitdefender) already carries the +expected-error suppression (_should_log_error + GZ_SUPPRESS_ERRORLOG + OID validation) +— no work needed; (d) errorlog.md header documents the (xN) convention. + +Round 2: (e) NEW skill `errorlog-dream` (.claude/skills/errorlog-dream/) — the linter the +log was waiting for; sibling of memory-dream: reports top contexts, repeat refs, cross-day +noise clusters, [RESOLVED] entries, machine drift, archive candidates; PROPOSED section +with [STRENGTHEN?]/[SUPPRESS?]/[ARCHIVE?]; --apply-archive rotates >60-day entries to +errorlog-archive/YYYY-MM.md; fixture selftest green; live run reproduces the manual +analysis. (f) vault.sh get-field null guard (D:/vault repo) — a missing field made yq +print literal "null" (4 chars) which callers consumed as a real credential (both +wrong-4-char-value incidents); now errors exit 1 and prints the entry's key paths. +(g) Discord hardening — post-bot-alert.sh: payload via stdin not argv (argv encoding +layer mangled non-ASCII into 50109) + >1900-char truncation; discord-dm.sh: lossless +newline-aware chunking into <=1900-char sequential messages (50035 fix that preserves +long content); live DM test to Mike with em-dash/smart-quotes/arrow passed. + +Round 3 (Mike: "keep going with the EncodedCommand wrapper and /tmp hook"): +(h) NEW .claude/scripts/ps-encoded.sh — encode prints a paste-safe +powershell -EncodedCommand one-liner (ScreenConnect/plink); rmm +dispatches via GuruRMM as command_type=shell (single quote-free base64 token) with +timeout_seconds + optional --user-session, then polls /api/commands/{id}; size guards +(warn 4KB / refuse 6KB encoded, --force) encode the ~7KB agent body limit. Verified: +byte-exact roundtrip; live powershell exec with embedded quotes and UNC \\ intact +(len=21 test). Strengthened feedback_windows_quote_stripping memory + MEMORY.md index ++ /rmm command doc (new "Quote-safe dispatch" section) to lead with the wrapper. +(i) NEW .claude/hooks/block-tmp-path.sh PreToolUse hook — blocks Bash WRITES to /tmp +on Windows only (redirects, tee, -o/--output, cp/mv/mktemp targets; reads and quoted +mentions pass); wired as second PreToolUse Bash hook in settings.json and added to +baseline manifest (required_hook_files + required_settings_hooks) so self-check +enforces fleet-wide. Effective for NEW sessions. + +Self-check re-run: caught my own /rmm doc edit as repo-vs-global divergence (WARN), +reconciled, published GREEN (86 pass / 0 warn) to coord. + +### Key Decisions + +- Dedup window = same calendar day (identical full line), not a rolling 24h — deterministic, + no timestamps needed, matches the (xN) display convention. +- errorlog-dream mirrors memory-dream's posture exactly: read-only default, one additive/ + rotational mutation (--apply-archive), judgment stays in PROPOSED for the operator. +- ps-encoded.sh dispatches as command_type=shell (cmd.exe) not powershell — avoids a + nested powershell-in-powershell spawn; the base64 token traverses cmd.exe unparsed. +- Scripts for ps-encoded MUST be authored with the Write tool — Git-bash heredocs + collapse \\ to \ even single-quoted (proved by od during testing; documented in the + memory + /rmm doc). +- /tmp hook is Windows-gated (uname MINGW/MSYS/CYGWIN) so shared settings.json does not + block Mac/Linux machines where /tmp is one real directory. +- vault get-field lists KEY PATHS only (never values) on failure — no secret leakage in + the error path. +- discord-dm chunks add NO markers so the receiver can copy-paste chunks back together + verbatim; post-bot-alert truncates instead (alerts are one-liners by contract). + +### Problems Encountered + +- Test harness bugs, not production bugs: jq --rawfile with /dev/stdin fails on Windows + jq (no /proc) — rebuilt test events with jq -n --arg; cmd.exe /c from Git-bash had /c + MSYS-converted to C:\ opening an interactive cmd (2-min timeout) — used powershell.exe + directly. Both logged to errorlog as [friction]. +- selftest fixture initially put both "old" entries in the same month — spread to d(95)/ + d(65) to exercise the month-split path. +- cp/mv /tmp pattern missed line-start cp (required leading whitespace) — anchored with + (^|[[:space:]]|;|\|). +- The UNC backslash "loss" during wrapper testing was the Git-bash heredoc collapsing \\ + BEFORE encoding (od-verified) — wrapper itself byte-exact; became the Write-tool + authoring rule. + +### Configuration Changes + +Created: +- .claude/skills/errorlog-dream/{SKILL.md, scripts/errorlog_dream.py, scripts/selftest.py} + (+ copies in ~/.claude/skills/errorlog-dream/) +- .claude/scripts/ps-encoded.sh +- .claude/hooks/block-tmp-path.sh (exec bit set via git update-index --chmod=+x) + +Modified: +- .claude/scripts/log-skill-error.sh (dedup pass + .machine field fix + doc header) +- errorlog.md (header documents (xN); 2 new friction entries from testing) +- D:/vault/scripts/vault.sh (get-field null guard + key-path listing) [vault repo] +- .claude/scripts/post-bot-alert.sh (stdin payload + truncation) +- .claude/scripts/discord-dm.sh (chunking loop) +- .claude/memory/feedback_windows_quote_stripping.md (+ mechanical-fix headline) +- .claude/memory/MEMORY.md (index line updated for the same memory) +- .claude/commands/rmm.md (+ Quote-safe dispatch section; synced to ~/.claude/commands/) +- .claude/settings.json (second PreToolUse Bash hook: block-tmp-path.sh) +- .claude/skills/self-check/baseline/manifest.json (required_hook_files + + required_settings_hooks += block-tmp-path) + +### Credentials & Secrets + +None created/rotated. vault get-field behavior change: missing/null field now exits 1 +with a key-path listing (keys only, values never printed). + +### Commands & Outputs + +- Lint the log: `bash .claude/scripts/py.sh .claude/skills/errorlog-dream/scripts/errorlog_dream.py [--apply-archive]` +- Quote-safe PS dispatch: `bash .claude/scripts/ps-encoded.sh rmm script.ps1 --timeout 120 [--user-session]` + / paste one-liner: `... encode script.ps1` +- Selftests: errorlog-dream selftest.py all-pass; discord chunker lossless (3-chunk + + hard-split); /tmp hook 11-case matrix + grok decision JSON; EncodedCommand len=21 UNC proof. +- Final: self-check GREEN 86/0/0 published (selfcheck_GURU-5070). + +### Pending / Incomplete Tasks + +- Remaining from the improvement list: backend health cache (agy/grok/rmm/coord chronic + failures — skip retries when a backend is known-down) and Syncro preflight gates in the + skill script (priority format, product_category null check, prepay detail-GET, unbilled + line-item check before invoice). +- Howard-Home env checks (websocket-client, ff.py daemon, tailscale backend state) as + self-check capability checks — proposed, not built. +- Other machines: pull + rerun /self-check (new hook enforced), and their identity.json + case drift (HOWARD-HOME vs Howard-Home) normalizes automatically via the .machine fix. +- errorlog-dream --apply-archive has nothing to rotate until entries age past 60 days + (log starts 2026-06-14). + +### Reference Information + +- New skill: .claude/skills/errorlog-dream/ ; wrapper: .claude/scripts/ps-encoded.sh ; + hook: .claude/hooks/block-tmp-path.sh +- Errorlog stats at analysis time: 391 entries, top contexts bitdefender 111 / synology 45 / + rmm 30; repeat refs quote-stripping 9x, tmp-path 6x; machines Howard-Home 256 / GURU-5070 116. +- RMM dispatch shape: POST $RMM/api/agents/{id}/command {command_type, command, + timeout_seconds[, context]}; poll GET $RMM/api/commands/{command_id}. +- Discord test message id 1522005953896120494 (mike DM, non-ASCII survival test).