#!/usr/bin/env python3 """ Trellis configuration reader. Reads settings from .trellis/config.yaml with sensible defaults. """ from __future__ import annotations import sys from pathlib import Path from .paths import DIR_WORKFLOW, get_repo_root # ============================================================================= # YAML Simple Parser (no dependencies) # ============================================================================= def _unquote(s: str) -> str: """Remove exactly one layer of matching surrounding quotes. Unlike str.strip('"'), this only removes the outermost pair, preserving any nested quotes inside the value. Examples: _unquote('"hello"') -> 'hello' _unquote("'hello'") -> 'hello' _unquote('"echo \\'hi\\'"') -> "echo 'hi'" _unquote('hello') -> 'hello' _unquote('"hello\\'') -> '"hello\\'' (mismatched, unchanged) """ if len(s) >= 2 and s[0] == s[-1] and s[0] in ('"', "'"): return s[1:-1] return s def _strip_inline_comment(value: str) -> str: """Strip ` # …` inline comments while preserving `#` inside quoted strings. YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token is part of the value. Quoted strings are immune. Mirrors :func:`common.trellis_config._strip_inline_comment` so both parsers handle ``key: value # comment`` identically. """ in_quote: str | None = None for idx, ch in enumerate(value): if in_quote: if ch == in_quote: in_quote = None continue if ch in ('"', "'"): in_quote = ch continue if ch == "#" and (idx == 0 or value[idx - 1].isspace()): return value[:idx] return value def parse_simple_yaml(content: str) -> dict: """Parse simple YAML with nested dict support (no dependencies). Supports: - key: value (string) - key: (followed by list items) - item1 - item2 - key: (followed by nested dict) nested_key: value nested_key2: - item Uses indentation to detect nesting (2+ spaces deeper = child). Args: content: YAML content string. Returns: Parsed dict (values can be str, list[str], or dict). """ lines = content.splitlines() result: dict = {} _parse_yaml_block(lines, 0, 0, result) return result def _parse_yaml_block( lines: list[str], start: int, min_indent: int, target: dict ) -> int: """Parse a YAML block into target dict, returning next line index.""" i = start current_list: list | None = None while i < len(lines): line = lines[i] stripped = line.strip() # Skip empty lines and comments if not stripped or stripped.startswith("#"): i += 1 continue # Calculate indentation indent = len(line) - len(line.lstrip()) # If dedented past our block, we're done if indent < min_indent: break if stripped.startswith("- "): if current_list is not None: current_list.append(_unquote(stripped[2:].strip())) i += 1 elif ":" in stripped: key, _, value = stripped.partition(":") key = key.strip() value = _strip_inline_comment(value).strip() was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'") value = _unquote(value) current_list = None if value or was_quoted: # key: value (an explicit quoted "" is a value, not "no value") target[key] = value i += 1 else: # key: (no value) — peek ahead to determine list vs nested dict next_i, next_line = _next_content_line(lines, i + 1) if next_i >= len(lines): target[key] = {} i = next_i elif next_line.strip().startswith("- "): # It's a list current_list = [] target[key] = current_list i += 1 else: next_indent = len(next_line) - len(next_line.lstrip()) if next_indent > indent: # It's a nested dict nested: dict = {} target[key] = nested i = _parse_yaml_block(lines, i + 1, next_indent, nested) else: # Empty value, same or less indent follows target[key] = {} i += 1 else: i += 1 return i def _next_content_line(lines: list[str], start: int) -> tuple[int, str]: """Find the next non-empty, non-comment line.""" i = start while i < len(lines): stripped = lines[i].strip() if stripped and not stripped.startswith("#"): return i, lines[i] i += 1 return i, "" # Defaults DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal" DEFAULT_MAX_JOURNAL_LINES = 2000 DEFAULT_SESSION_AUTO_COMMIT = True DEFAULT_CODEX_DISPATCH_MODE = "auto" CONFIG_FILE = "config.yaml" def _is_true_config_value(value: object) -> bool: """Return True when a config value represents an enabled flag.""" if isinstance(value, bool): return value if isinstance(value, str): return value.strip().lower() == "true" return False def _get_config_path(repo_root: Path | None = None) -> Path: """Get path to config.yaml.""" root = repo_root or get_repo_root() return root / DIR_WORKFLOW / CONFIG_FILE def _load_config(repo_root: Path | None = None) -> dict: """Load and parse config.yaml. Returns empty dict on any error.""" config_file = _get_config_path(repo_root) try: content = config_file.read_text(encoding="utf-8") return parse_simple_yaml(content) except (OSError, IOError): return {} def get_session_commit_message(repo_root: Path | None = None) -> str: """Get the commit message for auto-committing session records.""" config = _load_config(repo_root) return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE) def get_max_journal_lines(repo_root: Path | None = None) -> int: """Get the maximum lines per journal file.""" config = _load_config(repo_root) value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES) try: return int(value) except (ValueError, TypeError): return DEFAULT_MAX_JOURNAL_LINES def get_session_auto_commit(repo_root: Path | None = None) -> bool: """Whether scripts should auto-stage + auto-commit session/task changes. Governs both ``add_session.py:_auto_commit_workspace`` and ``task_store.py:_auto_commit_archive``. Default: ``True`` (existing behavior — auto-stage + auto-commit). Set ``session_auto_commit: false`` in ``.trellis/config.yaml`` to skip auto-staging entirely; the journal/archive files are still written to disk, but the user manages ``git add`` / ``git commit`` themselves. Accepts native YAML booleans (``true`` / ``false``) and the string aliases ``true / false / yes / no / 1 / 0 / on / off`` (case-insensitive). Invalid values fall back to ``True`` with a stderr warning. """ config = _load_config(repo_root) raw = config.get("session_auto_commit", DEFAULT_SESSION_AUTO_COMMIT) if isinstance(raw, bool): return raw s = str(raw).strip().lower() if s in ("true", "yes", "1", "on"): return True if s in ("false", "no", "0", "off"): return False print( f"[WARN] invalid session_auto_commit value: {raw!r}; using true (default)", file=sys.stderr, ) return DEFAULT_SESSION_AUTO_COMMIT def get_codex_dispatch_mode(repo_root: Path | None = None) -> str: """Return Codex dispatch mode. Default is ``auto``, which dispatches Trellis sub-agents and uses native context injection with a child-side fallback. ``inline`` is an explicit opt-out. ``sub-agent`` remains a backwards-compatible alias for ``auto``. Invalid explicit configuration falls back to ``inline`` rather than unexpectedly dispatching a sub-agent. This CLI-facing parser is the only place that emits a warning for invalid values; hook readers fail safely without producing per-turn warning noise. """ config = _load_config(repo_root) codex = config.get("codex") if codex is None: return DEFAULT_CODEX_DISPATCH_MODE if not isinstance(codex, dict): print( f"[WARN] invalid codex config: {codex!r}; using inline", file=sys.stderr, ) return "inline" raw = codex.get("dispatch_mode", DEFAULT_CODEX_DISPATCH_MODE) mode = str(raw).strip().lower() if mode in ("auto", "inline"): return mode if mode == "sub-agent": return "auto" print( f"[WARN] invalid codex.dispatch_mode value: {raw!r}; using inline", file=sys.stderr, ) return "inline" DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768 DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536 DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072 def get_context_injection_limits(repo_root: Path | None = None) -> dict[str, int]: """Return sub-agent context injection byte limits. Reads the ``context_injection:`` section of ``.trellis/config.yaml``: context_injection: max_file_bytes: 32768 max_artifact_bytes: 65536 max_total_bytes: 131072 ``0`` disables the corresponding limit. Missing keys use their default; invalid (non-int or negative) values fall back to the default for that key with a stderr warning. """ defaults = { "max_file_bytes": DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES, "max_artifact_bytes": DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES, "max_total_bytes": DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES, } config = _load_config(repo_root) section = config.get("context_injection") if not isinstance(section, dict): return defaults result = dict(defaults) for key, default_value in defaults.items(): if key not in section: continue raw = section[key] try: value = int(raw) except (TypeError, ValueError): print( f"[WARN] invalid context_injection.{key} value: {raw!r}; " f"using default {default_value}", file=sys.stderr, ) continue if value < 0: print( f"[WARN] invalid context_injection.{key} value: {raw!r}; " f"using default {default_value}", file=sys.stderr, ) continue result[key] = value return result DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD = "no-trellis" def get_prompt_injection_config(repo_root: Path | None = None) -> dict[str, str]: """Return per-turn prompt injection config. Reads the ``prompt_injection:`` section of ``.trellis/config.yaml``: prompt_injection: skip_keyword: "no-trellis" # "" disables the escape hatch entirely ``skip_keyword`` is the word-boundary, case-insensitive keyword that, when present in the user's prompt, makes the per-turn workflow-state injection emit nothing for that turn. Defaults to ``"no-trellis"``. A non-string value falls back to the default. """ defaults = {"skip_keyword": DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD} config = _load_config(repo_root) section = config.get("prompt_injection") if not isinstance(section, dict): return defaults result = dict(defaults) raw = section.get("skip_keyword", DEFAULT_PROMPT_INJECTION_SKIP_KEYWORD) if isinstance(raw, str): result["skip_keyword"] = raw return result def get_hooks(event: str, repo_root: Path | None = None) -> list[str]: """Get hook commands for a lifecycle event. Args: event: Event name (e.g. "after_create", "after_archive"). repo_root: Repository root path. Returns: List of shell commands to execute, empty if none configured. """ config = _load_config(repo_root) hooks = config.get("hooks") if not isinstance(hooks, dict): return [] commands = hooks.get(event) if isinstance(commands, list): return [str(c) for c in commands] return [] # ============================================================================= # Monorepo / Packages # ============================================================================= def get_packages(repo_root: Path | None = None) -> dict[str, dict] | None: """Get monorepo package declarations. Returns: Dict mapping package name to its config (path, type, etc.), or None if not configured (single-repo mode). Example return: {"cli": {"path": "packages/cli"}, "docs-site": {"path": "docs-site", "type": "submodule"}} """ config = _load_config(repo_root) packages = config.get("packages") if not isinstance(packages, dict): return None # Ensure each value is a dict (filter out scalar entries) filtered = {k: v for k, v in packages.items() if isinstance(v, dict)} if not filtered: return None return filtered def get_default_package(repo_root: Path | None = None) -> str | None: """Get the default package name from config. Returns: Package name string, or None if not configured. """ config = _load_config(repo_root) value = config.get("default_package") return str(value) if value else None def get_submodule_packages(repo_root: Path | None = None) -> dict[str, str]: """Get packages that are git submodules. Returns: Dict mapping package name to its path for submodule-type packages. Empty dict if none configured. Example return: {"docs-site": "docs-site"} """ packages = get_packages(repo_root) if packages is None: return {} return { name: cfg.get("path", name) for name, cfg in packages.items() if cfg.get("type") == "submodule" } def get_git_packages(repo_root: Path | None = None) -> dict[str, str]: """Get packages that have their own independent git repository. These are sub-directories with their own .git (not submodules), marked with ``git: true`` in config.yaml. Returns: Dict mapping package name to its path for git-repo packages. Empty dict if none configured. Example config:: packages: backend: path: iqs git: true Example return:: {"backend": "iqs"} """ packages = get_packages(repo_root) if packages is None: return {} return { name: cfg.get("path", name) for name, cfg in packages.items() if _is_true_config_value(cfg.get("git")) } def is_monorepo(repo_root: Path | None = None) -> bool: """Check if the project is configured as a monorepo (has packages in config).""" return get_packages(repo_root) is not None def get_spec_base(package: str | None = None, repo_root: Path | None = None) -> str: """Get the spec directory base path relative to .trellis/. Single-repo: returns "spec" Monorepo with package: returns "spec/" Monorepo without package: returns "spec" (caller should specify package) """ if package and is_monorepo(repo_root): return f"spec/{package}" return "spec" def validate_package(package: str, repo_root: Path | None = None) -> bool: """Check if a package name is valid in this project. Single-repo (no packages configured): always returns True. Monorepo: returns True only if package exists in config.yaml packages. """ packages = get_packages(repo_root) if packages is None: return True # Single-repo, no validation needed return package in packages def resolve_package( task_package: str | None = None, repo_root: Path | None = None, ) -> str | None: """Resolve package from inferred sources with validation. Checks in order: task_package → default_package. Invalid inferred values print a warning to stderr and are skipped. Returns: Resolved package name, or None if no valid package found. Note: CLI --package should be validated separately by the caller (fail-fast with available packages list on error). """ packages = get_packages(repo_root) if packages is None: return None # Single-repo, no package needed # Try task_package (guard against non-string values from malformed JSON) if task_package and isinstance(task_package, str): if task_package in packages: return task_package print( f"Warning: task.json package '{task_package}' not found in config, skipping", file=sys.stderr, ) # Try default_package default = get_default_package(repo_root) if default: if default in packages: return default print( f"Warning: default_package '{default}' not found in config, skipping", file=sys.stderr, ) return None def get_spec_scope(repo_root: Path | None = None) -> list[str] | str | None: """Get session.spec_scope configuration. Returns: list[str]: Package names to include in spec scanning. str: "active_task" to use current task's package. None: No scope configured (scan all packages). """ config = _load_config(repo_root) session = config.get("session") if not isinstance(session, dict): return None scope = session.get("spec_scope") if scope is None: return None if isinstance(scope, str): return scope # e.g. "active_task" if isinstance(scope, list): return [str(s) for s in scope] return None