@mindfoldhq/trellis 0.6.9 → 0.7.0-beta.0

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