diff --git a/hooks/instruction-compliance.py b/hooks/instruction-compliance.py old mode 100644 new mode 100755 diff --git a/hooks/posttool-auto-test.py b/hooks/posttool-auto-test.py old mode 100644 new mode 100755 diff --git a/hooks/posttool-voice-quality-check.py b/hooks/posttool-voice-quality-check.py new file mode 100755 index 00000000..a4f47688 --- /dev/null +++ b/hooks/posttool-voice-quality-check.py @@ -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() diff --git a/hooks/pretool-voice-publish-gate.py b/hooks/pretool-voice-publish-gate.py new file mode 100755 index 00000000..736d4842 --- /dev/null +++ b/hooks/pretool-voice-publish-gate.py @@ -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) diff --git a/hooks/voice-output-gate.py b/hooks/voice-output-gate.py new file mode 100755 index 00000000..8a8cb773 --- /dev/null +++ b/hooks/voice-output-gate.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# hook-version: 1.0.0 +""" +UserPromptSubmit hook — inject voice output gate for external text actions. + +When the user prompt indicates external text production (PR reviews, standup +updates, README edits, comments), this hook injects a mandatory validation +block requiring scan-ai-patterns.py and joy-check before posting. + +Lives in private-skills/hooks/ and gets symlinked into vexjoy-agent/private-hooks/. +The installer places it in ~/.claude/hooks/ and registers it in settings.json. +""" + +import json +import os +import re +import sys +from pathlib import Path + +# Add hook lib to path — use the installed location (~/.claude/hooks/lib), +# not the resolved symlink target (which lives in private-skills) +HOOKS_DIR = Path.home() / ".claude" / "hooks" +sys.path.insert(0, str(HOOKS_DIR / "lib")) + +try: + from hook_utils import HookOutput, empty_output +except ImportError: + # Graceful degradation if hook_utils not available + def _noop(): + print(json.dumps({})) + sys.exit(0) + + _noop() + +# Patterns that indicate external text production +EXTERNAL_TEXT_PATTERNS = [ + r"\bpr\s+review\b", + r"\bpr\s+comment\b", + r"\bgh\s+pr\b", + r"\breview\s+(this|the)\s+pr\b", + r"\bcreate\s+(a\s+)?pr\b", + r"\bopen\s+(a\s+)?pr\b", + r"\bsubmit\s+(a\s+)?pr\b", + r"\bpull\s+request\b", + r"\bstandup\b", + r"\bweekly\s+update\b", + r"\bhedgedoc\b", + r"\bscrum\b", + r"\bpost\s+(a\s+)?comment\b", + r"\bwrite\s+(an?\s+)?(review|comment|article|post|blog)\b", + r"\bupdate\s+(the\s+)?(readme|doc)\b", +] + +# Voice-specific patterns (adds voice profile validation on top) +VOICE_PATTERNS = [ + r"\bvoice\b", + r"\bandy\s*nemm", + r"\bvexjoy\b", + r"\bfeynman\b", + r"\bjoy\s*check\b", + r"\banti[\s-]?ai\b", +] + +SCAN_SCRIPT = Path.home() / "pgh/vexjoy-agent/scripts/scan-ai-patterns.py" +JOY_CHECK_RUBRIC = Path.home() / ".claude/skills/joy-check/references/writing-rubric.md" + + +def detect_external_text(prompt: str) -> bool: + """Check if prompt indicates external text production.""" + prompt_lower = prompt.lower() + return any(re.search(p, prompt_lower) for p in EXTERNAL_TEXT_PATTERNS) + + +def detect_voice_mode(prompt: str) -> bool: + """Check if prompt requests voice-specific validation.""" + prompt_lower = prompt.lower() + return any(re.search(p, prompt_lower) for p in VOICE_PATTERNS) + + +def build_gate_instruction(voice_mode: bool) -> str: + """Build the validation gate instruction block.""" + lines = [ + "[voice-output-gate: active]", + "Before posting any external text (PR comment, standup update, doc edit):", + "", + "1. Write draft to /tmp/voice-gate-draft.md", + f"2. Run: python3 {SCAN_SCRIPT} /tmp/voice-gate-draft.md --errors-only", + "3. Fix all hits:", + " - em-dashes: replace with colons, commas, or periods", + " - corporate verbs: use plain verbs (use, help, improve)", + " - throat-clearing: delete, start with the point", + " - abstract nouns: name the specific thing", + "4. Confirm joy-check: positive framing, no grievance patterns", + "5. Post the cleaned version only after step 2 returns zero errors", + "", + "This gate is mandatory. Do not skip it. Do not self-assess as clean.", + "Run the script.", + ] + + if voice_mode: + lines.extend( + [ + "", + "Voice mode active: also load the requested voice skill and validate", + "the draft matches the voice profile before posting.", + ] + ) + + lines.append("[/voice-output-gate]") + return "\n".join(lines) + + +def main(): + # Read the user prompt from stdin (Claude Code passes it as JSON) + try: + input_data = json.loads(sys.stdin.read()) + prompt = input_data.get("prompt", "") or input_data.get("message", "") + except (json.JSONDecodeError, KeyError): + empty_output("UserPromptSubmit").print_and_exit() + return + + if not prompt: + empty_output("UserPromptSubmit").print_and_exit() + return + + is_external = detect_external_text(prompt) + is_voice = detect_voice_mode(prompt) + + if not is_external and not is_voice: + empty_output("UserPromptSubmit").print_and_exit() + return + + # Build and inject the gate + gate_text = build_gate_instruction(voice_mode=is_voice) + + output = HookOutput( + event_name="UserPromptSubmit", + additional_context=gate_text, + ) + output.print_and_exit() + + +if __name__ == "__main__": + try: + main() + except Exception as e: + if os.environ.get("CLAUDE_HOOKS_DEBUG"): + import traceback + + print(f"[voice-output-gate] HOOK-ERROR: {type(e).__name__}: {e}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + finally: + sys.exit(0) diff --git a/hooks/voice-pipeline-tracker.py b/hooks/voice-pipeline-tracker.py new file mode 100755 index 00000000..039a3f77 --- /dev/null +++ b/hooks/voice-pipeline-tracker.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# hook-version: 1.0.0 +""" +Voice Pipeline Tracker — utility script (not a hook). + +Tracks completion of the 13-phase voice-writer pipeline for VexJoy blog posts. +State stored in ~/.claude/state/voice-pipeline-state.json. + +Usage: + python3 voice-pipeline-tracker.py record + python3 voice-pipeline-tracker.py status + python3 voice-pipeline-tracker.py reset + python3 voice-pipeline-tracker.py list +""" + +import json +import os +import sys +import tempfile +from pathlib import Path + +REQUIRED_PHASES = [ + "LOAD", + "GROUND", + "STATS-CHECKPOINT", + "GENERATE", + "HOOK-GATE", + "VALIDATE", + "REFINE", + "VARIETY-GATE", + "JOY-CHECK", + "ANTI-AI", + "CLOSE-GATE", + "OUTPUT", + "CLEANUP", +] + +STATE_FILE = Path.home() / ".claude" / "state" / "voice-pipeline-state.json" + + +def _load_state() -> dict: + """Load state file, return empty dict on any error.""" + try: + if STATE_FILE.exists(): + return json.loads(STATE_FILE.read_text()) + except (json.JSONDecodeError, OSError): + pass + return {} + + +def _save_state(state: dict) -> None: + """Atomic write: temp file + rename.""" + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(STATE_FILE.parent), suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, str(STATE_FILE)) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def cmd_record(slug: str, phase: str) -> None: + phase_upper = phase.upper() + if phase_upper not in REQUIRED_PHASES: + print(f"Unknown phase: {phase_upper}", file=sys.stderr) + print(f"Valid phases: {', '.join(REQUIRED_PHASES)}", file=sys.stderr) + sys.exit(1) + state = _load_state() + if slug not in state: + state[slug] = {"phases_complete": []} + phases = state[slug]["phases_complete"] + if phase_upper not in phases: + phases.append(phase_upper) + _save_state(state) + print(f"[voice-tracker] Recorded {phase_upper} for '{slug}' ({len(phases)}/{len(REQUIRED_PHASES)})") + + +def cmd_status(slug: str) -> None: + state = _load_state() + entry = state.get(slug, {"phases_complete": []}) + complete = entry.get("phases_complete", []) + missing = [p for p in REQUIRED_PHASES if p not in complete] + result = { + "slug": slug, + "phases_complete": complete, + "phases_missing": missing, + "ready_to_publish": len(missing) == 0, + } + print(json.dumps(result, indent=2)) + + +def cmd_reset(slug: str) -> None: + state = _load_state() + if slug in state: + del state[slug] + _save_state(state) + print(f"[voice-tracker] Reset tracking for '{slug}'") + else: + print(f"[voice-tracker] No tracking data for '{slug}'") + + +def cmd_list() -> None: + state = _load_state() + if not state: + print("[voice-tracker] No articles tracked") + return + for slug, entry in state.items(): + n = len(entry.get("phases_complete", [])) + ready = "READY" if n == len(REQUIRED_PHASES) else f"{n}/{len(REQUIRED_PHASES)}" + print(f" {slug}: {ready}") + + +def main() -> None: + if len(sys.argv) < 2: + print("Usage: voice-pipeline-tracker.py [args]", file=sys.stderr) + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "record": + if len(sys.argv) < 4: + print("Usage: voice-pipeline-tracker.py record ", file=sys.stderr) + sys.exit(1) + cmd_record(sys.argv[2], sys.argv[3]) + elif cmd == "status": + if len(sys.argv) < 3: + print("Usage: voice-pipeline-tracker.py status ", file=sys.stderr) + sys.exit(1) + cmd_status(sys.argv[2]) + elif cmd == "reset": + if len(sys.argv) < 3: + print("Usage: voice-pipeline-tracker.py reset ", file=sys.stderr) + sys.exit(1) + cmd_reset(sys.argv[2]) + elif cmd == "list": + cmd_list() + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/pre-route.py b/scripts/pre-route.py old mode 100644 new mode 100755 diff --git a/scripts/tests/test_auto_test_hook.py b/scripts/tests/test_auto_test_hook.py old mode 100644 new mode 100755 diff --git a/scripts/tests/test_instruction_compliance.py b/scripts/tests/test_instruction_compliance.py old mode 100644 new mode 100755 diff --git a/scripts/tests/test_pipeline_phase_gate.py b/scripts/tests/test_pipeline_phase_gate.py old mode 100644 new mode 100755 diff --git a/scripts/tests/test_pre_route.py b/scripts/tests/test_pre_route.py old mode 100644 new mode 100755 diff --git a/scripts/tests/test_skill_reorganization.py b/scripts/tests/test_skill_reorganization.py old mode 100644 new mode 100755