@mindfoldhq/trellis 0.6.10 → 0.7.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +20 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +0 -8
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts +17 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +222 -0
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/opencode.d.ts.map +1 -1
- package/dist/configurators/opencode.js +4 -0
- package/dist/configurators/opencode.js.map +1 -1
- package/dist/configurators/workflow.d.ts +0 -14
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +1 -37
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/migrations/manifests/0.7.0-beta.0.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.1.json +9 -0
- package/dist/templates/claude/settings.json +22 -0
- package/dist/templates/codex/agents/trellis-check.toml +2 -2
- package/dist/templates/codex/agents/trellis-implement.toml +2 -2
- package/dist/templates/codex/agents/trellis-research.toml +2 -2
- package/dist/templates/codex/hooks/session-start.py +20 -1
- package/dist/templates/codex/hooks.json +25 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +2 -1
- package/dist/templates/copilot/hooks/session-start.py +20 -1
- package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
- package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
- package/dist/templates/pi/extensions/trellis/index.ts.txt +61 -4
- package/dist/templates/shared-hooks/index.d.ts +8 -2
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +13 -1
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
- package/dist/templates/shared-hooks/inject-subagent-context.py +4 -3
- package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
- package/dist/templates/shared-hooks/session-start.py +22 -1
- package/dist/templates/trellis/config.yaml +47 -0
- package/dist/templates/trellis/index.d.ts +4 -3
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +7 -3
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +0 -45
- package/dist/templates/trellis/scripts/common/config.py +18 -0
- package/dist/templates/trellis/scripts/common/git_context.py +34 -2
- package/dist/templates/trellis/scripts/common/paths.py +28 -0
- package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
- package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
- package/dist/templates/trellis/scripts/common/task_store.py +30 -0
- package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
- package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
- package/dist/templates/trellis/scripts/task.py +82 -0
- package/package.json +2 -2
- package/dist/templates/trellis/gitattributes.txt +0 -9
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Path-Scoped Spec Context Injection Hook (ticket-refresh model)
|
|
5
|
+
|
|
6
|
+
When the agent touches a file, the specs that govern that file are surfaced
|
|
7
|
+
right then — small, relevant, budgeted — instead of everything up front or
|
|
8
|
+
nothing at all. Spec .md files under .trellis/spec/ declare which code paths
|
|
9
|
+
they govern via YAML frontmatter (`paths:` glob list); the matching engine
|
|
10
|
+
lives in .trellis/scripts/common/spec_match.py and the decision engine in
|
|
11
|
+
.trellis/scripts/common/spec_inject.py. This file is the IO shell: stdin,
|
|
12
|
+
config, identity, state files, locking, GC, one print.
|
|
13
|
+
|
|
14
|
+
Triggers:
|
|
15
|
+
|
|
16
|
+
* Claude Code PostToolUse (matcher "Read|Edit|Write|MultiEdit") receives one
|
|
17
|
+
structured file path after the tool runs.
|
|
18
|
+
* Codex PreToolUse (matcher "Edit|Write") receives an ``apply_patch`` command.
|
|
19
|
+
Every patch header is matched before the patch runs. When a FULL spec is
|
|
20
|
+
emitted, the patch is denied once so the model can read the injected rules
|
|
21
|
+
and retry; ticket-only reminders do not block.
|
|
22
|
+
* OpenCode ``tool.execute.before`` adapts ``write``, ``edit``, and
|
|
23
|
+
``apply_patch`` into the same PreToolUse payload. FULL delivery uses the same
|
|
24
|
+
deny-once decision before the JS plugin surfaces context as a tool error.
|
|
25
|
+
|
|
26
|
+
Behavior — per matched spec, per event (recency-decay aware):
|
|
27
|
+
|
|
28
|
+
h = sha256(spec bytes)
|
|
29
|
+
last = newest emission recorded for (identity, spec) # stateless → None
|
|
30
|
+
if stateless: emit TICKET # bounded cost, always
|
|
31
|
+
elif last is None: emit FULL # first time this session
|
|
32
|
+
elif last.sha256 != h: emit FULL # spec changed → re-teach
|
|
33
|
+
elif reset since last: emit FULL # the text is gone, re-teach
|
|
34
|
+
elif within window: silent # fixed window; no state append
|
|
35
|
+
else: emit TICKET # refresh attention cheaply
|
|
36
|
+
|
|
37
|
+
A FULL block inlines the (budgeted) spec body with a sha256 attr; a TICKET is
|
|
38
|
+
a short reminder pointing back at the spec. Both append a state record; silent
|
|
39
|
+
hits do not (fixed window, not sliding — continuous editing is exactly when
|
|
40
|
+
drift is worst).
|
|
41
|
+
|
|
42
|
+
Identity (misfire asymmetry: a collision that MISSES an injection is
|
|
43
|
+
unacceptable; drift that OVER-injects is fine): the session/window key is
|
|
44
|
+
delegated to common.active_task.resolve_context_key — the shared resolver
|
|
45
|
+
every other hook uses — called payload-first, environment-inclusive second,
|
|
46
|
+
so two live sessions can never collapse onto one exported env value. A
|
|
47
|
+
`+a-<agent_id>` suffix (appended after each part is sanitized) keeps a
|
|
48
|
+
subagent's state separate from its parent's. When the resolver is unavailable
|
|
49
|
+
(older installed scripts tree), a minimal payload-only ladder (session keys,
|
|
50
|
+
then transcript hash) keeps the hook working. No key from any source, or an
|
|
51
|
+
unwritable state dir → stateless: no state IO at all, every hit is a TICKET
|
|
52
|
+
(circuit breaker — never a FULL re-emission loop).
|
|
53
|
+
|
|
54
|
+
State: user-global, out of the repo, one append-only JSONL file per identity
|
|
55
|
+
under ${TRELLIS_SPEC_STATE_DIR:-~/.trellis/spec-inject}/<project16>/<identity>.jsonl.
|
|
56
|
+
SessionStart(clear|compact) appends an opaque reset marker to the base session
|
|
57
|
+
shard; parent and subagent emission histories stay separate but observe that
|
|
58
|
+
shared marker. A best-effort fcntl lock is held across read→decide→append;
|
|
59
|
+
where fcntl is unavailable (Windows) the worst case is a duplicate injection.
|
|
60
|
+
A once-per-hour GC prunes conforming shards older than 48 h.
|
|
61
|
+
|
|
62
|
+
Budget (config.yaml `spec_injection:`): per-spec cap `max_spec_chars`
|
|
63
|
+
(default 9400) with code-point truncation + in-body notice; per-event cap
|
|
64
|
+
`max_total_chars` (default 9500 — below Claude Code's documented
|
|
65
|
+
additionalContext ceiling, and enforced directly for Codex). Once the total
|
|
66
|
+
budget is exhausted, remaining FULL bodies degrade to one <spec-index> block;
|
|
67
|
+
tickets are counted last and dropped (with a stderr warning) only if even they
|
|
68
|
+
do not fit.
|
|
69
|
+
|
|
70
|
+
Refresh window (config.yaml `spec_injection:`): `refresh_window_seconds`
|
|
71
|
+
(default 2700; `0` = never refresh unchanged content solely because time
|
|
72
|
+
passed).
|
|
73
|
+
|
|
74
|
+
Fail-open on errors: non-matching events, malformed paths, no matches, or any
|
|
75
|
+
internal error → exit 0 with no stdout (stderr warnings allowed). The only
|
|
76
|
+
deliberate block is a Codex patch that just received a FULL governing spec.
|
|
77
|
+
"""
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
# IMPORTANT: Suppress all warnings FIRST
|
|
81
|
+
import warnings
|
|
82
|
+
warnings.filterwarnings("ignore")
|
|
83
|
+
|
|
84
|
+
import hashlib
|
|
85
|
+
import json
|
|
86
|
+
import os
|
|
87
|
+
import re
|
|
88
|
+
import sys
|
|
89
|
+
import time
|
|
90
|
+
import uuid
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
|
|
93
|
+
# IMPORTANT: Force UTF-8 on Windows for the streams this hook uses.
|
|
94
|
+
# stdin carries the payload (non-ASCII file paths), stdout carries the spec
|
|
95
|
+
# bodies, stderr carries warnings that quote both; without this the default
|
|
96
|
+
# ANSI codepage raises UnicodeDecodeError / UnicodeEncodeError.
|
|
97
|
+
if sys.platform.startswith("win"):
|
|
98
|
+
import io as _io
|
|
99
|
+
for _stream_name in ("stdin", "stdout", "stderr"):
|
|
100
|
+
_stream = getattr(sys, _stream_name, None)
|
|
101
|
+
if _stream is None:
|
|
102
|
+
continue
|
|
103
|
+
if hasattr(_stream, "reconfigure"):
|
|
104
|
+
try:
|
|
105
|
+
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # Optional Windows stream setup; keep hook startup non-fatal.
|
|
108
|
+
elif hasattr(_stream, "detach"):
|
|
109
|
+
try:
|
|
110
|
+
setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
|
|
111
|
+
except Exception:
|
|
112
|
+
pass # Optional Windows stream setup; keep hook startup non-fatal.
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# =============================================================================
|
|
116
|
+
# Constants
|
|
117
|
+
# =============================================================================
|
|
118
|
+
|
|
119
|
+
DIR_WORKFLOW = ".trellis"
|
|
120
|
+
DIR_SPEC = "spec"
|
|
121
|
+
|
|
122
|
+
# Tools whose events trigger spec matching (Claude Code tool names). Touching a
|
|
123
|
+
# file — even a Read — counts; the miss path stays a fast exit. Overridable via
|
|
124
|
+
# config `spec_injection.tools` (e.g. to drop "Read").
|
|
125
|
+
DEFAULT_EDIT_TOOLS = ("Read", "Edit", "Write", "MultiEdit")
|
|
126
|
+
|
|
127
|
+
# Budget defaults sized against Claude Code's documented 10,000-CHARACTER
|
|
128
|
+
# additionalContext ceiling — stay under with margin. The <spec-context>
|
|
129
|
+
# wrapper (~152 chars with typical rel paths) counts against the total, so a
|
|
130
|
+
# spec lands whole only up to ~9348 chars; at the per-spec cap the block is
|
|
131
|
+
# derived-truncated further to fit the event ceiling. `0` = unlimited.
|
|
132
|
+
DEFAULT_MAX_SPEC_CHARS = 9400
|
|
133
|
+
DEFAULT_MAX_TOTAL_CHARS = 9500
|
|
134
|
+
|
|
135
|
+
# Refresh-window default. `0` = never refresh solely because time passed.
|
|
136
|
+
DEFAULT_REFRESH_WINDOW_SECONDS = 2700
|
|
137
|
+
|
|
138
|
+
# State-file base dir (overridable for tests / hermeticity) and GC policy.
|
|
139
|
+
STATE_ENV_DIR = "TRELLIS_SPEC_STATE_DIR"
|
|
140
|
+
STATE_DEFAULT_DIR = "~/.trellis/spec-inject"
|
|
141
|
+
GC_MARKER = ".last-gc"
|
|
142
|
+
GC_INTERVAL_SECONDS = 60 * 60 # GC runs at most once per hour
|
|
143
|
+
STATE_MAX_AGE_SECONDS = 48 * 60 * 60 # shards older than this are pruned
|
|
144
|
+
|
|
145
|
+
# GC scope: exactly `<base>/<project16>/<identity>[.<pid>].jsonl`, never a
|
|
146
|
+
# recursive walk — a hostile or mistyped TRELLIS_SPEC_STATE_DIR must not turn
|
|
147
|
+
# this hook into an unlink loop over someone's files. The optional `.<pid>`
|
|
148
|
+
# alternative covers shards written by the pre-lock layout.
|
|
149
|
+
GC_PROJECT_DIR_RE = re.compile(r"^[0-9a-f]{16}$")
|
|
150
|
+
# `+` is part of the identity charset: subagent shards use the `+a-<agent_id>`
|
|
151
|
+
# suffix (contract amendment 2 — without it those shards were never pruned).
|
|
152
|
+
GC_SHARD_NAME_RE = re.compile(r"^[A-Za-z0-9_+-]+(\.[0-9]+)?\.jsonl$")
|
|
153
|
+
PATCH_PATH_RE = re.compile(
|
|
154
|
+
r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _warn(message: str) -> None:
|
|
158
|
+
print(f"[inject-spec-context] WARN: {message}", file=sys.stderr)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _patch_paths(command: str) -> list[str]:
|
|
162
|
+
"""Return file paths from the shared apply_patch grammar."""
|
|
163
|
+
paths: list[str] = []
|
|
164
|
+
for line in command.splitlines():
|
|
165
|
+
match = PATCH_PATH_RE.fullmatch(line)
|
|
166
|
+
if match:
|
|
167
|
+
path = match.group(1).strip()
|
|
168
|
+
if path and path not in paths:
|
|
169
|
+
paths.append(path)
|
|
170
|
+
return paths
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _agent_id(payload: dict) -> str:
|
|
174
|
+
"""The subagent id carried by the event, or "" for a main-session event."""
|
|
175
|
+
raw = payload.get("agent_id")
|
|
176
|
+
return raw.strip() if isinstance(raw, str) else ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def find_trellis_root(start: Path) -> Path | None:
|
|
180
|
+
"""Walk up from start to find the directory containing .trellis/.
|
|
181
|
+
|
|
182
|
+
Handles CWD drift: subdirectory launches, monorepo packages, etc.
|
|
183
|
+
Returns None if no .trellis/ found (silent no-op).
|
|
184
|
+
"""
|
|
185
|
+
cur = start.resolve()
|
|
186
|
+
while cur != cur.parent:
|
|
187
|
+
if (cur / DIR_WORKFLOW).is_dir():
|
|
188
|
+
return cur
|
|
189
|
+
cur = cur.parent
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _scripts_dir_on_path(root: Path) -> None:
|
|
194
|
+
scripts_dir = root / DIR_WORKFLOW / "scripts"
|
|
195
|
+
if str(scripts_dir) not in sys.path:
|
|
196
|
+
sys.path.insert(0, str(scripts_dir))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# =============================================================================
|
|
200
|
+
# Config (.trellis/config.yaml `spec_injection:` section)
|
|
201
|
+
# =============================================================================
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _read_trellis_config(root: Path) -> dict:
|
|
205
|
+
"""Load .trellis/config.yaml via the bundled trellis_config helper.
|
|
206
|
+
|
|
207
|
+
The helper lives in .trellis/scripts/common; the hook lives outside the
|
|
208
|
+
scripts tree, so we extend sys.path before importing.
|
|
209
|
+
"""
|
|
210
|
+
_scripts_dir_on_path(root)
|
|
211
|
+
try:
|
|
212
|
+
from common.trellis_config import read_trellis_config # type: ignore[import-not-found]
|
|
213
|
+
except Exception:
|
|
214
|
+
return {}
|
|
215
|
+
try:
|
|
216
|
+
return read_trellis_config(root)
|
|
217
|
+
except Exception:
|
|
218
|
+
return {}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _parse_tools(raw: object) -> tuple[str, ...] | None:
|
|
222
|
+
"""Parse `spec_injection.tools` into a tuple of tool names.
|
|
223
|
+
|
|
224
|
+
Two grammars, because the bundled YAML reader hands the value over in two
|
|
225
|
+
shapes: a block list (``- Edit`` items) arrives as a list, a flow sequence
|
|
226
|
+
(``tools: [Edit, Write]``) arrives as the raw string. ``[]`` in either
|
|
227
|
+
shape is a deliberate "never trigger" and is respected. Returns None for a
|
|
228
|
+
value that is neither (the caller warns and keeps the defaults).
|
|
229
|
+
"""
|
|
230
|
+
if isinstance(raw, list):
|
|
231
|
+
return tuple(t.strip() for t in raw if isinstance(t, str) and t.strip())
|
|
232
|
+
if isinstance(raw, str):
|
|
233
|
+
text = raw.strip()
|
|
234
|
+
if text.startswith("[") and text.endswith("]"):
|
|
235
|
+
items = (part.strip().strip("\"'").strip() for part in text[1:-1].split(","))
|
|
236
|
+
return tuple(item for item in items if item)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_spec_injection_settings(
|
|
241
|
+
root: Path,
|
|
242
|
+
) -> tuple[bool, int, int, int, tuple[str, ...]]:
|
|
243
|
+
"""Return (enabled, max_spec_chars, max_total_chars,
|
|
244
|
+
refresh_window_seconds, tools).
|
|
245
|
+
|
|
246
|
+
Reads the ``spec_injection:`` section of ``.trellis/config.yaml``:
|
|
247
|
+
|
|
248
|
+
spec_injection:
|
|
249
|
+
enabled: true
|
|
250
|
+
max_spec_chars: 9400
|
|
251
|
+
max_total_chars: 9500
|
|
252
|
+
refresh_window_seconds: 2700
|
|
253
|
+
tools:
|
|
254
|
+
- Read
|
|
255
|
+
- Edit
|
|
256
|
+
- Write
|
|
257
|
+
- MultiEdit
|
|
258
|
+
|
|
259
|
+
Missing keys use their defaults; ``0`` disables the corresponding limit
|
|
260
|
+
(``max_spec_chars: 0`` = inline the whole body, ``max_total_chars: 0`` =
|
|
261
|
+
no per-event ceiling) or refresh (window keys). ``tools`` also accepts a
|
|
262
|
+
flow sequence (``tools: [Edit, Write]``), and ``tools: []`` disables every
|
|
263
|
+
trigger. Invalid values fall back to the default for that key with a
|
|
264
|
+
stderr warning; tool names outside the known set warn once.
|
|
265
|
+
"""
|
|
266
|
+
enabled = True
|
|
267
|
+
tools = DEFAULT_EDIT_TOOLS
|
|
268
|
+
numbers = {
|
|
269
|
+
"max_spec_chars": DEFAULT_MAX_SPEC_CHARS,
|
|
270
|
+
"max_total_chars": DEFAULT_MAX_TOTAL_CHARS,
|
|
271
|
+
"refresh_window_seconds": DEFAULT_REFRESH_WINDOW_SECONDS,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
config = _read_trellis_config(root)
|
|
275
|
+
section = config.get("spec_injection") if isinstance(config, dict) else None
|
|
276
|
+
if isinstance(section, dict):
|
|
277
|
+
raw_enabled = section.get("enabled", True)
|
|
278
|
+
if isinstance(raw_enabled, bool):
|
|
279
|
+
enabled = raw_enabled
|
|
280
|
+
else:
|
|
281
|
+
s = str(raw_enabled).strip().lower()
|
|
282
|
+
if s in ("false", "no", "0", "off"):
|
|
283
|
+
enabled = False
|
|
284
|
+
elif s not in ("true", "yes", "1", "on"):
|
|
285
|
+
_warn(
|
|
286
|
+
f"invalid spec_injection.enabled value: {raw_enabled!r}; "
|
|
287
|
+
f"using true (default)"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# int() coercion stays local to this hook by decision (audit round,
|
|
291
|
+
# 2026-07-25): widening the shared common/config.py helpers has more
|
|
292
|
+
# blast radius than this small duplication costs.
|
|
293
|
+
for key, default_value in list(numbers.items()):
|
|
294
|
+
if key not in section:
|
|
295
|
+
continue
|
|
296
|
+
raw = section[key]
|
|
297
|
+
try:
|
|
298
|
+
value = int(raw)
|
|
299
|
+
except (TypeError, ValueError):
|
|
300
|
+
value = -1
|
|
301
|
+
if value < 0:
|
|
302
|
+
_warn(
|
|
303
|
+
f"invalid spec_injection.{key} value: {raw!r}; "
|
|
304
|
+
f"using default {default_value}"
|
|
305
|
+
)
|
|
306
|
+
continue
|
|
307
|
+
numbers[key] = value
|
|
308
|
+
|
|
309
|
+
if "tools" in section:
|
|
310
|
+
parsed_tools = _parse_tools(section["tools"])
|
|
311
|
+
if parsed_tools is None:
|
|
312
|
+
_warn(
|
|
313
|
+
f"invalid spec_injection.tools value: {section['tools']!r}; "
|
|
314
|
+
f"using default {list(DEFAULT_EDIT_TOOLS)}"
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
tools = parsed_tools
|
|
318
|
+
unknown = [t for t in tools if t not in DEFAULT_EDIT_TOOLS]
|
|
319
|
+
if unknown:
|
|
320
|
+
_warn(
|
|
321
|
+
f"unknown spec_injection.tools entries {unknown} — "
|
|
322
|
+
f"they will never match; known tools: "
|
|
323
|
+
f"{list(DEFAULT_EDIT_TOOLS)}"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
enabled,
|
|
328
|
+
numbers["max_spec_chars"],
|
|
329
|
+
numbers["max_total_chars"],
|
|
330
|
+
numbers["refresh_window_seconds"],
|
|
331
|
+
tools,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# =============================================================================
|
|
336
|
+
# Identity ladder
|
|
337
|
+
# =============================================================================
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _sanitize(raw: str) -> str:
|
|
341
|
+
"""Map a session/agent id to a filename-safe, collision-free token.
|
|
342
|
+
|
|
343
|
+
A readable head (the first 80 characters, every character outside
|
|
344
|
+
``[A-Za-z0-9_-]`` replaced one-for-one by ``-``) plus, whenever anything
|
|
345
|
+
was replaced or the id was longer than 80 characters, ``-`` and 8 hex of
|
|
346
|
+
sha256(raw). The suffix is what makes the mapping injective: without it,
|
|
347
|
+
"a/b" and "a:b" — or two ids sharing an 80-character prefix — would fold
|
|
348
|
+
onto one state file, and a collision that MISSES an injection is the
|
|
349
|
+
unacceptable failure. Output stays inside the GC name class.
|
|
350
|
+
"""
|
|
351
|
+
raw = raw.strip()
|
|
352
|
+
head = raw[:80]
|
|
353
|
+
safe = re.sub(r"[^A-Za-z0-9_-]", "-", head)
|
|
354
|
+
if safe != head or len(raw) > 80:
|
|
355
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
|
|
356
|
+
return f"{safe}-{digest}"
|
|
357
|
+
return safe
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _shared_context_key(root: Path, payload: dict) -> str | None:
|
|
361
|
+
"""Session/window key from the shared resolver every other hook uses.
|
|
362
|
+
|
|
363
|
+
``common.active_task.resolve_context_key`` is the single source of truth
|
|
364
|
+
for session identity: payload keys in all casings (``session_id`` /
|
|
365
|
+
``sessionId`` / ``sessionID``, conversation and transcript variants),
|
|
366
|
+
nested payload shapes, the explicit ``TRELLIS_CONTEXT_ID`` override,
|
|
367
|
+
per-platform env fallbacks, and Cursor shell tickets — plus the platform
|
|
368
|
+
fixes accumulated behind them. Payload identity is preferred over
|
|
369
|
+
environment context so two live sessions can never collapse onto one
|
|
370
|
+
exported env value (collision → missed injection is the unacceptable
|
|
371
|
+
direction); the environment pass still runs when the payload carries
|
|
372
|
+
nothing.
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
_scripts_dir_on_path(root)
|
|
376
|
+
from common.active_task import resolve_context_key # type: ignore[import-not-found]
|
|
377
|
+
|
|
378
|
+
key = resolve_context_key(payload, allow_environment_context=False)
|
|
379
|
+
if key:
|
|
380
|
+
return key
|
|
381
|
+
return resolve_context_key(payload)
|
|
382
|
+
except Exception:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def resolve_base_identity(root: Path, payload: dict) -> tuple[str, bool]:
|
|
387
|
+
"""Return the base session identity for reset and refresh state.
|
|
388
|
+
|
|
389
|
+
When the shared resolver is unavailable (older installed scripts tree), a
|
|
390
|
+
minimal payload-only ladder keeps the hook working. ``stateless=True``
|
|
391
|
+
means no state IO is possible.
|
|
392
|
+
"""
|
|
393
|
+
identity_payload = payload
|
|
394
|
+
if payload.get("hook_event_name") == "SessionStart":
|
|
395
|
+
# In this event `source` means startup/clear/compact, not platform.
|
|
396
|
+
# The shared resolver also accepts a generic `source` platform hint,
|
|
397
|
+
# so remove the lifecycle field to keep the same session identity as
|
|
398
|
+
# later PostToolUse events.
|
|
399
|
+
identity_payload = dict(payload)
|
|
400
|
+
identity_payload.pop("source", None)
|
|
401
|
+
key = _shared_context_key(root, identity_payload)
|
|
402
|
+
|
|
403
|
+
if not key:
|
|
404
|
+
# Minimal payload-only fallback for scripts trees that predate
|
|
405
|
+
# resolve_context_key. Mirrors its payload lookup order.
|
|
406
|
+
for k in ("session_id", "sessionId", "sessionID"):
|
|
407
|
+
value = payload.get(k)
|
|
408
|
+
if isinstance(value, str) and value.strip():
|
|
409
|
+
key = "s-" + value.strip()
|
|
410
|
+
break
|
|
411
|
+
if not key:
|
|
412
|
+
transcript = payload.get("transcript_path")
|
|
413
|
+
if isinstance(transcript, str) and transcript.strip():
|
|
414
|
+
digest = hashlib.sha256(
|
|
415
|
+
transcript.strip().encode("utf-8")
|
|
416
|
+
).hexdigest()
|
|
417
|
+
key = "t-" + digest[:16]
|
|
418
|
+
|
|
419
|
+
if not key:
|
|
420
|
+
return "", True
|
|
421
|
+
|
|
422
|
+
return _sanitize(key), False
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# =============================================================================
|
|
426
|
+
# State (one append-only JSONL file per identity, locked, user-global)
|
|
427
|
+
# =============================================================================
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _state_base_dir() -> Path:
|
|
431
|
+
override = os.environ.get(STATE_ENV_DIR)
|
|
432
|
+
if override and override.strip():
|
|
433
|
+
return Path(override.strip())
|
|
434
|
+
return Path(os.path.expanduser(STATE_DEFAULT_DIR))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _project_id(root: Path) -> str:
|
|
438
|
+
return hashlib.sha256(os.path.realpath(str(root)).encode("utf-8")).hexdigest()[:16]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _maybe_gc(base_dir: Path) -> None:
|
|
442
|
+
"""Prune conforming shards older than 48 h, at most once per hour.
|
|
443
|
+
|
|
444
|
+
Scope is exact-depth (``<base>/<project16>/<shard>.jsonl``) and name-gated;
|
|
445
|
+
foreign files and directories are never touched. Containment is enforced
|
|
446
|
+
against symlinks on both levels — a symlinked project dir or shard is
|
|
447
|
+
skipped outright, and every unlink candidate must still be under the
|
|
448
|
+
resolved base after realpath — so a planted link cannot walk this GC out
|
|
449
|
+
of its own tree. Best-effort, errors ignored.
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
base_real = os.path.realpath(str(base_dir))
|
|
453
|
+
marker = base_dir / GC_MARKER
|
|
454
|
+
now = time.time()
|
|
455
|
+
try:
|
|
456
|
+
age = now - marker.stat().st_mtime
|
|
457
|
+
except OSError:
|
|
458
|
+
age = None
|
|
459
|
+
if age is not None and age < GC_INTERVAL_SECONDS:
|
|
460
|
+
return
|
|
461
|
+
try:
|
|
462
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
463
|
+
marker.touch()
|
|
464
|
+
except OSError:
|
|
465
|
+
return
|
|
466
|
+
try:
|
|
467
|
+
project_dirs = list(base_dir.iterdir())
|
|
468
|
+
except OSError:
|
|
469
|
+
return
|
|
470
|
+
for project_dir in project_dirs:
|
|
471
|
+
if not GC_PROJECT_DIR_RE.match(project_dir.name):
|
|
472
|
+
continue
|
|
473
|
+
try:
|
|
474
|
+
if project_dir.is_symlink() or not project_dir.is_dir():
|
|
475
|
+
continue
|
|
476
|
+
shards = list(project_dir.iterdir())
|
|
477
|
+
except OSError:
|
|
478
|
+
continue
|
|
479
|
+
for shard in shards:
|
|
480
|
+
if not GC_SHARD_NAME_RE.match(shard.name):
|
|
481
|
+
continue
|
|
482
|
+
try:
|
|
483
|
+
if shard.is_symlink() or not shard.is_file():
|
|
484
|
+
continue
|
|
485
|
+
shard_real = os.path.realpath(str(shard))
|
|
486
|
+
if not shard_real.startswith(base_real + os.sep):
|
|
487
|
+
continue
|
|
488
|
+
if now - shard.stat().st_mtime > STATE_MAX_AGE_SECONDS:
|
|
489
|
+
shard.unlink()
|
|
490
|
+
except OSError:
|
|
491
|
+
continue
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def open_shard(shard_path: Path) -> int | None:
|
|
497
|
+
"""Open (creating) the identity's shard for read+append.
|
|
498
|
+
|
|
499
|
+
Doubles as the writability probe: a failure here trips the circuit breaker
|
|
500
|
+
and the event runs stateless (ticket-only), which is bounded, instead of
|
|
501
|
+
re-emitting full specs on every event forever.
|
|
502
|
+
"""
|
|
503
|
+
try:
|
|
504
|
+
shard_path.parent.mkdir(parents=True, exist_ok=True)
|
|
505
|
+
except OSError:
|
|
506
|
+
_warn(f"state dir {shard_path.parent} unusable — running stateless")
|
|
507
|
+
return None
|
|
508
|
+
try:
|
|
509
|
+
return os.open(
|
|
510
|
+
str(shard_path),
|
|
511
|
+
os.O_RDWR | os.O_CREAT | os.O_APPEND,
|
|
512
|
+
0o644,
|
|
513
|
+
)
|
|
514
|
+
except OSError:
|
|
515
|
+
_warn(f"state shard {shard_path} unusable — running stateless")
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def lock_shard(fd: int) -> None:
|
|
520
|
+
"""Best-effort exclusive lock held across read→decide→append.
|
|
521
|
+
|
|
522
|
+
Closes the duplicate-injection race between concurrent hook processes on
|
|
523
|
+
POSIX. No fcntl (Windows) or an unsupported filesystem → no lock; the
|
|
524
|
+
worst case is a duplicate injection, never a lost one.
|
|
525
|
+
"""
|
|
526
|
+
try:
|
|
527
|
+
import fcntl
|
|
528
|
+
|
|
529
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
530
|
+
except Exception:
|
|
531
|
+
pass
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def unlock_shard(fd: int) -> None:
|
|
535
|
+
try:
|
|
536
|
+
import fcntl
|
|
537
|
+
|
|
538
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
539
|
+
except Exception:
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def load_state(
|
|
544
|
+
fd: int,
|
|
545
|
+
state_version: int,
|
|
546
|
+
) -> tuple[dict[str, dict], str | None] | None:
|
|
547
|
+
"""Read the shard through the already-open fd; newest record per spec wins
|
|
548
|
+
(``ts`` decides, and on an exact tie the later line in the file does —
|
|
549
|
+
appends are ordered, and two records one float apart must not resolve to
|
|
550
|
+
the older one). The latest reset marker is returned separately. Malformed
|
|
551
|
+
lines and foreign schema versions are skipped silently; read failures
|
|
552
|
+
return None so the caller uses stateless ticket mode."""
|
|
553
|
+
result: dict[str, dict] = {}
|
|
554
|
+
latest_reset: str | None = None
|
|
555
|
+
try:
|
|
556
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
557
|
+
chunks: list[bytes] = []
|
|
558
|
+
while True:
|
|
559
|
+
chunk = os.read(fd, 1 << 20)
|
|
560
|
+
if not chunk:
|
|
561
|
+
break
|
|
562
|
+
chunks.append(chunk)
|
|
563
|
+
except OSError:
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
text = b"".join(chunks).decode("utf-8", errors="replace")
|
|
567
|
+
for line in text.splitlines():
|
|
568
|
+
line = line.strip()
|
|
569
|
+
if not line:
|
|
570
|
+
continue
|
|
571
|
+
try:
|
|
572
|
+
record = json.loads(line)
|
|
573
|
+
except (json.JSONDecodeError, ValueError):
|
|
574
|
+
continue
|
|
575
|
+
if not isinstance(record, dict):
|
|
576
|
+
continue
|
|
577
|
+
if record.get("v") != state_version:
|
|
578
|
+
continue
|
|
579
|
+
spec = record.get("spec")
|
|
580
|
+
reset = record.get("reset")
|
|
581
|
+
if not isinstance(spec, str):
|
|
582
|
+
if isinstance(reset, str) and reset:
|
|
583
|
+
latest_reset = reset
|
|
584
|
+
continue
|
|
585
|
+
ts = record.get("ts")
|
|
586
|
+
if not isinstance(ts, (int, float)):
|
|
587
|
+
continue
|
|
588
|
+
previous = result.get(spec)
|
|
589
|
+
if previous is None or ts >= previous.get("ts", float("-inf")):
|
|
590
|
+
result[spec] = record
|
|
591
|
+
return result, latest_reset
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def append_records(fd: int, records: list[dict]) -> bool:
|
|
595
|
+
"""Append records as JSONL (O_APPEND) and report whether all bytes landed."""
|
|
596
|
+
if not records:
|
|
597
|
+
return True
|
|
598
|
+
try:
|
|
599
|
+
blob = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records)
|
|
600
|
+
encoded = blob.encode("utf-8")
|
|
601
|
+
if os.write(fd, encoded) != len(encoded):
|
|
602
|
+
_warn("could not write complete state shard — state may be incomplete")
|
|
603
|
+
return False
|
|
604
|
+
return True
|
|
605
|
+
except OSError:
|
|
606
|
+
_warn("could not write state shard — state may be incomplete")
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# =============================================================================
|
|
611
|
+
# Entry
|
|
612
|
+
# =============================================================================
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def main() -> int:
|
|
616
|
+
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
|
|
617
|
+
return 0
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
input_data = json.load(sys.stdin)
|
|
621
|
+
except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
|
|
622
|
+
return 0
|
|
623
|
+
if not isinstance(input_data, dict):
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
cwd = input_data.get("cwd") or os.getcwd()
|
|
627
|
+
root = find_trellis_root(Path(cwd))
|
|
628
|
+
if root is None:
|
|
629
|
+
return 0
|
|
630
|
+
# Bail out before any spec scan when the project has no spec directory.
|
|
631
|
+
if not (root / DIR_WORKFLOW / DIR_SPEC).is_dir():
|
|
632
|
+
return 0
|
|
633
|
+
|
|
634
|
+
(
|
|
635
|
+
enabled,
|
|
636
|
+
max_spec_chars,
|
|
637
|
+
max_total_chars,
|
|
638
|
+
win_seconds,
|
|
639
|
+
tools,
|
|
640
|
+
) = get_spec_injection_settings(root)
|
|
641
|
+
if not enabled:
|
|
642
|
+
return 0
|
|
643
|
+
|
|
644
|
+
_scripts_dir_on_path(root)
|
|
645
|
+
try:
|
|
646
|
+
from common.spec_inject import STATE_VERSION # type: ignore[import-not-found]
|
|
647
|
+
except Exception:
|
|
648
|
+
return 0
|
|
649
|
+
|
|
650
|
+
if input_data.get("hook_event_name") == "SessionStart":
|
|
651
|
+
if input_data.get("source") not in ("clear", "compact"):
|
|
652
|
+
return 0
|
|
653
|
+
base_identity, stateless = resolve_base_identity(root, input_data)
|
|
654
|
+
if stateless:
|
|
655
|
+
_warn("SessionStart reset has no stable session identity")
|
|
656
|
+
return 0
|
|
657
|
+
base_dir = _state_base_dir()
|
|
658
|
+
_maybe_gc(base_dir)
|
|
659
|
+
reset_path = base_dir / _project_id(root) / f"{base_identity}.jsonl"
|
|
660
|
+
reset_fd = open_shard(reset_path)
|
|
661
|
+
if reset_fd is None:
|
|
662
|
+
return 0
|
|
663
|
+
try:
|
|
664
|
+
lock_shard(reset_fd)
|
|
665
|
+
append_records(
|
|
666
|
+
reset_fd,
|
|
667
|
+
[{"v": STATE_VERSION, "reset": uuid.uuid4().hex, "ts": time.time()}],
|
|
668
|
+
)
|
|
669
|
+
finally:
|
|
670
|
+
unlock_shard(reset_fd)
|
|
671
|
+
try:
|
|
672
|
+
os.close(reset_fd)
|
|
673
|
+
except OSError:
|
|
674
|
+
pass
|
|
675
|
+
return 0
|
|
676
|
+
|
|
677
|
+
event_name = input_data.get("hook_event_name")
|
|
678
|
+
tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "")
|
|
679
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
680
|
+
return 0
|
|
681
|
+
is_pre_tool_use = event_name == "PreToolUse"
|
|
682
|
+
is_patch_tool = event_name == "PreToolUse" and tool_name == "apply_patch"
|
|
683
|
+
logical_tool = "Edit" if is_patch_tool else tool_name
|
|
684
|
+
# An empty `tools` list is the documented "disable every trigger" switch.
|
|
685
|
+
if not tools or logical_tool not in tools:
|
|
686
|
+
return 0
|
|
687
|
+
|
|
688
|
+
# snake_case is Claude Code's shape; camelCase keeps parity with the
|
|
689
|
+
# sibling hooks that already accept both (other platforms emit toolInput).
|
|
690
|
+
tool_input = input_data.get("tool_input")
|
|
691
|
+
if not isinstance(tool_input, dict):
|
|
692
|
+
tool_input = input_data.get("toolInput")
|
|
693
|
+
if not isinstance(tool_input, dict):
|
|
694
|
+
return 0
|
|
695
|
+
try:
|
|
696
|
+
from common.spec_match import ( # type: ignore[import-not-found]
|
|
697
|
+
match_specs_for_file,
|
|
698
|
+
normalize_repo_relative,
|
|
699
|
+
)
|
|
700
|
+
from common.spec_inject import ( # type: ignore[import-not-found]
|
|
701
|
+
assemble_payload,
|
|
702
|
+
)
|
|
703
|
+
except Exception:
|
|
704
|
+
return 0 # matching/decision engine unavailable — degrade to nothing
|
|
705
|
+
|
|
706
|
+
if is_patch_tool:
|
|
707
|
+
command = tool_input.get("command")
|
|
708
|
+
if not isinstance(command, str):
|
|
709
|
+
return 0
|
|
710
|
+
raw_paths = _patch_paths(command)
|
|
711
|
+
else:
|
|
712
|
+
file_path = tool_input.get("file_path")
|
|
713
|
+
if not isinstance(file_path, str) or not file_path.strip():
|
|
714
|
+
return 0
|
|
715
|
+
raw_paths = [file_path.strip()]
|
|
716
|
+
|
|
717
|
+
file_paths: list[str] = []
|
|
718
|
+
for raw_path in raw_paths:
|
|
719
|
+
normalized = normalize_repo_relative(root, raw_path)
|
|
720
|
+
if normalized is not None and normalized not in file_paths:
|
|
721
|
+
file_paths.append(normalized)
|
|
722
|
+
if not file_paths:
|
|
723
|
+
return 0
|
|
724
|
+
|
|
725
|
+
matches = []
|
|
726
|
+
match_files: dict[str, str] = {}
|
|
727
|
+
for file_path in file_paths:
|
|
728
|
+
for match in match_specs_for_file(root, file_path):
|
|
729
|
+
if match.rel_path in match_files:
|
|
730
|
+
continue
|
|
731
|
+
matches.append(match)
|
|
732
|
+
match_files[match.rel_path] = file_path
|
|
733
|
+
if not matches:
|
|
734
|
+
return 0
|
|
735
|
+
|
|
736
|
+
base_identity, stateless = resolve_base_identity(root, input_data)
|
|
737
|
+
identity = base_identity
|
|
738
|
+
agent = _agent_id(input_data)
|
|
739
|
+
if agent:
|
|
740
|
+
identity += "+a-" + _sanitize(agent)
|
|
741
|
+
|
|
742
|
+
state_records: dict[str, dict] = {}
|
|
743
|
+
clock = {"reset": None, "ts": time.time()}
|
|
744
|
+
fd: int | None = None
|
|
745
|
+
|
|
746
|
+
if not stateless:
|
|
747
|
+
base_dir = _state_base_dir()
|
|
748
|
+
_maybe_gc(base_dir)
|
|
749
|
+
project_dir = base_dir / _project_id(root)
|
|
750
|
+
base_fd = open_shard(project_dir / f"{base_identity}.jsonl")
|
|
751
|
+
if base_fd is None:
|
|
752
|
+
# Circuit breaker: unwritable state → ticket-only for this event.
|
|
753
|
+
stateless = True
|
|
754
|
+
else:
|
|
755
|
+
lock_shard(base_fd)
|
|
756
|
+
base_snapshot = load_state(base_fd, STATE_VERSION)
|
|
757
|
+
if base_snapshot is None:
|
|
758
|
+
stateless = True
|
|
759
|
+
unlock_shard(base_fd)
|
|
760
|
+
os.close(base_fd)
|
|
761
|
+
else:
|
|
762
|
+
base_records, reset_id = base_snapshot
|
|
763
|
+
if identity == base_identity:
|
|
764
|
+
fd = base_fd
|
|
765
|
+
state_records = base_records
|
|
766
|
+
else:
|
|
767
|
+
unlock_shard(base_fd)
|
|
768
|
+
os.close(base_fd)
|
|
769
|
+
fd = open_shard(project_dir / f"{identity}.jsonl")
|
|
770
|
+
if fd is None:
|
|
771
|
+
stateless = True
|
|
772
|
+
else:
|
|
773
|
+
lock_shard(fd)
|
|
774
|
+
snapshot = load_state(fd, STATE_VERSION)
|
|
775
|
+
if snapshot is None:
|
|
776
|
+
stateless = True
|
|
777
|
+
unlock_shard(fd)
|
|
778
|
+
os.close(fd)
|
|
779
|
+
fd = None
|
|
780
|
+
else:
|
|
781
|
+
state_records, _ = snapshot
|
|
782
|
+
|
|
783
|
+
if not stateless:
|
|
784
|
+
clock = {
|
|
785
|
+
"reset": reset_id,
|
|
786
|
+
"ts": time.time(),
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
edited_rel = match_files[matches[0].rel_path]
|
|
790
|
+
records_persisted = True
|
|
791
|
+
try:
|
|
792
|
+
payload, records = assemble_payload(
|
|
793
|
+
edited_rel,
|
|
794
|
+
matches,
|
|
795
|
+
stateless,
|
|
796
|
+
state_records,
|
|
797
|
+
clock,
|
|
798
|
+
max_spec_chars,
|
|
799
|
+
max_total_chars,
|
|
800
|
+
win_seconds,
|
|
801
|
+
match_files=match_files,
|
|
802
|
+
)
|
|
803
|
+
if fd is not None and records:
|
|
804
|
+
records_persisted = append_records(fd, records)
|
|
805
|
+
finally:
|
|
806
|
+
if fd is not None:
|
|
807
|
+
unlock_shard(fd)
|
|
808
|
+
try:
|
|
809
|
+
os.close(fd)
|
|
810
|
+
except OSError:
|
|
811
|
+
pass
|
|
812
|
+
|
|
813
|
+
if not payload:
|
|
814
|
+
return 0
|
|
815
|
+
|
|
816
|
+
hook_specific_output = {
|
|
817
|
+
"hookEventName": "PreToolUse" if is_pre_tool_use else "PostToolUse",
|
|
818
|
+
"additionalContext": payload,
|
|
819
|
+
}
|
|
820
|
+
if (
|
|
821
|
+
is_pre_tool_use
|
|
822
|
+
and records_persisted
|
|
823
|
+
and any(record.get("mode") == "full" for record in records)
|
|
824
|
+
):
|
|
825
|
+
hook_specific_output.update(
|
|
826
|
+
{
|
|
827
|
+
"permissionDecision": "deny",
|
|
828
|
+
"permissionDecisionReason": (
|
|
829
|
+
"Trellis injected governing specs. Review them, then retry "
|
|
830
|
+
"this tool call."
|
|
831
|
+
),
|
|
832
|
+
}
|
|
833
|
+
)
|
|
834
|
+
output = {"hookSpecificOutput": hook_specific_output}
|
|
835
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
836
|
+
return 0
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
if __name__ == "__main__":
|
|
840
|
+
try:
|
|
841
|
+
sys.exit(main())
|
|
842
|
+
except Exception:
|
|
843
|
+
# Hook failures must never break the tool result or the session.
|
|
844
|
+
sys.exit(0)
|