feat(trellis): enhance bundled skills and workflow integration

- Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths.
- Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions.
- Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context.
- Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence.
- Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability.
This commit is contained in:
yuxuanhui
2026-09-07 11:23:15 +08:00
parent ca3bd7a4b1
commit e567e5f717
23 changed files with 2525 additions and 130 deletions
+844
View File
@@ -0,0 +1,844 @@
#!/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)
+13 -5
View File
@@ -215,11 +215,12 @@ def truncate_utf8(data: bytes, cap: int) -> bytes:
seq_len = 4
else:
seq_len = 1
# Drop the lead byte too if its full sequence didn't fit.
# Cut before the lead byte when its full sequence didn't fit;
# otherwise the trailing sequence is complete — keep it whole.
if (i - 1) + seq_len > len(truncated):
i -= 1
return truncated[: i - 1]
return truncated[:i]
return truncated
class _Budget:
@@ -877,8 +878,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None:
if not subagent_type or not parent_session_id:
return
cwd = _string_value(input_data.get("cwd")) or os.getcwd()
repo_root = find_repo_root(cwd)
# Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4)
# report "/" for every hook event. See inject-workflow-state.py.
repo_root = None
for candidate in (_string_value(input_data.get("cwd")), os.getcwd()):
if not candidate:
continue
repo_root = find_repo_root(candidate)
if repo_root:
break
if not repo_root:
return
+52 -16
View File
@@ -10,19 +10,24 @@ The emitted ``hookEventName`` field is platform-aware: most hosts expect
CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
legacy name. ``_detect_platform`` picks the right value at runtime.
Breadcrumb text is pulled exclusively from workflow.md
[workflow-state:STATUS] tag blocks — workflow.md is the single source of
truth. There are no fallback dicts in this script: when workflow.md is
Breadcrumb text is pulled exclusively from the resolved workflow file's
[workflow-state:STATUS] tag blocks — the active task may select a
per-task variant (`.trellis/workflows/<id>.md` via task.json `workflow`),
otherwise personal, team, and global defaults are resolved in order.
There are no fallback dicts in this script: when the resolved workflow is
missing or a tag is absent, the breadcrumb degrades to a generic
"Refer to workflow.md for current step." line so users see (and fix)
the broken state instead of the hook silently masking it.
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
in templates/shared-hooks/index.ts — currently Claude, Codex, Gemini,
Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae and ZCode. That table is the
source of truth; each listed platform's collect<Platform>Templates() pulls
this file into its template map through collectSharedHooks(), and a single
writer puts that map on disk at init time. Kiro wires this via the CLI
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context). Written to
each platform's hooks directory via writeSharedHooks() at init time.
(Kiro adds hook stdout directly to the conversation context).
Silent exit 0 cases (no output):
- No .trellis/ directory found (not a Trellis project)
@@ -95,11 +100,16 @@ def find_trellis_root(start: Path) -> Optional[Path]:
def _detect_platform(input_data: dict) -> str | None:
if isinstance(input_data.get("cursor_version"), str):
return "cursor"
# CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set
# alongside their own variable — CodeBuddy, ZCode and Trae all do. It must
# therefore be checked LAST, or every one of them is detected as claude and
# the context key becomes `claude_<their-session-id>`. That key does not
# match the session file `task.py start` wrote under the host's real name,
# so every turn reports no_task while the pointer exists on disk.
# Observed on CodeBuddy IDE 4.10.4: session file `codebuddy_ae54840e….json`
# alongside marker `update-check-claude_ae54840e….marker`, same id.
env_map = {
# ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check
# ZCODE first so ZCode sessions aren't misdetected as claude.
"ZCODE_PROJECT_DIR": "zcode",
"CLAUDE_PROJECT_DIR": "claude",
"CURSOR_PROJECT_DIR": "cursor",
"CODEBUDDY_PROJECT_DIR": "codebuddy",
"FACTORY_PROJECT_DIR": "droid",
@@ -108,6 +118,8 @@ def _detect_platform(input_data: dict) -> str | None:
"KIRO_PROJECT_DIR": "kiro",
"COPILOT_PROJECT_DIR": "copilot",
"TRAE_PROJECT_DIR": "trae",
# Last: the shared alias, only meaningful once no vendor key matched.
"CLAUDE_PROJECT_DIR": "claude",
}
for env_name, platform in env_map.items():
if os.environ.get(env_name):
@@ -183,16 +195,40 @@ _TAG_RE = re.compile(
re.DOTALL,
)
def load_breadcrumbs(root: Path) -> dict[str, str]:
"""Parse workflow.md for [workflow-state:STATUS] blocks.
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.
Returns {status: body_text}. workflow.md is the single source of
The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]
return resolve_workflow_md(
root, input_data, platform=_detect_platform(input_data)
)
except Exception:
return root / ".trellis" / "workflow.md"
def load_breadcrumbs(root: Path, input_data: dict) -> dict[str, str]:
"""Parse the resolved workflow file for [workflow-state:STATUS] blocks.
Returns {status: body_text}. The workflow file is the single source of
truth — there are no fallback dicts in this script. Missing tags
(or a missing/unreadable workflow.md) fall back to a generic line
(or a missing/unreadable workflow file) fall back to a generic line
in build_breadcrumb so users see the broken state and fix
workflow.md, rather than the hook silently masking the issue.
The active task's per-task workflow selection (task.json `workflow`
field) is honored via _resolve_workflow_md; without a selection this
reads the global .trellis/workflow.md exactly as before.
"""
workflow = root / ".trellis" / "workflow.md"
workflow = _resolve_workflow_md(root, input_data)
if not workflow.is_file():
return {}
try:
@@ -411,7 +447,7 @@ def main() -> int:
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
return 0 # user opted out of the per-turn breadcrumb for this turn
templates = load_breadcrumbs(root)
templates = load_breadcrumbs(root, data)
platform = _detect_platform(data)
task = get_active_task(root, data)
if task is None:
Regular → Executable
+20 -1
View File
@@ -452,6 +452,25 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
return re.sub(r"\n{3,}", "\n\n", stripped).strip()
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.
The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]
return resolve_workflow_md(root, input_data, platform="codex")
except Exception:
return root / ".trellis" / "workflow.md"
def _build_workflow_toc(workflow_path: Path) -> str:
"""Inject only the compact Phase Index summary for SessionStart."""
content = read_file(workflow_path)
@@ -505,7 +524,7 @@ Trellis compact SessionStart context. Use it to orient the session; load details
output.write("\n</current-state>\n\n")
output.write("<trellis-workflow>\n")
output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input)))
output.write("\n</trellis-workflow>\n\n")
output.write("<guidelines>\n")