| name | error-log |
| description | Captures non-obvious errors and their solutions as structured Obsidian notes for future reference. Use when: (1) /error-log command to capture an error from the current session, (2) /error-log <error description> to log a specific error, (3) user wants to document a tricky bug fix or error resolution. |
| metadata | {"version":"1.0.0"} |
Error Log โ Capture Error Solutions to Obsidian
Analyze the current conversation for error -> investigation -> fix patterns, structure them as reusable troubleshooting notes, and save to the Obsidian vault.
Tools needed: Bash, Read
Procedure
Follow these steps exactly. Do not skip steps or reorder them.
Step 1 โ Read config
Run:
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
python3 -c '
import sys, os
import glob, json, os, re, sys
def _ob_hooks():
try:
for _m in json.load(open(os.path.expanduser("~/.claude/plugins/known_marketplaces.json"))).values():
_s = _m.get("source") if isinstance(_m, dict) else None
if not (isinstance(_s, dict) and _s.get("source") == "directory"):
continue
_i = _m.get("installLocation") if isinstance(_m, dict) else None
if not (isinstance(_i, str) and os.path.isabs(_i)):
continue
_h = os.path.join(_i, "hooks")
if os.path.isfile(os.path.join(_h, "obsidian_utils.py")):
return _h
except Exception:
pass
_c = [_d for _d in glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")) if re.fullmatch("[0-9]+([.][0-9]+)*", _d.split("/")[-2])]
return max(_c, key=lambda _p: ([int(_n) for _n in _p.split("/")[-2].split(".")], _p), default="hooks")
sys.path.insert(0, _ob_hooks())
from obsidian_utils import load_config
c = load_config()
if not c.get("vault_path"):
print("ERROR: vault_path not configured", file=sys.stderr)
sys.exit(1)
print("VAULT=" + c["vault_path"])
print("SESS=" + c.get("sessions_folder", "claude-sessions"))
print("INS=" + c.get("insights_folder", "claude-insights"))
'
Parse each output line as KEY=VALUE, splitting on the first =.
If the file does not exist or is invalid JSON, tell the user:
Config not found. Please run /obsidian-setup first to configure your Obsidian vault.
Stop here if config is missing.
Step 2 โ Validate vault access
Run:
test -d "$VAULT_PATH/$INSIGHTS_FOLDER" && test -w "$VAULT_PATH/$INSIGHTS_FOLDER" && echo "OK" || echo "FAIL"
If FAIL, tell the user:
The insights folder $VAULT_PATH/$INSIGHTS_FOLDER does not exist or is not writable. Run /obsidian-setup to fix this.
Stop here if FAIL.
Step 3 โ Identify the error
Check if the user provided an argument after /error-log.
- With argument (e.g.
/error-log BrokenPipeError in subprocess): Use the description to search the current conversation for matching error context, investigation steps, and resolution.
- Without argument (bare
/error-log): Scan the full conversation for error -> investigation -> fix patterns. Look for:
- Stack traces, error messages, or exception output
- Debugging steps taken (hypothesis, investigation, failed attempts)
- The fix that ultimately resolved the issue
- If multiple errors were resolved, present a numbered list and ask the user which to log
If no error pattern is found in the conversation, tell the user:
No error -> fix pattern detected in this session. You can run /error-log <description> to manually describe an error to document.
Stop here if no error is found.
Step 4 โ Structure the error note
Draft the note body with these four sections. Each section should be concise but complete enough to be useful months later when encountering the same error:
- Error: The exact error message, symptoms, and context where it appeared (include relevant stack trace snippets or command output if available, formatted as code blocks)
- Root Cause: Why the error happened โ the underlying reason, not just the surface symptom
- Fix: The specific change or command that resolved it โ include code diffs, config changes, or commands as code blocks
- Prevention: How to avoid this error in the future โ linting rules, config patterns, pre-checks, or design principles
Step 5 โ Auto-generate topic tags
Based on the error content, generate 1-3 topic tags. Tags should be lowercase, hyphenated, and specific to the technology or domain. Examples:
claude/topic/subprocess-pipes
claude/topic/python-async
claude/topic/npm-dependencies
claude/topic/git-hooks
Step 6 โ Show preview and ask for edits
Present the full note to the user including frontmatter:
---
type: claude-error-fix
date: YYYY-MM-DD
created_at: <ISO-8601-UTC>
source_session: <current-session-id>
source_session_note: "[[<session-note-filename>]]"
project: <project-name>
tags:
- claude/error-fix
- claude/project/<project-name>
- claude/topic/<auto-generated-topic-1>
- claude/topic/<auto-generated-topic-2>
---
# <Error Title>
## Error
<error message and symptoms>
## Root Cause
<why it happened>
## Fix
<what resolved it>
## Prevention
<how to avoid it in future>
Where:
-
YYYY-MM-DD is today's date
-
<ISO-8601-UTC> is the current UTC timestamp at second precision. Get it via:
python3 -c 'from datetime import datetime, timezone; print(datetime.now(timezone.utc).isoformat(timespec="seconds"))'
Example: 2026-04-24T18:42:11+00:00
-
<current-session-id> and <session-note-filename> are derived together. Get session context via the shared helper:
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
python3 -c '
import sys, os
import glob, json, os, re, sys
def _ob_hooks():
try:
for _m in json.load(open(os.path.expanduser("~/.claude/plugins/known_marketplaces.json"))).values():
_s = _m.get("source") if isinstance(_m, dict) else None
if not (isinstance(_s, dict) and _s.get("source") == "directory"):
continue
_i = _m.get("installLocation") if isinstance(_m, dict) else None
if not (isinstance(_i, str) and os.path.isabs(_i)):
continue
_h = os.path.join(_i, "hooks")
if os.path.isfile(os.path.join(_h, "obsidian_utils.py")):
return _h
except Exception:
pass
_c = [_d for _d in glob.glob(os.path.expanduser("~/.claude/plugins/cache/*/obsidian-brain/*/hooks")) if re.fullmatch("[0-9]+([.][0-9]+)*", _d.split("/")[-2])]
return max(_c, key=lambda _p: ([int(_n) for _n in _p.split("/")[-2].split(".")], _p), default="hooks")
sys.path.insert(0, _ob_hooks())
from obsidian_utils import load_config, get_session_context
c = load_config()
ctx = get_session_context(c["vault_path"], c.get("sessions_folder", "claude-sessions"))
print("SID=" + ctx["session_id"] + " HASH=" + ctx["hash"] + " PROJECT=" + ctx["project"] + " SESSION_NOTE=" + ctx["session_note_name"])
'
Parse the output to get SESSION_ID, HASH, PROJECT, and SESSION_NOTE.
Important: If SESSION_ID is unknown, use unknown for source_session and omit source_session_note entirely.
-
<project-name> is the PROJECT value from get_session_context() (lowercased, hyphenated basename of cwd)
-
<Error Title> is a short, descriptive title for the error (e.g. "BrokenPipeError when piping subprocess output to head")
-
The source_session_note field creates an Obsidian backlink to the source session note
Ask the user:
Preview above. Would you like to:
- save as-is
- edit tags โ add or remove tags
- edit content โ tell me what to change
- cancel โ discard this note
Wait for the user's response. Apply any requested edits and show the updated preview. Repeat until the user says save or cancel.
If cancel, stop here.
Step 7 โ Generate filename
Construct the filename from these parts:
- Date:
YYYY-MM-DD (today)
- Slug: The error title, lowercased, spaces replaced with hyphens, non-alphanumeric characters (except hyphens) removed, truncated to 50 characters
- Hash: 4-character hex hash derived from the current timestamp:
date +%s | md5 | cut -c29-32 (macOS) or date +%s | md5sum | cut -c1-4 (Linux). Do NOT use tail -c 4 โ it counts the trailing newline as a byte and returns only 3 visible characters.
- Suffix:
-error
Final filename: YYYY-MM-DD-<slug>-<hash>-error.md
Example: 2026-04-04-brokenpipeerror-subprocess-pipe-a3f2-error.md
Step 8 โ Write the note
Run the note-writer CLI, piping the full note (frontmatter + body) in on stdin. It creates $INSIGHTS_FOLDER if needed and writes the file atomically at mode 0o600 โ no mkdir/chmod needed. Two rules for the heredoc terminator, both load-bearing. (1) It must stay quoted (<<'OB_NOTE_EOF_<eof4>') โ do not drop the quotes in a future edit. (2) It must be unique per invocation: substitute the same 4 random hex characters for <eof4> in BOTH the <<'OB_NOTE_EOF_<eof4>' opener and the terminator line, then confirm that no line of the content you are about to emit is exactly that terminator โ if one is, pick different hex characters and re-check. Never replace this with a fixed delimiter. Quoting stops $/backtick expansion but does NOT stop early termination: a line equal to the terminator at column 0 ends the heredoc there, silently truncating the content AND handing everything after it to the shell as commands to execute. Notes written by this plugin routinely quote these very blocks, so a fixed terminator is a live hazard, not a theoretical one. Self-check before you emit the block: if the terminator still contains < or >, you have not substituted it. Stop and substitute it โ the literal <eof4> form appears at column 0 inside these SKILL.md blocks themselves, so a note quoting one of them collides all over again, and nothing on the shell side can catch that. The HOOKS= line below checks the marketplace-registered directory-source install location FIRST (#278 โ on a local checkout that is what loads, not the released cache), and only falls back to the plugin cache, where it sorts versions numerically (a plain max() is lexicographic and picks 3.9.0 over 3.10.0, resolving to a cache with no note_writer.py); the test -f line turns a stale/incomplete cache into the documented ERROR: shape instead of a raw Python can't open file message. An unquoted delimiter lets the shell expand $ variables and backtick commands embedded in the note body, silently corrupting it:
cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
HOOKS=$(python3 -c "
import glob, json, os, re
def _ob_hooks():
try:
for _m in json.load(open(os.path.expanduser('~/.claude/plugins/known_marketplaces.json'))).values():
_s = _m.get('source') if isinstance(_m, dict) else None
if not (isinstance(_s, dict) and _s.get('source') == 'directory'):
continue
_i = _m.get('installLocation') if isinstance(_m, dict) else None
if not (isinstance(_i, str) and os.path.isabs(_i)):
continue
_h = os.path.join(_i, 'hooks')
if os.path.isfile(os.path.join(_h, 'obsidian_utils.py')):
return _h
except Exception:
pass
_c = [_d for _d in glob.glob(os.path.expanduser('~/.claude/plugins/cache/*/obsidian-brain/*/hooks')) if re.fullmatch('[0-9]+([.][0-9]+)*', _d.split('/')[-2])]
return max(_c, key=lambda _p: ([int(_n) for _n in _p.split('/')[-2].split('.')], _p), default='hooks')
print(_ob_hooks())
")
test -f "$HOOKS/note_writer.py" || { echo "ERROR: note_writer.py not found under $HOOKS - resolution checks the marketplace registered install location first, then falls back to the plugin cache; neither path produced a hooks directory containing it. Verify the obsidian-brain install resolved at $HOOKS is complete (git pull for a directory-source checkout, or run /plugin marketplace update for a cache install), then retry." >&2; exit 1; }
python3 "$HOOKS/note_writer.py" write "$VAULT_PATH" "$INSIGHTS_FOLDER" "YYYY-MM-DD-<slug>-<hash>-error.md" <<'OB_NOTE_EOF_<eof4>'
---
type: claude-error-fix
...
---
...
OB_NOTE_EOF_<eof4>
On success this prints OK: <absolute path> โ that is the file at $VAULT_PATH/$INSIGHTS_FOLDER/<filename>. On failure it prints ERROR: <reason> to stderr and exits non-zero; surface that message to the user and stop here.
If the error is note already exists, the 4-hex filename hash collided with a note written in the same second. Regenerate the hash (Step 7's command), rebuild the filename, and retry the write once. If it fails again for any reason, surface the error and stop โ do not loop.
Step 9 โ Confirm
Print:
Error fix logged!
- File:
$VAULT_PATH/$INSIGHTS_FOLDER/<filename>
- Tags:
claude/error-fix, claude/project/<name>, claude/topic/<topic1>, ...
- Open in Obsidian to view. This note will appear in the "Error Fixes" section of the Project Index dashboard.