Logbook lives in Harbor and keeps itself current

The board's Logbook view replaces the published page: statistics, the chart
of the executable, where the code stands (with the function tiers), the
trend, milestones and the ship's log, stored as JSON in the Harbor home.
A server thread follows main: each new commit becomes a log entry and the
statistics, chart and trend point are rebuilt with the open-seas progress
tools on a scratch copy. 'harbor logbook add|stats|map|milestone|refresh'.
This commit is contained in:
KeelrLeek committed 2026-10-05 16:30:21 -05:00
1 parent 87631bec52
commit b7e09cfbd1
7 files changed
+497 -6

No files matched your search

+35
View File
@@ -262,6 +262,13 @@ def build_parser():
p.add_argument("--max-bytes", type=int, dest="max_bytes")
p.add_argument("--count", type=int, default=16)
p.add_argument("--as", dest="as_type", default="hex", choices=["hex", "int32", "uint32", "float", "double", "string"])
p = sub.add_parser("logbook", help="the project Logbook: add a log entry, set statistics, the chart or a milestone")
p.add_argument("action", choices=["add", "stats", "map", "milestone", "show", "import", "refresh"])
p.add_argument("--title")
p.add_argument("--kind", default="note")
p.add_argument("--detail", help="the entry text (or --file)")
p.add_argument("--file", help="a text file (add) or a JSON file (stats, map, milestone) or an export folder (import)")
p.add_argument("--no-history", action="store_true", help="stats: do not add a trend point")
p = sub.add_parser("next-functions", help="functions in your bands to decompile next, easiest first")
p.add_argument("-n", type=int, default=10)
p = sub.add_parser("defer", help="set a function aside with a reason and evidence")
@@ -718,6 +725,34 @@ def main(argv=None):
else:
res = r.lookup(args.target)
out(res, json.dumps(res, indent=1))
elif cmd == "logbook":
from . import logbook
project = harbor.project
if args.action in ("add", "stats", "map", "milestone", "import", "refresh") and who() not in harbor.coordinators:
raise HarborError("the Logbook is written by a coordinator")
text = Path(args.file).read_text(encoding="utf-8") if args.file and args.action != "import" else None
if args.action == "add":
res = logbook.add_entry(project, args.title, args.detail or text, kind=args.kind)
out(res, f"logged {res['id']}: {res['title']}")
elif args.action == "stats":
res = logbook.set_stats(project, json.loads(text), history_point=not args.no_history)
out(res, "statistics updated")
elif args.action == "map":
res = logbook.set_map(project, json.loads(text))
out(res, f"chart updated ({res['cells']} cells)")
elif args.action == "milestone":
res = logbook.upsert_milestone(project, json.loads(text))
out(res, f"milestone {res['id']} saved")
elif args.action == "refresh":
from . import logbook_auto
res = logbook_auto.refresh(project, force=True)
out(res, json.dumps(res))
elif args.action == "import":
res = logbook.import_export(project, args.file)
out(res, json.dumps(res))
else:
res = logbook.read_all(project, log_limit=10)
out(res, "\n".join(f"{e['at'][:16]} [{e['kind']}] {e['title']}" for e in res["log"]))
elif cmd in ("next-functions", "defer", "progress"):
from .queue import FunctionQueue
q = FunctionQueue(harbor)
+130
View File
@@ -0,0 +1,130 @@
"""The project's Logbook, kept by Harbor and shown on the board's Logbook view: headline statistics, the chart of the
executable, the trend, milestones and the ship's log. (It replaced a separately published page on 2026-10-05.)
One JSON file per collection in the project's logbook folder ([logbook] dir, default <harbor home>/logbook/<project>):
stats.json the latest statistics (functions, bytes, by_category, percentages, commit, updated_at)
map.json the chart of the executable (low, high, cells: "game,engine,platform,crt,unsorted,done" per cell)
history.json [{at, classified, decompiled, weighted, verified}] one point per statistics report
milestones.json [{id, code, title, phase, state, order, items: [{text, done}]}]
log.json [{id, at, kind, title, detail}] the ship's log, newest last
Writes replace the file atomically. Nothing here talks to the network."""
import datetime
import json
import os
import threading
from pathlib import Path
from . import projects
from .util import HarborError
KINDS = ("milestone", "tooling", "progress", "decision", "finding", "naming", "review", "setback", "note")
_LOCK = threading.Lock()
def folder(project):
configured = (getattr(project, "logbook", None) or {}).get("dir")
path = Path(configured) if configured else projects.harbor_home() / "logbook" / project.name
path.mkdir(parents=True, exist_ok=True)
return path
def _read(project, name, default):
path = folder(project) / name
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except ValueError:
raise HarborError(f"{path} is not valid JSON") from None
def _write(project, name, value):
path = folder(project) / name
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(value, indent=1, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, path)
def now():
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def read_all(project, log_limit=80):
log = _read(project, "log.json", [])
return {"stats": _read(project, "stats.json", None), "map": _read(project, "map.json", None),
"history": sorted(_read(project, "history.json", []), key=lambda p: p.get("at", "")),
"milestones": sorted(_read(project, "milestones.json", []), key=lambda m: m.get("order", 0)),
"log": sorted(log, key=lambda e: e.get("at", ""), reverse=True)[:log_limit], "log_total": len(log),
"auto": _read(project, "auto.json", {})}
def add_entry(project, title, detail, kind="note", at=None):
title, detail = (title or "").strip(), (detail or "").strip()
if not title or not detail:
raise HarborError("a log entry needs a title and a detail")
if kind not in KINDS:
raise HarborError("kind must be one of " + ", ".join(KINDS))
with _LOCK:
log = _read(project, "log.json", [])
at = at or now()
stem = at[:16].replace("-", "").replace(":", "").replace("T", "-")
n = 1 + sum(1 for e in log if str(e.get("id", "")).startswith(stem))
entry = {"id": f"{stem}-{n}", "at": at, "kind": kind, "title": title, "detail": detail}
log.append(entry)
_write(project, "log.json", log)
return entry
def set_stats(project, stats, history_point=True):
"""Replace the statistics; also record a trend point (classified, decompiled, weighted, verified)."""
if not isinstance(stats, dict) or "functions" not in stats:
raise HarborError("statistics need at least 'functions'")
stats = {**stats, "updated_at": stats.get("updated_at") or now()}
with _LOCK:
_write(project, "stats.json", stats)
if history_point:
history = _read(project, "history.json", [])
history.append({"at": stats["updated_at"], "classified": stats.get("classified_percent", 0),
"decompiled": stats.get("game_bytes_decompiled_percent", 0),
"weighted": stats.get("weighted_percent", 0),
"verified": stats.get("game_bytes_verified_percent", 0)})
_write(project, "history.json", history)
return stats
def set_map(project, chart):
if not isinstance(chart, dict) or "cells" not in chart:
raise HarborError("the chart needs 'cells'")
with _LOCK:
_write(project, "map.json", chart)
return {"cells": len(chart["cells"])}
def upsert_milestone(project, milestone):
if not isinstance(milestone, dict) or not milestone.get("id"):
raise HarborError("a milestone needs an id")
with _LOCK:
milestones = [m for m in _read(project, "milestones.json", []) if m.get("id") != milestone["id"]]
milestones.append(milestone)
_write(project, "milestones.json", sorted(milestones, key=lambda m: m.get("order", 0)))
return milestone
def import_export(project, export_dir):
"""One-time import of the published page's database export (one JSON file per document)."""
export = Path(export_dir)
def docs(collection):
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted((export / collection).glob("*.json"))]
with _LOCK:
log = [{**d, "id": d.get("id") or p.stem} for d, p in zip(docs("log"), sorted((export / "log").glob("*.json")))]
_write(project, "log.json", sorted(log, key=lambda e: e.get("at", "")))
_write(project, "history.json", sorted(docs("history"), key=lambda e: e.get("at", "")))
_write(project, "milestones.json", sorted(docs("milestones"), key=lambda m: m.get("order", 0)))
if (export / "stats" / "current.json").exists():
_write(project, "stats.json", json.loads((export / "stats" / "current.json").read_text(encoding="utf-8")))
if (export / "stats" / "map.json").exists():
_write(project, "map.json", json.loads((export / "stats" / "map.json").read_text(encoding="utf-8")))
data = read_all(project, log_limit=0)
return {"log": data["log_total"], "history": len(data["history"]), "milestones": len(data["milestones"]),
"stats": bool(data["stats"]), "map": bool(data["map"])}
+144
View File
@@ -0,0 +1,144 @@
"""Keeps the Logbook current without anyone writing to it: when the decomp repository's main branch moves,
1. each new commit on main becomes a ship's log entry (its subject and the first lines of its message), and
2. the statistics, the chart of the executable and a trend point are rebuilt with the open-seas progress tools,
run on a scratch copy of that repository (the real one stays untouched).
Configured per project in a [logbook] table:
auto = true
decomp = 'R:/.../open-seas-decomp' the repository whose main branch is followed
stats_repo = 'R:/.../stats-work/repo' a scratch copy of open-seas holding tools/progress/*.py
interval_minutes = 10 how often the server looks (default 10)
Milestones and narrative entries stay with people: they need judgement.
State (the last commit seen) is kept in the logbook folder as auto.json."""
import json
import re
import subprocess
import sys
import tempfile
import time
import traceback
from pathlib import Path
from . import logbook
MAX_ENTRIES = 25
def _git(repo, *args):
result = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=60)
if result.returncode != 0:
raise RuntimeError(f"git {' '.join(args)}: {result.stderr.strip()[:300]}")
return result.stdout
def _state(project):
path = logbook.folder(project) / "auto.json"
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
def _save_state(project, state):
path = logbook.folder(project) / "auto.json"
path.write_text(json.dumps(state, indent=1), encoding="utf-8")
def _utc(iso):
"""git's %cI (local time with an offset) as UTC, the Logbook's format."""
import datetime
return datetime.datetime.fromisoformat(iso).astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _kind(files):
if files and all(f.startswith("annotations/") for f in files):
return "naming"
if any(f.startswith("src/") for f in files):
return "progress"
return "note"
def log_commits(project, repo, since, head):
"""One ship's log entry per commit on main after `since` (oldest first); a summary when there are many."""
raw = _git(repo, "log", "--reverse", "--no-merges", "--format=%H%x1f%cI%x1f%s%x1f%b%x1e", f"{since}..{head}")
commits = [c.strip().split("\x1f") for c in raw.split("\x1e") if c.strip()]
added = []
if len(commits) > MAX_ENTRIES:
subjects = "\n".join(f"- {c[2]} ({c[0][:8]})" for c in commits[-40:])
added.append(logbook.add_entry(project, f"{len(commits)} commits landed on main",
subjects, kind="progress", at=_utc(commits[-1][1])))
return added
for sha, when, subject, body in commits:
files = [f for f in _git(repo, "show", "--name-only", "--format=", sha).splitlines() if f.strip()]
lines = [l for l in body.strip().splitlines() if l.strip() and not l.lower().startswith("co-authored-by")]
detail = "\n".join(lines[:8]) if lines else subject
detail += f"\n(main {sha[:8]}, {len(files)} file{'s' if len(files) != 1 else ''})"
added.append(logbook.add_entry(project, subject, detail, kind=_kind(files), at=_utc(when)))
return added
def rebuild_stats(project, repo, stats_repo, head):
"""Run the open-seas progress tools on the scratch copy and store their statistics and chart."""
tools = Path(stats_repo) / "tools" / "progress"
run = lambda *argv: subprocess.run([sys.executable, "-B", *map(str, argv)], cwd=str(stats_repo),
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900)
sync = run(tools / "sync_decomp.py", "--decomp", repo)
if sync.returncode != 0:
raise RuntimeError("sync_decomp: " + (sync.stdout + sync.stderr).strip()[-400:])
report = run(tools / "progress.py")
if report.returncode != 0:
raise RuntimeError("progress: " + (report.stdout + report.stderr).strip()[-400:])
with tempfile.TemporaryDirectory() as out:
payload = run(tools / "dashboard_payload.py", out)
if payload.returncode != 0:
raise RuntimeError("dashboard_payload: " + (payload.stdout + payload.stderr).strip()[-400:])
stats = json.loads((Path(out) / "stats_current.json").read_text(encoding="utf-8"))
chart = json.loads((Path(out) / "stats_map.json").read_text(encoding="utf-8"))
stats["commit"] = head[:7]
stats["updated_at"] = logbook.now()
logbook.set_stats(project, stats)
logbook.set_map(project, chart)
return {"classified": stats.get("classified_percent"), "weighted": stats.get("weighted_percent")}
def refresh(project, force=False):
"""Bring the Logbook up to main. Returns what changed."""
config = getattr(project, "logbook", None) or {}
repo, stats_repo = config.get("decomp"), config.get("stats_repo")
if not repo:
return {"changed": False, "reason": "no [logbook] decomp"}
head = _git(repo, "rev-parse", "HEAD").strip()
state = _state(project)
if state.get("last_head") == head and not force:
return {"changed": False, "head": head[:8]}
out = {"changed": True, "head": head[:8]}
if state.get("last_head"):
try:
out["entries"] = len(log_commits(project, repo, state["last_head"], head))
except RuntimeError as error: # e.g. the old commit vanished after a rewrite
out["log_error"] = str(error)
if stats_repo:
try:
out["stats"] = rebuild_stats(project, repo, stats_repo, head)
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as error:
out["stats_error"] = str(error)
_save_state(project, {**state, "last_head": head, "last_refresh": logbook.now(),
**({"last_error": out.get("stats_error") or out.get("log_error")}
if out.get("stats_error") or out.get("log_error") else {"last_error": None})})
return out
def loop(registry):
"""Server thread: refresh every project with [logbook] auto = true."""
while True:
wait = 600
for name in registry.names():
try:
h, _ = registry.get(name)
config = getattr(h.project, "logbook", None) or {}
if config.get("auto"):
wait = min(wait, int(config.get("interval_minutes", 10)) * 60)
refresh(h.project)
except Exception:
traceback.print_exc()
time.sleep(max(60, wait))
+2
View File
@@ -94,6 +94,7 @@ class Project:
policy: dict = field(default_factory=dict)
autopilot: bool = False # running agents keep working instead of ending their turns (hooks)
shared_files: tuple = () # glob patterns (repo-relative) every agent edits and the integrator merges: not collisions
logbook: dict = field(default_factory=dict) # [logbook]: dir, auto, decomp, stats_repo (harbor.logbook_auto)
reverse: dict = field(default_factory=dict) # [reverse]: image, base, facts, imports, annotations, src (harbor.reverse)
@property
@@ -209,6 +210,7 @@ def project_from_dict(data, source=None, home=None):
templates=_templates(data.get("templates", {}), names), policy=_policy(data.get("policy"), names),
autopilot=bool(data.get("autopilot", False)),
shared_files=tuple(str(p).replace("\\", "/") for p in data.get("shared_files", [])),
logbook=dict(data.get("logbook", {})),
reverse=dict(data.get("reverse", {})))
+5
View File
@@ -330,6 +330,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
"stopped": any(h.stopped(a) for a in h.project.ai_names)})
return self._reply(200, out)
h, lock = REGISTRY.get(name)
if path == "/api/logbook":
from . import logbook
return self._reply(200, logbook.read_all(h.project, log_limit=min(int(query.get("limit", 80)), 500)))
if path.startswith("/api/decomp/"):
# its own connection and lock: these answers take seconds and must not hold up the chat
from . import decomp_api
@@ -548,6 +551,8 @@ def main(argv=None):
threading.Thread(target=backup_loop, daemon=True).start()
threading.Thread(target=git_loop, daemon=True).start()
threading.Thread(target=toast_loop, daemon=True).start()
from . import logbook_auto
threading.Thread(target=logbook_auto.loop, args=(REGISTRY,), daemon=True).start()
print(f"Harbor on http://127.0.0.1:{PORT} - projects: {', '.join(REGISTRY.names())}", flush=True)
server.serve_forever()
+119 -6
View File
@@ -55,6 +55,37 @@ body { overflow: hidden; }
.content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 32px 56px; }
.view { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
.view.on { display: grid; }
.lb { --lb-game: var(--blue); --lb-engine: var(--violet); --lb-platform: var(--amber); --lb-crt: var(--muted); --lb-unsorted: var(--line-2); --lb-done: var(--green); }
.lb-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; }
.lb-head .chip { display: inline-block; font: 600 11.5px/1 var(--font, inherit); letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); background: var(--accent-soft); border-radius: 999px; padding: 6px 10px; }
.lb-head p { margin: 8px 0 0; color: var(--muted); max-width: 70ch; }
.lb-stamp { font: 12px/1.5 var(--mono); color: var(--muted); text-align: right; } .lb-stamp b { color: var(--text); font-weight: 600; }
.lb-figs { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
.lb-fig { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-2, 12px); padding: 14px 16px; }
.lb-fig .v { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; } .lb-fig .l { color: var(--muted); font-size: 13px; margin-top: 4px; } .lb-fig .n { color: var(--faint); font: 11.5px/1.35 var(--mono); margin-top: 6px; }
canvas#lbMap { width: 100%; height: 150px; display: block; }
.lb-axis { display: flex; justify-content: space-between; font: 11.5px var(--mono); color: var(--faint); margin-top: 6px; }
.lb-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; font-size: 12.5px; color: var(--muted); }
.lb-sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
.lb-stack { display: flex; height: 24px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; margin-top: 10px; }
.lb-stack i { display: block; height: 100%; min-width: 2px; }
.lb-hatch { background: repeating-linear-gradient(135deg, var(--surface-3) 0 5px, var(--line-2) 5px 10px) !important; }
.lb-cat { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 2px 10px; padding: 10px 0; border-top: 1px solid var(--line); align-items: start; }
.lb-cat .sw { width: 12px; height: 12px; border-radius: 3px; margin-top: 4px; } .lb-cat .plan { color: var(--muted); font-size: 12.5px; }
.lb-cat .nums { text-align: right; font: 12.5px/1.45 var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--muted); } .lb-cat .nums b { color: var(--text); }
.lb-cat .sub { grid-column: 2 / 4; margin-top: 8px; }
.lb-big { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; }
svg#lbTrend { width: 100%; height: 110px; display: block; }
.lb-ms h3 { font-size: 14px; margin: 0 0 6px; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; } .lb-ms h3 .code { font-family: var(--mono); color: var(--accent-ink); }
.lb-ms { padding: 10px 0; border-top: 1px solid var(--line); } .lb-ms:first-child { border-top: 0; }
.lb-ms ul { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; } .lb-ms li { display: grid; grid-template-columns: 16px 1fr; gap: 6px; font-size: 13px; color: var(--muted); }
.lb-ms li::before { content: "○"; } .lb-ms li.done { color: var(--text); } .lb-ms li.done::before { content: "●"; color: var(--green); }
.lb-phase { font-weight: 700; color: var(--accent-ink); margin-top: 10px; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
.lb-log { list-style: none; margin: 0; padding: 0; } .lb-log li { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-top: 1px solid var(--line); }
.lb-log li:first-child { border-top: 0; } .lb-log time { font: 11.5px/1.5 var(--mono); color: var(--faint); }
.lb-log .kind { font: 700 10px/1 inherit; letter-spacing: .08em; text-transform: uppercase; color: var(--accent-ink); margin-right: 8px; }
.lb-log .t { font-weight: 600; } .lb-log .d { color: var(--muted); font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 4px; }
@media (max-width: 560px) { .lb-log li { grid-template-columns: 1fr; gap: 2px; } .lb-stamp { text-align: left; } }
.tierbar { display: flex; height: 22px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; }
.tierbar span { display: block; height: 100%; min-width: 3px; }
.tier-exact { background: var(--green); } .tier-verified { background: var(--blue); } .tier-readable { background: var(--violet); }
@@ -442,6 +473,7 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
<nav class="nav" aria-label="Sections">
<div class="nav-label">Board</div>
<button class="nav-btn" data-go="overview" title="Overview (1)"><svg class="i"><use href="static/icons.svg#home"/></svg><span>Overview</span><span class="count-badge" id="navOverview" hidden></span></button>
<button class="nav-btn" data-go="logbook" title="Logbook (9)"><svg class="i"><use href="static/icons.svg#anchor"/></svg><span>Logbook</span></button>
<button class="nav-btn" data-go="launch" title="Launch (2)"><svg class="i"><use href="static/icons.svg#rocket"/></svg><span>Launch</span><span class="count-badge" id="navLaunch" hidden></span></button>
<button class="nav-btn" data-go="tasks" title="Tasks (3)"><svg class="i"><use href="static/icons.svg#tasks"/></svg><span>Tasks</span><span class="count-badge" id="navTasks" hidden></span></button>
<button class="nav-btn" data-go="review" title="Review (4)"><svg class="i"><use href="static/icons.svg#review"/></svg><span>Review</span><span class="count-badge" id="navReview" hidden></span></button>
@@ -637,6 +669,43 @@ button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
</div>
</section>
<!-- logbook -->
<section class="view lb" id="view-logbook" aria-label="Logbook">
<section class="card"><div class="body lb-head">
<div><span class="chip" id="lbMilestone">Milestone</span>
<p>Decompiling Sid Meier’s Pirates! (2004) into readable C, then rebuilding it in Rust, charted function by function against the PC 1.0.2 executable.</p></div>
<div class="lb-stamp" id="lbStamp">Loading…</div></div></section>
<div class="lb-figs" id="lbFigs"></div>
<section class="card" aria-labelledby="h-lbmap">
<header><h2 id="h-lbmap"><svg class="i"><use href="static/icons.svg#layout"/></svg>Chart of the executable</h2><span class="note">bytes per category along the code addresses</span></header>
<div class="body"><canvas id="lbMap" aria-label="Address space of the executable, bytes per category"></canvas>
<div class="lb-axis"><span id="lbLow">0x401000</span><span>code address →</span><span id="lbHigh"></span></div>
<div class="lb-legend" id="lbLegend"></div></div>
</section>
<div class="cols">
<section class="card" aria-labelledby="h-lbwhere">
<header><h2 id="h-lbwhere"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Where the code stands</h2></header>
<div class="body" id="lbWhere"></div>
</section>
<section class="card" aria-labelledby="h-lbtrend">
<header><h2 id="h-lbtrend"><svg class="i"><use href="static/icons.svg#activity"/></svg>Trend</h2></header>
<div class="body"><svg id="lbTrend" viewBox="0 0 300 110" preserveAspectRatio="none" aria-label="Progress over time"></svg>
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--lb-game)"></i>classified</span><span><i class="lb-sw" style="background:var(--lb-platform)"></i>game code decompiled</span><span><i class="lb-sw" style="background:var(--lb-done)"></i>weighted progress</span></div>
<div class="note" id="lbTrendNote" style="margin-top:8px"></div></div>
</section>
</div>
<div class="cols">
<section class="card" aria-labelledby="h-lbms">
<header><h2 id="h-lbms"><svg class="i"><use href="static/icons.svg#flag"/></svg>Milestones</h2></header>
<div class="body" id="lbMilestones"></div>
</section>
<section class="card" aria-labelledby="h-lblog">
<header><h2 id="h-lblog"><svg class="i"><use href="static/icons.svg#book"/></svg>Ship’s log</h2><span class="count" id="lbLogCount"></span></header>
<div class="body"><ul class="lb-log" id="lbLog"></ul></div>
</section>
</div>
</section>
<!-- decomp -->
<section class="view" id="view-decomp" aria-label="Decomp">
<section class="card" aria-labelledby="h-progress">
@@ -757,7 +826,8 @@ const VIEWS={overview:['Overview','Who is working, what needs you, and what happ
tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'],
activity:['Activity','Live tool calls, the team pulse and the scoreboard','activity'],knowledge:['Knowledge','Search every message, pinned decisions and threads','book'],
health:['Health','Harbor in every agent app, the database, backups and the server','heart'],
decomp:['Decomp','Progress by function, the function inspector and what each agent takes next','terminal'],chat:['Chat','The team’s conversation','chat']};
decomp:['Decomp','Progress by function, the function inspector and what each agent takes next','terminal'],
logbook:['Logbook','Where the decomp stands, how it got here and what comes next','anchor'],chat:['Chat','The team’s conversation','chat']};
function show(view){if(!VIEWS[view])view='overview';current=view;
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('on',v.id==='view-'+view));
document.querySelectorAll('.nav-btn[data-go]').forEach(b=>{if(b.dataset.go===view)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current')});
@@ -771,6 +841,7 @@ function show(view){if(!VIEWS[view])view='overview';current=view;
if(data&&view==='activity'){renderPulse();renderFeed()}
if(data&&view==='launch')renderLaunch();
if(data&&view==='decomp')renderDecomp();
if(data&&view==='logbook')renderLogbook();
if(view==='health')renderTools();
if(view==='health'&&!doctorRan)runDoctor()}
function paintTitle(){document.title=(unseen&&document.hidden?'('+unseen+') ':'')+(data?data.project.title+' · ':'')+(current?VIEWS[current][0]:'Harbor')}
@@ -1020,10 +1091,10 @@ function renderDecomp(){renderProgress();renderNextUp();renderDeferred()}
async function renderProgress(){let p;try{p=await get('api/decomp/progress')}catch(e){put('progress','<div class="empty err">'+esc(e.message)+'</div>');return}
const t=p.tiers,own=['exact','verified','readable','deferred','raw'].reduce((a,k)=>a+t[k].functions,0),done=t.exact.functions+t.verified.functions+t.readable.functions;
el('progressSum').textContent=fmt(done)+' of '+fmt(own)+' game functions decompiled';
put('progress','<div class="tierbar" role="img" aria-label="'+esc(TIERS.map(([k,l])=>l+' '+t[k].functions).join(', '))+'">'+TIERS.map(([k,l])=>t[k].functions?'<span class="tier-'+k+'" style="flex:'+t[k].functions+'" title="'+esc(l)+': '+fmt(t[k].functions)+' functions"></span>':'').join('')+'</div>'
+'<table style="margin-top:14px"><thead><tr><th>Tier</th><th style="text-align:right">Functions</th><th style="text-align:right">Bytes</th><th>Meaning</th></tr></thead><tbody>'
+TIERS.map(([k,l,d])=>'<tr><td><span class="tier-key tier-'+k+'"></span>'+esc(l)+'</td><td class="num">'+fmt(t[k].functions)+'</td><td class="num">'+fmt(t[k].bytes)+'</td><td class="note">'+esc(d)+'</td></tr>').join('')+'</tbody></table>'
+(p.sources&&!p.sources.diffrun_results?'<div class="note" style="margin-top:10px">The emulator check has not reported yet, so Verified is still empty.</div>':''))}
put('progress',tierBar(t)+'<div class="lb-legend">'+TIERS.map(([k,l])=>'<span><i class="tier-key tier-'+k+'"></i>'+esc(l)+' '+fmt(t[k].functions)+'</span>').join('')+'</div><div class="note" style="margin-top:10px">Bytes, categories, the trend and the ship’s log: <a href="#logbook" data-go="logbook">open the Logbook</a>.</div>');}
function tierTable(t){return '<table style="margin-top:14px"><thead><tr><th>Tier</th><th style="text-align:right">Functions</th><th style="text-align:right">Bytes</th><th>Meaning</th></tr></thead><tbody>'
+TIERS.map(([k,l,d])=>'<tr><td><span class="tier-key tier-'+k+'"></span>'+esc(l)+'</td><td class="num">'+fmt(t[k].functions)+'</td><td class="num">'+fmt(t[k].bytes)+'</td><td class="note">'+esc(d)+'</td></tr>').join('')+'</tbody></table>'}
function tierBar(t){return '<div class="tierbar" role="img" aria-label="'+esc(TIERS.map(([k,l])=>l+' '+t[k].functions).join(', '))+'">'+TIERS.map(([k,l])=>t[k].functions?'<span class="tier-'+k+'" style="flex:'+t[k].functions+'" title="'+esc(l)+': '+fmt(t[k].functions)+' functions"></span>':'').join('')+'</div>'}
async function renderNextUp(){if(!el('nextUp')._html)put('nextUp',empty('clock','Ranking the bands…','This takes a few seconds the first time.'));let q;
try{q=await get('api/decomp/queues')}catch(e){put('nextUp','<div class="empty err">'+esc(e.message)+'</div>');return}
put('nextUp',q.length?q.map(a=>'<div class="band"><div class="cell-who">'+avatar(a.agent,'xs')+'<b>'+esc(a.agent)+'</b><span class="note mono">'+esc(a.bands.map(b=>b.join('–')).join(', '))+'</span></div>'
@@ -1055,6 +1126,48 @@ async function inspectFn(){const target=el('fnTarget').value.trim();if(!target)r
async function renderTools(){let t;try{t=await get('api/decomp/tools')}catch(e){put('retools','<div class="empty err">'+esc(e.message)+'</div>');return}
const bad=t.filter(x=>!x.ok).length;el('reSum').textContent=bad?bad+' to check':'all in place';
put('retools','<table><thead><tr><th>Status</th><th>Tool</th></tr></thead><tbody>'+t.map(x=>'<tr><td><span class="tag '+(x.ok?'green':'amber')+'"><span class="dot"></span>'+(x.ok?'ok':'check')+'</span></td><td>'+esc(x.tool)+'<div class="note">'+esc(x.detail)+'</div></td></tr>').join('')+'</tbody></table>')}
const LB_CATS=[['game','Game code','Firaxis game logic: decompiled to readable C, then ported to Rust'],['engine','Engine','Gamebryo: named and documented, replaced in the port'],
['platform','Platform','Miles, Bink, D3D9 and input wrappers: replaced in the port'],['crt','C runtime','MSVC 7.1 runtime: replaced by Rust’s standard library'],['unsorted','Unsorted','not classified yet; each lands in one of the four above']];
let LB=null;
const pct=n=>Number(n||0).toFixed(Number(n)>=10?1:2)+'%';
const kb=b=>b>=1048576?(b/1048576).toFixed(2)+' MB':Math.round((b||0)/1024)+' KB';
const cssVar=n=>getComputedStyle(el('view-logbook')).getPropertyValue(n).trim();
async function renderLogbook(){try{LB=await get('api/logbook')}catch(e){put('lbStamp','<span class="err">'+esc(e.message)+'</span>');return}
const s=LB.stats||{},cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0};
const cur=LB.milestones.find(m=>/progress/i.test(m.state||''));el('lbMilestone').textContent=cur?'Milestone '+cur.code+' · '+cur.title:'Milestones';
const au=LB.auto||{};put('lbStamp',(s.updated_at?'Statistics updated <b>'+esc(central(s.updated_at))+'</b><br>main at <b>'+esc(s.commit||'–')+'</b>':'No statistics yet')+(au.last_refresh?'<br>Keeps itself current: last checked '+esc(central(au.last_refresh))+(au.last_error?' · <span class="err">'+esc(String(au.last_error).slice(0,120))+'</span>':''):''));
put('lbFigs',[[fmt(s.functions),'functions in the original executable',fmt(s.bytes)+' bytes of code'],[pct(s.classified_percent),'classified (category known)',fmt(cat('unsorted').functions)+' still unsorted'],
[pct(s.weighted_percent),'weighted progress','documented 20 · decompiled 40 · reimplemented 75 · verified 100'],[pct(s.game_bytes_decompiled_percent),'of game code decompiled to readable C','Rust: '+pct(s.game_bytes_reimplemented_percent)+' reimplemented']]
.map(([v,l,n])=>'<div class="lb-fig"><div class="v">'+esc(v)+'</div><div class="l">'+esc(l)+'</div><div class="n">'+esc(n)+'</div></div>').join(''));
put('lbLegend',LB_CATS.map(([k,n])=>'<span><i class="lb-sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></i>'+esc(n)+'</span>').join('')+'<span><i class="lb-sw" style="background:var(--lb-done)"></i>decompiled share under each column</span>');
drawLbMap();renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
el('lbLogCount').textContent=LB.log_total?LB.log_total+' entries':'';
put('lbLog',LB.log.length?LB.log.map(e=>'<li><time>'+esc(central(e.at))+'</time><div><div class="t"><span class="kind">'+esc(e.kind)+'</span>'+esc(e.title)+'</div><div class="d">'+esc(e.detail)+'</div></div></li>').join(''):'<li class="note">No entries yet.</li>')}
async function renderLbWhere(s){const total=s.bytes||0,cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0},uns=cat('unsorted'),sorted=total?100*(total-uns.bytes)/total:0;
let tiers='';try{const p=await get('api/decomp/progress');tiers='<h3 class="sub-h" style="margin-top:18px">Game functions by how far they have come</h3>'+tierBar(p.tiers)+tierTable(p.tiers)}catch(e){}
put('lbWhere','<div><span class="lb-big">'+pct(sorted)+'</span> <span class="note">of the executable’s code is sorted into a category · '+fmt(uns.functions)+' functions ('+kb(uns.bytes)+') still unsorted</span></div>'
+'<div class="lb-stack" role="img" aria-label="Code bytes by category">'+LB_CATS.map(([k,n])=>{const sh=total?100*cat(k).bytes/total:0;return '<i class="'+(k==='unsorted'?'lb-hatch':'')+'" style="flex:0 0 '+sh.toFixed(3)+'%;background:var(--lb-'+k+')" title="'+esc(n)+': '+sh.toFixed(1)+'% of bytes"></i>'}).join('')+'</div>'
+'<div class="lb-axis"><span>0</span><span>sorted '+pct(sorted)+' · unsorted '+pct(100-sorted)+'</span><span>'+kb(total)+'</span></div>'
+LB_CATS.map(([k,n,plan])=>{const c=cat(k),sh=total?100*c.bytes/total:0;return '<div class="lb-cat"><span class="sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></span><div><b>'+esc(n)+'</b><div class="plan">'+esc(plan)+'</div></div><div class="nums"><b>'+sh.toFixed(1)+'%</b> of bytes<br>'+fmt(c.functions)+' fn · '+kb(c.bytes)+'</div>'
+(k==='game'?'<div class="sub"><div class="tierbar" style="height:8px"><span class="tier-readable" style="flex:'+(s.game_bytes_decompiled_percent||0)+'"></span><span style="flex:'+Math.max(0,100-(s.game_bytes_decompiled_percent||0))+';background:transparent"></span></div><div class="note">'+pct(s.game_bytes_decompiled_percent)+' of game bytes decompiled</div></div>':'')+'</div>'}).join('')
+'<div class="note" style="margin-top:10px">Bar and shares are by bytes of code; counts are functions. Only game code is decompiled and ported; the rest is named and replaced.</div>'+tiers)}
function drawLbMap(){const m=LB&&LB.map,c=el('lbMap');if(!c||!m||!c.clientWidth)return;const r=window.devicePixelRatio||1,w=c.clientWidth,h=c.clientHeight;
c.width=Math.round(w*r);c.height=Math.round(h*r);const g=c.getContext('2d');g.setTransform(r,0,0,r,0,0);g.clearRect(0,0,w,h);
const cells=m.cells.map(x=>x.split(',').map(Number)),max=Math.max(1,...cells.map(x=>x.slice(0,5).reduce((a,b)=>a+b,0)));
const colors=LB_CATS.map(([k])=>cssVar('--lb-'+k)),done=cssVar('--lb-done'),rule=cssVar('--line'),cw=w/cells.length,base=h-12;
g.fillStyle=rule;g.fillRect(0,base,w,1);
cells.forEach((x,i)=>{let y=base;for(let k=0;k<5;k++){const hh=x[k]/max*(base-4);if(hh<=0)continue;g.fillStyle=colors[k];g.fillRect(i*cw+.5,y-hh,Math.max(1,cw-1),hh);y-=hh}
const tot=x.slice(0,5).reduce((a,b)=>a+b,0);if(tot>0&&x[5]>0){g.fillStyle=done;g.fillRect(i*cw+.5,base+4,Math.max(1,(cw-1)*Math.min(1,x[5]/tot)),6)}});
el('lbLow').textContent=m.low;el('lbHigh').textContent=m.high}
function renderLbTrend(points){const svg=el('lbTrend');if(!points.length){svg.innerHTML='';el('lbTrendNote').textContent='The trend starts with the second report.';return}
const n=points.length,x=i=>n===1?150:i*300/(n-1),y=v=>104-Math.min(100,v||0)/100*96,path=k=>points.map((p,i)=>(i?'L':'M')+x(i).toFixed(1)+','+y(p[k]).toFixed(1)).join(' ');
svg.innerHTML=[0,25,50,75,100].map(v=>'<line x1="0" x2="300" y1="'+y(v)+'" y2="'+y(v)+'" stroke="var(--line)" stroke-width="0.5"/>').join('')
+[['classified','--lb-game'],['decompiled','--lb-platform'],['weighted','--lb-done']].map(([k,c])=>'<path d="'+path(k)+'" fill="none" stroke="var('+c+')" stroke-width="2" vector-effect="non-scaling-stroke"/>').join('');
el('lbTrendNote').textContent=n+' reports since '+new Date(points[0].at).toLocaleDateString()+'. Scale 0–100%.'}
function renderLbMilestones(list){let last='';put('lbMilestones',list.length?list.map(m=>{const head=m.phase&&m.phase!==last?'<div class="lb-phase">'+esc(m.phase)+'</div>':'';last=m.phase||last;
const items=m.items||[],done=items.filter(i=>i.done).length,li=items.map(i=>'<li class="'+(i.done?'done':'')+'">'+esc(i.text)+'</li>').join(''),open=/progress/i.test(m.state||'');
return head+'<div class="lb-ms"><h3><span class="code">'+esc(m.code)+'</span>'+esc(m.title)+'<span class="note">'+done+'/'+items.length+(m.state?' · '+esc(m.state):'')+'</span></h3>'+(open?'<ul>'+li+'</ul>':'<details data-k="ms-'+esc(m.id)+'"><summary class="note">Show '+items.length+' steps</summary><ul>'+li+'</ul></details>')+'</div>'}).join(''):empty('flag','No milestones yet',''))}
window.addEventListener('resize',()=>{if(current==='logbook')drawLbMap()});
el('fnForm').addEventListener('submit',e=>{e.preventDefault();inspectFn()});
el('refreshDecomp').addEventListener('click',renderDecomp);
document.addEventListener('click',e=>{const a=e.target.closest('[data-fn]');if(!a)return;e.preventDefault();if(current!=='decomp')show('decomp');el('fnTarget').value=a.dataset.fn;inspectFn();el('h-inspect').scrollIntoView({behavior:'smooth',block:'start'})});
@@ -1304,7 +1417,7 @@ document.addEventListener('visibilitychange',()=>{if(!document.hidden){unseen=0;
const startQ=new URLSearchParams(location.search).get('q');
const onBoard=/\/board\/?$/.test(location.pathname);
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat')));
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='logbook')renderLogbook();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
setInterval(refresh,3000);
setInterval(()=>{if(current==='overview')renderDigest()},30000);
setInterval(()=>{if(current==='activity')renderPulse()},60000);
+62
View File
@@ -0,0 +1,62 @@
"""The Logbook store and its automatic commit log."""
import json
import shutil
import subprocess
import unittest
from helpers import HarborCase
from harbor import logbook, logbook_auto
from harbor.util import HarborError
class LogbookStore(HarborCase):
def setUp(self):
super().setUp()
self.h.project.logbook = {"dir": str(self.dir / "logbook")}
def test_entries_stats_and_milestones(self):
e = logbook.add_entry(self.h.project, "First", "Something happened.", kind="tooling", at="2026-10-05T10:00:00Z")
self.assertEqual(e["id"], "20261005-1000-1")
with self.assertRaises(HarborError):
logbook.add_entry(self.h.project, "x", "y", kind="gossip")
logbook.set_stats(self.h.project, {"functions": 10, "classified_percent": 50.0, "weighted_percent": 5.0})
logbook.upsert_milestone(self.h.project, {"id": "m1", "order": 1, "code": "M1", "title": "T", "items": []})
data = logbook.read_all(self.h.project)
self.assertEqual((data["log"][0]["title"], data["stats"]["functions"], data["history"][0]["classified"],
data["milestones"][0]["code"]), ("First", 10, 50.0, "M1"))
class LogbookAuto(HarborCase):
def setUp(self):
super().setUp()
self.repo = self.dir / "repo"
self.repo.mkdir()
git = lambda *a: subprocess.run(["git", "-C", str(self.repo), *a], check=True, capture_output=True)
git("init", "-q")
git("config", "user.name", "T")
git("config", "user.email", "t@example.com")
(self.repo / "a.txt").write_text("1", encoding="utf-8")
git("add", "-A")
git("commit", "-q", "-m", "first")
self.first = subprocess.run(["git", "-C", str(self.repo), "rev-parse", "HEAD"], capture_output=True,
text=True).stdout.strip()
(self.repo / "annotations").mkdir()
(self.repo / "annotations" / "names.csv").write_text("x", encoding="utf-8")
git("add", "-A")
git("commit", "-q", "-m", "Name things", "-m", "Body line one.\nCo-Authored-By: someone")
self.h.project.logbook = {"dir": str(self.dir / "logbook"), "decomp": str(self.repo)}
def test_new_commits_become_entries_once(self):
logbook_auto._save_state(self.h.project, {"last_head": self.first})
out = logbook_auto.refresh(self.h.project)
self.assertEqual(out["entries"], 1)
entry = logbook.read_all(self.h.project)["log"][0]
self.assertEqual((entry["title"], entry["kind"]), ("Name things", "naming"))
self.assertIn("Body line one.", entry["detail"])
self.assertNotIn("Co-Authored-By", entry["detail"])
self.assertTrue(entry["at"].endswith("Z"))
self.assertFalse(logbook_auto.refresh(self.h.project)["changed"])
if __name__ == "__main__":
unittest.main()