#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Codex Session Start Hook - Inject Trellis context into Codex sessions. Output format follows Codex hook protocol: stdout JSON → { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: "..." } } """ from __future__ import annotations import json import os import re import subprocess import sys import warnings from io import StringIO from pathlib import Path # Force UTF-8 on stdin/stdout/stderr on Windows. Default codepage there is # cp936 / cp1252 / etc. — non-ASCII content (Chinese task names, prd snippets) # both in stdin (hook payload from host CLI) and stdout (our emitted blocks) # raises UnicodeDecodeError / UnicodeEncodeError. Equivalent to `python -X utf8` # but applied per-stream so we don't depend on host CLI's command wiring. if sys.platform.startswith("win"): import io as _io for _stream_name in ("stdin", "stdout", "stderr"): _stream = getattr(sys, _stream_name, None) if _stream is None: continue if hasattr(_stream, "reconfigure"): try: _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] except Exception: pass elif hasattr(_stream, "detach"): try: setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace")) except Exception: pass def _normalize_windows_shell_path(path_str: str) -> str: """Normalize Unix-style shell paths to real Windows paths. On Windows, shells like Git Bash / MSYS2 / Cygwin may report paths like `/d/Users/...` or `/cygdrive/d/Users/...`. `Path.resolve()` will misinterpret these as `D:/d/Users...` on drive D: (or similar), breaking repo root detection. This function is intentionally conservative: it only rewrites patterns that unambiguously represent a drive letter mount. """ if not isinstance(path_str, str) or not path_str: return path_str # Only relevant on Windows; keep other platforms untouched. if not sys.platform.startswith("win"): return path_str p = path_str.strip() # Already a Windows drive path (C:\... or C:/...) if re.match(r"^[A-Za-z]:[\/]", p): return p # MSYS/Git-Bash style: /c/Users/... or /d/Work/... m = re.match(r"^/([A-Za-z])/(.*)", p) if m: drive, rest = m.group(1).upper(), m.group(2) rest = rest.replace('/', '\\') return f"{drive}:\\{rest}" # Cygwin style: /cygdrive/c/Users/... m = re.match(r"^/cygdrive/([A-Za-z])/(.*)", p) if m: drive, rest = m.group(1).upper(), m.group(2) rest = rest.replace('/', '\\') return f"{drive}:\\{rest}" # WSL mounted drive (sometimes leaked into env): /mnt/c/Users/... m = re.match(r"^/mnt/([A-Za-z])/(.*)", p) if m: drive, rest = m.group(1).upper(), m.group(2) rest = rest.replace('/', '\\') return f"{drive}:\\{rest}" return path_str warnings.filterwarnings("ignore") FIRST_REPLY_NOTICE = """ On the first visible assistant reply in this session, begin with exactly one short Chinese sentence: Trellis SessionStart 已注入:workflow、当前任务状态、开发者身份、git 状态、active tasks、spec 索引已加载。 Then continue directly with the user's request. This notice is one-shot: do not repeat it after the first assistant reply in the same session. """ def should_skip_injection() -> bool: if os.environ.get("TRELLIS_HOOKS") == "0": return True if os.environ.get("TRELLIS_DISABLE_HOOKS") == "1": return True return os.environ.get("CODEX_NON_INTERACTIVE") == "1" def configure_project_encoding(project_dir: Path) -> None: """Reuse Trellis' shared Windows stdio encoding helper before JSON output.""" scripts_dir = project_dir / ".trellis" / "scripts" if str(scripts_dir) not in sys.path: sys.path.insert(0, str(scripts_dir)) try: from common import configure_encoding # type: ignore[import-not-found] configure_encoding() except Exception: pass def _has_curated_jsonl_entry(jsonl_path: Path) -> bool: """Return True iff jsonl has at least one row with a ``file`` field. A freshly seeded jsonl only contains a ``{"_example": ...}`` row (no ``file`` key) — that is NOT "ready". Readiness requires at least one curated entry. Matches the contract used by ``inject-subagent-context.py``. """ try: for line in jsonl_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: row = json.loads(line) except json.JSONDecodeError: continue if isinstance(row, dict) and row.get("file"): return True except (OSError, UnicodeDecodeError): return False return False def read_file(path: Path, fallback: str = "") -> str: try: return path.read_text(encoding="utf-8") except (FileNotFoundError, PermissionError): return fallback def _resolve_context_key(project_dir: Path, hook_input: dict) -> str | None: scripts_dir = project_dir / ".trellis" / "scripts" if str(scripts_dir) not in sys.path: sys.path.insert(0, str(scripts_dir)) try: from common.active_task import resolve_context_key # type: ignore[import-not-found] except Exception: return None return resolve_context_key(hook_input, platform="codex") def _resolve_active_task(trellis_dir: Path, hook_input: dict): scripts_dir = trellis_dir / "scripts" if str(scripts_dir) not in sys.path: sys.path.insert(0, str(scripts_dir)) from common.active_task import resolve_active_task # type: ignore[import-not-found] return resolve_active_task(trellis_dir.parent, hook_input, platform="codex") def run_script(script_path: Path, context_key: str | None = None) -> str: try: env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" if context_key: env["TRELLIS_CONTEXT_ID"] = context_key cmd = [sys.executable, "-W", "ignore", str(script_path)] result = subprocess.run( cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, cwd=str(script_path.parent.parent.parent), env=env, ) return result.stdout if result.returncode == 0 else "No context available" except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): return "No context available" def _normalize_task_ref(task_ref: str) -> str: normalized = task_ref.strip() if not normalized: return "" path_obj = Path(normalized) if path_obj.is_absolute(): return str(path_obj) normalized = normalized.replace("\\", "/") while normalized.startswith("./"): normalized = normalized[2:] if normalized.startswith("tasks/"): return f".trellis/{normalized}" return normalized def _resolve_task_dir(trellis_dir: Path, task_ref: str) -> Path: normalized = _normalize_task_ref(task_ref) path_obj = Path(normalized) if path_obj.is_absolute(): return path_obj if normalized.startswith(".trellis/"): return trellis_dir.parent / path_obj return trellis_dir / "tasks" / path_obj def _get_task_status(trellis_dir: Path, hook_input: dict) -> str: active = _resolve_active_task(trellis_dir, hook_input) if not active.task_path: return ( "Status: NO ACTIVE TASK\n" "Next: Classify the current turn and ask for task-creation consent " "before creating any Trellis task." ) task_ref = active.task_path task_dir = _resolve_task_dir(trellis_dir, task_ref) if active.stale or not task_dir.is_dir(): return ( f"Status: STALE POINTER\nTask: {task_ref}\n" "Next: Task directory not found. Run: python3 ./.trellis/scripts/task.py finish" ) task_json_path = task_dir / "task.json" task_data: dict = {} if task_json_path.is_file(): try: task_data = json.loads(task_json_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, PermissionError): pass task_title = task_data.get("title", task_ref) task_status = task_data.get("status", "unknown") if task_status == "completed": return ( f"Status: COMPLETED\nTask: {task_title}\n" f"Next: Archive with `python3 ./.trellis/scripts/task.py archive {task_dir.name}` " "or start a new task." ) has_prd = (task_dir / "prd.md").is_file() has_design = (task_dir / "design.md").is_file() has_implement = (task_dir / "implement.md").is_file() present = [ name for name in ("prd.md", "design.md", "implement.md", "implement.jsonl", "check.jsonl") if (task_dir / name).is_file() ] present_line = ", ".join(present) if present else "none" if not has_prd: return ( f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" "Next: Load trellis-brainstorm and write prd.md. Stay in planning." ) if task_status == "planning": if has_design and has_implement: next_action = "Review planning artifacts with the user before `task.py start`." else: next_action = ( "Lightweight task can ask for start review with PRD-only; " "complex task must add design.md and implement.md before `task.py start`." ) return ( f"Status: PLANNING\nTask: {task_title}\nPresent: {present_line}\n" f"Next: {next_action}" ) return ( f"Status: {task_status.upper()}\nTask: {task_title}\nPresent: {present_line}\n" "Next: Follow the matching per-turn workflow-state. Context order is jsonl entries, " "prd.md, design.md if present, implement.md if present." ) def _run_git(repo_root: Path, args: list[str]) -> str: try: result = subprocess.run( ["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3, cwd=str(repo_root), ) except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError): return "" if result.returncode != 0: return "" return result.stdout.strip() def _format_git_state(repo_root: Path) -> str: branch = _run_git(repo_root, ["branch", "--show-current"]) or "(detached)" dirty_lines = [ line for line in _run_git(repo_root, ["status", "--porcelain"]).splitlines() if line.strip() ] dirty_text = "clean" if not dirty_lines else f"dirty {len(dirty_lines)} paths" return f"Git: branch {branch}; {dirty_text}." def _repo_relative(repo_root: Path, path: Path) -> str: try: return path.relative_to(repo_root).as_posix() except ValueError: return str(path) def _collect_spec_index_paths(trellis_dir: Path) -> list[str]: paths: list[str] = [] guides_index = trellis_dir / "spec" / "guides" / "index.md" if guides_index.is_file(): paths.append(".trellis/spec/guides/index.md") spec_dir = trellis_dir / "spec" if not spec_dir.is_dir(): return paths for sub in sorted(spec_dir.iterdir()): if not sub.is_dir() or sub.name.startswith(".") or sub.name == "guides": continue index_file = sub / "index.md" if index_file.is_file(): paths.append(f".trellis/spec/{sub.name}/index.md") continue for nested in sorted(sub.iterdir()): if not nested.is_dir(): continue nested_index = nested / "index.md" if nested_index.is_file(): paths.append(f".trellis/spec/{sub.name}/{nested.name}/index.md") return paths def _build_compact_current_state( trellis_dir: Path, hook_input: dict, spec_index_paths: list[str], ) -> str: repo_root = trellis_dir.parent lines: list[str] = [] try: from common.paths import get_active_journal_file, get_developer, get_tasks_dir, count_lines # type: ignore[import-not-found] from common.tasks import iter_active_tasks # type: ignore[import-not-found] except Exception: get_active_journal_file = None # type: ignore[assignment] get_developer = None # type: ignore[assignment] get_tasks_dir = None # type: ignore[assignment] count_lines = None # type: ignore[assignment] iter_active_tasks = None # type: ignore[assignment] developer = get_developer(repo_root) if get_developer else None lines.append(f"Developer: {developer or '(not initialized)'}") lines.append(_format_git_state(repo_root)) active = _resolve_active_task(trellis_dir, hook_input) if active.task_path: task_dir = _resolve_task_dir(trellis_dir, active.task_path) status = "unknown" task_json = task_dir / "task.json" if task_json.is_file(): try: data = json.loads(task_json.read_text(encoding="utf-8")) if isinstance(data, dict): status = str(data.get("status") or "unknown") except (json.JSONDecodeError, OSError): pass lines.append(f"Current task: {_repo_relative(repo_root, task_dir)}; status={status}.") else: lines.append("Current task: none.") if get_tasks_dir and iter_active_tasks: try: task_count = sum(1 for _ in iter_active_tasks(get_tasks_dir(repo_root))) lines.append( f"Active tasks: {task_count} total. Use `python3 ./.trellis/scripts/task.py list --mine` only if needed." ) except Exception: pass if get_active_journal_file and count_lines: journal = get_active_journal_file(repo_root) if journal: lines.append( f"Journal: {_repo_relative(repo_root, journal)}, {count_lines(journal)} / 2000 lines." ) if spec_index_paths: lines.append(f"Spec indexes: {len(spec_index_paths)} available.") return "\n".join(lines) def _extract_range(content: str, start_header: str, end_header: str) -> str: """Extract lines starting at `## start_header` up to (but excluding) `## end_header`.""" lines = content.splitlines() start: "int | None" = None end: int = len(lines) start_match = f"## {start_header}" end_match = f"## {end_header}" for i, line in enumerate(lines): stripped = line.strip() if start is None and stripped == start_match: start = i continue if start is not None and stripped == end_match: end = i break if start is None: return "" return "\n".join(lines[start:end]).rstrip() _BREADCRUMB_TAG_RE = re.compile( r"\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n.*?\n\s*\[/workflow-state:\1\]", re.DOTALL, ) def _strip_breadcrumb_tag_blocks(content: str) -> str: stripped = _BREADCRUMB_TAG_RE.sub("", content) stripped = re.sub(r"", "", stripped, flags=re.DOTALL) stripped = re.sub(r"^\[(?!/?workflow-state:)/?[^\]\n]+\]\s*\n?", "", stripped, flags=re.MULTILINE) return re.sub(r"\n{3,}", "\n\n", stripped).strip() def _build_workflow_toc(workflow_path: Path) -> str: """Inject only the compact Phase Index summary for SessionStart.""" content = read_file(workflow_path) if not content: return "No workflow.md found" out_lines = [ "# Development Workflow - Session Summary", "Full guide: .trellis/workflow.md. Step detail: `python3 ./.trellis/scripts/get_context.py --mode phase --step `.", "", ] phases = _extract_range(content, "Phase Index", "Phase 1: Plan") if phases: out_lines.append(_strip_breadcrumb_tag_blocks(phases).rstrip()) return "\n".join(out_lines).rstrip() def main() -> None: if should_skip_injection(): sys.exit(0) # Read hook input from stdin try: hook_input = json.loads(sys.stdin.read()) if not isinstance(hook_input, dict): hook_input = {} project_dir = Path(_normalize_windows_shell_path(hook_input.get("cwd", "."))).resolve() except (json.JSONDecodeError, KeyError): hook_input = {} project_dir = Path(".").resolve() configure_project_encoding(project_dir) trellis_dir = project_dir / ".trellis" spec_index_paths = _collect_spec_index_paths(trellis_dir) output = StringIO() output.write(""" Trellis compact SessionStart context. Use it to orient the session; load details on demand. """) output.write(FIRST_REPLY_NOTICE) output.write("\n\n") output.write("\n") output.write(_build_compact_current_state(trellis_dir, hook_input, spec_index_paths)) output.write("\n\n\n") output.write("\n") output.write(_build_workflow_toc(trellis_dir / "workflow.md")) output.write("\n\n\n") output.write("\n") output.write( "Task context order for implementation/check: jsonl entries -> `prd.md` -> " "`design.md if present` -> `implement.md if present`. Missing optional artifacts " "are skipped for lightweight tasks.\n\n" ) if spec_index_paths: output.write("## Available indexes (read on demand)\n") for p in spec_index_paths: output.write(f"- {p}\n") output.write("\n") output.write( "Discover more via: " "`python3 ./.trellis/scripts/get_context.py --mode packages`\n" ) output.write("\n\n") task_status = _get_task_status(trellis_dir, hook_input) output.write(f"\n{task_status}\n\n\n") output.write(""" Context loaded. Follow . Load workflow/spec/task details only when needed. """) context = output.getvalue() result = { "suppressOutput": True, "systemMessage": f"Trellis context injected ({len(context)} chars)", "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": context, }, } print(json.dumps(result, ensure_ascii=False), flush=True) if __name__ == "__main__": main()