800 lines
30 KiB
Python
800 lines
30 KiB
Python
#!/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()
|