sync: auto-sync from GURU-5070 at 2026-07-01 15:49:56

Author: Mike Swanson
Machine: GURU-5070
Timestamp: 2026-07-01 15:49:56
This commit is contained in:
2026-07-01 15:50:48 -07:00
parent 1775571abb
commit 2937b00ebf
15 changed files with 1217 additions and 67 deletions

View File

@@ -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.) **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 ### Basic dispatch
```bash ```bash

55
.claude/hooks/block-tmp-path.sh Executable file
View File

@@ -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

View File

@@ -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. - [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. - [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. - [/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. - [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). - [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. - [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.

View File

@@ -5,6 +5,16 @@ metadata:
type: feedback 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 <agent-uuid> <file>` 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 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 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 arg survives; the `"` characters vanish, so the receiving program sees broken

View File

@@ -99,17 +99,45 @@ if [ "$MODE" = "dm" ]; then
TARGET="$CHID" TARGET="$CHID"
fi fi
# --- post the message (printf | --data-binary @- — direct -d mangles multiline JSON) --- # --- chunk: Discord caps content at 2000 chars (code 50035 on overflow).
RESP="$(printf '%s' "$(jq -nc --arg c "$MSG" '{content:$c}')" | \ # This tool's job is delivering LONG content intact, so split into <=1900-char
curl -s -m 15 -w $'\n%{http_code}' "${auth[@]}" \ # pieces (preferring newline boundaries) and send them in order — no markers
-X POST "$API/channels/${TARGET}/messages" --data-binary @-)" # added, so the receiver can copy-paste the sequence back together verbatim.
HTTP="$(printf '%s' "$RESP" | tail -n1)" LIMIT=1900
BODY="$(printf '%s' "$RESP" | sed '$d')" 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'))" echo "[OK] discord-dm: sent to ${LABEL} (message_id=$(printf '%s' "$BODY" | jq -r '.id // empty'))"
exit 0
fi fi
echo "[ERROR] discord-dm: Discord returned ${HTTP:-no-response}${BODY}" >&2 exit 0
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

View File

@@ -33,6 +33,11 @@
# Writes: YYYY-MM-DD | MACHINE | <skill> | [<type>] <error> [ctx: <context>] # Writes: YYYY-MM-DD | MACHINE | <skill> | [<type>] <error> [ctx: <context>]
# (newest entry inserted at the top, just under the append marker). # (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, # Soft-fail by design: this NEVER breaks the caller. Missing log, missing jq,
# empty message -> prints a [WARN] to stderr and exits 0. # empty message -> prints a [WARN] to stderr and exits 0.
set -u set -u
@@ -62,7 +67,7 @@ DATE="$(date -u +%F)"
IDF="$ROOT/.claude/identity.json" IDF="$ROOT/.claude/identity.json"
MACHINE="" MACHINE=""
if command -v jq >/dev/null 2>&1 && [ -f "$IDF" ]; then 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 fi
[ -z "$MACHINE" ] && MACHINE="$(hostname 2>/dev/null || echo unknown)" [ -z "$MACHINE" ] && MACHINE="$(hostname 2>/dev/null || echo unknown)"
@@ -76,6 +81,34 @@ ENTRY="$DATE | $MACHINE | $SKILL | $MSG"
MARK="<!-- Append entries below this line -->" MARK="<!-- Append entries below this line -->"
TMP="$LOG.tmp.$$" 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" ' if awk -v entry="$ENTRY" -v mark="$MARK" '
{ print } { print }
($0==mark && !done) { print ""; print entry; done=1 } ($0==mark && !done) { print ""; print entry; done=1 }

View File

@@ -35,6 +35,13 @@ if [ -z "$MSG" ]; then
exit 0 exit 0
fi 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 --- # --- channel routing ---
# Optional 2nd arg: "dev"/"bot" keyword, a raw channel id, or omit for auto. # Optional 2nd arg: "dev"/"bot" keyword, a raw channel id, or omit for auto.
# Auto: RMM/Dev-category prefixes -> #dev-alerts (private); everything else # Auto: RMM/Dev-category prefixes -> #dev-alerts (private); everything else
@@ -67,14 +74,15 @@ if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
exit 0 exit 0
fi fi
# --- post (jq builds JSON so the message is safely escaped) --- # --- post (jq builds the JSON; piped to curl via STDIN, never argv — a payload
PAYLOAD="$(jq -nc --arg c "$MSG" '{content: $c}')" # passed as a command-line arg on Windows goes through the argv encoding layer,
RESP="$(curl -s -m 15 -w $'\n%{http_code}' \ # 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" \ -X POST "https://discord.com/api/v10/channels/${CHANNEL_ID}/messages" \
-H "Authorization: Bot ${TOKEN}" \ -H "Authorization: Bot ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "User-Agent: ClaudeToolsBot (claudetools, 1.0)" \ -H "User-Agent: ClaudeToolsBot (claudetools, 1.0)" \
--data-binary "$PAYLOAD" 2>/dev/null)" --data-binary @- 2>/dev/null)"
HTTP="$(printf '%s' "$RESP" | tail -n1)" HTTP="$(printf '%s' "$RESP" | tail -n1)"
BODY="$(printf '%s' "$RESP" | sed '$d')" BODY="$(printf '%s' "$RESP" | sed '$d')"

View File

@@ -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 <script.ps1|-> print the one-liner for
# ScreenConnect / plink / any paste
# ps-encoded.sh rmm <agent-uuid> <script.ps1|-> dispatch via GuruRMM + poll
# [--timeout <sec>] 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 <path>'. 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

View File

@@ -19,6 +19,11 @@
"type": "command", "type": "command",
"command": "bash -c 'h=\"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-backslash-winpath.sh\"; [ -f \"$h\" ] && exec bash \"$h\" || exit 0'", "command": "bash -c 'h=\"${CLAUDE_PROJECT_DIR}/.claude/hooks/block-backslash-winpath.sh\"; [ -f \"$h\" ] && exec bash \"$h\" || exit 0'",
"timeout": 10 "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
} }
] ]
} }

View File

@@ -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 <path>` — explicit report path.
- `--days <n>` — 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 <date>]` 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)
```

View File

@@ -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 = "<!-- Append entries below this line -->"
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,}", "<uuid>", m)
m = re.sub(r"\b[0-9a-fA-F]{12,}\b", "<hex>", m)
m = re.sub(r"\d+", "<n>", 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())

View File

@@ -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())

View File

@@ -4,7 +4,6 @@
"derived_from": "GURU-5070", "derived_from": "GURU-5070",
"derived_at": "2026-06-02", "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.", "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": { "harness": {
"min_version": "1.4.0", "min_version": "1.4.0",
"version_file": ".claude/harness/VERSION", "version_file": ".claude/harness/VERSION",
@@ -18,48 +17,104 @@
], ],
"guard_wired_in": ".claude/scripts/sync.sh" "guard_wired_in": ".claude/scripts/sync.sh"
}, },
"command_standard_links": [ "command_standard_links": [
{ {
"topic": "syncro-billing", "topic": "syncro-billing",
"standard": ".claude/standards/syncro/time-entry-protocol.md", "standard": ".claude/standards/syncro/time-entry-protocol.md",
"must_reference": "syncro\\.md|single source of truth", "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.", "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": [ "required_tools": [
{ "name": "bash", "why": "hooks, scripts, sync, vault wrapper" }, {
{ "name": "git", "why": "repo + submodules + Gitea sync" }, "name": "bash",
{ "name": "jq", "why": "every hook and coord script parses JSON with jq" }, "why": "hooks, scripts, sync, vault wrapper"
{ "name": "curl", "why": "coord API, vault, RMM, all HTTP calls" }, },
{ "name": "sops", "why": "vault decryption (SOPS)" }, {
{ "name": "age", "why": "SOPS age recipient/decrypt" }, "name": "git",
{ "name": "ssh", "why": "infra access; must be system OpenSSH" } "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": { "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)." "why": "JSON sanitizer in check-messages.sh, identity migration, skill scripts. The resolved command is recorded in identity.json (.python.command)."
}, },
"capability_tools": [ "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": "ollama",
{ "name": "node", "capability": "node_build", "why": "dashboard / TS builds" }, "capability": "ollama_local",
{ "name": "gh", "capability": "github_cli", "why": "optional GitHub operations" }, "why": "Tier-0 local inference (prose/classification)"
{ "name": "docker", "capability": "containers", "why": "optional container workflows" }, },
{ "name": "op", "capability": "onepassword_cli","why": "1Password fallback credential access" } {
"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": [ "required_identity_fields": [
"user", "full_name", "email", "role", "machine", "user",
"vault_path", "claudetools_root", "platform", "architecture", "full_name",
"python.command", "ollama.endpoint", "ollama.fallback", "ollama.prose_model" "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": [ "required_scripts": [
".claude/scripts/vault.sh", ".claude/scripts/vault.sh",
".claude/scripts/sync.sh", ".claude/scripts/sync.sh",
@@ -70,21 +125,40 @@
"grok_recovery_scripts": [ "grok_recovery_scripts": [
".claude/scripts/recover_grok_session.py" ".claude/scripts/recover_grok_session.py"
], ],
"required_hook_files": [ "required_hook_files": [
".claude/hooks/block-backslash-winpath.sh", ".claude/hooks/block-backslash-winpath.sh",
".claude/hooks/block-tmp-path.sh",
".claude/hooks/post-commit.template" ".claude/hooks/post-commit.template"
], ],
"grok_hook_files": [ "grok_hook_files": [
".grok/hooks/claudetools.json" ".grok/hooks/claudetools.json"
], ],
"required_settings_hooks": [ "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": "PreToolUse",
{ "event": "SessionStart", "matcher": "", "command_contains": "sync-memory.sh", "why": "pulls shared memory at session start" } "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": { "git": {
"remote_host_contains": "git.azcomputerguru.com", "remote_host_contains": "git.azcomputerguru.com",
"remote_host_internal_ip": "172.16.3.20", "remote_host_internal_ip": "172.16.3.20",
@@ -92,32 +166,75 @@
"post_commit_hook_expected": true, "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)." "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": [ "skills": [
"1password", "b2", "bitdefender", "frontend-design", "gc-audit", "1password",
"impeccable", "memory-dream", "remediation-tool", "rmm-audit", "b2",
"screenconnect", "skill-creator", "stop-slop", "theme-factory", "self-check" "bitdefender",
"frontend-design",
"gc-audit",
"impeccable",
"memory-dream",
"remediation-tool",
"rmm-audit",
"screenconnect",
"skill-creator",
"stop-slop",
"theme-factory",
"self-check"
], ],
"commands": [ "commands": [
"1password", "checkpoint", "context", "create-spec", "1password",
"feature-request", "forum-post", "gc-feature-request", "import", "checkpoint",
"inject-standards", "mailbox", "mode", "recover", "remediation-tool", "context",
"rmm", "save", "scc", "shape-spec", "sync", "syncro-emergency-billing", "create-spec",
"syncro", "wiki-compile", "wiki-lint", "self-check" "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": [ "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.", "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": [ "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": "coord_api",
{ "name": "gitea_internal", "url": "http://172.16.3.20:3000", "required": false, "why": "internal Gitea (git/API on-network)" } "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": { "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_field> == 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.", "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_field> == 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": { "pattern_schema": {
@@ -128,7 +245,6 @@
}, },
"contradiction_patterns": [] "contradiction_patterns": []
}, },
"capability_rules": { "capability_rules": {
"ollama_local": { "ollama_local": {
"tier0_engine": "local ollama (localhost:11434) for summarize/classify/extract/draft", "tier0_engine": "local ollama (localhost:11434) for summarize/classify/extract/draft",

View File

@@ -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 "<skill/context>" "<brief>" [--correction|--friction] [--context "k=v"]` `bash .claude/scripts/log-skill-error.sh "<skill/context>" "<brief>" [--correction|--friction] [--context "k=v"]`
Format: `YYYY-MM-DD | MACHINE | command/skill/context | [type] error (brief) [ctx: ...]` 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 · Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
`[correction]` = user corrected an improper assumption I made · `[correction]` = user corrected an improper assumption I made ·
@@ -17,6 +19,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
<!-- Append entries below this line --> <!-- Append entries below this line -->
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-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] 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]

View File

@@ -113,3 +113,143 @@ None surfaced, created, or rotated.
- Registry description totals: before 15,658 / after 9,786 (budget 10,500). - Registry description totals: before 15,658 / after 9,786 (budget 10,500).
- Semantic pass artifacts checked: `.claude/memory/feedback_ollama_tier0_routing.md`, - Semantic pass artifacts checked: `.claude/memory/feedback_ollama_tier0_routing.md`,
`.claude/standards/syncro/time-entry-protocol.md`, `.claude/commands/syncro.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 <file|-> prints a paste-safe
powershell -EncodedCommand one-liner (ScreenConnect/plink); rmm <agent-uuid> <file>
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 <agent-uuid> 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).