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.
+ + + + +
+
+ + + + 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"