Read the script
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Eugeniu Ghelbur and contributors
# SPDX-License-Identifier: MIT
# Author: Eugeniu Ghelbur (AI Automation Engineer, AI Tooling Engineer, AI Educator), https://github.com/eugeniughelbur/obsidian-second-brain
# Questions or consulting: e.ghelbur@gmail.com | https://www.linkedin.com/in/eugeniu-ghelbur/ | More: https://theaioperator.io | https://x.com/eugeniu_ghelbur
# =============================================================================
# validate-ai-first.sh - Enforce the AI-first vault rule on Write/Edit
# =============================================================================
# Fires as a Claude Code PostToolUse hook after Write/Edit (terminal) or
# create_file (VS Code extension). Inspects the written file and warns if it
# does not follow the AI-first rule defined in references/ai-first-rules.md.
#
# This is the write-time enforcement primitive: the vault stays AI-first
# because every write is checked, not because future agent remembers all
# seven rules every time.
#
# Validation (warnings, non-blocking):
# 1. Frontmatter delimiters (--- ... ---) are well-formed
# 2. No tabs inside frontmatter (YAML requires spaces)
# 3. Required AI-first fields present: date, type, tags, ai-first: true
# 4. `## For future agent` preamble exists in the body
# 5. No banned non-ASCII substitution characters (em/en-dashes, curly
# quotes, smart apostrophes, Unicode math). Reports codepoint +
# suggested ASCII replacement. Explicit ban list; anything not in
# the list passes. Dashes, quotes and the ellipsis are skipped on a
# line containing CJK text, where they are that language's correct
# punctuation and not a substitution at all (#271); Unicode math and
# the non-breaking space stay banned in every language.
# 6. No secret material (API keys, private key blocks, quoted passwords)
# 7. Every tag is valid Obsidian tag syntax. Obsidian renders a bad tag
# struck through with no error anywhere, so an agent never learns it
# wrote one (#221). Rules: letters, digits, `_`, `-`, `/` only; no
# spaces or dots; at least one character that is not a digit.
#
# Scope:
# - Only inspects files inside OBSIDIAN_VAULT_PATH (env var)
# - Skips raw/, templates/, _export/, .obsidian/, .claude/ (slash-command
# copies and settings, not notes - #249), boards/ (kanban exception:
# an H2 preamble renders as a phantom column), vault-surface files
# (_CLAUDE.md, Home.md, index.md, log.md, catchup.md, per-day Logs/ -
# operating surfaces, not knowledge notes), and any path containing
# /.git/ - those are system/template paths, not first-class notes
# - Skips any file not ending in .md
# - AI_FIRST_SKIP_CHECKS (env or the toolkit .env) turns individual checks
# off for a vault, comma-separated: AI_FIRST_SKIP_CHECKS=5
#
# Exit codes:
# 0 = pass (silent), or warn via JSON on stdout (write is NOT reverted)
# =============================================================================
# Warn via Claude Code hook JSON (systemMessage + additionalContext). stderr
# is mirrored for logs; exit 0 so the host parses stdout.
emit_ai_first_warning() {
local msg="$1"
printf '%s\n' "$msg" >&2
jq -n --arg msg "$msg" '{
systemMessage: $msg,
decision: "block",
reason: $msg,
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $msg
}
}'
exit 0
}
# ── Per-vault opt-out ────────────────────────────────────────────────────────
# AI_FIRST_SKIP_CHECKS is a comma-separated list of check numbers a vault turns
# off, e.g. AI_FIRST_SKIP_CHECKS=5. A check that fires on ordinary prose in a
# vault's own language teaches the session to ignore the hook wholesale, which
# costs more than the check earns (#271, and check 6's own comment says the same
# about false positives). The escape is a config value so a vault does not have
# to carry a local patch of this file. Read from the environment or the config
# .env below, next to the vault path, so both halves of a marketplace install
# are configured in one place.
check_enabled() {
[[ "$SKIP_CHECKS" != *",$1,"* ]]
}
# ── osb_python ───────────────────────────────────────────────────────────────
# Echo a Python that actually runs, or nothing with a non-zero status.
# `command -v python3` is not enough, and on Windows it is actively wrong: the
# python.org installers - the default way to get Python there - ship python.exe
# and py.exe and never python3.exe, so `python3` resolves to the Microsoft Store
# App Execution Alias. That stub exists, prints nothing and exits non-zero, so an
# existence test passes and the caller silently does nothing (#269). Every
# candidate is therefore executed, not looked up. Uses bash 3.2 features only.
osb_python() {
local candidate
# Unquoted on purpose: "py -3" is a command plus an argument.
for candidate in python3 python "py -3"; do
if $candidate -c "import sys" >/dev/null 2>&1; then
printf '%s' "$candidate"
return 0
fi
done
# Last resort: uv, which the toolkit already requires for its research scripts
# and which brings its own interpreter when the system has none on PATH.
if uv run --no-project python -c "import sys" >/dev/null 2>&1; then
printf '%s' "uv run --no-project python"
return 0
fi
return 1
}
# Compare paths in one form: forward slashes and a lowercase drive letter (the
# same normalization hooks/load_vault_context.py applies). On Windows, Claude
# Code hands the hook tool_input.file_path with backslashes ("C:\Users\...")
# while OBSIDIAN_VAULT_PATH may be written with forward slashes, in the MSYS
# form (/c/Users/...), or as /cygdrive/c/..., so a plain prefix match never hit
# and the hook was a silent no-op there. On Windows shells cygpath -m maps
# every spelling to the mixed form (C:/Users/...) first; without cygpath the
# backslash flip below still covers the form Claude Code produces. Uses bash
# 3.