Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions core/hooks/workflow_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand All @@ -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, "")
Expand Down
4 changes: 2 additions & 2 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- episteme-lifecycle: status=living; reviewed_as_of=E173 -->
<!-- episteme-lifecycle: status=living; reviewed_as_of=E174 -->
# episteme — command reference

A one-page map of every `episteme` subcommand, grouped by lifecycle phase.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
9 changes: 7 additions & 2 deletions src/episteme/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
174 changes: 174 additions & 0 deletions src/episteme/viewer/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>episteme · operator console</title>
<style>
:root {
--bg: #0b0e14; --panel: #12161f; --edge: #1e2530;
--text: #d7dde8; --dim: #7a8494; --accent: #5ec2a6;
--warn: #e0b25e; --bad: #e06c75; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (prefers-color-scheme: light) {
:root { --bg:#f6f7f9; --panel:#ffffff; --edge:#e3e7ee; --text:#1c2330; --dim:#6b7482; }
}
* { box-sizing: border-box; margin: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 var(--mono); padding: 24px; }
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
h1 { font-size: 16px; font-weight: 600; letter-spacing: .04em; }
#beat { color: var(--dim); font-size: 12px; }
#beat .dot { color: var(--accent); }
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
section { background: var(--panel); border: 1px solid var(--edge); border-radius: 8px; padding: 16px 18px; }
section h2 { font-size: 11px; font-weight: 600; letter-spacing: .12em; color: var(--dim); text-transform: uppercase; margin-bottom: 12px; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; }
.row .k { color: var(--dim); }
.row .v { text-align: right; overflow-wrap: anywhere; }
.ok { color: var(--accent); } .warn { color: var(--warn); } .bad { color: var(--bad); }
.feed { grid-column: 1 / -1; }
.feed table { width: 100%; border-collapse: collapse; font-size: 13px; }
.feed th { text-align: left; color: var(--dim); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--edge); }
.feed td { padding: 4px 8px; border-bottom: 1px solid var(--edge); vertical-align: top; overflow-wrap: anywhere; }
.feed td.t { white-space: nowrap; color: var(--dim); }
.empty { color: var(--dim); padding: 8px 0; }
.q { color: var(--dim); font-size: 12px; margin-top: 8px; overflow-wrap: anywhere; }
</style>
</head>
<body>
<header>
<h1>episteme · operator console</h1>
<span id="beat"><span class="dot">●</span> <span id="beat-text">connecting…</span></span>
</header>

<div class="grid">
<section>
<h2>Project — <span id="p-name">…</span></h2>
<div class="row"><span class="k">branch</span><span class="v" id="p-branch">…</span></div>
<div class="row"><span class="k">reasoning surface</span><span class="v" id="p-surface">…</span></div>
<div class="row"><span class="k">posture</span><span class="v" id="p-posture">…</span></div>
<div class="row"><span class="k">doc map</span><span class="v" id="p-docmap">…</span></div>
<div class="row"><span class="k">living docs stale</span><span class="v" id="p-stale">…</span></div>
<div class="row"><span class="k">latest event</span><span class="v" id="p-event">…</span></div>
<div class="q" id="p-question"></div>
</section>

<section>
<h2>Global — operator home</h2>
<div class="row"><span class="k">gated ops (24h)</span><span class="v" id="g-ops">…</span></div>
<div class="row"><span class="k">verdicts</span><span class="v" id="g-verdicts">…</span></div>
<div class="row"><span class="k">spot-check pending</span><span class="v" id="g-queue">…</span></div>
<div class="row"><span class="k">protocols</span><span class="v" id="g-protocols">…</span></div>
<div class="row"><span class="k">deferred discoveries</span><span class="v" id="g-deferred">…</span></div>
<div class="row"><span class="k">noise watch</span><span class="v" id="g-noise">…</span></div>
</section>

<section class="feed">
<h2>Doc advisories — docs tracked as code changes</h2>
<div id="feed-empty" class="empty">No advisories logged yet in this project. Edit an implementation file that a doc cites and it will appear here.</div>
<table id="feed-table" hidden>
<thead><tr><th>when</th><th>edited path</th><th>docs claiming to describe it</th></tr></thead>
<tbody id="feed-body"></tbody>
</table>
</section>
</div>

<script>
const POLL_MS = 3000;
const $ = (id) => document.getElementById(id);

function fmtAge(mins) {
if (mins == null) return "—";
return mins < 90 ? `${Math.round(mins)}m` : `${(mins / 60).toFixed(1)}h`;
}
function since(ts) {
const d = (Date.now() - Date.parse(ts)) / 1000;
if (!isFinite(d)) return "—";
if (d < 90) return `${Math.round(d)}s ago`;
if (d < 5400) return `${Math.round(d / 60)}m ago`;
return `${(d / 3600).toFixed(1)}h ago`;
}

async function getJSON(path) {
const res = await fetch(path, { cache: "no-store" });
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json();
}

function renderProject(p) {
$("p-name").textContent = p.name ?? "—";
$("p-branch").textContent = p.branch ?? "—";
const s = p.surface || {};
const el = $("p-surface");
if (!s.exists) { el.textContent = "none declared"; el.className = "v bad"; }
else if (s.fresh) { el.textContent = `fresh (${fmtAge(s.age_minutes)})`; el.className = "v ok"; }
else { el.textContent = `STALE (${fmtAge(s.age_minutes)})`; el.className = "v warn"; }
$("p-posture").textContent = s.posture ?? "—";
const dm = p.doc_map || {};
$("p-docmap").textContent = dm.targets != null ? `${dm.targets} targets · ${dm.edges} edges` : "unavailable";
const ds = p.doc_staleness || {};
const stale = $("p-stale");
stale.textContent = ds.living_docs != null ? `${ds.stale_docs}/${ds.living_docs} (worst ${ds.worst_lag_events} events)` : "—";
stale.className = "v " + (ds.stale_docs > 0 ? "warn" : "ok");
$("p-event").textContent = ds.latest_event != null ? `E${ds.latest_event}` : "—";
$("p-question").textContent = s.core_question ? `Q: ${s.core_question}` : "";
}

function renderGlobal(g) {
$("g-ops").textContent = g.gate_ops_24h ?? "—";
const v = g.gate_verdicts_24h || {};
$("g-verdicts").textContent = Object.keys(v).length
? Object.entries(v).map(([k, n]) => `${k}:${n}`).join(" · ") : "—";
$("g-queue").textContent = g.spot_check_pending != null
? g.spot_check_pending
: (g.spot_check_queue != null ? `${g.spot_check_queue} lines (raw)` : "—");
$("g-protocols").textContent = g.framework?.protocols ?? "—";
$("g-deferred").textContent = g.framework?.deferred_discoveries ?? "—";
$("g-noise").textContent = Array.isArray(g.noise_watch) && g.noise_watch.length
? g.noise_watch.join(", ") : "—";
}

function renderFeed(items) {
const body = $("feed-body");
const table = $("feed-table");
const empty = $("feed-empty");
if (!items.length) { table.hidden = true; empty.hidden = false; return; }
table.hidden = false; empty.hidden = true;
body.replaceChildren(...items.slice().reverse().map((a) => {
const tr = document.createElement("tr");
const t = document.createElement("td"); t.className = "t"; t.textContent = since(a.ts);
const p = document.createElement("td"); p.textContent = a.path ?? "—";
const d = document.createElement("td");
const docs = a.docs || [];
d.textContent = docs.join(", ") + (a.doc_count > docs.length ? ` … +${a.doc_count - docs.length}` : "");
tr.append(t, p, d);
return tr;
}));
}

async function tick() {
// allSettled: one failing endpoint must degrade its own panel, never
// blank the other two (review finding, E174).
const [g, p, feed] = await Promise.allSettled([
getJSON("/api/live/global"),
getJSON("/api/live/project"),
getJSON("/api/live/advisories"),
]);
try {
if (g.status === "fulfilled") renderGlobal(g.value);
if (p.status === "fulfilled") renderProject(p.value);
if (feed.status === "fulfilled") renderFeed(feed.value);
const failed = [g, p, feed].filter((r) => r.status === "rejected").length;
$("beat-text").textContent = failed
? `partial (${3 - failed}/3 panels) · ${new Date().toLocaleTimeString()}`
: `live · ${new Date().toLocaleTimeString()}`;
} catch (err) {
$("beat-text").textContent = `render error — ${err.message}`;
} finally {
setTimeout(tick, POLL_MS);
}
}
tick();
</script>
</body>
</html>
Loading
Loading