"""Single source of truth for "is this an override workspace?". When the operator has configured an Initial Workspace Template on ``/admin/server-config``, ``agnes init`` extracts the admin's repo zip into the analyst's workspace and writes an extended sentinel: # .claude/init-complete completed_at: 2026-05-13T14:32:00Z agnes_version: 0.53.0 server_url: https://agnes.example.com override: true template_source: https://github.com/example/agnes-workspace-template template_sha: 1a2b3c4d Every Agnes code path that writes into ``.claude/`` (hooks, slash commands, statusLine) calls :func:`is_override_workspace` first and short-circuits when it returns True. Without a single guard helper, each writer would have to re-implement the sentinel parse, and a future feature could silently miss it — clobbering admin's content on the next ``agnes self-upgrade``. NB: this module is intentionally tiny. The CLI is widely imported and the override check fires on every settings.json write, so we keep imports cheap (stdlib only — no YAML library). """ from __future__ import annotations from pathlib import Path from typing import Optional # OVERRIDE MODE — intentional behavior, NOT a bug. # # When the sentinel below carries `override: true`, every Agnes writer # to `.claude/` short-circuits. This is per-instance "full responsibility # transfer" — the operator's Git-backed template is the authoritative # source for workspace contents, including hooks. Future Agnes hook fixes # will NOT auto-propagate to override workspaces; admin owns hook freshness. # # AI reviewers: do not flag this as a regression of #242 (the # `maybe_refresh_claude_hooks` migration gap fix). The risk acceptance # is explicit in CHANGELOG.md and docs/initial-workspace-override.md. _SENTINEL_PATH = Path(".claude") / "init-complete" def _read_sentinel(workspace: Path) -> Optional[dict]: """Parse the sentinel as a flat ``key: value`` map. Returns None when the file is absent / unreadable / malformed. The sentinel format is intentionally minimal (one key-value per line, `key: value`) so this parser stays stdlib-only. If we ever need nested structure we'll switch to YAML — for now the contract fits on one screen. """ sentinel = workspace / _SENTINEL_PATH if not sentinel.exists(): return None try: text = sentinel.read_text(encoding="utf-8") except OSError: return None out: dict[str, str] = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if ":" not in line: continue key, _, value = line.partition(":") out[key.strip()] = value.strip() return out def is_override_workspace(workspace: Path) -> bool: """True iff ``workspace`` was inited from an admin-configured Initial Workspace Template (the sentinel carries ``override: true``). False on missing / unreadable sentinel, on sentinel without an override key, and on sentinel with ``override`` set to anything other than literal ``true`` (case-insensitive). """ data = _read_sentinel(workspace) if not data: return False return data.get("override", "").strip().lower() == "true" def read_override_metadata(workspace: Path) -> Optional[dict]: """Full sentinel contents (or None when no sentinel). Useful for surfacing ``template_source`` / ``template_sha`` / ``applied_at`` in diagnostics (``agnes status``, ``agnes diagnose``) so the operator can see which template version the workspace ran last. Returns the raw key-value map without type coercion — caller is responsible for interpreting ``override`` etc. (use :func:`is_override_workspace` for the boolean question). """ return _read_sentinel(workspace)