diff --git a/core/hooks/workflow_guard.py b/core/hooks/workflow_guard.py
index 3d26880..68164ef 100644
--- a/core/hooks/workflow_guard.py
+++ b/core/hooks/workflow_guard.py
@@ -22,6 +22,11 @@
#: Cap on docs named inline; the overflow is counted, never silently dropped.
_MAX_ADVISORY_DOCS = 6
+#: Advisory log rotation: rewrite keeping the newest lines once the file
+#: exceeds the byte cap (budgets are code — the log must not grow unbounded).
+_ADVISORY_LOG_MAX_BYTES = 262_144
+_ADVISORY_LOG_KEEP_LINES = 500
+
ALLOWED_DOC_PATHS = {
"AGENTS.md",
@@ -95,6 +100,45 @@ def _relative_to_cwd(cwd: Path, target_path: str) -> str:
return Path(target_path).name
+def _log_advisory(cwd: Path, rel_path: str, docs: list) -> None:
+ """Append one JSONL record per targeted advisory (E174 dashboard feed).
+
+ A log, not a queue (M1-clean: nothing awaits a drain) — it exists so the
+ viewer can SHOW docs being tracked as code changes. Written only where
+ ``.episteme/`` already exists (same footprint rule as the doc-map cache),
+ size-capped by rewrite, and failure-silent: observability must never
+ break the edit it observes.
+ """
+ try:
+ state_dir = cwd / ".episteme" / "state"
+ if not (cwd / ".episteme").is_dir():
+ return
+ state_dir.mkdir(parents=True, exist_ok=True)
+ log = state_dir / "doc_advisories.jsonl"
+ from datetime import datetime, timezone
+
+ record = json.dumps(
+ {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "path": rel_path,
+ "docs": docs[:_MAX_ADVISORY_DOCS],
+ "doc_count": len(docs),
+ }
+ )
+ with open(log, "a", encoding="utf-8") as fh:
+ fh.write(record + "\n")
+ if log.stat().st_size > _ADVISORY_LOG_MAX_BYTES:
+ lines = log.read_text(encoding="utf-8", errors="replace").splitlines()
+ tmp = log.with_suffix(f".jsonl.tmp.{os.getpid()}")
+ tmp.write_text(
+ "\n".join(lines[-_ADVISORY_LOG_KEEP_LINES:]) + "\n",
+ encoding="utf-8",
+ )
+ tmp.replace(log)
+ except Exception:
+ pass
+
+
def _targeted_advisory(cwd: Path, target_path: str) -> "str | None":
"""The doc-map advisory for ``target_path``, or ``None`` for fallback.
@@ -112,6 +156,7 @@ def _targeted_advisory(cwd: Path, target_path: str) -> "str | None":
docs = [e.doc for e in edges]
labels = dict(dr.annotate_docs(cwd, docs))
rel = _relative_to_cwd(cwd, target_path)
+ _log_advisory(cwd, rel, docs)
lines = [f"DOC ADVISORY: {len(docs)} doc(s) claim to describe '{rel}' —"]
for e in edges[:_MAX_ADVISORY_DOCS]:
label = labels.get(e.doc, "")
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index 69fe8d4..78c4c10 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -1,4 +1,4 @@
-
+
# episteme — command reference
A one-page map of every `episteme` subcommand, grouped by lifecycle phase.
@@ -46,7 +46,7 @@ Scope key:
| `episteme detect [path]` | project | Score which harness type fits the project. |
| `episteme harness {list,apply}` | project | List available harnesses; apply one to a project. A harness defines execution profile + workflow constraints for a project type. |
| `episteme worktree` | project | Create a git worktree for a bounded task in the current repo. |
-| `episteme viewer` | project | Start a local read-only dashboard over this repo. |
+| `episteme viewer` | project | Live local governance dashboard (E174): global operator-home state + current-project surface/doc-map/staleness + the DOC ADVISORY feed, polling every 3s at `localhost:37776`; auto-opens the browser (`--no-open` to suppress). Menu-bar companion: `tools/xbar/episteme.30s.sh`. |
| `episteme capture` | project | Draft a `reasoning-surface.json` skeleton from unstructured text (Slack thread, PR desc, ticket, email). Reads stdin. |
## Framework internals
diff --git a/docs/HOOKS.md b/docs/HOOKS.md
index ce76782..7e99155 100644
--- a/docs/HOOKS.md
+++ b/docs/HOOKS.md
@@ -25,7 +25,7 @@ change; it is the actual registry, not this prose.
| `reasoning_surface_guard.py` | `PreToolUse Bash\|Write\|Edit\|MultiEdit` | Blocks high-impact / irreversible ops and architectural-cascade edits that lack a valid Reasoning Surface |
| `block_dangerous.py` | `PreToolUse Bash` | Blocks `rm -rf`, `git reset --hard`, `git push --force`, `sudo`, destructive SQL, and more |
| `_arm_a_pre.py` | `PreToolUse Write\|Edit\|MultiEdit` | Cognitive Arm A pre-snapshot of watched profile/policy files for trajectory diffing |
-| `workflow_guard.py` | `PreToolUse Write\|Edit\|MultiEdit` (balanced/strict) | Targeted DOC ADVISORY (E173): names the docs whose citations claim to describe the edited path — the reverse index of `episteme docs map`, derived from citation edges — with lifecycle state; falls back to the generic `EVENTS.md` / `NEXT_STEPS.md` nudge when no doc cites the path (positive system) or the index is unavailable (non-git project, plugin-only install) |
+| `workflow_guard.py` | `PreToolUse Write\|Edit\|MultiEdit` (balanced/strict) | Targeted DOC ADVISORY (E173): names the docs whose citations claim to describe the edited path — the reverse index of `episteme docs map`, derived from citation edges — with lifecycle state; falls back to the generic `EVENTS.md` / `NEXT_STEPS.md` nudge when no doc cites the path (positive system) or the index is unavailable (non-git project, plugin-only install). Each targeted advisory is also appended to `.episteme/state/doc_advisories.jsonl` (E174; size-capped log, not a queue) — the live feed `episteme viewer` renders |
| `prompt_guard.py` | `PreToolUse Write\|Edit\|MultiEdit` (balanced/strict) | Advisory prompt-injection detection when writing durable context |
| `format.py` | `PostToolUse Write\|Edit\|MultiEdit` (async) | Auto-runs `ruff` (Python) / `prettier` (JS/TS) after a file write |
| `test_runner.py` | `PostToolUse Write\|Edit\|MultiEdit` | Runs pytest / jest when the edited file is a test |
diff --git a/src/episteme/cli.py b/src/episteme/cli.py
index 12b9782..e10fc03 100644
--- a/src/episteme/cli.py
+++ b/src/episteme/cli.py
@@ -6602,9 +6602,10 @@ def build_parser() -> argparse.ArgumentParser:
start = sub.add_parser("start", help="Start the preferred agent surface")
start.add_argument("tool", nargs="?", default="claude", choices=["claude"])
- viewer = sub.add_parser("viewer", help="Start a local read-only dashboard over this repo")
+ viewer = sub.add_parser("viewer", help="Live local governance dashboard — global + current-project runtime state (auto-opens browser)")
viewer.add_argument("--host", default="127.0.0.1")
viewer.add_argument("--port", type=int, default=37776)
+ viewer.add_argument("--no-open", action="store_true", help="Do not auto-open the browser")
capture = sub.add_parser(
"capture",
@@ -7173,7 +7174,11 @@ def main(argv: Iterable[str] | None = None) -> int:
return _start(args.tool, Path.cwd())
if args.command == "viewer":
from episteme.viewer.server import serve
- return serve(host=args.host, port=args.port)
+ return serve(
+ host=args.host,
+ port=args.port,
+ open_browser=not getattr(args, "no_open", False),
+ )
if args.command == "capture":
from episteme.capture import run_capture
return run_capture(
diff --git a/src/episteme/viewer/index.html b/src/episteme/viewer/index.html
new file mode 100644
index 0000000..1c7534c
--- /dev/null
+++ b/src/episteme/viewer/index.html
@@ -0,0 +1,174 @@
+
+
+
+
+
+episteme · operator console
+
+
+
+
+ episteme · operator console
+ ● connecting…
+
+
+
+
+ Project — …
+ branch…
+ reasoning surface…
+ posture…
+ doc map…
+ living docs stale…
+ latest event…
+
+
+
+
+ Global — operator home
+ gated ops (24h)…
+ verdicts…
+ spot-check pending…
+ protocols…
+ deferred discoveries…
+ noise watch…
+
+
+
+ Doc advisories — docs tracked as code changes
+ No advisories logged yet in this project. Edit an implementation file that a doc cites and it will appear here.
+
+ | when | edited path | docs claiming to describe it |
+
+
+
+
+
+
+
+
diff --git a/src/episteme/viewer/live.py b/src/episteme/viewer/live.py
new file mode 100644
index 0000000..67eb444
--- /dev/null
+++ b/src/episteme/viewer/live.py
@@ -0,0 +1,287 @@
+"""Live runtime-state readers for the viewer dashboard (Event 174).
+
+Pure functions over the two state roots — the operator home
+(``$EPISTEME_HOME``, default ``~/.episteme``) and the governed project's
+``/.episteme`` — so every reader is unit-testable with an injected
+path and the HTTP layer stays a thin route table. All reads are bounded
+(tail-limited JSONL, byte-capped text) because the dashboard polls every few
+seconds while hooks append per tool call; a reader must never scale with
+history length. Every failure degrades to an empty/partial payload — a
+dashboard that renders less is correct, one that 500s is not.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, List, Optional
+
+#: Maximum bytes read from the end of a JSONL stream per request.
+_TAIL_BYTES = 262_144
+#: Reasoning-surface freshness TTL, matching the gate (minutes).
+_SURFACE_TTL_MIN = 30
+#: Doc-staleness event lag, matching the SessionStart banner.
+_DOC_STALENESS_EVENT_LAG = 15
+
+
+def episteme_home() -> Path:
+ return Path(os.environ.get("EPISTEME_HOME") or (Path.home() / ".episteme"))
+
+
+def tail_jsonl(path: Path, max_lines: int) -> List[dict]:
+ """Last ``max_lines`` parseable JSON objects of a JSONL file, oldest first.
+
+ Reads at most :data:`_TAIL_BYTES` from the end of the file, so cost is
+ constant regardless of history length; a line straddling the byte
+ boundary is dropped (it is the oldest in the window, never the newest).
+ """
+ try:
+ size = path.stat().st_size
+ with open(path, "rb") as fh:
+ if size > _TAIL_BYTES:
+ fh.seek(size - _TAIL_BYTES)
+ fh.readline() # discard the partial first line
+ raw = fh.read().decode("utf-8", errors="replace")
+ except OSError:
+ return []
+ out: List[dict] = []
+ for line in raw.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ obj = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(obj, dict):
+ out.append(obj)
+ return out[-max_lines:]
+
+
+def _count_lines(path: Path) -> int:
+ try:
+ with open(path, "rb") as fh:
+ return sum(1 for _ in fh)
+ except OSError:
+ return 0
+
+
+def _parse_ts(value: Any) -> Optional[datetime]:
+ """ISO timestamp → aware UTC datetime, or None.
+
+ A naive timestamp is COERCED to UTC rather than returned naive: the
+ subtraction against aware `now` would raise TypeError, 500 the route,
+ and (via the client's fan-out fetch) blank the whole dashboard — the
+ exact failure the review's disconfirmation test produced from one
+ hand-edited surface timestamp.
+ """
+ if not isinstance(value, str):
+ return None
+ try:
+ ts = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if ts.tzinfo is None:
+ ts = ts.replace(tzinfo=timezone.utc)
+ return ts
+
+
+def global_status(home: Optional[Path] = None) -> dict:
+ """Operator-home panel: gate activity, queues, framework, knobs."""
+ home = home or episteme_home()
+ now = datetime.now(timezone.utc)
+
+ audit = tail_jsonl(home / "audit.jsonl", 200)
+ day_ago_ops = 0
+ verdicts: dict = {}
+ for rec in audit:
+ ts = _parse_ts(rec.get("timestamp") or rec.get("ts"))
+ if ts is not None and (now - ts).total_seconds() <= 86_400:
+ day_ago_ops += 1
+ # The canonical audit writer (reasoning_surface_guard) emits
+ # "status" (ok/incomplete/missing/invalid/stale) and "action";
+ # review confirmed zero real records carry decision/verdict.
+ verdict = str(
+ rec.get("status") or rec.get("action") or "unknown"
+ )
+ verdicts[verdict] = verdicts.get(verdict, 0) + 1
+
+ knobs: dict = {}
+ try:
+ knobs = json.loads((home / "derived_knobs.json").read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ knobs = {}
+
+ last_session = None
+ try:
+ payload = json.loads(
+ (home / "state" / "last_session.json").read_text(encoding="utf-8")
+ )
+ last_session = payload.get("timestamp") or payload.get("ts")
+ except (OSError, ValueError):
+ last_session = None
+
+ return {
+ "generated_at": now.isoformat(),
+ "home": str(home),
+ "gate_ops_24h": day_ago_ops,
+ "gate_verdicts_24h": verdicts,
+ "spot_check_pending": _spot_check_pending(home),
+ "spot_check_queue": _count_lines(home / "state" / "spot_check_queue.jsonl"),
+ "framework": {
+ "protocols": _count_lines(home / "framework" / "protocols.jsonl"),
+ "deferred_discoveries": _count_lines(
+ home / "framework" / "deferred_discoveries.jsonl"
+ ),
+ },
+ "noise_watch": knobs.get("noise_watch_set") or knobs.get("noise_watch") or [],
+ "last_session": last_session,
+ }
+
+
+def _spot_check_pending(home: Path) -> Optional[int]:
+ """Pending spot-check entries via the queue's OWN semantics.
+
+ The SessionStart banner counts entries still awaiting review
+ (``_spot_check.count_pending``), not raw queue lines — replicating that
+ filter here would be a second divergent notion of "pending", so the hook
+ lib is imported with the repo-root self-resolve pattern instead. ``None``
+ when unavailable (installed package without the repo tree); the caller
+ also reports the raw line count under its own honestly-named key.
+ """
+ try:
+ repo_root = Path(__file__).resolve().parents[3]
+ if (repo_root / "core" / "hooks").is_dir():
+ p = str(repo_root)
+ if p not in sys.path:
+ sys.path.insert(0, p)
+ from core.hooks import _spot_check # noqa: PLC0415
+
+ return _spot_check.count_pending(
+ path=home / "state" / "spot_check_queue.jsonl"
+ )
+ except Exception:
+ return None
+
+
+def _git_branch(root: Path) -> Optional[str]:
+ try:
+ proc = subprocess.run(
+ ["git", "-C", str(root), "rev-parse", "--abbrev-ref", "HEAD"],
+ capture_output=True,
+ text=True,
+ timeout=2,
+ )
+ return proc.stdout.strip() or None if proc.returncode == 0 else None
+ except (OSError, subprocess.SubprocessError):
+ return None
+
+
+def _surface_status(root: Path, now: datetime) -> dict:
+ path = root / ".episteme" / "reasoning-surface.json"
+ out: dict = {"exists": False, "fresh": None, "age_minutes": None,
+ "core_question": None, "posture": None}
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return out
+ out["exists"] = True
+ out["core_question"] = payload.get("core_question")
+ out["posture"] = payload.get("posture_selected")
+ ts = _parse_ts(payload.get("timestamp"))
+ if ts is not None:
+ age = (now - ts).total_seconds() / 60.0
+ out["age_minutes"] = round(age, 1)
+ out["fresh"] = age <= _SURFACE_TTL_MIN
+ return out
+
+
+def _latest_event_number(root: Path) -> Optional[int]:
+ try:
+ text = (root / "docs" / "EVENTS.md").read_text(
+ encoding="utf-8", errors="replace"
+ )
+ except OSError:
+ return None
+ nums = [int(m) for m in re.findall(r"\bE(\d+)\b", text)]
+ return max(nums) if nums else None
+
+
+def _doc_staleness(root: Path) -> dict:
+ """Living-doc lag summary, same rules as the SessionStart banner."""
+ docs_dir = root / "docs"
+ latest = _latest_event_number(root)
+ total_living = 0
+ stale = 0
+ worst_lag = 0
+ if docs_dir.is_dir():
+ for path in docs_dir.glob("*.md"):
+ if path.is_symlink():
+ continue
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ first = fh.readline()
+ except OSError:
+ continue
+ if "episteme-lifecycle:" not in first:
+ continue
+ sm = re.search(r"status=([^;\s]+)", first)
+ if sm is None or sm.group(1) != "living":
+ continue
+ total_living += 1
+ rm = re.search(r"reviewed_as_of=E(\d+)", first)
+ if rm is None or latest is None:
+ continue
+ lag = latest - int(rm.group(1))
+ worst_lag = max(worst_lag, lag)
+ if lag > _DOC_STALENESS_EVENT_LAG:
+ stale += 1
+ return {
+ "latest_event": latest,
+ "living_docs": total_living,
+ "stale_docs": stale,
+ "worst_lag_events": worst_lag,
+ }
+
+
+def _doc_map_stats(root: Path) -> dict:
+ """Reverse-index shape for the project (E173), degrade-to-empty."""
+ try:
+ from episteme import doc_references
+
+ index = doc_references.cached_reverse_index(root)
+ return {
+ "targets": len(index),
+ "edges": sum(len(v) for v in index.values()),
+ }
+ except Exception:
+ return {"targets": None, "edges": None}
+
+
+def advisories(root: Path, limit: int = 50) -> List[dict]:
+ """Recent DOC ADVISORY records for the project, newest last."""
+ return tail_jsonl(
+ root / ".episteme" / "state" / "doc_advisories.jsonl", limit
+ )
+
+
+def project_status(root: Optional[Path] = None) -> dict:
+ """Project panel: git, surface freshness, doc map, doc staleness."""
+ root = Path(root) if root is not None else Path.cwd()
+ now = datetime.now(timezone.utc)
+ recent = advisories(root, limit=50)
+ return {
+ "generated_at": now.isoformat(),
+ "root": str(root),
+ "name": root.name,
+ "branch": _git_branch(root),
+ "surface": _surface_status(root, now),
+ "doc_map": _doc_map_stats(root),
+ "doc_staleness": _doc_staleness(root),
+ "advisories_recent": len(recent),
+ }
diff --git a/src/episteme/viewer/server.py b/src/episteme/viewer/server.py
index 9df1d6e..b8337b8 100644
--- a/src/episteme/viewer/server.py
+++ b/src/episteme/viewer/server.py
@@ -4,6 +4,9 @@
not reachable from the network without explicit --host. Endpoints:
GET / dashboard (renders index.html)
+ GET /api/live/global JSON: operator-home runtime state (gate ops, queues, framework) — E174
+ GET /api/live/project JSON: governed-project state (surface, doc map, staleness) — E174
+ GET /api/live/advisories JSON: recent DOC ADVISORY records for the project — E174
GET /api/overview JSON: kernel files + doc counts + latest benchmark
GET /api/profile JSON: operator profile scorecard (if generated)
GET /api/reasoning-surfaces JSON: recent reasoning-surface.json files discovered under the repo
@@ -12,6 +15,10 @@
GET /api/benchmarks JSON: latest RESULTS.json
GET /static/ CSS/JS assets
GET /raw/ Raw text of a file (whitelisted dirs only)
+
+The ``/api/live/*`` family reads the project the viewer was LAUNCHED in
+(git top-level of the starting cwd), not the episteme checkout — the viewer
+must dashboard whatever governed project the operator is standing in.
"""
from __future__ import annotations
@@ -20,15 +27,40 @@
import html
import json
import mimetypes
+import subprocess
+import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlsplit
+from episteme.viewer import live as _live
+
REPO_ROOT = Path(__file__).resolve().parents[3]
VIEWER_DIR = Path(__file__).resolve().parent
+
+def _project_root() -> Path:
+ """Git top-level of the cwd the viewer was launched from, else the cwd."""
+ cwd = Path.cwd()
+ try:
+ proc = subprocess.run(
+ ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
+ capture_output=True,
+ text=True,
+ timeout=2,
+ )
+ top = proc.stdout.strip()
+ if proc.returncode == 0 and top:
+ return Path(top)
+ except (OSError, subprocess.SubprocessError):
+ pass
+ return cwd
+
+
+PROJECT_ROOT = _project_root()
+
# Whitelist of repo-relative path prefixes that `/raw/` may read.
RAW_PREFIXES = (
"kernel",
@@ -165,6 +197,9 @@ def do_GET(self) -> None:
path = parsed.path
routes: dict[str, Callable[[], Any]] = {
+ "/api/live/global": _live.global_status,
+ "/api/live/project": lambda: _live.project_status(PROJECT_ROOT),
+ "/api/live/advisories": lambda: _live.advisories(PROJECT_ROOT),
"/api/overview": _overview,
"/api/profile": _operator_profile,
"/api/reasoning-surfaces": _find_reasoning_surfaces,
@@ -213,10 +248,31 @@ def do_GET(self) -> None:
self._send_text(404, "not found", "text/plain")
-def serve(host: str = "127.0.0.1", port: int = 37776) -> int:
+def serve(host: str = "127.0.0.1", port: int = 37776, open_browser: bool = True) -> int:
server = ThreadingHTTPServer((host, port), _Handler)
- print(f"episteme viewer: http://{host}:{port}/")
+ loopback = host in ("127.0.0.1", "localhost", "::1")
+ # A non-loopback bind serves the operator's core_question, absolute
+ # home/project paths, and edit activity over UNAUTHENTICATED HTTP —
+ # never do it silently (review finding, E174).
+ if not loopback:
+ print(
+ f"⚠ episteme viewer: binding {host} exposes live governance state "
+ "(reasoning surface, paths, edit activity) to the network WITHOUT "
+ "authentication. Use only on a network you trust."
+ )
+ # Browsers cannot connect to a 0.0.0.0/:: bind address; open loopback.
+ open_host = "127.0.0.1" if host in ("0.0.0.0", "::") else host
+ url = f"http://{open_host}:{port}/"
+ print(f"episteme viewer: {url}")
+ print(f"project: {PROJECT_ROOT}")
print("(Ctrl-C to stop)")
+ if open_browser:
+ # Socket is bound once the server is constructed, so the browser's
+ # first request cannot race the listener.
+ try:
+ webbrowser.open(url)
+ except Exception:
+ pass
try:
server.serve_forever()
except KeyboardInterrupt:
@@ -231,8 +287,9 @@ def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=37776)
+ p.add_argument("--no-open", action="store_true", help="Do not auto-open the browser")
args = p.parse_args()
- return serve(host=args.host, port=args.port)
+ return serve(host=args.host, port=args.port, open_browser=not args.no_open)
if __name__ == "__main__":
diff --git a/tests/test_viewer_live.py b/tests/test_viewer_live.py
new file mode 100644
index 0000000..b184439
--- /dev/null
+++ b/tests/test_viewer_live.py
@@ -0,0 +1,282 @@
+"""Event 174 — viewer revival: live readers, advisory log, HTTP endpoints.
+
+The readers are pure functions over injected roots; the HTTP layer is tested
+against a REAL ThreadingHTTPServer on an ephemeral port (no mocked handlers —
+the regression this guards is the E172 class, where a surface only ever
+worked from one accidental context).
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import subprocess
+import threading
+import unittest
+import urllib.request
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from core.hooks import workflow_guard
+from episteme.viewer import live
+
+
+def _write(root: Path, rel: str, text: str) -> None:
+ path = root / rel
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+
+
+class TailJsonlTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = TemporaryDirectory()
+ self.root = Path(self._tmp.name)
+ self.addCleanup(self._tmp.cleanup)
+
+ def test_missing_file_yields_empty(self):
+ self.assertEqual(live.tail_jsonl(self.root / "nope.jsonl", 10), [])
+
+ def test_tail_returns_last_n_parseable_oldest_first(self):
+ lines = [json.dumps({"i": i}) for i in range(20)] + ["{broken", ""]
+ _write(self.root, "s.jsonl", "\n".join(lines) + "\n")
+ out = live.tail_jsonl(self.root / "s.jsonl", 5)
+ self.assertEqual([o["i"] for o in out], [15, 16, 17, 18, 19])
+
+ def test_large_file_reads_are_byte_bounded(self):
+ # A file far beyond the tail cap must still return the newest lines.
+ big = "\n".join(json.dumps({"i": i, "pad": "x" * 200}) for i in range(5000))
+ _write(self.root, "big.jsonl", big + "\n")
+ out = live.tail_jsonl(self.root / "big.jsonl", 3)
+ self.assertEqual([o["i"] for o in out], [4997, 4998, 4999])
+
+
+class GlobalStatusTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = TemporaryDirectory()
+ self.home = Path(self._tmp.name)
+ self.addCleanup(self._tmp.cleanup)
+
+ def test_empty_home_degrades_to_zeroes(self):
+ g = live.global_status(self.home)
+ self.assertEqual(g["gate_ops_24h"], 0)
+ self.assertEqual(g["spot_check_queue"], 0)
+ self.assertEqual(g["framework"], {"protocols": 0, "deferred_discoveries": 0})
+
+ def test_counts_come_from_state_files_using_the_real_audit_schema(self):
+ # The canonical audit writer emits "status"/"action", NOT
+ # decision/verdict — the first version of this test invented a schema
+ # and the review caught the panel dead against real records. This
+ # test now pins the REAL field names.
+ from datetime import datetime, timezone
+
+ now = datetime.now(timezone.utc).isoformat()
+ _write(
+ self.home,
+ "audit.jsonl",
+ "\n".join(
+ json.dumps({"timestamp": now, "status": s})
+ for s in ["ok", "ok", "incomplete"]
+ )
+ + "\n",
+ )
+ _write(self.home, "framework/protocols.jsonl", '{"a":1}\n{"a":2}\n')
+ _write(self.home, "state/spot_check_queue.jsonl", '{"q":1}\n')
+ _write(self.home, "derived_knobs.json", '{"noise_watch_set": ["status-pressure"]}')
+ g = live.global_status(self.home)
+ self.assertEqual(g["gate_ops_24h"], 3)
+ self.assertEqual(g["gate_verdicts_24h"], {"ok": 2, "incomplete": 1})
+ self.assertEqual(g["framework"]["protocols"], 2)
+ self.assertEqual(g["spot_check_queue"], 1)
+ self.assertEqual(g["noise_watch"], ["status-pressure"])
+
+ def test_real_corpus_verdict_keys_are_not_all_unknown(self):
+ # Guard against schema drift between the audit writer and this
+ # reader: on the operator's real audit.jsonl (when present and
+ # active in the last 24h), the verdict breakdown must contain at
+ # least one canonical key — a panel of only 'unknown' means the
+ # reader's field names diverged from the writer's again.
+ g = live.global_status()
+ if g["gate_ops_24h"] == 0:
+ self.skipTest("no gated ops in the last 24h on this machine")
+ self.assertTrue(
+ set(g["gate_verdicts_24h"]) - {"unknown"},
+ f"all verdicts unknown: {g['gate_verdicts_24h']}",
+ )
+
+
+class ProjectStatusTests(unittest.TestCase):
+ def setUp(self):
+ self._tmp = TemporaryDirectory()
+ self.root = Path(self._tmp.name).resolve()
+ self.addCleanup(self._tmp.cleanup)
+
+ def test_bare_project_degrades(self):
+ p = live.project_status(self.root)
+ self.assertFalse(p["surface"]["exists"])
+ self.assertEqual(p["advisories_recent"], 0)
+
+ def test_naive_surface_timestamp_must_not_raise(self):
+ # Review disconfirmation scenario: a hand-edited surface with a
+ # zone-less timestamp made `now - ts` raise TypeError, 500 the
+ # route, and blank the whole dashboard. _parse_ts now coerces to
+ # UTC; this pins the no-raise contract on untrusted input.
+ _write(
+ self.root,
+ ".episteme/reasoning-surface.json",
+ json.dumps({"timestamp": "2026-01-01T00:00:00", "core_question": "Q"}),
+ )
+ p = live.project_status(self.root)
+ self.assertTrue(p["surface"]["exists"])
+ self.assertFalse(p["surface"]["fresh"]) # months old once coerced
+ self.assertIsNotNone(p["surface"]["age_minutes"])
+
+ def test_surface_and_staleness_render(self):
+ from datetime import datetime, timezone
+
+ _write(
+ self.root,
+ ".episteme/reasoning-surface.json",
+ json.dumps(
+ {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "core_question": "Q?",
+ "posture_selected": "patch",
+ }
+ ),
+ )
+ _write(self.root, "docs/EVENTS.md", "| E20 | d | x | r |\n")
+ _write(
+ self.root,
+ "docs/OLD.md",
+ "\n# old\n",
+ )
+ _write(
+ self.root,
+ "docs/NEW.md",
+ "\n# new\n",
+ )
+ p = live.project_status(self.root)
+ self.assertTrue(p["surface"]["exists"])
+ self.assertTrue(p["surface"]["fresh"])
+ ds = p["doc_staleness"]
+ self.assertEqual(ds["latest_event"], 20)
+ self.assertEqual(ds["living_docs"], 2)
+ self.assertEqual(ds["stale_docs"], 1) # E1 is 19 events behind
+ self.assertEqual(ds["worst_lag_events"], 19)
+
+
+class AdvisoryLogTests(unittest.TestCase):
+ """workflow_guard's JSONL feed: footprint rule, shape, rotation cap."""
+
+ def setUp(self):
+ self._tmp = TemporaryDirectory()
+ self.root = Path(self._tmp.name).resolve()
+ self.addCleanup(self._tmp.cleanup)
+
+ def _log_path(self) -> Path:
+ return self.root / ".episteme" / "state" / "doc_advisories.jsonl"
+
+ def test_no_log_without_episteme_dir(self):
+ workflow_guard._log_advisory(self.root, "src/a.py", ["docs/A.md"])
+ self.assertFalse(self._log_path().exists())
+
+ def test_appends_shape_and_reads_back_via_live(self):
+ (self.root / ".episteme").mkdir()
+ workflow_guard._log_advisory(self.root, "src/a.py", ["docs/A.md", "docs/B.md"])
+ records = live.advisories(self.root)
+ self.assertEqual(len(records), 1)
+ self.assertEqual(records[0]["path"], "src/a.py")
+ self.assertEqual(records[0]["docs"], ["docs/A.md", "docs/B.md"])
+ self.assertEqual(records[0]["doc_count"], 2)
+ self.assertIn("ts", records[0])
+
+ def test_rotation_keeps_newest_lines(self):
+ (self.root / ".episteme").mkdir()
+ with patch.object(workflow_guard, "_ADVISORY_LOG_MAX_BYTES", 2_000), patch.object(
+ workflow_guard, "_ADVISORY_LOG_KEEP_LINES", 5
+ ):
+ for i in range(50):
+ workflow_guard._log_advisory(self.root, f"src/f{i}.py", ["docs/A.md"])
+ lines = self._log_path().read_text(encoding="utf-8").strip().splitlines()
+ self.assertLessEqual(len(lines), 6) # keep-lines + at most one append
+ self.assertIn("f49", lines[-1])
+
+ def test_hook_end_to_end_writes_the_feed(self):
+ # Real main(): a citing doc + a git repo + .episteme -> advisory + log.
+ _write(self.root, "AGENTS.md", "# agents\n")
+ _write(self.root, "src/app.py", "pass\n")
+ _write(self.root, "docs/APP.md", "Entry point: `src/app.py`.\n")
+ (self.root / ".episteme").mkdir()
+ subprocess.run(["git", "-C", str(self.root), "init", "-q"], check=True)
+ subprocess.run(["git", "-C", str(self.root), "add", "-A"], check=True)
+ payload = json.dumps(
+ {
+ "tool_name": "Edit",
+ "tool_input": {"file_path": str(self.root / "src" / "app.py")},
+ "session_type": "main",
+ "cwd": str(self.root),
+ }
+ )
+ with patch("sys.stdin", new=io.StringIO(payload)), patch(
+ "sys.stdout", new=io.StringIO()
+ ):
+ self.assertEqual(workflow_guard.main(), 0)
+ records = live.advisories(self.root)
+ self.assertEqual(len(records), 1)
+ self.assertEqual(records[0]["docs"], ["docs/APP.md"])
+
+
+class HttpEndpointTests(unittest.TestCase):
+ """The real server on an ephemeral port — routes, JSON shape, index.html."""
+
+ @classmethod
+ def setUpClass(cls):
+ from episteme.viewer import server as viewer_server
+ from http.server import ThreadingHTTPServer
+
+ cls._server = ThreadingHTTPServer(("127.0.0.1", 0), viewer_server._Handler)
+ cls._port = cls._server.server_address[1]
+ cls._thread = threading.Thread(target=cls._server.serve_forever, daemon=True)
+ cls._thread.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls._server.shutdown()
+ cls._server.server_close()
+
+ def _get(self, path: str):
+ with urllib.request.urlopen(
+ f"http://127.0.0.1:{self._port}{path}", timeout=5
+ ) as res:
+ return res.status, res.read().decode("utf-8"), res.headers
+
+ def test_index_html_is_served_not_the_missing_stub(self):
+ status, body, _ = self._get("/")
+ self.assertEqual(status, 200)
+ self.assertIn("operator console", body)
+ self.assertNotIn("index.html missing", body)
+
+ def test_live_global_returns_json_shape(self):
+ status, body, headers = self._get("/api/live/global")
+ self.assertEqual(status, 200)
+ self.assertIn("application/json", headers["Content-Type"])
+ payload = json.loads(body)
+ for key in ("gate_ops_24h", "spot_check_queue", "framework", "noise_watch"):
+ self.assertIn(key, payload)
+
+ def test_live_project_returns_json_shape(self):
+ status, body, _ = self._get("/api/live/project")
+ self.assertEqual(status, 200)
+ payload = json.loads(body)
+ for key in ("name", "branch", "surface", "doc_map", "doc_staleness"):
+ self.assertIn(key, payload)
+
+ def test_live_advisories_returns_list(self):
+ status, body, _ = self._get("/api/live/advisories")
+ self.assertEqual(status, 200)
+ self.assertIsInstance(json.loads(body), list)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/xbar/episteme.30s.sh b/tools/xbar/episteme.30s.sh
new file mode 100755
index 0000000..36b8ab4
--- /dev/null
+++ b/tools/xbar/episteme.30s.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# episteme
+# v1.0
+# Menu-bar glance at episteme governance state (E174). Consumes `episteme status --json` for the project it is pointed at; click-through opens the live dashboard (`episteme viewer`).
+# episteme,jq
+#
+# Install: copy (or symlink) into xbar/SwiftBar's plugin folder, e.g.
+# ln -s ~/episteme/tools/xbar/episteme.30s.sh \
+# "$HOME/Library/Application Support/xbar/plugins/episteme.30s.sh"
+# Set EPISTEME_XBAR_PROJECT to the project to watch (default: ~/episteme).
+# The 30s cadence is in the filename, per xbar convention.
+
+set -u
+
+PROJECT="${EPISTEME_XBAR_PROJECT:-$HOME/episteme}"
+VIEWER_URL="http://127.0.0.1:37776/"
+EPISTEME_BIN="$(command -v episteme || true)"
+JQ_BIN="$(command -v jq || true)"
+
+if [ -z "$EPISTEME_BIN" ] || [ -z "$JQ_BIN" ]; then
+ echo "epi ?"
+ echo "---"
+ echo "episteme or jq not on PATH | color=red"
+ exit 0
+fi
+
+STATUS="$(cd "$PROJECT" 2>/dev/null && "$EPISTEME_BIN" status --json 2>/dev/null)"
+if [ -z "$STATUS" ]; then
+ echo "epi ✕"
+ echo "---"
+ echo "episteme status failed in $PROJECT | color=red"
+ exit 0
+fi
+
+FRESH="$(echo "$STATUS" | "$JQ_BIN" -r '.surface.fresh')"
+AGE="$(echo "$STATUS" | "$JQ_BIN" -r '.surface.age_minutes // empty')"
+EXISTS="$(echo "$STATUS" | "$JQ_BIN" -r '.surface.exists')"
+# Strip xbar's directive separator from repo-controlled strings — a branch
+# named `feat|color=red` must not inject menu directives (review nit).
+BRANCH="$(echo "$STATUS" | "$JQ_BIN" -r '.branch' | tr -d '|')"
+PROTOCOLS="$(echo "$STATUS" | "$JQ_BIN" -r '.framework.protocols // 0')"
+DEFERRED="$(echo "$STATUS" | "$JQ_BIN" -r '.framework.deferred_discoveries // 0')"
+RIGOR="$(echo "$STATUS" | "$JQ_BIN" -r '.rigor.level // "?"')"
+
+# Menu-bar line: one glyph carries the surface state.
+if [ "$EXISTS" != "true" ]; then
+ echo "epi ○" # no surface declared
+elif [ "$FRESH" = "true" ]; then
+ echo "epi ●" # fresh
+else
+ echo "epi ◐" # stale
+fi
+
+echo "---"
+echo "project: $(basename "$PROJECT") ($BRANCH)"
+if [ "$EXISTS" != "true" ]; then
+ echo "surface: none declared | color=red"
+elif [ "$FRESH" = "true" ]; then
+ echo "surface: fresh (${AGE}m) | color=green"
+else
+ echo "surface: STALE (${AGE}m) | color=orange"
+fi
+echo "rigor: $RIGOR"
+echo "protocols: $PROTOCOLS · deferred: $DEFERRED"
+echo "---"
+echo "Open dashboard | href=$VIEWER_URL"
+echo "Refresh | refresh=true"