@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.
Files changed (55) hide show
  1. package/dist/cli/index.d.ts.map +1 -1
  2. package/dist/cli/index.js +20 -2
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/commands/update.d.ts.map +1 -1
  5. package/dist/commands/update.js +0 -8
  6. package/dist/commands/update.js.map +1 -1
  7. package/dist/commands/workflow.d.ts +17 -0
  8. package/dist/commands/workflow.d.ts.map +1 -1
  9. package/dist/commands/workflow.js +222 -0
  10. package/dist/commands/workflow.js.map +1 -1
  11. package/dist/configurators/opencode.d.ts.map +1 -1
  12. package/dist/configurators/opencode.js +4 -0
  13. package/dist/configurators/opencode.js.map +1 -1
  14. package/dist/configurators/workflow.d.ts +0 -14
  15. package/dist/configurators/workflow.d.ts.map +1 -1
  16. package/dist/configurators/workflow.js +1 -37
  17. package/dist/configurators/workflow.js.map +1 -1
  18. package/dist/migrations/manifests/0.7.0-beta.0.json +9 -0
  19. package/dist/migrations/manifests/0.7.0-beta.1.json +9 -0
  20. package/dist/templates/claude/settings.json +22 -0
  21. package/dist/templates/codex/agents/trellis-check.toml +2 -2
  22. package/dist/templates/codex/agents/trellis-implement.toml +2 -2
  23. package/dist/templates/codex/agents/trellis-research.toml +2 -2
  24. package/dist/templates/codex/hooks/session-start.py +20 -1
  25. package/dist/templates/codex/hooks.json +25 -0
  26. package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +2 -1
  27. package/dist/templates/copilot/hooks/session-start.py +20 -1
  28. package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
  29. package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
  30. package/dist/templates/pi/extensions/trellis/index.ts.txt +61 -4
  31. package/dist/templates/shared-hooks/index.d.ts +8 -2
  32. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  33. package/dist/templates/shared-hooks/index.js +13 -1
  34. package/dist/templates/shared-hooks/index.js.map +1 -1
  35. package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
  36. package/dist/templates/shared-hooks/inject-subagent-context.py +4 -3
  37. package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
  38. package/dist/templates/shared-hooks/session-start.py +22 -1
  39. package/dist/templates/trellis/config.yaml +47 -0
  40. package/dist/templates/trellis/index.d.ts +4 -3
  41. package/dist/templates/trellis/index.d.ts.map +1 -1
  42. package/dist/templates/trellis/index.js +7 -3
  43. package/dist/templates/trellis/index.js.map +1 -1
  44. package/dist/templates/trellis/scripts/add_session.py +0 -45
  45. package/dist/templates/trellis/scripts/common/config.py +18 -0
  46. package/dist/templates/trellis/scripts/common/git_context.py +34 -2
  47. package/dist/templates/trellis/scripts/common/paths.py +28 -0
  48. package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
  49. package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
  50. package/dist/templates/trellis/scripts/common/task_store.py +30 -0
  51. package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
  52. package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
  53. package/dist/templates/trellis/scripts/task.py +82 -0
  54. package/package.json +2 -2
  55. 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)