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:
@@ -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
|
||||
|
||||
55
.claude/hooks/block-tmp-path.sh
Executable file
55
.claude/hooks/block-tmp-path.sh
Executable 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,11 @@
|
||||
# Writes: YYYY-MM-DD | MACHINE | <skill> | [<type>] <error> [ctx: <context>]
|
||||
# (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="<!-- Append entries below this line -->"
|
||||
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 }
|
||||
|
||||
@@ -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')"
|
||||
|
||||
141
.claude/scripts/ps-encoded.sh
Normal file
141
.claude/scripts/ps-encoded.sh
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
107
.claude/skills/errorlog-dream/SKILL.md
Normal file
107
.claude/skills/errorlog-dream/SKILL.md
Normal 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)
|
||||
```
|
||||
347
.claude/skills/errorlog-dream/scripts/errorlog_dream.py
Normal file
347
.claude/skills/errorlog-dream/scripts/errorlog_dream.py
Normal 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())
|
||||
136
.claude/skills/errorlog-dream/scripts/selftest.py
Normal file
136
.claude/skills/errorlog-dream/scripts/selftest.py
Normal 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())
|
||||
@@ -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_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": {
|
||||
@@ -128,7 +245,6 @@
|
||||
},
|
||||
"contradiction_patterns": []
|
||||
},
|
||||
|
||||
"capability_rules": {
|
||||
"ollama_local": {
|
||||
"tier0_engine": "local ollama (localhost:11434) for summarize/classify/extract/draft",
|
||||
|
||||
Reference in New Issue
Block a user