Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified hooks/instruction-compliance.py
100644 → 100755
Empty file.
Empty file modified hooks/posttool-auto-test.py
100644 → 100755
Empty file.
113 changes: 113 additions & 0 deletions hooks/posttool-voice-quality-check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
# hook-version: 1.0.0
"""
PostToolUse:Write Hook: Voice Quality Check

Advisory-only check for AI writing patterns after blog post writes.
Cannot block — prints warnings to stderr for awareness.

Checks:
- Banned AI-sounding words (delve, leverage, robust, etc.)
- Em-dash density
- Curly quote presence
- "It's not X. It's Y." rhetorical pivot pattern
"""

import json
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "lib"))
from stdin_timeout import read_stdin

_BANNED_WORDS = re.compile(
r"\b(delve|leverage|robust|utilize|furthermore|moreover|additionally|consequently)\b",
re.IGNORECASE,
)

_PIVOT_PATTERN = re.compile(
r"It'?s not [^.]+\.\s*It'?s [^.]+\.",
re.IGNORECASE,
)


def _is_blog_post(file_path: str) -> bool:
normalised = file_path.replace("\\", "/")
return "content/posts/" in normalised and normalised.endswith(".md")


def _check_content(content: str) -> list:
"""Run all quality checks, return list of issue strings."""
issues = []

# Banned words
banned_found = _BANNED_WORDS.findall(content)
if banned_found:
unique = sorted(set(w.lower() for w in banned_found))
issues.append(f"Banned AI words: {', '.join(unique)}")

# Em-dash count (high density = AI pattern)
em_dashes = content.count("—")
# Threshold: more than 5 per 1000 words is suspicious
word_count = len(content.split())
if word_count > 0 and em_dashes > 0:
density = em_dashes / (word_count / 1000)
if density > 5:
issues.append(f"Em-dash density: {em_dashes} dashes ({density:.1f}/1k words)")

# Curly quotes (AI models tend to produce these)
curly_quotes = len(re.findall(r"[\u201c\u201d\u2018\u2019]", content))
if curly_quotes > 0:
issues.append(f"Curly quotes found: {curly_quotes} instances")

# Rhetorical pivot pattern
pivots = _PIVOT_PATTERN.findall(content)
if pivots:
issues.append(f"Rhetorical pivot pattern ('It's not X. It's Y.'): {len(pivots)} instances")

return issues


def main():
try:
event_data = read_stdin(timeout=2)
event = json.loads(event_data)

tool_input = event.get("tool_input", {})
file_path = tool_input.get("file_path", "")

if not file_path or not _is_blog_post(file_path):
return

# Read the written file to check content
content = ""
try:
content = Path(file_path).read_text()
except (OSError, UnicodeDecodeError):
return

if not content:
return

issues = _check_content(content)

if issues:
detail = "; ".join(issues)
print(f"[voice-quality] WARNING: {len(issues)} issues found — {detail}")
else:
print("[voice-quality] PASS: 0 issues")

except Exception as e:
if os.environ.get("CLAUDE_HOOKS_DEBUG"):
import traceback

print(f"[voice-quality] HOOK-ERROR: {type(e).__name__}: {e}", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
finally:
sys.exit(0)


if __name__ == "__main__":
main()
165 changes: 165 additions & 0 deletions hooks/pretool-voice-publish-gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
# hook-version: 1.0.0
"""
PreToolUse:Write Hook: Voice Pipeline Publish Gate

Blocks publishing VexJoy blog posts (draft: false) unless all 13 phases
of the voice-writer pipeline have been completed.

Detection logic:
- Tool is Write (enforced by matcher in settings.json)
- Target path matches content/posts/*.md
- Content sets draft: false

Allow-through conditions:
- File is NOT in content/posts/
- Content has draft: true (still in progress)
- All 13 pipeline phases are complete
- VOICE_GATE_BYPASS=1 env var set

Follows pretool-plan-gate.py pattern exactly.
"""

import json
import os
import re
import subprocess
import sys
import traceback
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "lib"))
from stdin_timeout import read_stdin

_BYPASS_ENV = "VOICE_GATE_BYPASS"
_TRACKER = str(Path(__file__).parent / "voice-pipeline-tracker.py")


def _extract_slug(file_path: str) -> str:
"""Extract slug from blog post filename, stripping date prefix and .md suffix.

Example: content/posts/2025-12-29-my-post.md -> my-post
"""
name = Path(file_path).stem # strip .md
# Strip YYYY-MM-DD- prefix if present
return re.sub(r"^\d{4}-\d{2}-\d{2}-", "", name)


def _is_blog_post(file_path: str) -> bool:
"""Return True if path is a blog post in content/posts/."""
normalised = file_path.replace("\\", "/")
return "content/posts/" in normalised and normalised.endswith(".md")


def _has_draft_false(content: str) -> bool:
"""Return True if content sets draft: false in frontmatter."""
# Match draft: false in YAML frontmatter
return bool(re.search(r"^draft:\s*false\s*$", content, re.MULTILINE))


def _check_pipeline(slug: str) -> dict:
"""Query the voice pipeline tracker for status. Returns parsed JSON or None."""
try:
result = subprocess.run(
[sys.executable, _TRACKER, "status", slug],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
pass
return None


def main() -> None:
debug = os.environ.get("CLAUDE_HOOKS_DEBUG")

raw = read_stdin(timeout=2)
try:
event = json.loads(raw)
except (json.JSONDecodeError, ValueError):
sys.exit(0)

# Bypass env var
if os.environ.get(_BYPASS_ENV) == "1":
if debug:
print(f"[voice-publish-gate] Bypassed via {_BYPASS_ENV}=1", file=sys.stderr)
sys.exit(0)

tool_input = event.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if not file_path:
sys.exit(0)

# Only gate blog posts
if not _is_blog_post(file_path):
if debug:
print(f"[voice-publish-gate] Not a blog post, allowing: {file_path}", file=sys.stderr)
sys.exit(0)

content = tool_input.get("content", "")

# Draft: true means still in progress — allow through
if not _has_draft_false(content):
if debug:
print("[voice-publish-gate] draft: true or no draft field, allowing", file=sys.stderr)
sys.exit(0)

# draft: false — check pipeline completion
slug = _extract_slug(file_path)
if debug:
print(f"[voice-publish-gate] Checking pipeline for slug: {slug}", file=sys.stderr)

status = _check_pipeline(slug)

# If tracker fails, fail open
if status is None:
if debug:
print("[voice-publish-gate] Tracker query failed, failing open", file=sys.stderr)
sys.exit(0)

if status.get("ready_to_publish", False):
if debug:
print(f"[voice-publish-gate] All phases complete for '{slug}', allowing", file=sys.stderr)
sys.exit(0)

# Phases missing — block publication
missing = status.get("phases_missing", [])
complete = status.get("phases_complete", [])

reason = (
f"Voice pipeline incomplete for '{slug}'. "
f"{len(complete)}/13 phases done. "
f"Missing: {', '.join(missing)}. "
f"Complete all phases or set VOICE_GATE_BYPASS=1 to override."
)

print(f"[voice-publish-gate] BLOCKED: {reason}", file=sys.stderr)
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
)
)
sys.exit(0)


if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as e:
if os.environ.get("CLAUDE_HOOKS_DEBUG"):
traceback.print_exc(file=sys.stderr)
else:
print(f"[voice-publish-gate] Error: {type(e).__name__}: {e}", file=sys.stderr)
finally:
sys.exit(0)
Loading