sync: auto-stage dirty hub state (recovery)
This commit is contained in:
799
.claude/hooks/prompt-guard.py
Normal file
799
.claude/hooks/prompt-guard.py
Normal file
@@ -0,0 +1,799 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crosslink behavioral hook for Claude Code.
|
||||
Injects best practice reminders on every prompt submission.
|
||||
Loads rules from .crosslink/rules/ markdown files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
import subprocess
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
# Fix Windows encoding issues with Unicode characters
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
|
||||
# Add hooks directory to path for shared module import
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from crosslink_config import (
|
||||
find_crosslink_dir,
|
||||
get_project_root,
|
||||
is_agent_context,
|
||||
load_config_merged,
|
||||
load_guard_state,
|
||||
load_tracking_mode,
|
||||
save_guard_state,
|
||||
)
|
||||
|
||||
|
||||
def load_rule_file(rules_dir, filename, rules_local_dir=None):
|
||||
"""Load a rule file, preferring rules.local/ override if present."""
|
||||
if not rules_dir:
|
||||
return ""
|
||||
# Check rules.local/ first for an override
|
||||
if rules_local_dir:
|
||||
local_path = os.path.join(rules_local_dir, filename)
|
||||
try:
|
||||
with open(local_path, 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
# Fall back to rules/
|
||||
path = os.path.join(rules_dir, filename)
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return f.read().strip()
|
||||
except (OSError, IOError):
|
||||
return ""
|
||||
|
||||
|
||||
def load_all_rules(crosslink_dir):
|
||||
"""Load all rule files from .crosslink/rules/, with .crosslink/rules.local/ overrides.
|
||||
|
||||
Auto-discovers all .md files in the rules directory. Files are categorized as:
|
||||
- Well-known names: global.md, project.md, knowledge.md, quality.md
|
||||
- Language files: matched by known language filename patterns
|
||||
- Extra rules: any other .md file (loaded as additional general rules)
|
||||
|
||||
Files in rules.local/ override same-named files in rules/.
|
||||
"""
|
||||
if not crosslink_dir:
|
||||
return {}, "", "", "", ""
|
||||
|
||||
rules_dir = os.path.join(crosslink_dir, 'rules')
|
||||
rules_local_dir = os.path.join(crosslink_dir, 'rules.local')
|
||||
if not os.path.isdir(rules_dir) and not os.path.isdir(rules_local_dir):
|
||||
return {}, "", "", "", ""
|
||||
|
||||
if not os.path.isdir(rules_local_dir):
|
||||
rules_local_dir = None
|
||||
|
||||
# Well-known non-language files (loaded into specific return values)
|
||||
WELL_KNOWN = {'global.md', 'project.md', 'knowledge.md', 'quality.md'}
|
||||
|
||||
# Internal/structural files (not injected as rules)
|
||||
SKIP_FILES = {
|
||||
'sanitize-patterns.txt',
|
||||
'tracking-strict.md', 'tracking-normal.md', 'tracking-relaxed.md',
|
||||
}
|
||||
|
||||
# Language filename -> display name mapping
|
||||
LANGUAGE_MAP = {
|
||||
'rust.md': 'Rust', 'python.md': 'Python',
|
||||
'javascript.md': 'JavaScript', 'typescript.md': 'TypeScript',
|
||||
'typescript-react.md': 'TypeScript/React',
|
||||
'javascript-react.md': 'JavaScript/React',
|
||||
'go.md': 'Go', 'java.md': 'Java', 'c.md': 'C', 'cpp.md': 'C++',
|
||||
'csharp.md': 'C#', 'ruby.md': 'Ruby', 'php.md': 'PHP',
|
||||
'swift.md': 'Swift', 'kotlin.md': 'Kotlin', 'scala.md': 'Scala',
|
||||
'zig.md': 'Zig', 'odin.md': 'Odin',
|
||||
'elixir.md': 'Elixir', 'elixir-phoenix.md': 'Elixir/Phoenix',
|
||||
'web.md': 'Web',
|
||||
}
|
||||
|
||||
# Load well-known files
|
||||
global_rules = load_rule_file(rules_dir, 'global.md', rules_local_dir)
|
||||
project_rules = load_rule_file(rules_dir, 'project.md', rules_local_dir)
|
||||
knowledge_rules = load_rule_file(rules_dir, 'knowledge.md', rules_local_dir)
|
||||
quality_rules = load_rule_file(rules_dir, 'quality.md', rules_local_dir)
|
||||
|
||||
# Auto-discover all files from both directories
|
||||
language_rules = {}
|
||||
all_files = set()
|
||||
|
||||
try:
|
||||
if os.path.isdir(rules_dir):
|
||||
for entry in os.listdir(rules_dir):
|
||||
if entry.endswith('.md') or entry.endswith('.txt'):
|
||||
all_files.add(entry)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if rules_local_dir:
|
||||
try:
|
||||
for entry in os.listdir(rules_local_dir):
|
||||
if entry.endswith('.md') or entry.endswith('.txt'):
|
||||
all_files.add(entry)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for filename in sorted(all_files):
|
||||
if filename in WELL_KNOWN or filename in SKIP_FILES:
|
||||
continue
|
||||
if filename in LANGUAGE_MAP:
|
||||
content = load_rule_file(rules_dir, filename, rules_local_dir)
|
||||
if content:
|
||||
language_rules[LANGUAGE_MAP[filename]] = content
|
||||
elif filename.endswith('.md'):
|
||||
content = load_rule_file(rules_dir, filename, rules_local_dir)
|
||||
if content:
|
||||
lang_name = os.path.splitext(filename)[0].replace('-', '/').title()
|
||||
language_rules[lang_name] = content
|
||||
|
||||
return language_rules, global_rules, project_rules, knowledge_rules, quality_rules
|
||||
|
||||
|
||||
# Detect language from common file extensions in the working directory
|
||||
def detect_languages():
|
||||
"""Scan for common source files to determine active languages."""
|
||||
extensions = {
|
||||
'.rs': 'Rust',
|
||||
'.py': 'Python',
|
||||
'.js': 'JavaScript',
|
||||
'.ts': 'TypeScript',
|
||||
'.tsx': 'TypeScript/React',
|
||||
'.jsx': 'JavaScript/React',
|
||||
'.go': 'Go',
|
||||
'.java': 'Java',
|
||||
'.c': 'C',
|
||||
'.cpp': 'C++',
|
||||
'.cs': 'C#',
|
||||
'.rb': 'Ruby',
|
||||
'.php': 'PHP',
|
||||
'.swift': 'Swift',
|
||||
'.kt': 'Kotlin',
|
||||
'.scala': 'Scala',
|
||||
'.zig': 'Zig',
|
||||
'.odin': 'Odin',
|
||||
'.ex': 'Elixir',
|
||||
'.exs': 'Elixir',
|
||||
'.heex': 'Elixir/Phoenix',
|
||||
}
|
||||
|
||||
found = set()
|
||||
cwd = get_project_root()
|
||||
|
||||
# Check for project config files first (more reliable than scanning)
|
||||
config_indicators = {
|
||||
'Cargo.toml': 'Rust',
|
||||
'package.json': 'JavaScript',
|
||||
'tsconfig.json': 'TypeScript',
|
||||
'pyproject.toml': 'Python',
|
||||
'requirements.txt': 'Python',
|
||||
'go.mod': 'Go',
|
||||
'pom.xml': 'Java',
|
||||
'build.gradle': 'Java',
|
||||
'Gemfile': 'Ruby',
|
||||
'composer.json': 'PHP',
|
||||
'Package.swift': 'Swift',
|
||||
'mix.exs': 'Elixir',
|
||||
}
|
||||
|
||||
# Check cwd and immediate subdirs for config files
|
||||
check_dirs = [cwd]
|
||||
try:
|
||||
for entry in os.listdir(cwd):
|
||||
subdir = os.path.join(cwd, entry)
|
||||
if os.path.isdir(subdir) and not entry.startswith('.'):
|
||||
check_dirs.append(subdir)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
for check_dir in check_dirs:
|
||||
for config_file, lang in config_indicators.items():
|
||||
if os.path.exists(os.path.join(check_dir, config_file)):
|
||||
found.add(lang)
|
||||
|
||||
# Also scan for source files in src/ directories
|
||||
scan_dirs = [cwd]
|
||||
src_dir = os.path.join(cwd, 'src')
|
||||
if os.path.isdir(src_dir):
|
||||
scan_dirs.append(src_dir)
|
||||
# Check nested project src dirs too
|
||||
for check_dir in check_dirs:
|
||||
nested_src = os.path.join(check_dir, 'src')
|
||||
if os.path.isdir(nested_src):
|
||||
scan_dirs.append(nested_src)
|
||||
|
||||
for scan_dir in scan_dirs:
|
||||
try:
|
||||
for entry in os.listdir(scan_dir):
|
||||
ext = os.path.splitext(entry)[1].lower()
|
||||
if ext in extensions:
|
||||
found.add(extensions[ext])
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
return list(found) if found else ['the project']
|
||||
|
||||
|
||||
def get_language_section(languages, language_rules):
|
||||
"""Build language-specific best practices section from loaded rules."""
|
||||
sections = []
|
||||
for lang in languages:
|
||||
if lang in language_rules:
|
||||
content = language_rules[lang]
|
||||
# If the file doesn't start with a header, add one
|
||||
if not content.startswith('#'):
|
||||
sections.append(f"### {lang} Best Practices\n{content}")
|
||||
else:
|
||||
sections.append(content)
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
# Directories to skip when building project tree
|
||||
SKIP_DIRS = {
|
||||
'.git', 'node_modules', 'target', 'venv', '.venv', 'env', '.env',
|
||||
'__pycache__', '.crosslink', '.claude', 'dist', 'build', '.next',
|
||||
'.nuxt', 'vendor', '.idea', '.vscode', 'coverage', '.pytest_cache',
|
||||
'.mypy_cache', '.tox', 'eggs', '*.egg-info', '.sass-cache',
|
||||
'_build', 'deps', '.elixir_ls', '.fetch'
|
||||
}
|
||||
|
||||
|
||||
def get_project_tree(max_depth=3, max_entries=50):
|
||||
"""Generate a compact project tree to prevent path hallucinations."""
|
||||
cwd = get_project_root()
|
||||
entries = []
|
||||
|
||||
def should_skip(name):
|
||||
if name.startswith('.') and name not in ('.github', '.claude'):
|
||||
return True
|
||||
return name in SKIP_DIRS or name.endswith('.egg-info')
|
||||
|
||||
def walk_dir(path, prefix="", depth=0):
|
||||
if depth > max_depth or len(entries) >= max_entries:
|
||||
return
|
||||
|
||||
try:
|
||||
items = sorted(os.listdir(path))
|
||||
except (PermissionError, OSError):
|
||||
return
|
||||
|
||||
# Separate dirs and files
|
||||
dirs = [i for i in items if os.path.isdir(os.path.join(path, i)) and not should_skip(i)]
|
||||
files = [i for i in items if os.path.isfile(os.path.join(path, i)) and not i.startswith('.')]
|
||||
|
||||
# Add files first (limit per directory)
|
||||
for f in files[:10]: # Max 10 files per dir shown
|
||||
if len(entries) >= max_entries:
|
||||
return
|
||||
entries.append(f"{prefix}{f}")
|
||||
|
||||
if len(files) > 10:
|
||||
entries.append(f"{prefix}... ({len(files) - 10} more files)")
|
||||
|
||||
# Then recurse into directories
|
||||
for d in dirs:
|
||||
if len(entries) >= max_entries:
|
||||
return
|
||||
entries.append(f"{prefix}{d}/")
|
||||
walk_dir(os.path.join(path, d), prefix + " ", depth + 1)
|
||||
|
||||
walk_dir(cwd)
|
||||
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
if len(entries) >= max_entries:
|
||||
entries.append(f"... (tree truncated at {max_entries} entries)")
|
||||
|
||||
return "\n".join(entries)
|
||||
|
||||
|
||||
|
||||
def get_lock_file_hash(lock_path):
|
||||
"""Get a hash of the lock file for cache invalidation."""
|
||||
try:
|
||||
mtime = os.path.getmtime(lock_path)
|
||||
return hashlib.md5(f"{lock_path}:{mtime}".encode()).hexdigest()[:12]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def run_command(cmd, timeout=5):
|
||||
"""Run a command and return output, or None on failure."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
shell=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, OSError, Exception):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_dependencies(max_deps=30):
|
||||
"""Get installed dependencies with versions. Uses caching based on lock file mtime."""
|
||||
cwd = get_project_root()
|
||||
deps = []
|
||||
|
||||
# Check for Rust (Cargo.toml)
|
||||
cargo_toml = os.path.join(cwd, 'Cargo.toml')
|
||||
if os.path.exists(cargo_toml):
|
||||
# Parse Cargo.toml for direct dependencies (faster than cargo tree)
|
||||
try:
|
||||
with open(cargo_toml, 'r') as f:
|
||||
content = f.read()
|
||||
in_deps = False
|
||||
for line in content.split('\n'):
|
||||
if line.strip().startswith('[dependencies]'):
|
||||
in_deps = True
|
||||
continue
|
||||
if line.strip().startswith('[') and in_deps:
|
||||
break
|
||||
if in_deps and '=' in line and not line.strip().startswith('#'):
|
||||
parts = line.split('=', 1)
|
||||
name = parts[0].strip()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ''
|
||||
if rest.startswith('{'):
|
||||
# Handle { version = "x.y", features = [...] } format
|
||||
import re
|
||||
match = re.search(r'version\s*=\s*"([^"]+)"', rest)
|
||||
if match:
|
||||
deps.append(f" {name} = \"{match.group(1)}\"")
|
||||
elif rest.startswith('"') or rest.startswith("'"):
|
||||
version = rest.strip('"').strip("'")
|
||||
deps.append(f" {name} = \"{version}\"")
|
||||
if len(deps) >= max_deps:
|
||||
break
|
||||
except (OSError, Exception):
|
||||
pass
|
||||
if deps:
|
||||
return "Rust (Cargo.toml):\n" + "\n".join(deps[:max_deps])
|
||||
|
||||
# Check for Node.js (package.json)
|
||||
package_json = os.path.join(cwd, 'package.json')
|
||||
if os.path.exists(package_json):
|
||||
try:
|
||||
with open(package_json, 'r') as f:
|
||||
pkg = json.load(f)
|
||||
for dep_type in ['dependencies', 'devDependencies']:
|
||||
if dep_type in pkg:
|
||||
for name, version in list(pkg[dep_type].items())[:max_deps]:
|
||||
deps.append(f" {name}: {version}")
|
||||
if len(deps) >= max_deps:
|
||||
break
|
||||
except (OSError, json.JSONDecodeError, Exception):
|
||||
pass
|
||||
if deps:
|
||||
return "Node.js (package.json):\n" + "\n".join(deps[:max_deps])
|
||||
|
||||
# Check for Python (requirements.txt or pyproject.toml)
|
||||
requirements = os.path.join(cwd, 'requirements.txt')
|
||||
if os.path.exists(requirements):
|
||||
try:
|
||||
with open(requirements, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and not line.startswith('-'):
|
||||
deps.append(f" {line}")
|
||||
if len(deps) >= max_deps:
|
||||
break
|
||||
except (OSError, Exception):
|
||||
pass
|
||||
if deps:
|
||||
return "Python (requirements.txt):\n" + "\n".join(deps[:max_deps])
|
||||
|
||||
# Check for Elixir (mix.exs)
|
||||
mix_exs = os.path.join(cwd, 'mix.exs')
|
||||
if os.path.exists(mix_exs):
|
||||
try:
|
||||
import re
|
||||
with open(mix_exs, 'r') as f:
|
||||
content = f.read()
|
||||
# Match {:dep_name, "~> x.y"} or {:dep_name, ">= x.y"} patterns
|
||||
for match in re.finditer(r'\{:(\w+),\s*"([^"]+)"', content):
|
||||
deps.append(f" {match.group(1)}: {match.group(2)}")
|
||||
if len(deps) >= max_deps:
|
||||
break
|
||||
except (OSError, Exception):
|
||||
pass
|
||||
if deps:
|
||||
return "Elixir (mix.exs):\n" + "\n".join(deps[:max_deps])
|
||||
|
||||
# Check for Go (go.mod)
|
||||
go_mod = os.path.join(cwd, 'go.mod')
|
||||
if os.path.exists(go_mod):
|
||||
try:
|
||||
with open(go_mod, 'r') as f:
|
||||
in_require = False
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line.startswith('require ('):
|
||||
in_require = True
|
||||
continue
|
||||
if line == ')' and in_require:
|
||||
break
|
||||
if in_require and line:
|
||||
deps.append(f" {line}")
|
||||
if len(deps) >= max_deps:
|
||||
break
|
||||
except (OSError, Exception):
|
||||
pass
|
||||
if deps:
|
||||
return "Go (go.mod):\n" + "\n".join(deps[:max_deps])
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def build_reminder(languages, project_tree, dependencies, language_rules, global_rules, project_rules, tracking_mode="strict", crosslink_dir=None, knowledge_rules="", quality_rules=""):
|
||||
"""Build the full reminder context."""
|
||||
lang_section = get_language_section(languages, language_rules)
|
||||
lang_list = ", ".join(languages) if languages else "this project"
|
||||
current_year = datetime.now().year
|
||||
|
||||
# Build tree section if available
|
||||
tree_section = ""
|
||||
if project_tree:
|
||||
tree_section = f"""
|
||||
### Project Structure (use these exact paths)
|
||||
```
|
||||
{project_tree}
|
||||
```
|
||||
"""
|
||||
|
||||
# Build dependencies section if available
|
||||
deps_section = ""
|
||||
if dependencies:
|
||||
deps_section = f"""
|
||||
### Installed Dependencies (use these exact versions)
|
||||
```
|
||||
{dependencies}
|
||||
```
|
||||
"""
|
||||
|
||||
# Build global rules section (from .crosslink/rules/global.md)
|
||||
# Then append/replace the tracking section based on tracking_mode
|
||||
global_section = ""
|
||||
if global_rules:
|
||||
global_section = f"\n{global_rules}\n"
|
||||
else:
|
||||
# Fallback to hardcoded defaults if no rules file
|
||||
global_section = f"""
|
||||
### Pre-Coding Grounding (PREVENT HALLUCINATIONS)
|
||||
Before writing code that uses external libraries, APIs, or unfamiliar patterns:
|
||||
1. **VERIFY IT EXISTS**: Use WebSearch to confirm the crate/package/module exists and check its actual API
|
||||
2. **CHECK THE DOCS**: Fetch documentation to see real function signatures, not imagined ones
|
||||
3. **CONFIRM SYNTAX**: If unsure about language features or library usage, search first
|
||||
4. **USE LATEST VERSIONS**: Always check for and use the latest stable version of dependencies (security + features)
|
||||
5. **NO GUESSING**: If you can't verify it, tell the user you need to research it
|
||||
|
||||
Examples of when to search:
|
||||
- Using a crate/package you haven't used recently → search "[package] [language] docs {current_year}"
|
||||
- Uncertain about function parameters → search for actual API reference
|
||||
- New language feature or syntax → verify it exists in the version being used
|
||||
- System calls or platform-specific code → confirm the correct API
|
||||
- Adding a dependency → search "[package] latest version {current_year}" to get current release
|
||||
|
||||
### General Requirements
|
||||
1. **NO STUBS - ABSOLUTE RULE**:
|
||||
- NEVER write `TODO`, `FIXME`, `pass`, `...`, `unimplemented!()` as implementation
|
||||
- NEVER write empty function bodies or placeholder returns
|
||||
- NEVER say "implement later" or "add logic here"
|
||||
- If logic is genuinely too complex for one turn, use `raise NotImplementedError("Descriptive reason: what needs to be done")` and create a crosslink issue
|
||||
- The PostToolUse hook WILL detect and flag stub patterns - write real code the first time
|
||||
2. **NO DEAD CODE**: Discover if dead code is truly dead or if it's an incomplete feature. If incomplete, complete it. If truly dead, remove it.
|
||||
3. **FULL FEATURES**: Implement the complete feature as requested. Don't stop partway or suggest "you could add X later."
|
||||
4. **ERROR HANDLING**: Proper error handling everywhere. No panics/crashes on bad input.
|
||||
5. **SECURITY**: Validate input, use parameterized queries, no command injection, no hardcoded secrets.
|
||||
6. **READ BEFORE WRITE**: Always read a file before editing it. Never guess at contents.
|
||||
|
||||
### Conciseness Protocol
|
||||
Minimize chattiness. Your output should be:
|
||||
- **Code blocks** with implementation
|
||||
- **Tool calls** to accomplish tasks
|
||||
- **Brief explanations** only when the code isn't self-explanatory
|
||||
|
||||
NEVER output:
|
||||
- "Here is the code" / "Here's how to do it" (just show the code)
|
||||
- "Let me know if you need anything else" / "Feel free to ask"
|
||||
- "I'll now..." / "Let me..." (just do it)
|
||||
- Restating what the user asked
|
||||
- Explaining obvious code
|
||||
- Multiple paragraphs when one sentence suffices
|
||||
|
||||
When writing code: write it. When making changes: make them. Skip the narration.
|
||||
|
||||
### Large File Management (500+ lines)
|
||||
If you need to write or modify code that will exceed 500 lines:
|
||||
1. Create a parent issue for the overall feature: `crosslink issue create "<feature name>" -p high`
|
||||
2. Break down into subissues: `crosslink issue subissue <parent_id> "<component 1>"`, etc.
|
||||
3. Inform the user: "This implementation will require multiple files/components. I've created issue #X with Y subissues to track progress."
|
||||
4. Work on one subissue at a time, marking each complete before moving on.
|
||||
|
||||
### Context Window Management
|
||||
If the conversation is getting long OR the task requires many more steps:
|
||||
1. Create a crosslink issue to track remaining work: `crosslink issue create "Continue: <task summary>" -p high`
|
||||
2. Add detailed notes as a comment: `crosslink issue comment <id> "<what's done, what's next>"`
|
||||
3. Inform the user: "This task will require additional turns. I've created issue #X to track progress."
|
||||
|
||||
Use `crosslink session work <id>` to mark what you're working on.
|
||||
"""
|
||||
|
||||
# Inject tracking rules from per-mode markdown file
|
||||
tracking_rules = load_tracking_rules(crosslink_dir, tracking_mode) if crosslink_dir else ""
|
||||
tracking_section = f"\n{tracking_rules}\n" if tracking_rules else ""
|
||||
|
||||
# Build project rules section (from .crosslink/rules/project.md)
|
||||
project_section = ""
|
||||
if project_rules:
|
||||
project_section = f"\n### Project-Specific Rules\n{project_rules}\n"
|
||||
|
||||
# Build knowledge section (from .crosslink/rules/knowledge.md)
|
||||
knowledge_section = ""
|
||||
if knowledge_rules:
|
||||
knowledge_section = f"\n{knowledge_rules}\n"
|
||||
|
||||
# Build quality section (from .crosslink/rules/quality.md)
|
||||
quality_section = ""
|
||||
if quality_rules:
|
||||
quality_section = f"\n{quality_rules}\n"
|
||||
|
||||
reminder = f"""<crosslink-behavioral-guard>
|
||||
## Code Quality Requirements
|
||||
|
||||
You are working on a {lang_list} project. Follow these requirements strictly:
|
||||
{tree_section}{deps_section}{global_section}{tracking_section}{quality_section}{lang_section}{project_section}{knowledge_section}
|
||||
</crosslink-behavioral-guard>"""
|
||||
|
||||
return reminder
|
||||
|
||||
|
||||
def get_guard_marker_path(crosslink_dir):
|
||||
"""Get the path to the guard-full-sent marker file."""
|
||||
if not crosslink_dir:
|
||||
return None
|
||||
cache_dir = os.path.join(crosslink_dir, '.cache')
|
||||
return os.path.join(cache_dir, 'guard-full-sent')
|
||||
|
||||
|
||||
def should_send_full_guard(crosslink_dir):
|
||||
"""Check if this is the first prompt (no marker) or marker is stale."""
|
||||
marker = get_guard_marker_path(crosslink_dir)
|
||||
if not marker:
|
||||
return True
|
||||
if not os.path.exists(marker):
|
||||
return True
|
||||
# Re-send full guard if marker is older than 4 hours (new session likely)
|
||||
try:
|
||||
age = datetime.now().timestamp() - os.path.getmtime(marker)
|
||||
if age > 4 * 3600:
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mark_full_guard_sent(crosslink_dir):
|
||||
"""Create marker file indicating full guard has been sent this session."""
|
||||
marker = get_guard_marker_path(crosslink_dir)
|
||||
if not marker:
|
||||
return
|
||||
try:
|
||||
cache_dir = os.path.dirname(marker)
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
with open(marker, 'w') as f:
|
||||
f.write(str(datetime.now().timestamp()))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_tracking_rules(crosslink_dir, tracking_mode):
|
||||
"""Load the tracking rules markdown file for the given mode.
|
||||
|
||||
Checks rules.local/ first for a local override, then falls back to rules/.
|
||||
"""
|
||||
if not crosslink_dir:
|
||||
return ""
|
||||
filename = f"tracking-{tracking_mode}.md"
|
||||
# Check rules.local/ first
|
||||
local_path = os.path.join(crosslink_dir, "rules.local", filename)
|
||||
try:
|
||||
with open(local_path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
# Fall back to rules/
|
||||
path = os.path.join(crosslink_dir, "rules", filename)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except (OSError, IOError):
|
||||
return ""
|
||||
|
||||
|
||||
# Condensed reminders kept short — these don't need full markdown files
|
||||
CONDENSED_REMINDERS = {
|
||||
"strict": (
|
||||
"- **MANDATORY — Crosslink Issue Tracking**: You MUST create a crosslink issue BEFORE writing ANY code. "
|
||||
"NO EXCEPTIONS. Use `crosslink quick \"title\" -p <priority> -l <label>` BEFORE your first Write/Edit/Bash. "
|
||||
"If you skip this, the PreToolUse hook WILL block you. Do NOT treat this as optional.\n"
|
||||
"- **Session**: ALWAYS use `crosslink session work <id>` to mark focus. "
|
||||
"End with `crosslink session end --notes \"...\"`. This is NOT optional."
|
||||
),
|
||||
"normal": (
|
||||
"- **Crosslink**: Create issues before work. Use `crosslink quick` for create+label+work. Close with `crosslink close`.\n"
|
||||
"- **Session**: Use `crosslink session work <id>`. End with `crosslink session end --notes \"...\"`."
|
||||
),
|
||||
"relaxed": "",
|
||||
}
|
||||
|
||||
|
||||
def build_condensed_reminder(languages, tracking_mode):
|
||||
"""Build a short reminder for subsequent prompts (after full guard already sent)."""
|
||||
lang_list = ", ".join(languages) if languages else "this project"
|
||||
tracking_lines = CONDENSED_REMINDERS.get(tracking_mode, "")
|
||||
|
||||
return f"""<crosslink-behavioral-guard>
|
||||
## Quick Reminder ({lang_list})
|
||||
|
||||
{tracking_lines}
|
||||
- **Security**: Use `mcp__crosslink-safe-fetch__safe_fetch` for web requests. Parameterized queries only.
|
||||
- **Quality**: No stubs/TODOs. Read before write. Complete features fully. Proper error handling.
|
||||
- **Testing**: Run tests after changes. Fix warnings, don't suppress them.
|
||||
|
||||
Full rules were injected on first prompt. Use `crosslink issue list -s open` to see current issues.
|
||||
</crosslink-behavioral-guard>"""
|
||||
|
||||
|
||||
def estimate_prompt_chars(input_data):
|
||||
"""Estimate characters consumed by this prompt turn.
|
||||
|
||||
The hook only sees the user prompt, not tool outputs or model responses.
|
||||
We apply a multiplier (5x) to account for the full turn cost:
|
||||
user prompt + tool calls + tool results + model response.
|
||||
"""
|
||||
TURN_MULTIPLIER = 5
|
||||
try:
|
||||
prompt_text = input_data.get("prompt", "")
|
||||
if isinstance(prompt_text, str):
|
||||
return len(prompt_text) * TURN_MULTIPLIER
|
||||
return 2000 * TURN_MULTIPLIER
|
||||
except (AttributeError, TypeError):
|
||||
return 2000 * TURN_MULTIPLIER
|
||||
|
||||
|
||||
def check_context_budget(crosslink_dir, state, prompt_chars):
|
||||
"""Check if estimated context usage has exceeded the budget.
|
||||
|
||||
Returns True if the budget is exceeded and full reinjection is needed.
|
||||
Default budget: 1,000,000 chars ~ 250k tokens.
|
||||
"""
|
||||
config = load_config_merged(crosslink_dir) if crosslink_dir else {}
|
||||
budget = int(config.get("context_budget_chars", 1_000_000))
|
||||
if budget <= 0:
|
||||
return False
|
||||
|
||||
current = state.get("estimated_context_chars", 0)
|
||||
current += prompt_chars
|
||||
state["estimated_context_chars"] = current
|
||||
|
||||
return current >= budget
|
||||
|
||||
|
||||
def build_context_budget_warning(languages, tracking_mode):
|
||||
"""Build the compression directive when context budget is exceeded."""
|
||||
lang_list = ", ".join(languages) if languages else "this project"
|
||||
tracking_lines = CONDENSED_REMINDERS.get(tracking_mode, "")
|
||||
|
||||
return f"""<crosslink-context-budget-exceeded>
|
||||
## CONTEXT BUDGET EXCEEDED — COMPRESSION REQUIRED
|
||||
|
||||
Your estimated context usage has exceeded 250k tokens. Research shows instruction
|
||||
adherence degrades significantly past this point. You MUST take the following steps
|
||||
IMMEDIATELY, before doing anything else:
|
||||
|
||||
1. **Record your current state**: Run `crosslink session action "Context budget reached. Working on: <current task summary>"`
|
||||
2. **Save any in-progress work context** as a crosslink comment: `crosslink issue comment <id> "Progress: <what's done, what's next>" --kind observation`
|
||||
3. **The system will compress context automatically.** After compression, re-read any files you need and continue working.
|
||||
|
||||
## Re-injected Rules ({lang_list})
|
||||
|
||||
{tracking_lines}
|
||||
- **Security**: Use `mcp__crosslink-safe-fetch__safe_fetch` for web requests. Parameterized queries only.
|
||||
- **Quality**: No stubs/TODOs. Read before write. Complete features fully. Proper error handling.
|
||||
- **Testing**: Run tests after changes. Fix warnings, don't suppress them.
|
||||
- **Documentation**: Add typed crosslink comments (--kind plan/decision/observation/result) at every step.
|
||||
</crosslink-context-budget-exceeded>"""
|
||||
|
||||
|
||||
def main():
|
||||
input_data = {}
|
||||
try:
|
||||
# Read input from stdin (Claude Code passes prompt info)
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find crosslink directory and load rules
|
||||
crosslink_dir = find_crosslink_dir()
|
||||
tracking_mode = load_tracking_mode(crosslink_dir)
|
||||
|
||||
# Agents always get condensed reminders — skip expensive tree/deps scanning
|
||||
if is_agent_context(crosslink_dir):
|
||||
languages = detect_languages()
|
||||
print(build_condensed_reminder(languages, tracking_mode))
|
||||
sys.exit(0)
|
||||
|
||||
# Check if we should send full or condensed guard
|
||||
if not should_send_full_guard(crosslink_dir):
|
||||
config = load_config_merged(crosslink_dir)
|
||||
interval = int(config.get("reminder_drift_threshold", 3))
|
||||
|
||||
state = load_guard_state(crosslink_dir)
|
||||
state["total_prompts"] = state.get("total_prompts", 0) + 1
|
||||
|
||||
# Check context budget — if exceeded, reinject full guard + compression directive
|
||||
prompt_chars = estimate_prompt_chars(input_data)
|
||||
if check_context_budget(crosslink_dir, state, prompt_chars):
|
||||
languages = detect_languages()
|
||||
language_rules, global_rules, project_rules, knowledge_rules, quality_rules = load_all_rules(crosslink_dir)
|
||||
project_tree = get_project_tree()
|
||||
dependencies = get_dependencies()
|
||||
print(build_reminder(languages, project_tree, dependencies, language_rules, global_rules, project_rules, tracking_mode, crosslink_dir, knowledge_rules, quality_rules))
|
||||
print(build_context_budget_warning(languages, tracking_mode))
|
||||
state["estimated_context_chars"] = 0
|
||||
state["context_budget_reinjections"] = state.get("context_budget_reinjections", 0) + 1
|
||||
save_guard_state(crosslink_dir, state)
|
||||
sys.exit(0)
|
||||
|
||||
# Normal condensed reminder at interval
|
||||
if interval == 0 or state["total_prompts"] % interval == 0:
|
||||
languages = detect_languages()
|
||||
print(build_condensed_reminder(languages, tracking_mode))
|
||||
|
||||
save_guard_state(crosslink_dir, state)
|
||||
sys.exit(0)
|
||||
|
||||
language_rules, global_rules, project_rules, knowledge_rules, quality_rules = load_all_rules(crosslink_dir)
|
||||
|
||||
# Detect languages in the project
|
||||
languages = detect_languages()
|
||||
|
||||
# Generate project tree to prevent path hallucinations
|
||||
project_tree = get_project_tree()
|
||||
|
||||
# Get installed dependencies to prevent version hallucinations
|
||||
dependencies = get_dependencies()
|
||||
|
||||
# Output the full reminder
|
||||
print(build_reminder(languages, project_tree, dependencies, language_rules, global_rules, project_rules, tracking_mode, crosslink_dir, knowledge_rules, quality_rules))
|
||||
|
||||
# Mark that we've sent the full guard this session
|
||||
mark_full_guard_sent(crosslink_dir)
|
||||
|
||||
# Initialize context budget tracking for this session
|
||||
state = load_guard_state(crosslink_dir)
|
||||
state["estimated_context_chars"] = 0
|
||||
save_guard_state(crosslink_dir, state)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user