diff --git a/cmd/engineering-dashboard/main.go b/cmd/engineering-dashboard/main.go
new file mode 100644
index 00000000..43edb2a3
--- /dev/null
+++ b/cmd/engineering-dashboard/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "chat-agent-system/internal/engineeringdashboard"
+)
+
+func main() {
+ if len(os.Args) < 2 {
+ usage()
+ os.Exit(2)
+ }
+ cmd := os.Args[1]
+ args := os.Args[2:]
+ switch cmd {
+ case "serve":
+ if err := runServe(args); err != nil {
+ fmt.Fprintf(os.Stderr, "serve: %v\n", err)
+ os.Exit(1)
+ }
+ case "export":
+ if err := runExport(args); err != nil {
+ fmt.Fprintf(os.Stderr, "export: %v\n", err)
+ os.Exit(1)
+ }
+ case "help", "-h", "--help":
+ usage()
+ default:
+ fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd)
+ usage()
+ os.Exit(2)
+ }
+}
+
+func usage() {
+ fmt.Fprintf(os.Stderr, `engineering-dashboard — local read-only personal engineering control center
+
+Usage:
+ engineering-dashboard serve [--repo PATH] [--addr 127.0.0.1:PORT]
+ engineering-dashboard export [--repo PATH] [--output FILE]
+
+Does not write docs, operate git/docker, deploy, or control Herdr.
+`)
+}
+
+func parseFlags(args []string) (repo, addr, output string) {
+ addr = "127.0.0.1:8787"
+ output = "engineering-dashboard-snapshot.html"
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--repo":
+ i++
+ if i < len(args) {
+ repo = args[i]
+ }
+ case "--addr":
+ i++
+ if i < len(args) {
+ addr = args[i]
+ }
+ case "--output":
+ i++
+ if i < len(args) {
+ output = args[i]
+ }
+ }
+ }
+ return repo, addr, output
+}
+
+func runServe(args []string) error {
+ repo, addr, _ := parseFlags(args)
+ root, err := engineeringdashboard.ResolveRepoRoot(repo)
+ if err != nil {
+ return err
+ }
+ if !strings.HasPrefix(addr, "127.0.0.1:") && !strings.HasPrefix(addr, "localhost:") && !strings.HasPrefix(addr, "[::1]:") {
+ return fmt.Errorf("addr must be loopback (got %s)", addr)
+ }
+ c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{})
+ ctx := context.Background()
+ if _, err := c.Refresh(ctx); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: initial refresh: %v\n", err)
+ }
+ srv, err := engineeringdashboard.NewServer(c)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token)
+ fmt.Printf("repo=%s\n", root)
+ return engineeringdashboard.ListenAndServe(addr, srv.Handler())
+}
+
+func runExport(args []string) error {
+ repo, _, output := parseFlags(args)
+ root, err := engineeringdashboard.ResolveRepoRoot(repo)
+ if err != nil {
+ return err
+ }
+ if !filepath.IsAbs(output) {
+ wd, _ := os.Getwd()
+ output = filepath.Join(wd, output)
+ }
+ c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{})
+ snap, err := c.Refresh(context.Background())
+ if err != nil {
+ return err
+ }
+ return engineeringdashboard.ExportHTML(*snap, output)
+}
diff --git a/internal/engineeringdashboard/assets/app.css b/internal/engineeringdashboard/assets/app.css
new file mode 100644
index 00000000..36febf7d
--- /dev/null
+++ b/internal/engineeringdashboard/assets/app.css
@@ -0,0 +1,95 @@
+:root {
+ --bg: #0f1419;
+ --panel: #1a222c;
+ --text: #e7ecf1;
+ --muted: #9aa7b5;
+ --accent: #3d8bfd;
+ --warn: #d4a017;
+ --bad: #d64545;
+ --ok: #3d9a6a;
+ --border: #2b3642;
+ --font: "IBM Plex Sans", "Noto Sans SC", sans-serif;
+ --mono: "IBM Plex Mono", ui-monospace, monospace;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ font-family: var(--font);
+ background: radial-gradient(1200px 600px at 10% -10%, #1b2735, var(--bg));
+ color: var(--text);
+ min-height: 100vh;
+}
+.skip {
+ position: absolute; left: -9999px;
+}
+.skip:focus { left: 1rem; top: 1rem; background: #fff; color: #000; padding: .5rem; z-index: 9; }
+.top {
+ display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem;
+ padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
+ background: rgba(26,34,44,.85); backdrop-filter: blur(6px);
+}
+.top h1 { margin: 0; font-size: 1.35rem; letter-spacing: .02em; }
+.meta { margin: .35rem 0 0; color: var(--muted); font-size: .85rem; font-family: var(--mono); }
+.banner { margin: 0; color: var(--warn); font-size: .85rem; max-width: 28rem; }
+.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: calc(100vh - 5rem); }
+.nav-toggle { display: none; margin: .75rem; }
+nav {
+ border-right: 1px solid var(--border); padding: 1rem; background: var(--panel);
+}
+nav ul { list-style: none; margin: 0; padding: 0; }
+nav li { margin: 0 0 .35rem; }
+.nav-btn {
+ width: 100%; text-align: left; background: transparent; color: var(--text);
+ border: 1px solid transparent; padding: .5rem .65rem; border-radius: .35rem; cursor: pointer;
+}
+.nav-btn.active, .nav-btn:hover, .nav-btn:focus-visible {
+ border-color: var(--accent); background: #243041;
+}
+.search-label { display: block; margin-top: 1rem; font-size: .8rem; color: var(--muted); }
+#q {
+ width: 100%; margin-top: .35rem; padding: .45rem .55rem; border-radius: .35rem;
+ border: 1px solid var(--border); background: #10161d; color: var(--text);
+}
+.source-strip { margin-top: 1rem; font-size: .75rem; color: var(--muted); }
+.source-strip .chip {
+ display: inline-block; margin: .15rem .2rem 0 0; padding: .15rem .4rem;
+ border: 1px solid var(--border); border-radius: .25rem;
+}
+.chip.ok { border-color: var(--ok); }
+.chip.stale, .chip.degraded { border-color: var(--warn); color: var(--warn); }
+.chip.error { border-color: var(--bad); color: var(--bad); }
+main { padding: 1rem 1.25rem 2rem; overflow: auto; }
+.view-status { min-height: 1.25rem; color: var(--muted); font-size: .85rem; margin-bottom: .75rem; }
+.panel {
+ background: rgba(26,34,44,.7); border: 1px solid var(--border); border-radius: .5rem;
+ padding: 1rem; margin-bottom: 1rem;
+}
+.panel h2 { margin: 0 0 .75rem; font-size: 1.05rem; }
+.table-wrap { overflow-x: auto; }
+table { width: 100%; border-collapse: collapse; font-size: .9rem; }
+th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; }
+th { color: var(--muted); font-weight: 600; }
+.badge {
+ display: inline-block; padding: .1rem .4rem; border-radius: .25rem;
+ border: 1px solid var(--border); font-size: .75rem; margin-right: .25rem;
+}
+.badge.warn { border-color: var(--warn); color: var(--warn); }
+.badge.bad { border-color: var(--bad); color: var(--bad); }
+.pre {
+ white-space: pre-wrap; word-break: break-word; font-family: var(--mono);
+ font-size: .82rem; background: #0c1117; padding: .75rem; border-radius: .35rem;
+ border: 1px solid var(--border); max-height: 28rem; overflow: auto;
+}
+.axes { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .5rem; }
+.axis { border: 1px solid var(--border); border-radius: .35rem; padding: .5rem; font-size: .8rem; }
+.axis strong { display: block; color: var(--muted); margin-bottom: .25rem; }
+a, button.linkish {
+ color: var(--accent); background: none; border: none; cursor: pointer; padding: 0; font: inherit;
+}
+@media (max-width: 860px) {
+ .layout { grid-template-columns: 1fr; }
+ .nav-toggle { display: inline-block; }
+ nav { display: none; border-right: 0; border-bottom: 1px solid var(--border); }
+ nav.open { display: block; }
+ .axes { grid-template-columns: 1fr 1fr; }
+}
diff --git a/internal/engineeringdashboard/assets/app.js b/internal/engineeringdashboard/assets/app.js
new file mode 100644
index 00000000..eecb4d03
--- /dev/null
+++ b/internal/engineeringdashboard/assets/app.js
@@ -0,0 +1,300 @@
+(function () {
+ const token = window.__DASHBOARD_TOKEN__;
+ const state = {
+ snap: null,
+ view: "overview",
+ q: "",
+ selectedDoc: "",
+ scrollY: 0,
+ };
+
+ const el = {
+ content: document.getElementById("content"),
+ meta: document.getElementById("repo-meta"),
+ sources: document.getElementById("source-strip"),
+ status: document.getElementById("view-status"),
+ q: document.getElementById("q"),
+ navToggle: document.getElementById("nav-toggle"),
+ nav: document.getElementById("nav"),
+ };
+
+ function qs() {
+ const u = new URL(window.location.href);
+ return u.searchParams;
+ }
+
+ function syncURL() {
+ const u = new URL(window.location.href);
+ u.searchParams.set("token", token);
+ u.searchParams.set("view", state.view);
+ if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q");
+ if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc");
+ history.replaceState(null, "", u.toString());
+ }
+
+ function loadURL() {
+ const p = qs();
+ state.view = p.get("view") || "overview";
+ state.q = p.get("q") || "";
+ state.selectedDoc = p.get("doc") || "";
+ el.q.value = state.q;
+ document.querySelectorAll(".nav-btn").forEach((b) => {
+ b.classList.toggle("active", b.dataset.view === state.view);
+ });
+ }
+
+ async function fetchSnapshot(force) {
+ el.status.textContent = state.snap ? "刷新中…" : "首次加载中…";
+ const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : "");
+ const headers = {};
+ if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"';
+ const res = await fetch(url, { headers, credentials: "same-origin" });
+ if (res.status === 304) {
+ el.status.textContent = "已是最新 revision " + state.snap.revision_id;
+ return false;
+ }
+ if (!res.ok) {
+ el.status.textContent = "加载失败 HTTP " + res.status;
+ if (!state.snap) renderHardError("无法获取快照");
+ return false;
+ }
+ state.snap = await res.json();
+ el.status.textContent = "generation " + state.snap.generation_id + " · revision " + state.snap.revision_id;
+ return true;
+ }
+
+ function escapeText(s) {
+ const d = document.createElement("div");
+ d.textContent = s == null ? "" : String(s);
+ return d.innerHTML;
+ }
+
+ function badge(text, cls) {
+ return '' + escapeText(text) + "";
+ }
+
+ function renderMeta() {
+ const s = state.snap;
+ if (!s) return;
+ el.meta.textContent = [
+ s.repository.root,
+ "branch=" + s.repository.branch,
+ "HEAD=" + (s.repository.head || "").slice(0, 12),
+ s.repository.dirty ? "dirty" : "clean",
+ "at " + s.generated_at,
+ ].join(" · ");
+ el.sources.innerHTML = (s.sources || []).map((src) => {
+ const cls = src.freshness === "stale" || src.status === "degraded" ? "stale" :
+ src.status === "error" ? "error" : "ok";
+ return '' + escapeText(src.name + ":" + src.status + "/" + src.freshness) + "";
+ }).join("");
+ }
+
+ function matchQuery(text) {
+ if (!state.q) return true;
+ return String(text || "").toLowerCase().includes(state.q.toLowerCase());
+ }
+
+ function renderOverview() {
+ const s = state.snap;
+ const tasks = (s.tasks || []).filter((t) => ["planned", "in_progress", "blocked"].includes(t.doc_status) || matchQuery(t.title + t.path));
+ const conflicts = s.conflicts || [];
+ let html = ' 证据链焦点任务
';
+ tasks.slice(0, 40).forEach((t) => {
+ html += "任务 计划 Git 验证 实装 ";
+ });
+ html += "" + escapeText(t.title) + " ";
+ html += "" + badge(t.doc_status) + (t.claim && t.claim.claimed ? badge("claimed") : badge("unclaimed", "warn")) + " ";
+ html += "" + escapeText((t.git && t.git.branch) || "—") + " " + escapeText((t.git && t.git.cleanliness) || "") + " ";
+ html += "" + escapeText((t.verification && t.verification.result) || "unknown") + " ";
+ const dep = t.deployment || {};
+ html += "" + escapeText(Object.keys(dep).map((k) => k + ":" + (dep[k].app_readiness || "unknown")).join(" ")) + " 漂移 / 冲突
';
+ if (!conflicts.length) html += "";
+ else {
+ html += "" + conflicts.map((c) => "
";
+ }
+ html += "模块
';
+ rows.forEach((m) => {
+ html += "模块 状态 mutex ";
+ });
+ html += "" + escapeText(m.name) + " " + escapeText(m.status) + " " + escapeText(m.mutex) + " ' + escapeText(t.title) + "
";
+ html += '
" + escapeText((t.claim && t.claim.owner) || "unassigned") + " · " + escapeText((t.claim && t.claim.agent_kind) || "") + " · activity=" + escapeText((t.claim && t.claim.activity) || "unknown") + "" + t.evidence_chain.map((p) => '").join("") + "
";
+ }
+ html += "
无匹配任务
"; + } + + function renderGit() { + const rows = (state.snap.git || []).filter((g) => matchQuery(g.name + (g.worktree || ""))); + let html = '| 分支 | ahead/behind | worktree | 备注 |
|---|---|---|---|
| " + escapeText(g.name) + (g.is_current ? " *" : "") + " | " + escapeText(g.ahead + "/" + g.behind) + " | " + escapeText(g.worktree || "") + " | " + escapeText(g.remote_note || "") + " |
" + badge("container:" + (e.facets && e.facets.container_status)) + badge("ready:" + (e.facets && e.facets.app_readiness)) + badge("services:" + (e.facets && e.facets.services_ok), e.facets && e.facets.services_ok === "partial" ? "warn" : "") + "
"; + if (e.drift_notes && e.drift_notes.length) { + html += "| 容器 | 状态 | health | image |
|---|---|---|---|
| " + escapeText(c.name) + " | " + escapeText(c.status) + " | " + escapeText(c.health) + " | " + escapeText(c.image) + " |
| 标题 | 路径 | 状态 |
|---|---|---|
| " + escapeText(d.path) + " | " + escapeText(d.status || "") + " |
未找到文档
"; + else { + html += ""; + html += '' + escapeText(msg) + "
无可用缓存快照。