845 lines
31 KiB
Python
845 lines
31 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
Path-Scoped Spec Context Injection Hook (ticket-refresh model)
|
||
|
|
|
||
|
|
When the agent touches a file, the specs that govern that file are surfaced
|
||
|
|
right then — small, relevant, budgeted — instead of everything up front or
|
||
|
|
nothing at all. Spec .md files under .trellis/spec/ declare which code paths
|
||
|
|
they govern via YAML frontmatter (`paths:` glob list); the matching engine
|
||
|
|
lives in .trellis/scripts/common/spec_match.py and the decision engine in
|
||
|
|
.trellis/scripts/common/spec_inject.py. This file is the IO shell: stdin,
|
||
|
|
config, identity, state files, locking, GC, one print.
|
||
|
|
|
||
|
|
Triggers:
|
||
|
|
|
||
|
|
* Claude Code PostToolUse (matcher "Read|Edit|Write|MultiEdit") receives one
|
||
|
|
structured file path after the tool runs.
|
||
|
|
* Codex PreToolUse (matcher "Edit|Write") receives an ``apply_patch`` command.
|
||
|
|
Every patch header is matched before the patch runs. When a FULL spec is
|
||
|
|
emitted, the patch is denied once so the model can read the injected rules
|
||
|
|
and retry; ticket-only reminders do not block.
|
||
|
|
* OpenCode ``tool.execute.before`` adapts ``write``, ``edit``, and
|
||
|
|
``apply_patch`` into the same PreToolUse payload. FULL delivery uses the same
|
||
|
|
deny-once decision before the JS plugin surfaces context as a tool error.
|
||
|
|
|
||
|
|
Behavior — per matched spec, per event (recency-decay aware):
|
||
|
|
|
||
|
|
h = sha256(spec bytes)
|
||
|
|
last = newest emission recorded for (identity, spec) # stateless → None
|
||
|
|
if stateless: emit TICKET # bounded cost, always
|
||
|
|
elif last is None: emit FULL # first time this session
|
||
|
|
elif last.sha256 != h: emit FULL # spec changed → re-teach
|
||
|
|
elif reset since last: emit FULL # the text is gone, re-teach
|
||
|
|
elif within window: silent # fixed window; no state append
|
||
|
|
else: emit TICKET # refresh attention cheaply
|
||
|
|
|
||
|
|
A FULL block inlines the (budgeted) spec body with a sha256 attr; a TICKET is
|
||
|
|
a short reminder pointing back at the spec. Both append a state record; silent
|
||
|
|
hits do not (fixed window, not sliding — continuous editing is exactly when
|
||
|
|
drift is worst).
|
||
|
|
|
||
|
|
Identity (misfire asymmetry: a collision that MISSES an injection is
|
||
|
|
unacceptable; drift that OVER-injects is fine): the session/window key is
|
||
|
|
delegated to common.active_task.resolve_context_key — the shared resolver
|
||
|
|
every other hook uses — called payload-first, environment-inclusive second,
|
||
|
|
so two live sessions can never collapse onto one exported env value. A
|
||
|
|
`+a-<agent_id>` suffix (appended after each part is sanitized) keeps a
|
||
|
|
subagent's state separate from its parent's. When the resolver is unavailable
|
||
|
|
(older installed scripts tree), a minimal payload-only ladder (session keys,
|
||
|
|
then transcript hash) keeps the hook working. No key from any source, or an
|
||
|
|
unwritable state dir → stateless: no state IO at all, every hit is a TICKET
|
||
|
|
(circuit breaker — never a FULL re-emission loop).
|
||
|
|
|
||
|
|
State: user-global, out of the repo, one append-only JSONL file per identity
|
||
|
|
under ${TRELLIS_SPEC_STATE_DIR:-~/.trellis/spec-inject}/<project16>/<identity>.jsonl.
|
||
|
|
SessionStart(clear|compact) appends an opaque reset marker to the base session
|
||
|
|
shard; parent and subagent emission histories stay separate but observe that
|
||
|
|
shared marker. A best-effort fcntl lock is held across read→decide→append;
|
||
|
|
where fcntl is unavailable (Windows) the worst case is a duplicate injection.
|
||
|
|
A once-per-hour GC prunes conforming shards older than 48 h.
|
||
|
|
|
||
|
|
Budget (config.yaml `spec_injection:`): per-spec cap `max_spec_chars`
|
||
|
|
(default 9400) with code-point truncation + in-body notice; per-event cap
|
||
|
|
`max_total_chars` (default 9500 — below Claude Code's documented
|
||
|
|
additionalContext ceiling, and enforced directly for Codex). Once the total
|
||
|
|
budget is exhausted, remaining FULL bodies degrade to one <spec-index> block;
|
||
|
|
tickets are counted last and dropped (with a stderr warning) only if even they
|
||
|
|
do not fit.
|
||
|
|
|
||
|
|
Refresh window (config.yaml `spec_injection:`): `refresh_window_seconds`
|
||
|
|
(default 2700; `0` = never refresh unchanged content solely because time
|
||
|
|
passed).
|
||
|
|
|
||
|
|
Fail-open on errors: non-matching events, malformed paths, no matches, or any
|
||
|
|
internal error → exit 0 with no stdout (stderr warnings allowed). The only
|
||
|
|
deliberate block is a Codex patch that just received a FULL governing spec.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
# IMPORTANT: Suppress all warnings FIRST
|
||
|
|
import warnings
|
||
|
|
warnings.filterwarnings("ignore")
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
import uuid
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
# IMPORTANT: Force UTF-8 on Windows for the streams this hook uses.
|
||
|
|
# stdin carries the payload (non-ASCII file paths), stdout carries the spec
|
||
|
|
# bodies, stderr carries warnings that quote both; without this the default
|
||
|
|
# ANSI codepage raises UnicodeDecodeError / UnicodeEncodeError.
|
||
|
|
if sys.platform.startswith("win"):
|
||
|
|
import io as _io
|
||
|
|
for _stream_name in ("stdin", "stdout", "stderr"):
|
||
|
|
_stream = getattr(sys, _stream_name, None)
|
||
|
|
if _stream is None:
|
||
|
|
continue
|
||
|
|
if hasattr(_stream, "reconfigure"):
|
||
|
|
try:
|
||
|
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||
|
|
except Exception:
|
||
|
|
pass # Optional Windows stream setup; keep hook startup non-fatal.
|
||
|
|
elif hasattr(_stream, "detach"):
|
||
|
|
try:
|
||
|
|
setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
|
||
|
|
except Exception:
|
||
|
|
pass # Optional Windows stream setup; keep hook startup non-fatal.
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Constants
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
DIR_WORKFLOW = ".trellis"
|
||
|
|
DIR_SPEC = "spec"
|
||
|
|
|
||
|
|
# Tools whose events trigger spec matching (Claude Code tool names). Touching a
|
||
|
|
# file — even a Read — counts; the miss path stays a fast exit. Overridable via
|
||
|
|
# config `spec_injection.tools` (e.g. to drop "Read").
|
||
|
|
DEFAULT_EDIT_TOOLS = ("Read", "Edit", "Write", "MultiEdit")
|
||
|
|
|
||
|
|
# Budget defaults sized against Claude Code's documented 10,000-CHARACTER
|
||
|
|
# additionalContext ceiling — stay under with margin. The <spec-context>
|
||
|
|
# wrapper (~152 chars with typical rel paths) counts against the total, so a
|
||
|
|
# spec lands whole only up to ~9348 chars; at the per-spec cap the block is
|
||
|
|
# derived-truncated further to fit the event ceiling. `0` = unlimited.
|
||
|
|
DEFAULT_MAX_SPEC_CHARS = 9400
|
||
|
|
DEFAULT_MAX_TOTAL_CHARS = 9500
|
||
|
|
|
||
|
|
# Refresh-window default. `0` = never refresh solely because time passed.
|
||
|
|
DEFAULT_REFRESH_WINDOW_SECONDS = 2700
|
||
|
|
|
||
|
|
# State-file base dir (overridable for tests / hermeticity) and GC policy.
|
||
|
|
STATE_ENV_DIR = "TRELLIS_SPEC_STATE_DIR"
|
||
|
|
STATE_DEFAULT_DIR = "~/.trellis/spec-inject"
|
||
|
|
GC_MARKER = ".last-gc"
|
||
|
|
GC_INTERVAL_SECONDS = 60 * 60 # GC runs at most once per hour
|
||
|
|
STATE_MAX_AGE_SECONDS = 48 * 60 * 60 # shards older than this are pruned
|
||
|
|
|
||
|
|
# GC scope: exactly `<base>/<project16>/<identity>[.<pid>].jsonl`, never a
|
||
|
|
# recursive walk — a hostile or mistyped TRELLIS_SPEC_STATE_DIR must not turn
|
||
|
|
# this hook into an unlink loop over someone's files. The optional `.<pid>`
|
||
|
|
# alternative covers shards written by the pre-lock layout.
|
||
|
|
GC_PROJECT_DIR_RE = re.compile(r"^[0-9a-f]{16}$")
|
||
|
|
# `+` is part of the identity charset: subagent shards use the `+a-<agent_id>`
|
||
|
|
# suffix (contract amendment 2 — without it those shards were never pruned).
|
||
|
|
GC_SHARD_NAME_RE = re.compile(r"^[A-Za-z0-9_+-]+(\.[0-9]+)?\.jsonl$")
|
||
|
|
PATCH_PATH_RE = re.compile(
|
||
|
|
r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _warn(message: str) -> None:
|
||
|
|
print(f"[inject-spec-context] WARN: {message}", file=sys.stderr)
|
||
|
|
|
||
|
|
|
||
|
|
def _patch_paths(command: str) -> list[str]:
|
||
|
|
"""Return file paths from the shared apply_patch grammar."""
|
||
|
|
paths: list[str] = []
|
||
|
|
for line in command.splitlines():
|
||
|
|
match = PATCH_PATH_RE.fullmatch(line)
|
||
|
|
if match:
|
||
|
|
path = match.group(1).strip()
|
||
|
|
if path and path not in paths:
|
||
|
|
paths.append(path)
|
||
|
|
return paths
|
||
|
|
|
||
|
|
|
||
|
|
def _agent_id(payload: dict) -> str:
|
||
|
|
"""The subagent id carried by the event, or "" for a main-session event."""
|
||
|
|
raw = payload.get("agent_id")
|
||
|
|
return raw.strip() if isinstance(raw, str) else ""
|
||
|
|
|
||
|
|
|
||
|
|
def find_trellis_root(start: Path) -> Path | None:
|
||
|
|
"""Walk up from start to find the directory containing .trellis/.
|
||
|
|
|
||
|
|
Handles CWD drift: subdirectory launches, monorepo packages, etc.
|
||
|
|
Returns None if no .trellis/ found (silent no-op).
|
||
|
|
"""
|
||
|
|
cur = start.resolve()
|
||
|
|
while cur != cur.parent:
|
||
|
|
if (cur / DIR_WORKFLOW).is_dir():
|
||
|
|
return cur
|
||
|
|
cur = cur.parent
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _scripts_dir_on_path(root: Path) -> None:
|
||
|
|
scripts_dir = root / DIR_WORKFLOW / "scripts"
|
||
|
|
if str(scripts_dir) not in sys.path:
|
||
|
|
sys.path.insert(0, str(scripts_dir))
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Config (.trellis/config.yaml `spec_injection:` section)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
|
||
|
|
def _read_trellis_config(root: Path) -> dict:
|
||
|
|
"""Load .trellis/config.yaml via the bundled trellis_config helper.
|
||
|
|
|
||
|
|
The helper lives in .trellis/scripts/common; the hook lives outside the
|
||
|
|
scripts tree, so we extend sys.path before importing.
|
||
|
|
"""
|
||
|
|
_scripts_dir_on_path(root)
|
||
|
|
try:
|
||
|
|
from common.trellis_config import read_trellis_config # type: ignore[import-not-found]
|
||
|
|
except Exception:
|
||
|
|
return {}
|
||
|
|
try:
|
||
|
|
return read_trellis_config(root)
|
||
|
|
except Exception:
|
||
|
|
return {}
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_tools(raw: object) -> tuple[str, ...] | None:
|
||
|
|
"""Parse `spec_injection.tools` into a tuple of tool names.
|
||
|
|
|
||
|
|
Two grammars, because the bundled YAML reader hands the value over in two
|
||
|
|
shapes: a block list (``- Edit`` items) arrives as a list, a flow sequence
|
||
|
|
(``tools: [Edit, Write]``) arrives as the raw string. ``[]`` in either
|
||
|
|
shape is a deliberate "never trigger" and is respected. Returns None for a
|
||
|
|
value that is neither (the caller warns and keeps the defaults).
|
||
|
|
"""
|
||
|
|
if isinstance(raw, list):
|
||
|
|
return tuple(t.strip() for t in raw if isinstance(t, str) and t.strip())
|
||
|
|
if isinstance(raw, str):
|
||
|
|
text = raw.strip()
|
||
|
|
if text.startswith("[") and text.endswith("]"):
|
||
|
|
items = (part.strip().strip("\"'").strip() for part in text[1:-1].split(","))
|
||
|
|
return tuple(item for item in items if item)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def get_spec_injection_settings(
|
||
|
|
root: Path,
|
||
|
|
) -> tuple[bool, int, int, int, tuple[str, ...]]:
|
||
|
|
"""Return (enabled, max_spec_chars, max_total_chars,
|
||
|
|
refresh_window_seconds, tools).
|
||
|
|
|
||
|
|
Reads the ``spec_injection:`` section of ``.trellis/config.yaml``:
|
||
|
|
|
||
|
|
spec_injection:
|
||
|
|
enabled: true
|
||
|
|
max_spec_chars: 9400
|
||
|
|
max_total_chars: 9500
|
||
|
|
refresh_window_seconds: 2700
|
||
|
|
tools:
|
||
|
|
- Read
|
||
|
|
- Edit
|
||
|
|
- Write
|
||
|
|
- MultiEdit
|
||
|
|
|
||
|
|
Missing keys use their defaults; ``0`` disables the corresponding limit
|
||
|
|
(``max_spec_chars: 0`` = inline the whole body, ``max_total_chars: 0`` =
|
||
|
|
no per-event ceiling) or refresh (window keys). ``tools`` also accepts a
|
||
|
|
flow sequence (``tools: [Edit, Write]``), and ``tools: []`` disables every
|
||
|
|
trigger. Invalid values fall back to the default for that key with a
|
||
|
|
stderr warning; tool names outside the known set warn once.
|
||
|
|
"""
|
||
|
|
enabled = True
|
||
|
|
tools = DEFAULT_EDIT_TOOLS
|
||
|
|
numbers = {
|
||
|
|
"max_spec_chars": DEFAULT_MAX_SPEC_CHARS,
|
||
|
|
"max_total_chars": DEFAULT_MAX_TOTAL_CHARS,
|
||
|
|
"refresh_window_seconds": DEFAULT_REFRESH_WINDOW_SECONDS,
|
||
|
|
}
|
||
|
|
|
||
|
|
config = _read_trellis_config(root)
|
||
|
|
section = config.get("spec_injection") if isinstance(config, dict) else None
|
||
|
|
if isinstance(section, dict):
|
||
|
|
raw_enabled = section.get("enabled", True)
|
||
|
|
if isinstance(raw_enabled, bool):
|
||
|
|
enabled = raw_enabled
|
||
|
|
else:
|
||
|
|
s = str(raw_enabled).strip().lower()
|
||
|
|
if s in ("false", "no", "0", "off"):
|
||
|
|
enabled = False
|
||
|
|
elif s not in ("true", "yes", "1", "on"):
|
||
|
|
_warn(
|
||
|
|
f"invalid spec_injection.enabled value: {raw_enabled!r}; "
|
||
|
|
f"using true (default)"
|
||
|
|
)
|
||
|
|
|
||
|
|
# int() coercion stays local to this hook by decision (audit round,
|
||
|
|
# 2026-07-25): widening the shared common/config.py helpers has more
|
||
|
|
# blast radius than this small duplication costs.
|
||
|
|
for key, default_value in list(numbers.items()):
|
||
|
|
if key not in section:
|
||
|
|
continue
|
||
|
|
raw = section[key]
|
||
|
|
try:
|
||
|
|
value = int(raw)
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
value = -1
|
||
|
|
if value < 0:
|
||
|
|
_warn(
|
||
|
|
f"invalid spec_injection.{key} value: {raw!r}; "
|
||
|
|
f"using default {default_value}"
|
||
|
|
)
|
||
|
|
continue
|
||
|
|
numbers[key] = value
|
||
|
|
|
||
|
|
if "tools" in section:
|
||
|
|
parsed_tools = _parse_tools(section["tools"])
|
||
|
|
if parsed_tools is None:
|
||
|
|
_warn(
|
||
|
|
f"invalid spec_injection.tools value: {section['tools']!r}; "
|
||
|
|
f"using default {list(DEFAULT_EDIT_TOOLS)}"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
tools = parsed_tools
|
||
|
|
unknown = [t for t in tools if t not in DEFAULT_EDIT_TOOLS]
|
||
|
|
if unknown:
|
||
|
|
_warn(
|
||
|
|
f"unknown spec_injection.tools entries {unknown} — "
|
||
|
|
f"they will never match; known tools: "
|
||
|
|
f"{list(DEFAULT_EDIT_TOOLS)}"
|
||
|
|
)
|
||
|
|
|
||
|
|
return (
|
||
|
|
enabled,
|
||
|
|
numbers["max_spec_chars"],
|
||
|
|
numbers["max_total_chars"],
|
||
|
|
numbers["refresh_window_seconds"],
|
||
|
|
tools,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Identity ladder
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
|
||
|
|
def _sanitize(raw: str) -> str:
|
||
|
|
"""Map a session/agent id to a filename-safe, collision-free token.
|
||
|
|
|
||
|
|
A readable head (the first 80 characters, every character outside
|
||
|
|
``[A-Za-z0-9_-]`` replaced one-for-one by ``-``) plus, whenever anything
|
||
|
|
was replaced or the id was longer than 80 characters, ``-`` and 8 hex of
|
||
|
|
sha256(raw). The suffix is what makes the mapping injective: without it,
|
||
|
|
"a/b" and "a:b" — or two ids sharing an 80-character prefix — would fold
|
||
|
|
onto one state file, and a collision that MISSES an injection is the
|
||
|
|
unacceptable failure. Output stays inside the GC name class.
|
||
|
|
"""
|
||
|
|
raw = raw.strip()
|
||
|
|
head = raw[:80]
|
||
|
|
safe = re.sub(r"[^A-Za-z0-9_-]", "-", head)
|
||
|
|
if safe != head or len(raw) > 80:
|
||
|
|
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
|
||
|
|
return f"{safe}-{digest}"
|
||
|
|
return safe
|
||
|
|
|
||
|
|
|
||
|
|
def _shared_context_key(root: Path, payload: dict) -> str | None:
|
||
|
|
"""Session/window key from the shared resolver every other hook uses.
|
||
|
|
|
||
|
|
``common.active_task.resolve_context_key`` is the single source of truth
|
||
|
|
for session identity: payload keys in all casings (``session_id`` /
|
||
|
|
``sessionId`` / ``sessionID``, conversation and transcript variants),
|
||
|
|
nested payload shapes, the explicit ``TRELLIS_CONTEXT_ID`` override,
|
||
|
|
per-platform env fallbacks, and Cursor shell tickets — plus the platform
|
||
|
|
fixes accumulated behind them. Payload identity is preferred over
|
||
|
|
environment context so two live sessions can never collapse onto one
|
||
|
|
exported env value (collision → missed injection is the unacceptable
|
||
|
|
direction); the environment pass still runs when the payload carries
|
||
|
|
nothing.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
_scripts_dir_on_path(root)
|
||
|
|
from common.active_task import resolve_context_key # type: ignore[import-not-found]
|
||
|
|
|
||
|
|
key = resolve_context_key(payload, allow_environment_context=False)
|
||
|
|
if key:
|
||
|
|
return key
|
||
|
|
return resolve_context_key(payload)
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_base_identity(root: Path, payload: dict) -> tuple[str, bool]:
|
||
|
|
"""Return the base session identity for reset and refresh state.
|
||
|
|
|
||
|
|
When the shared resolver is unavailable (older installed scripts tree), a
|
||
|
|
minimal payload-only ladder keeps the hook working. ``stateless=True``
|
||
|
|
means no state IO is possible.
|
||
|
|
"""
|
||
|
|
identity_payload = payload
|
||
|
|
if payload.get("hook_event_name") == "SessionStart":
|
||
|
|
# In this event `source` means startup/clear/compact, not platform.
|
||
|
|
# The shared resolver also accepts a generic `source` platform hint,
|
||
|
|
# so remove the lifecycle field to keep the same session identity as
|
||
|
|
# later PostToolUse events.
|
||
|
|
identity_payload = dict(payload)
|
||
|
|
identity_payload.pop("source", None)
|
||
|
|
key = _shared_context_key(root, identity_payload)
|
||
|
|
|
||
|
|
if not key:
|
||
|
|
# Minimal payload-only fallback for scripts trees that predate
|
||
|
|
# resolve_context_key. Mirrors its payload lookup order.
|
||
|
|
for k in ("session_id", "sessionId", "sessionID"):
|
||
|
|
value = payload.get(k)
|
||
|
|
if isinstance(value, str) and value.strip():
|
||
|
|
key = "s-" + value.strip()
|
||
|
|
break
|
||
|
|
if not key:
|
||
|
|
transcript = payload.get("transcript_path")
|
||
|
|
if isinstance(transcript, str) and transcript.strip():
|
||
|
|
digest = hashlib.sha256(
|
||
|
|
transcript.strip().encode("utf-8")
|
||
|
|
).hexdigest()
|
||
|
|
key = "t-" + digest[:16]
|
||
|
|
|
||
|
|
if not key:
|
||
|
|
return "", True
|
||
|
|
|
||
|
|
return _sanitize(key), False
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# State (one append-only JSONL file per identity, locked, user-global)
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
|
||
|
|
def _state_base_dir() -> Path:
|
||
|
|
override = os.environ.get(STATE_ENV_DIR)
|
||
|
|
if override and override.strip():
|
||
|
|
return Path(override.strip())
|
||
|
|
return Path(os.path.expanduser(STATE_DEFAULT_DIR))
|
||
|
|
|
||
|
|
|
||
|
|
def _project_id(root: Path) -> str:
|
||
|
|
return hashlib.sha256(os.path.realpath(str(root)).encode("utf-8")).hexdigest()[:16]
|
||
|
|
|
||
|
|
|
||
|
|
def _maybe_gc(base_dir: Path) -> None:
|
||
|
|
"""Prune conforming shards older than 48 h, at most once per hour.
|
||
|
|
|
||
|
|
Scope is exact-depth (``<base>/<project16>/<shard>.jsonl``) and name-gated;
|
||
|
|
foreign files and directories are never touched. Containment is enforced
|
||
|
|
against symlinks on both levels — a symlinked project dir or shard is
|
||
|
|
skipped outright, and every unlink candidate must still be under the
|
||
|
|
resolved base after realpath — so a planted link cannot walk this GC out
|
||
|
|
of its own tree. Best-effort, errors ignored.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
base_real = os.path.realpath(str(base_dir))
|
||
|
|
marker = base_dir / GC_MARKER
|
||
|
|
now = time.time()
|
||
|
|
try:
|
||
|
|
age = now - marker.stat().st_mtime
|
||
|
|
except OSError:
|
||
|
|
age = None
|
||
|
|
if age is not None and age < GC_INTERVAL_SECONDS:
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
marker.touch()
|
||
|
|
except OSError:
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
project_dirs = list(base_dir.iterdir())
|
||
|
|
except OSError:
|
||
|
|
return
|
||
|
|
for project_dir in project_dirs:
|
||
|
|
if not GC_PROJECT_DIR_RE.match(project_dir.name):
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
if project_dir.is_symlink() or not project_dir.is_dir():
|
||
|
|
continue
|
||
|
|
shards = list(project_dir.iterdir())
|
||
|
|
except OSError:
|
||
|
|
continue
|
||
|
|
for shard in shards:
|
||
|
|
if not GC_SHARD_NAME_RE.match(shard.name):
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
if shard.is_symlink() or not shard.is_file():
|
||
|
|
continue
|
||
|
|
shard_real = os.path.realpath(str(shard))
|
||
|
|
if not shard_real.startswith(base_real + os.sep):
|
||
|
|
continue
|
||
|
|
if now - shard.stat().st_mtime > STATE_MAX_AGE_SECONDS:
|
||
|
|
shard.unlink()
|
||
|
|
except OSError:
|
||
|
|
continue
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def open_shard(shard_path: Path) -> int | None:
|
||
|
|
"""Open (creating) the identity's shard for read+append.
|
||
|
|
|
||
|
|
Doubles as the writability probe: a failure here trips the circuit breaker
|
||
|
|
and the event runs stateless (ticket-only), which is bounded, instead of
|
||
|
|
re-emitting full specs on every event forever.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
shard_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
except OSError:
|
||
|
|
_warn(f"state dir {shard_path.parent} unusable — running stateless")
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
return os.open(
|
||
|
|
str(shard_path),
|
||
|
|
os.O_RDWR | os.O_CREAT | os.O_APPEND,
|
||
|
|
0o644,
|
||
|
|
)
|
||
|
|
except OSError:
|
||
|
|
_warn(f"state shard {shard_path} unusable — running stateless")
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def lock_shard(fd: int) -> None:
|
||
|
|
"""Best-effort exclusive lock held across read→decide→append.
|
||
|
|
|
||
|
|
Closes the duplicate-injection race between concurrent hook processes on
|
||
|
|
POSIX. No fcntl (Windows) or an unsupported filesystem → no lock; the
|
||
|
|
worst case is a duplicate injection, never a lost one.
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
import fcntl
|
||
|
|
|
||
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def unlock_shard(fd: int) -> None:
|
||
|
|
try:
|
||
|
|
import fcntl
|
||
|
|
|
||
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def load_state(
|
||
|
|
fd: int,
|
||
|
|
state_version: int,
|
||
|
|
) -> tuple[dict[str, dict], str | None] | None:
|
||
|
|
"""Read the shard through the already-open fd; newest record per spec wins
|
||
|
|
(``ts`` decides, and on an exact tie the later line in the file does —
|
||
|
|
appends are ordered, and two records one float apart must not resolve to
|
||
|
|
the older one). The latest reset marker is returned separately. Malformed
|
||
|
|
lines and foreign schema versions are skipped silently; read failures
|
||
|
|
return None so the caller uses stateless ticket mode."""
|
||
|
|
result: dict[str, dict] = {}
|
||
|
|
latest_reset: str | None = None
|
||
|
|
try:
|
||
|
|
os.lseek(fd, 0, os.SEEK_SET)
|
||
|
|
chunks: list[bytes] = []
|
||
|
|
while True:
|
||
|
|
chunk = os.read(fd, 1 << 20)
|
||
|
|
if not chunk:
|
||
|
|
break
|
||
|
|
chunks.append(chunk)
|
||
|
|
except OSError:
|
||
|
|
return None
|
||
|
|
|
||
|
|
text = b"".join(chunks).decode("utf-8", errors="replace")
|
||
|
|
for line in text.splitlines():
|
||
|
|
line = line.strip()
|
||
|
|
if not line:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
record = json.loads(line)
|
||
|
|
except (json.JSONDecodeError, ValueError):
|
||
|
|
continue
|
||
|
|
if not isinstance(record, dict):
|
||
|
|
continue
|
||
|
|
if record.get("v") != state_version:
|
||
|
|
continue
|
||
|
|
spec = record.get("spec")
|
||
|
|
reset = record.get("reset")
|
||
|
|
if not isinstance(spec, str):
|
||
|
|
if isinstance(reset, str) and reset:
|
||
|
|
latest_reset = reset
|
||
|
|
continue
|
||
|
|
ts = record.get("ts")
|
||
|
|
if not isinstance(ts, (int, float)):
|
||
|
|
continue
|
||
|
|
previous = result.get(spec)
|
||
|
|
if previous is None or ts >= previous.get("ts", float("-inf")):
|
||
|
|
result[spec] = record
|
||
|
|
return result, latest_reset
|
||
|
|
|
||
|
|
|
||
|
|
def append_records(fd: int, records: list[dict]) -> bool:
|
||
|
|
"""Append records as JSONL (O_APPEND) and report whether all bytes landed."""
|
||
|
|
if not records:
|
||
|
|
return True
|
||
|
|
try:
|
||
|
|
blob = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records)
|
||
|
|
encoded = blob.encode("utf-8")
|
||
|
|
if os.write(fd, encoded) != len(encoded):
|
||
|
|
_warn("could not write complete state shard — state may be incomplete")
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
except OSError:
|
||
|
|
_warn("could not write state shard — state may be incomplete")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Entry
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
|
||
|
|
return 0
|
||
|
|
|
||
|
|
try:
|
||
|
|
input_data = json.load(sys.stdin)
|
||
|
|
except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
|
||
|
|
return 0
|
||
|
|
if not isinstance(input_data, dict):
|
||
|
|
return 0
|
||
|
|
|
||
|
|
cwd = input_data.get("cwd") or os.getcwd()
|
||
|
|
root = find_trellis_root(Path(cwd))
|
||
|
|
if root is None:
|
||
|
|
return 0
|
||
|
|
# Bail out before any spec scan when the project has no spec directory.
|
||
|
|
if not (root / DIR_WORKFLOW / DIR_SPEC).is_dir():
|
||
|
|
return 0
|
||
|
|
|
||
|
|
(
|
||
|
|
enabled,
|
||
|
|
max_spec_chars,
|
||
|
|
max_total_chars,
|
||
|
|
win_seconds,
|
||
|
|
tools,
|
||
|
|
) = get_spec_injection_settings(root)
|
||
|
|
if not enabled:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
_scripts_dir_on_path(root)
|
||
|
|
try:
|
||
|
|
from common.spec_inject import STATE_VERSION # type: ignore[import-not-found]
|
||
|
|
except Exception:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
if input_data.get("hook_event_name") == "SessionStart":
|
||
|
|
if input_data.get("source") not in ("clear", "compact"):
|
||
|
|
return 0
|
||
|
|
base_identity, stateless = resolve_base_identity(root, input_data)
|
||
|
|
if stateless:
|
||
|
|
_warn("SessionStart reset has no stable session identity")
|
||
|
|
return 0
|
||
|
|
base_dir = _state_base_dir()
|
||
|
|
_maybe_gc(base_dir)
|
||
|
|
reset_path = base_dir / _project_id(root) / f"{base_identity}.jsonl"
|
||
|
|
reset_fd = open_shard(reset_path)
|
||
|
|
if reset_fd is None:
|
||
|
|
return 0
|
||
|
|
try:
|
||
|
|
lock_shard(reset_fd)
|
||
|
|
append_records(
|
||
|
|
reset_fd,
|
||
|
|
[{"v": STATE_VERSION, "reset": uuid.uuid4().hex, "ts": time.time()}],
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
unlock_shard(reset_fd)
|
||
|
|
try:
|
||
|
|
os.close(reset_fd)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
return 0
|
||
|
|
|
||
|
|
event_name = input_data.get("hook_event_name")
|
||
|
|
tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "")
|
||
|
|
if not isinstance(tool_name, str) or not tool_name:
|
||
|
|
return 0
|
||
|
|
is_pre_tool_use = event_name == "PreToolUse"
|
||
|
|
is_patch_tool = event_name == "PreToolUse" and tool_name == "apply_patch"
|
||
|
|
logical_tool = "Edit" if is_patch_tool else tool_name
|
||
|
|
# An empty `tools` list is the documented "disable every trigger" switch.
|
||
|
|
if not tools or logical_tool not in tools:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
# snake_case is Claude Code's shape; camelCase keeps parity with the
|
||
|
|
# sibling hooks that already accept both (other platforms emit toolInput).
|
||
|
|
tool_input = input_data.get("tool_input")
|
||
|
|
if not isinstance(tool_input, dict):
|
||
|
|
tool_input = input_data.get("toolInput")
|
||
|
|
if not isinstance(tool_input, dict):
|
||
|
|
return 0
|
||
|
|
try:
|
||
|
|
from common.spec_match import ( # type: ignore[import-not-found]
|
||
|
|
match_specs_for_file,
|
||
|
|
normalize_repo_relative,
|
||
|
|
)
|
||
|
|
from common.spec_inject import ( # type: ignore[import-not-found]
|
||
|
|
assemble_payload,
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
return 0 # matching/decision engine unavailable — degrade to nothing
|
||
|
|
|
||
|
|
if is_patch_tool:
|
||
|
|
command = tool_input.get("command")
|
||
|
|
if not isinstance(command, str):
|
||
|
|
return 0
|
||
|
|
raw_paths = _patch_paths(command)
|
||
|
|
else:
|
||
|
|
file_path = tool_input.get("file_path")
|
||
|
|
if not isinstance(file_path, str) or not file_path.strip():
|
||
|
|
return 0
|
||
|
|
raw_paths = [file_path.strip()]
|
||
|
|
|
||
|
|
file_paths: list[str] = []
|
||
|
|
for raw_path in raw_paths:
|
||
|
|
normalized = normalize_repo_relative(root, raw_path)
|
||
|
|
if normalized is not None and normalized not in file_paths:
|
||
|
|
file_paths.append(normalized)
|
||
|
|
if not file_paths:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
matches = []
|
||
|
|
match_files: dict[str, str] = {}
|
||
|
|
for file_path in file_paths:
|
||
|
|
for match in match_specs_for_file(root, file_path):
|
||
|
|
if match.rel_path in match_files:
|
||
|
|
continue
|
||
|
|
matches.append(match)
|
||
|
|
match_files[match.rel_path] = file_path
|
||
|
|
if not matches:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
base_identity, stateless = resolve_base_identity(root, input_data)
|
||
|
|
identity = base_identity
|
||
|
|
agent = _agent_id(input_data)
|
||
|
|
if agent:
|
||
|
|
identity += "+a-" + _sanitize(agent)
|
||
|
|
|
||
|
|
state_records: dict[str, dict] = {}
|
||
|
|
clock = {"reset": None, "ts": time.time()}
|
||
|
|
fd: int | None = None
|
||
|
|
|
||
|
|
if not stateless:
|
||
|
|
base_dir = _state_base_dir()
|
||
|
|
_maybe_gc(base_dir)
|
||
|
|
project_dir = base_dir / _project_id(root)
|
||
|
|
base_fd = open_shard(project_dir / f"{base_identity}.jsonl")
|
||
|
|
if base_fd is None:
|
||
|
|
# Circuit breaker: unwritable state → ticket-only for this event.
|
||
|
|
stateless = True
|
||
|
|
else:
|
||
|
|
lock_shard(base_fd)
|
||
|
|
base_snapshot = load_state(base_fd, STATE_VERSION)
|
||
|
|
if base_snapshot is None:
|
||
|
|
stateless = True
|
||
|
|
unlock_shard(base_fd)
|
||
|
|
os.close(base_fd)
|
||
|
|
else:
|
||
|
|
base_records, reset_id = base_snapshot
|
||
|
|
if identity == base_identity:
|
||
|
|
fd = base_fd
|
||
|
|
state_records = base_records
|
||
|
|
else:
|
||
|
|
unlock_shard(base_fd)
|
||
|
|
os.close(base_fd)
|
||
|
|
fd = open_shard(project_dir / f"{identity}.jsonl")
|
||
|
|
if fd is None:
|
||
|
|
stateless = True
|
||
|
|
else:
|
||
|
|
lock_shard(fd)
|
||
|
|
snapshot = load_state(fd, STATE_VERSION)
|
||
|
|
if snapshot is None:
|
||
|
|
stateless = True
|
||
|
|
unlock_shard(fd)
|
||
|
|
os.close(fd)
|
||
|
|
fd = None
|
||
|
|
else:
|
||
|
|
state_records, _ = snapshot
|
||
|
|
|
||
|
|
if not stateless:
|
||
|
|
clock = {
|
||
|
|
"reset": reset_id,
|
||
|
|
"ts": time.time(),
|
||
|
|
}
|
||
|
|
|
||
|
|
edited_rel = match_files[matches[0].rel_path]
|
||
|
|
records_persisted = True
|
||
|
|
try:
|
||
|
|
payload, records = assemble_payload(
|
||
|
|
edited_rel,
|
||
|
|
matches,
|
||
|
|
stateless,
|
||
|
|
state_records,
|
||
|
|
clock,
|
||
|
|
max_spec_chars,
|
||
|
|
max_total_chars,
|
||
|
|
win_seconds,
|
||
|
|
match_files=match_files,
|
||
|
|
)
|
||
|
|
if fd is not None and records:
|
||
|
|
records_persisted = append_records(fd, records)
|
||
|
|
finally:
|
||
|
|
if fd is not None:
|
||
|
|
unlock_shard(fd)
|
||
|
|
try:
|
||
|
|
os.close(fd)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
if not payload:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
hook_specific_output = {
|
||
|
|
"hookEventName": "PreToolUse" if is_pre_tool_use else "PostToolUse",
|
||
|
|
"additionalContext": payload,
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
is_pre_tool_use
|
||
|
|
and records_persisted
|
||
|
|
and any(record.get("mode") == "full" for record in records)
|
||
|
|
):
|
||
|
|
hook_specific_output.update(
|
||
|
|
{
|
||
|
|
"permissionDecision": "deny",
|
||
|
|
"permissionDecisionReason": (
|
||
|
|
"Trellis injected governing specs. Review them, then retry "
|
||
|
|
"this tool call."
|
||
|
|
),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
output = {"hookSpecificOutput": hook_specific_output}
|
||
|
|
print(json.dumps(output, ensure_ascii=False))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
try:
|
||
|
|
sys.exit(main())
|
||
|
|
except Exception:
|
||
|
|
# Hook failures must never break the tool result or the session.
|
||
|
|
sys.exit(0)
|