summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 12:36:54 -0300
committerGabriel Schneider <[email protected]>2026-08-02 17:37:58 -0300
commite16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb (patch)
treede0db0c369522b5bf92ff32a8351de2224c5c9dc
parent151ea12251ed876aa87bb486da1bc337e25369e9 (diff)
downloadnotevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.tar.gz
notevi-e16f78b255bcf46d8c6f9a4b9e76dd7a3817edeb.zip
-rw-r--r--constrain/AGENTS.md6
-rw-r--r--constrain/README.txt34
-rw-r--r--constrain/claude-headless-settings.json2
-rw-r--r--constrain/codex-config.toml12
-rw-r--r--doc.txt11
-rw-r--r--main.go133
-rw-r--r--query-doc.txt8
-rw-r--r--sidecar/store.go329
-rw-r--r--sidecar/store_test.go164
-rw-r--r--vr-log.jsonl120
-rw-r--r--vrsite/app_test.go54
-rw-r--r--vrsite/assets/app.js20
-rw-r--r--vrsite/assets/style.css.tmpl25
-rw-r--r--vrsite/go.mod3
-rw-r--r--vrsite/jj.go27
-rw-r--r--vrsite/log.go56
-rw-r--r--vrsite/main.go128
-rw-r--r--vrsite/main_test.go2
-rw-r--r--vrsite/model.go52
-rw-r--r--vrsite/page.go52
-rw-r--r--vrsite/projects.go698
-rw-r--r--vrsite/projects_test.go315
-rw-r--r--vrsite/serve.go88
-rw-r--r--vrsite/static.go4
-rw-r--r--vrsite/templates/frag-jj.tmpl8
-rw-r--r--vrsite/templates/layout.tmpl3
-rw-r--r--vrsite/templates/page-change.tmpl5
-rw-r--r--vrsite/templates/page-index.tmpl2
-rw-r--r--vrsite/templates/page-jj.tmpl4
-rw-r--r--vrsite/templates/page-notes.tmpl4
-rw-r--r--vrsite/templates/projects.tmpl112
31 files changed, 2096 insertions, 385 deletions
diff --git a/constrain/AGENTS.md b/constrain/AGENTS.md
index abec095..871fc6c 100644
--- a/constrain/AGENTS.md
+++ b/constrain/AGENTS.md
@@ -2,10 +2,14 @@
Files are read with the `vr` tool — run `vr -doc` once for full usage.
+`vr` stores the trace in the repo's private local jj sidecar named `vr_log`.
+Do not set `VR_LOG`, pass `-log`, or create a working-tree `vr-log.jsonl`.
+
- `vr read FILE:START-END` — line-numbered read; prefer ranges over whole files
- `vr grep PATTERN [PATH]` — regex search; scope with a path
- `vr read -r REV FILE`, `vr grep -r REV PATTERN PATH` — at another jj change
- `vr note -f FILE:10-42 -t struct TEXT...` — record observations as you read
Navigate top-down: grep for the symbol, read the enclosing range, note what
-you learn. Reads and notes are keyed to the current jj change automatically.
+you learn. Every entry records the full current jj change and commit IDs; the
+sidecar append does not modify the project's working-copy change.
diff --git a/constrain/README.txt b/constrain/README.txt
index 4eee9a1..4da2733 100644
--- a/constrain/README.txt
+++ b/constrain/README.txt
@@ -11,7 +11,6 @@ vr-only-guard.sh PreToolUse hook: blocks cat/rg/sed/... and
the agent at `vr -doc`, so agents converge
even with zero instructions
vr-only.rules codex execpolicy rules (allow vr, forbid readers)
-codex-config.toml optional: centralize VR_LOG for codex
AGENTS.md how-to-navigate-with-vr guidance for the repo
── one-time setup ──────────────────────────────────────────────────────────
@@ -20,9 +19,8 @@ put both on PATH: go build -o ~/.local/bin/vr . (repo root)
(vr is what agents call; vrsite is how you read the trace)
── running a constrained CLAUDE investigation ──────────────────────────────
-No repo mutation needed; from the target repo dir:
+From the target jj repo dir:
- VR_LOG=/abs/path/trace.jsonl \
claude -p --settings <vr-repo>/constrain/claude-headless-settings.json \
--allowedTools 'Bash(vr)' 'Bash(vr:*)' \
< prompt.txt > report.md
@@ -40,29 +38,35 @@ No repo mutation needed; from the target repo dir:
── running a constrained CODEX investigation ───────────────────────────────
cp vr-only.rules ~/.codex/rules/ # activate (GLOBAL: constrains
# every codex session while there)
- VR_LOG=/abs/path/trace.jsonl \
codex exec -s danger-full-access "$(cat prompt.txt)" > report.md
rm ~/.codex/rules/vr-only.rules # deactivate when done
- - danger-full-access is required: workspace-write blocks .git/ writes,
- which kills jj's working-copy snapshot and with it `vr read`.
+ - danger-full-access is required: workspace-write blocks jj metadata writes,
+ including the sidecar rewrite and `vr read`'s working-copy snapshot.
- enforcement is pre-exec by codex's execpolicy engine, even through
`zsh -lc` wrappers; validate rules with:
codex execpolicy check --rules vr-only.rules -- cat foo.txt
── shared trace + rendering ────────────────────────────────────────────────
- - VR_LOG must point at a FILE path whose parent exists. If the path is a
- directory (or becomes one), agents improvise their own log files and you
- will be merging jsonl afterwards. Ask me how I know.
- - Multiple agents may share one log: note ids are per-session, appends are
- line-atomic. Same file = one merged timeline for free.
+ - vr creates one anonymous sidecar change directly under jj's root() on the
+ first append. The repo-local alias vr_log names it; git.private-commits
+ protects it from accidental pushes. No path or environment setup exists.
+ - Multiple agents in the repo share that sidecar. Rewrites are serialized,
+ note ids are assigned under the same lock, and the project @ is untouched.
+ - The sidecar has no bookmark and is local-only. It does not survive a fresh
+ clone; include the jj repository in backups when the trace matters.
- In the prompt, tell the agent to leave pinned notes
(vr note -f FILE:START-END -t kind "...") — that is the payload.
- - Read the trace afterwards — serve it, and write notes of your own back
- into the same log:
- vrsite -log trace.jsonl -repo <repo> -title "..."
+ - Read traces afterwards from one process. Bare `vrsite` scans below ~ at
+ startup and when Refresh is pressed, then offers every repo with a sidecar:
+ vrsite
+ Its explicit buttons can start an empty private sidecar in a discovered jj
+ repo or initialize colocated jj in a discovered Git repo. The project index
+ is only in memory; those two requested metadata changes are the only writes.
+ - To bypass the dashboard and serve one repository directly:
+ vrsite -repo <repo> -title "..."
or take a static copy to hand around (one change, no server features):
- vrsite -log trace.jsonl -repo <repo> -out site -title "..."
+ vrsite -repo <repo> -out site -title "..."
`vrsite -h` explains both, and what a log needs to be worth reading.
── known holes (accepted) ──────────────────────────────────────────────────
diff --git a/constrain/claude-headless-settings.json b/constrain/claude-headless-settings.json
index f125ed2..30aff9b 100644
--- a/constrain/claude-headless-settings.json
+++ b/constrain/claude-headless-settings.json
@@ -10,7 +10,7 @@
"hooks": [
{
"type": "command",
- "command": "/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger/constrain/vr-only-guard.sh"
+ "command": "/home/goblin/00-projects/01-tools/vr-agent-logger/constrain/vr-only-guard.sh"
}
]
}
diff --git a/constrain/codex-config.toml b/constrain/codex-config.toml
deleted file mode 100644
index fa5902f..0000000
--- a/constrain/codex-config.toml
+++ /dev/null
@@ -1,12 +0,0 @@
-# Optional: merge into ~/.codex/config.toml (or pass per-run with -c).
-# Enforcement lives in vr-only.rules (~/.codex/rules/); this only centralizes
-# the log and, if you want, tightens approvals for everything unmatched.
-
-# approval_policy = "untrusted"
-
-[shell_environment_policy]
-inherit = "all"
-
-[shell_environment_policy.set]
-# one shared vr log for every repo codex touches
-VR_LOG = "/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger/vr-log.jsonl"
diff --git a/doc.txt b/doc.txt
index 42929bf..87b0db4 100644
--- a/doc.txt
+++ b/doc.txt
@@ -1,4 +1,4 @@
-vr — read code and take notes; every call is logged.
+vr — read code and take notes; every call is logged in a private jj sidecar.
read a file (line-numbered, relative to a jj revision):
vr read FILE whole file
@@ -19,5 +19,10 @@ vr query -op note shows both the session-local id and the log-wide id used by
-reply and vrsite /note/N permalinks.
REV is any jj revset; default @ (the working copy).
-Every command takes -log FILE for where to append the log
-(default ./vr-log.jsonl, or $VR_LOG); use an absolute path across repos.
+The repo-local revset alias vr_log names the log change. vr creates it lazily
+as an anonymous child of root(); it never appears in the project working copy.
+Every new entry records the full target change id and exact target commit id.
+An existing repo-root vr-log.jsonl is read and imported on the first append;
+vr leaves that legacy working-tree file for you to remove after migration.
+The sidecar has no bookmark and is deliberately local-only: a fresh clone will
+not contain it, so include the jj repository in backups when notes matter.
diff --git a/main.go b/main.go
index 6876a8c..1d263cb 100644
--- a/main.go
+++ b/main.go
@@ -1,9 +1,9 @@
// vr — file reader / note taker for coding agents, logging every call.
//
-// Each operation appends one JSON line to a log file recording who read what:
-// session id, agent, model, reasoning effort, working dir, jj change id, file
-// and line range. Reads and greps are relative to a jj revision (default @, the
-// working copy) — the tool assumes it runs inside a jj repo.
+// Each operation appends one JSON line to a private jj sidecar recording who read what:
+// session id, agent, model, reasoning effort, working dir, full jj change and
+// commit ids, file and line range. Reads and greps are relative to a jj
+// revision (default @, the working copy) — the tool assumes a jj repo.
//
// Agent identity comes from the caller's environment (Claude Code exports
// CLAUDE_CODE_SESSION_ID, AI_AGENT, CLAUDE_EFFORT into tool subprocesses); when
@@ -18,6 +18,7 @@ import (
"bytes"
_ "embed"
"encoding/json"
+ "errors"
"flag"
"fmt"
"os"
@@ -27,6 +28,8 @@ import (
"strconv"
"strings"
"time"
+
+ "0x4200.cafe/vr/sidecar"
)
type Entry struct {
@@ -39,6 +42,7 @@ type Entry struct {
Dir string `json:"dir"`
Rev string `json:"rev,omitempty"`
Change string `json:"change,omitempty"`
+ Commit string `json:"commit,omitempty"`
File string `json:"file,omitempty"`
Start int `json:"start,omitempty"`
End int `json:"end,omitempty"`
@@ -51,6 +55,7 @@ type Entry struct {
}
var gSession, gAgent, gModel, gReasoning, gDir string
+var gStore *sidecar.Store
//go:embed doc.txt
var doc string
@@ -90,20 +95,20 @@ func usage() {
fmt.Fprint(os.Stderr, `vr — file reader / note taker for coding agents; logs every call.
usage:
- vr read [-log FILE] [-r REV] FILE[:START[-END]]
+ vr read [-r REV] FILE[:START[-END]]
print a file line-numbered, whole or a line range, at a jj revision
- vr grep [-log FILE] [-r REV] PATTERN [PATH]
+ vr grep [-r REV] PATTERN [PATH]
search the working copy (rg) or another revision (jj file list/show);
scope non-@ greps with PATH, they read every file through jj
- vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...
+ vr note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...
record a note about a file range or a kind of thing (struct, enum,
const, ...), optionally replying to a log-wide note number; ids count
up from 1 per session
- vr query [-log FILE] [-session S] [-op OP] [-file SUBSTR] [-n N]
+ vr query [-session S] [-op OP] [-file SUBSTR] [-n N]
print logged entries, oldest first
-REV is any jj revset, default @ (working copy). The log is JSON lines,
-default ./vr-log.jsonl, overridable with -log or $VR_LOG.
+REV is any jj revset, default @ (working copy). The JSONL log lives in the
+private jj sidecar named by the repo-local revset alias vr_log.
"vr -doc" prints the short agent-facing usage doc; "vr -doc-query" the query one.
`)
os.Exit(2)
@@ -114,13 +119,6 @@ func fatal(format string, a ...any) {
os.Exit(1)
}
-func defaultLog() string {
- if v := os.Getenv("VR_LOG"); v != "" {
- return v
- }
- return "vr-log.jsonl"
-}
-
// ── agent / session / model detection ─────────────────────────────────────
func detect() {
@@ -257,8 +255,14 @@ func jjOut(args ...string) string {
// snapshots, and this keeps `vr note`/`vr grep` working in sandboxes that
// block .git writes (codex workspace-write). read/grep -r snapshot anyway
// via their own jj file show/list calls.
-func changeID(rev string) string {
- return strings.TrimSpace(jjOut("log", "-r", rev, "--no-graph", "--ignore-working-copy", "-T", "change_id"))
+func revisionIDs(rev string) (change, commit string) {
+ out := jjOut("log", "-r", "exactly(("+rev+"), 1)", "--no-graph", "--ignore-working-copy",
+ "-T", "change_id ++ \"\\n\" ++ commit_id ++ \"\\n\"")
+ lines := strings.Fields(out)
+ if len(lines) != 2 {
+ fatal("revision %q did not resolve to one change and commit", rev)
+ }
+ return lines[0], lines[1]
}
// parseFileRange splits "file.go:10-20" / "file.go:10" / "file.go".
@@ -283,11 +287,10 @@ func parseFileRange(s string) (file string, start, end int) {
func cmdRead(args []string) {
fs := flag.NewFlagSet("read", flag.ExitOnError)
- logPath := fs.String("log", defaultLog(), "log file to append to")
rev := fs.String("r", "@", "jj revision to read at")
fs.Parse(args)
if fs.NArg() != 1 {
- fatal("usage: vr read [-log FILE] [-r REV] FILE[:START[-END]]")
+ fatal("usage: vr read [-r REV] FILE[:START[-END]]")
}
file, start, end := parseFileRange(fs.Arg(0))
@@ -308,16 +311,16 @@ func cmdRead(args []string) {
for i := start; i <= end; i++ {
fmt.Printf("%d\t%s\n", i, lines[i-1])
}
- writeLog(*logPath, Entry{Op: "read", Rev: *rev, Change: changeID(*rev), File: file, Start: start, End: end})
+ change, commit := revisionIDs(*rev)
+ writeLog(Entry{Op: "read", Rev: *rev, Change: change, Commit: commit, File: file, Start: start, End: end})
}
func cmdGrep(args []string) {
fs := flag.NewFlagSet("grep", flag.ExitOnError)
- logPath := fs.String("log", defaultLog(), "log file to append to")
rev := fs.String("r", "@", "jj revision to search at")
fs.Parse(args)
if fs.NArg() < 1 || fs.NArg() > 2 {
- fatal("usage: vr grep [-log FILE] [-r REV] PATTERN [PATH]")
+ fatal("usage: vr grep [-r REV] PATTERN [PATH]")
}
pattern, path := fs.Arg(0), fs.Arg(1)
@@ -356,12 +359,12 @@ func cmdGrep(args []string) {
}
}
}
- writeLog(*logPath, Entry{Op: "grep", Rev: *rev, Change: changeID(*rev), Pattern: pattern, File: path, Matches: matches})
+ change, commit := revisionIDs(*rev)
+ writeLog(Entry{Op: "grep", Rev: *rev, Change: change, Commit: commit, Pattern: pattern, File: path, Matches: matches})
}
func cmdNote(args []string) {
fs := flag.NewFlagSet("note", flag.ExitOnError)
- logPath := fs.String("log", defaultLog(), "log file to append to")
rev := fs.String("r", "@", "jj revision the note refers to")
file := fs.String("f", "", "FILE[:START[-END]] the note is about")
kind := fs.String("t", "", "kind of thing the note is about (struct, enum, const, ...)")
@@ -369,21 +372,23 @@ func cmdNote(args []string) {
fs.Parse(args)
text := strings.Join(fs.Args(), " ")
if text == "" {
- fatal("usage: vr note [-log FILE] [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...")
+ fatal("usage: vr note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...")
}
if *reply < 0 {
fatal("reply note must be positive")
}
- if *reply > 0 && !hasNote(readLog(*logPath), *reply) {
- fatal("no log-wide note #%d in %s", *reply, *logPath)
- }
-
- e := Entry{Op: "note", Rev: *rev, Change: changeID(*rev), ReplyTo: *reply, Type: *kind, Text: text}
+ change, commit := revisionIDs(*rev)
+ e := Entry{Op: "note", Rev: *rev, Change: change, Commit: commit, ReplyTo: *reply, Type: *kind, Text: text}
if *file != "" {
e.File, e.Start, e.End = parseFileRange(*file)
}
- e.NoteID = nextNoteID(*logPath)
- writeLog(*logPath, e)
+ writeLogWith(func(entries []Entry) (Entry, error) {
+ if *reply > 0 && !hasNote(entries, *reply) {
+ return Entry{}, fmt.Errorf("no log-wide note #%d in %s", *reply, store())
+ }
+ e.NoteID = nextNoteID(entries)
+ return e, nil
+ })
fmt.Printf("note %d\n", e.NoteID)
}
@@ -402,9 +407,9 @@ func hasNote(entries []Entry, id int) bool {
return false
}
-func nextNoteID(logPath string) int {
+func nextNoteID(entries []Entry) int {
max := 0
- for _, e := range readLog(logPath) {
+ for _, e := range entries {
if e.Op == "note" && e.Session == gSession && e.NoteID > max {
max = e.NoteID
}
@@ -414,14 +419,13 @@ func nextNoteID(logPath string) int {
func cmdQuery(args []string) {
fs := flag.NewFlagSet("query", flag.ExitOnError)
- logPath := fs.String("log", defaultLog(), "log file to read")
session := fs.String("session", "", "filter: session id substring")
op := fs.String("op", "", "filter: read | grep | note")
file := fs.String("file", "", "filter: file path substring")
n := fs.Int("n", 0, "show only the last N entries")
fs.Parse(args)
- entries := readLog(*logPath)
+ entries := readLog()
type result struct {
Entry
logNoteID int
@@ -493,13 +497,9 @@ func fileRange(e Entry) string {
return s
}
-// ── log file ──────────────────────────────────────────────────────────────
+// ── jj sidecar log ────────────────────────────────────────────────────────
-func readLog(path string) []Entry {
- b, err := os.ReadFile(path)
- if err != nil {
- return nil
- }
+func parseLog(b []byte) []Entry {
var entries []Entry
for _, line := range strings.Split(string(b), "\n") {
if line == "" {
@@ -513,16 +513,43 @@ func readLog(path string) []Entry {
return entries
}
-func writeLog(path string, e Entry) {
- e.Time = time.Now().Format(time.RFC3339)
- e.Session, e.Agent, e.Model, e.Reasoning, e.Dir = gSession, gAgent, gModel, gReasoning, gDir
- b, _ := json.Marshal(e)
- f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+func store() *sidecar.Store {
+ if gStore == nil {
+ var err error
+ gStore, err = sidecar.Open(gDir)
+ if err != nil {
+ fatal("open jj sidecar: %v", err)
+ }
+ }
+ return gStore
+}
+
+func readLog() []Entry {
+ b, err := store().Read()
+ if errors.Is(err, sidecar.ErrNoLog) {
+ return nil
+ }
if err != nil {
- fatal("open log: %v", err)
+ fatal("read %s: %v", store(), err)
}
- defer f.Close()
- if _, err := f.Write(append(b, '\n')); err != nil {
- fatal("write log: %v", err)
+ return parseLog(b)
+}
+
+func writeLog(e Entry) {
+ writeLogWith(func([]Entry) (Entry, error) { return e, nil })
+}
+
+func writeLogWith(build func([]Entry) (Entry, error)) {
+ err := store().Append(func(current []byte) ([]byte, error) {
+ e, err := build(parseLog(current))
+ if err != nil {
+ return nil, err
+ }
+ e.Time = time.Now().Format(time.RFC3339)
+ e.Session, e.Agent, e.Model, e.Reasoning, e.Dir = gSession, gAgent, gModel, gReasoning, gDir
+ return json.Marshal(e)
+ })
+ if err != nil {
+ fatal("write %s: %v", store(), err)
}
}
diff --git a/query-doc.txt b/query-doc.txt
index 7c0cfdc..8d4f58a 100644
--- a/query-doc.txt
+++ b/query-doc.txt
@@ -1,12 +1,14 @@
-vr query — inspect the read/note log (one line per entry, oldest first).
+vr query — inspect this jj repo's read/note sidecar (oldest first).
vr query everything
vr query -n 20 last 20 entries
vr query -op note only notes (or: read, grep)
vr query -file accounts_db entries touching a file path substring
vr query -session fa7cb1bf one session (id substring is enough)
- vr query -log FILE read another log (default ./vr-log.jsonl, or $VR_LOG)
-Filters combine. The log itself is plain JSON lines — jq works on it too.
+Filters combine. The sidecar content is JSON lines and can also be read with:
+ jj file show -r 'exactly(vr_log, 1)' -- vr-log.jsonl
+Its append history is:
+ jj evolog -r 'exactly(vr_log, 1)' --reversed -p --git
Note rows show the session-local id first and the log-wide id used by
`vr note -reply N` and vrsite permalinks in brackets.
diff --git a/sidecar/store.go b/sidecar/store.go
new file mode 100644
index 0000000..76e626f
--- /dev/null
+++ b/sidecar/store.go
@@ -0,0 +1,329 @@
+// Package sidecar stores vr's append-only JSONL log in a private jj change.
+//
+// The change is an anonymous direct child of root(), so it is visible and
+// durable in the jj repository without becoming part of project history or the
+// working copy. A repo-local revset alias names it and git.private-commits keeps
+// accidental --change pushes from publishing it.
+package sidecar
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+)
+
+const (
+ Alias = "vr_log"
+ LogFile = "vr-log.jsonl"
+ Description = "private: vr log"
+)
+
+var ErrNoLog = errors.New("vr log has not been initialized")
+
+// Store is a repository's vr log. Open is read-only with respect to jj; the
+// sidecar change and its config are created lazily by Append.
+type Store struct {
+ root string
+ lockPath string
+}
+
+// Open finds the jj workspace containing dir.
+func Open(dir string) (*Store, error) {
+ root, err := jjOutput(dir, "workspace", "root")
+ if err != nil {
+ return nil, err
+ }
+ root = strings.TrimSpace(root)
+ configPath, err := jjOutput(root, "config", "path", "--repo")
+ if err != nil {
+ return nil, err
+ }
+ return &Store{
+ root: root,
+ lockPath: filepath.Join(filepath.Dir(strings.TrimSpace(configPath)), "vr-agent-logger.lock"),
+ }, nil
+}
+
+func (s *Store) Root() string { return s.root }
+
+func (s *Store) String() string {
+ return fmt.Sprintf("jj sidecar %s:%s", Alias, LogFile)
+}
+
+// Read returns the current log. Before the first sidecar write it falls back
+// to a repo-root legacy log, which makes migration transparent.
+func (s *Store) Read() ([]byte, error) {
+ var data []byte
+ err := s.withLock(syscall.LOCK_SH, func() error {
+ rev, found, _, err := s.resolveExisting()
+ if err != nil {
+ return err
+ }
+ if found {
+ data, err = s.readRevision(rev)
+ return err
+ }
+ data, err = os.ReadFile(filepath.Join(s.root, LogFile))
+ if errors.Is(err, os.ErrNotExist) {
+ return ErrNoLog
+ }
+ return err
+ })
+ return data, err
+}
+
+// Generation is a cheap identity for the current contents, used by vrsite to
+// notice appends. A sidecar rewrite gets a new commit id.
+func (s *Store) Generation() (string, error) {
+ var generation string
+ err := s.withLock(syscall.LOCK_SH, func() error {
+ rev, found, _, err := s.resolveExisting()
+ if err != nil {
+ return err
+ }
+ if found {
+ generation, err = s.logValue(rev, "commit_id")
+ return err
+ }
+ info, err := os.Stat(filepath.Join(s.root, LogFile))
+ if errors.Is(err, os.ErrNotExist) {
+ generation = "empty"
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ generation = fmt.Sprintf("legacy:%d:%d", info.Size(), info.ModTime().UnixNano())
+ return nil
+ })
+ return generation, err
+}
+
+// Append serializes sidecar rewrites across vr processes. makePayload runs
+// while holding that lock and receives the complete current log, allowing note
+// numbering and reply validation to be atomic with the append.
+func (s *Store) Append(makePayload func(current []byte) ([]byte, error)) error {
+ return s.withLock(syscall.LOCK_EX, func() error {
+ rev, err := s.ensure()
+ if err != nil {
+ return err
+ }
+ sidecarData, err := s.readRevision(rev)
+ if err != nil {
+ return err
+ }
+ current := sidecarData
+ if len(current) == 0 {
+ legacy, legacyErr := os.ReadFile(filepath.Join(s.root, LogFile))
+ if legacyErr == nil {
+ current = legacy
+ } else if !errors.Is(legacyErr, os.ErrNotExist) {
+ return legacyErr
+ }
+ }
+ payload, err := makePayload(current)
+ if err != nil {
+ return err
+ }
+ if len(payload) == 0 {
+ return errors.New("refusing to append an empty vr log payload")
+ }
+
+ var appendData []byte
+ if len(sidecarData) == 0 && len(current) > 0 {
+ appendData = append(appendData, current...)
+ }
+ if len(current) > 0 && current[len(current)-1] != '\n' {
+ appendData = append(appendData, '\n')
+ }
+ appendData = append(appendData, payload...)
+ if appendData[len(appendData)-1] != '\n' {
+ appendData = append(appendData, '\n')
+ }
+ return s.runAppend(appendData)
+ })
+}
+
+func (s *Store) withLock(kind int, fn func() error) error {
+ if err := os.MkdirAll(filepath.Dir(s.lockPath), 0o755); err != nil {
+ return err
+ }
+ f, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if err := syscall.Flock(int(f.Fd()), kind); err != nil {
+ return err
+ }
+ defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck
+ return fn()
+}
+
+func (s *Store) ensure() (string, error) {
+ rev, found, configured, err := s.resolveExisting()
+ if err != nil {
+ return "", err
+ }
+ if !found {
+ if _, err := s.jj("new", "--no-edit", "root()", "-m", Description); err != nil {
+ return "", err
+ }
+ ids, err := s.descriptionMatches()
+ if err != nil {
+ return "", err
+ }
+ if len(ids) != 1 {
+ return "", fmt.Errorf("created %q sidecar but found %d matching root children", Description, len(ids))
+ }
+ rev = ids[0]
+ }
+ if !configured {
+ if _, err := s.jj("config", "set", "--repo", "revset-aliases."+Alias, strconv.Quote(rev)); err != nil {
+ return "", err
+ }
+ }
+ private, err := s.jj("config", "get", "git.private-commits")
+ if err != nil {
+ return "", err
+ }
+ if !containsRevsetName(private, Alias) {
+ expr := fmt.Sprintf("(%s) | %s", strings.TrimSpace(private), Alias)
+ if _, err := s.jj("config", "set", "--repo", "git.private-commits", strconv.Quote(expr)); err != nil {
+ return "", err
+ }
+ }
+ return rev, nil
+}
+
+func (s *Store) resolveExisting() (rev string, found, configured bool, err error) {
+ configuredValue, configErr := s.jj("config", "get", "revset-aliases."+Alias)
+ if configErr == nil {
+ rev, err = s.logValue("exactly("+Alias+", 1)", "change_id")
+ if err != nil {
+ return "", false, true, fmt.Errorf("repo-local revset alias %s is invalid: %w", Alias, err)
+ }
+ valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", Alias, strconv.Quote(Description))
+ if _, err := s.logValue(valid, "change_id"); err != nil {
+ return "", false, true, fmt.Errorf("repo-local revset alias %s=%q does not name vr's sidecar: %w",
+ Alias, strings.TrimSpace(configuredValue), err)
+ }
+ return rev, true, true, nil
+ }
+
+ ids, err := s.descriptionMatches()
+ if err != nil {
+ return "", false, false, err
+ }
+ switch len(ids) {
+ case 0:
+ return "", false, false, nil
+ case 1:
+ return ids[0], true, false, nil
+ default:
+ return "", false, false, fmt.Errorf("found %d root children described %q; cannot choose a vr sidecar", len(ids), Description)
+ }
+}
+
+func (s *Store) descriptionMatches() ([]string, error) {
+ revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(Description))
+ out, err := s.jj("log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"")
+ if err != nil {
+ return nil, err
+ }
+ var ids []string
+ for _, id := range strings.Fields(out) {
+ ids = append(ids, id)
+ }
+ return ids, nil
+}
+
+func (s *Store) logValue(revset, template string) (string, error) {
+ out, err := s.jj("log", "-G", "-r", revset, "-T", template)
+ if err != nil {
+ return "", err
+ }
+ value := strings.TrimSpace(out)
+ if value == "" {
+ return "", fmt.Errorf("revision %q produced no %s", revset, template)
+ }
+ return value, nil
+}
+
+func (s *Store) readRevision(rev string) ([]byte, error) {
+ listed, err := s.jj("file", "list", "-r", rev, "--", LogFile)
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(listed) == "" {
+ return nil, nil
+ }
+ out, err := s.jj("file", "show", "-r", rev, "--", LogFile)
+ return []byte(out), err
+}
+
+func (s *Store) runAppend(data []byte) error {
+ f, err := os.CreateTemp(filepath.Dir(s.lockPath), "vr-log-append-*")
+ if err != nil {
+ return err
+ }
+ tmp := f.Name()
+ defer os.Remove(tmp) //nolint:errcheck
+ if _, err := f.Write(data); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ _, err = s.jj("--config", "snapshot.auto-track=\"all()\"",
+ "--config", "snapshot.max-new-file-size=\"1GiB\"",
+ "run", "--clean", "--root", "-r", "exactly("+Alias+", 1)", "--",
+ "sh", "-c", "cat \"$1\" >> "+LogFile, "sh", tmp)
+ return err
+}
+
+func (s *Store) jj(args ...string) (string, error) {
+ return jjOutput(s.root, args...)
+}
+
+func jjOutput(dir string, args ...string) (string, error) {
+ full := append([]string{"--ignore-working-copy", "--no-pager"}, args...)
+ cmd := exec.Command("jj", full...)
+ cmd.Dir = dir
+ out, err := cmd.Output()
+ if err != nil {
+ message := strings.TrimSpace(string(out))
+ if ee, ok := err.(*exec.ExitError); ok {
+ message = strings.TrimSpace(string(ee.Stderr))
+ }
+ if message != "" {
+ return "", fmt.Errorf("jj %s: %w: %s", strings.Join(args, " "), err, message)
+ }
+ return "", fmt.Errorf("jj %s: %w", strings.Join(args, " "), err)
+ }
+ return string(out), nil
+}
+
+func containsRevsetName(expr, name string) bool {
+ for i := 0; i+len(name) <= len(expr); i++ {
+ if expr[i:i+len(name)] != name {
+ continue
+ }
+ before := i == 0 || !isNameByte(expr[i-1])
+ after := i+len(name) == len(expr) || !isNameByte(expr[i+len(name)])
+ if before && after {
+ return true
+ }
+ }
+ return false
+}
+
+func isNameByte(b byte) bool {
+ return b == '_' || b == '-' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
+}
diff --git a/sidecar/store_test.go b/sidecar/store_test.go
new file mode 100644
index 0000000..85f50e1
--- /dev/null
+++ b/sidecar/store_test.go
@@ -0,0 +1,164 @@
+package sidecar
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestAppendCreatesPrivateUnrelatedSidecar(t *testing.T) {
+ repo := testRepo(t)
+ // Store must be independent of a user's restrictive auto-track setting.
+ testJJ(t, repo, "config", "set", "--repo", "snapshot.auto-track", `"none()"`)
+ testJJ(t, repo, "config", "set", "--repo", "git.private-commits", `"description(glob:'secret:*')"`)
+ before := testJJ(t, repo, "status")
+ store, err := Open(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := store.Append(func(current []byte) ([]byte, error) {
+ if len(current) != 0 {
+ t.Fatalf("new store contained %q", current)
+ }
+ return []byte("one\n"), nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Append(func(current []byte) ([]byte, error) {
+ if string(current) != "one\n" {
+ t.Fatalf("second append saw %q", current)
+ }
+ return []byte("two\n"), nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := store.Read()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "one\ntwo\n" {
+ t.Fatalf("sidecar log = %q", got)
+ }
+ if after := testJJ(t, repo, "status"); after != before {
+ t.Fatalf("working copy changed:\n--- before\n%s--- after\n%s", before, after)
+ }
+ if _, err := os.Stat(filepath.Join(repo, LogFile)); !os.IsNotExist(err) {
+ t.Fatalf("log materialized in the project working copy: %v", err)
+ }
+
+ alias := strings.TrimSpace(testJJ(t, repo, "config", "get", "revset-aliases."+Alias))
+ if alias == "" {
+ t.Fatal("repo-local sidecar alias was not configured")
+ }
+ private := testJJ(t, repo, "config", "get", "git.private-commits")
+ if !containsRevsetName(private, Alias) {
+ t.Fatalf("private commits = %q, missing %s", private, Alias)
+ }
+ if !strings.Contains(private, "secret:*") {
+ t.Fatalf("private commits = %q, previous setting was overwritten", private)
+ }
+ parent := strings.TrimSpace(testJJ(t, repo, "log", "-G", "-r", "exactly("+Alias+", 1)",
+ "-T", "parents.map(|p| p.commit_id()).join(\"\")"))
+ if parent != strings.Repeat("0", 40) {
+ t.Fatalf("sidecar parent = %q, want virtual root", parent)
+ }
+}
+
+func TestLegacyLogIsReadThenImportedOnFirstAppend(t *testing.T) {
+ repo := testRepo(t)
+ legacy := []byte("legacy\n")
+ if err := os.WriteFile(filepath.Join(repo, LogFile), legacy, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ store, err := Open(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := store.Read(); err != nil || string(got) != string(legacy) {
+ t.Fatalf("pre-migration read = %q, %v", got, err)
+ }
+ if err := store.Append(func(current []byte) ([]byte, error) {
+ if string(current) != string(legacy) {
+ t.Fatalf("migration callback saw %q", current)
+ }
+ return []byte("sidecar\n"), nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := store.Read(); err != nil || string(got) != "legacy\nsidecar\n" {
+ t.Fatalf("migrated log = %q, %v", got, err)
+ }
+}
+
+func TestConcurrentAppendsAreSerialized(t *testing.T) {
+ repo := testRepo(t)
+ store, err := Open(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const writers = 6
+ errCh := make(chan error, writers)
+ var wg sync.WaitGroup
+ for range writers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ errCh <- store.Append(func(current []byte) ([]byte, error) {
+ id := len(strings.Fields(string(current))) + 1
+ return []byte(fmt.Sprintf("%d\n", id)), nil
+ })
+ }()
+ }
+ wg.Wait()
+ close(errCh)
+ for err := range errCh {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := store.Read()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != "1\n2\n3\n4\n5\n6\n" {
+ t.Fatalf("serialized appends = %q", got)
+ }
+}
+
+func testRepo(t *testing.T) string {
+ t.Helper()
+ if _, err := exec.LookPath("jj"); err != nil {
+ t.Skip("jj not installed")
+ }
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("JJ_USER", "test")
+ t.Setenv("JJ_EMAIL", "[email protected]")
+ repo := filepath.Join(home, "repo")
+ if err := os.Mkdir(repo, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ cmd := exec.Command("jj", "git", "init")
+ cmd.Dir = repo
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("jj git init: %v\n%s", err, out)
+ }
+ return repo
+}
+
+func testJJ(t *testing.T, repo string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("jj", append([]string{"--ignore-working-copy", "--no-pager"}, args...)...)
+ cmd.Dir = repo
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("jj %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+}
diff --git a/vr-log.jsonl b/vr-log.jsonl
deleted file mode 100644
index a2f3e10..0000000
--- a/vr-log.jsonl
+++ /dev/null
@@ -1,120 +0,0 @@
-{"time":"2026-07-24T11:39:36-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","start":44,"end":48}
-{"time":"2026-07-24T11:39:37-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","pattern":"func cmd\\w+","matches":4}
-{"time":"2026-07-24T11:39:37-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","start":107,"end":135,"note_id":1,"type":"func","text":"detect() prefers direct env (AI_AGENT, CLAUDE_CODE_SESSION_ID) and only walks the process tree as fallback"}
-{"time":"2026-07-24T11:39:43-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":".gitignore","start":1,"end":1}
-{"time":"2026-07-24T11:42:14-03:00","op":"note","agent":"claude","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":1,"type":"test","text":"fallback: agent from process tree, session unavailable on macOS"}
-{"time":"2026-07-24T11:42:35-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","pattern":"pub enum LoadHint","matches":1}
-{"time":"2026-07-24T11:42:35-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","pattern":"ancient","matches":60}
-{"time":"2026-07-24T11:42:46-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":620,"end":665}
-{"time":"2026-07-24T11:42:46-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":330,"end":350}
-{"time":"2026-07-24T11:42:53-03:00","op":"grep","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"vpmuoxxzvkrt","change":"vpmuoxxzvkrtxsntvukovztyqlpwrtzo","file":"svm/src/transaction_processor.rs","pattern":"sysvar_cache","matches":65}
-{"time":"2026-07-24T11:42:53-03:00","op":"read","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"vpmuoxxzvkrt","change":"vpmuoxxzvkrtxsntvukovztyqlpwrtzo","file":"svm/src/transaction_processor.rs","start":1,"end":15}
-{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":630,"end":648,"note_id":2,"type":"enum","text":"LoadHint is less a hint than a caller contract: FixedMaxRoot promises max_root won't move during the load, which lets do_load assert! strictly on the fast path instead of tolerating races. The name undersells it — choosing wrong isn't just slower, it changes which invariants get enforced. Doc comment grammar is rough ('unlike previously did allow')."}
-{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":650,"end":656,"note_id":3,"type":"enum","text":"PopulateReadCache is a bool dressed as a two-variant enum — good call-site ergonomics, .0 avoids the classic 'what does true mean here' problem in do_load args."}
-{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"@","change":"ytnxlkvskrqpqtmpwszxsqkwqkxrvpwt","file":"accounts-db/src/accounts_db.rs","start":337,"end":350,"note_id":4,"type":"const","text":"The ancient-slot offset constant has sign-dependent semantics (positive shrinks the modern window, negative extends it past an epoch) documented only by ASCII diagrams; easy to misread when tuning. A signed offset with two diagrams is doing the job of two named constants."}
-{"time":"2026-07-24T11:43:12-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/solana/00-agave/upstream","rev":"vpmuoxxzvkrt","change":"vpmuoxxzvkrtxsntvukovztyqlpwrtzo","file":"svm/src/transaction_processor.rs","start":205,"end":205,"note_id":5,"type":"struct","text":"sysvar_cache: RwLock\u003cSysvarCache\u003e — this change (svm: cache sysvar read lock per transaction batch, #11604) hoists the read-guard acquisition out of the per-transaction loop; sound since the cache is only written at epoch/slot boundaries, and it trims RwLock traffic on the hot path."}
-{"time":"2026-07-24T11:49:52-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":6,"type":"test","text":"detect: claude branch"}
-{"time":"2026-07-24T11:49:52-03:00","op":"note","session":"test-thread-123","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":1,"type":"test","text":"detect: codex branch"}
-{"time":"2026-07-24T11:49:52-03:00","op":"note","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":2,"type":"test","text":"detect: nested, tree decides"}
-{"time":"2026-07-24T11:50:24-03:00","op":"note","session":"fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb","agent":"claude-code_2-1-206_agent","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":7,"type":"test","text":"detect: nested, tree decides (fixed)"}
-{"time":"2026-07-24T11:50:24-03:00","op":"note","agent":"claude","model":"claude-fable-5[1m]","reasoning":"xhigh","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":3,"type":"test","text":"detect: pure walk (fixed)"}
-{"time":"2026-07-24T11:50:43-03:00","op":"read","session":"019f949b-5ccc-79a3-9d30-de99668a2bcb","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","file":"main.go","start":1,"end":13}
-{"time":"2026-07-24T11:50:44-03:00","op":"note","session":"019f949b-5ccc-79a3-9d30-de99668a2bcb","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/Users/goblin/00-projects/0x4200.cafe/vr-agent-logger","rev":"@","change":"xppsmrtkkusmsonnzyluwzlzkslnrnpu","note_id":1,"type":"test","text":"logged from a real codex session"}
-{"time":"2026-07-24T12:05:43-03:00","op":"read","session":"019f94a8-f7f2-7bd2-815e-887e06b21048","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/private/tmp/claude-501/-Users-goblin-00-projects-0x4200-cafe-vr-agent-logger/fa7cb1bf-3c9e-4de9-8d15-47f24809d9eb/scratchpad/constrained-demo","rev":"@","change":"nwrpvwutzoorsyxtzsrtwkqpwllsxnok","file":"hello.txt","start":1,"end":3}
-{"time":"2026-07-26T21:20:43-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"CHANGELOG.md","pattern":"(?i)alpenglow|certificate|reward|shred limit|snapshot|xdp","matches":37}
-{"time":"2026-07-26T21:20:48-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"CHANGELOG.md","start":1,"end":175}
-{"time":"2026-07-26T21:20:54-03:00","op":"note","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"CHANGELOG.md","start":10,"end":41,"note_id":1,"type":"architecture","text":"4.2 visible surface spans three distinct constraint classes: persisted RPC/blockstore compatibility is intentionally narrowed (legacy Rewards decode removed), validator ingress/egress bounds change (half-epoch future-shred horizon and XDP-by-default operational requirements), and CLI exposes Alpenglow vote accounts with a declared JSON break. This public summary is narrower than the working-copy code footprint, so review must trace latent consensus/runtime migrations separately from release-noted behavior."}
-{"time":"2026-07-26T21:21:00-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","pattern":"alpenglow|certificat|reward|block_component|shred","matches":34}
-{"time":"2026-07-26T21:21:00-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"feature-set/src/lib.rs","pattern":"alpenglow|certificat|reward|block_component|shred","matches":38}
-{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":35,"end":175}
-{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"feature-set/src/lib.rs","start":15,"end":175}
-{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":1300,"end":1370}
-{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":1535,"end":1570}
-{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":2420,"end":2450}
-{"time":"2026-07-26T21:21:12-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":2615,"end":2640}
-{"time":"2026-07-26T21:21:22-03:00","op":"note","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"feature-set/src/lib.rs","start":54,"end":55,"note_id":2,"type":"constraint","text":"BEFORE -\u003e AFTER: Alpenglow remains gated by its existing feature, while fast leader handover is split into a new independent feature bit. This preserves reachability of baseline Alpenglow after activation and makes the timing/leadership transition opt-in; every consumer must therefore tolerate all reachable states {legacy, Alpenglow without fast handover, Alpenglow with fast handover}. The snapshot does not itself encode a dependency that fast handover implies Alpenglow, so callers must nest the second behavior under the first."}
-{"time":"2026-07-26T21:21:22-03:00","op":"note","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"feature-set/src/lib.rs","start":20,"end":92,"note_id":3,"type":"finding","text":"The parent FeatureSnapshot still carried stake_minimum_delegation_for_rewards and remove_simple_vote_from_cost_model, while @ removes them. These removals are safe only if the features are guaranteed active on every supported cluster/replay starting point; otherwise old-path runtime economics/cost behavior becomes unreachable. This is a migration/replay support constraint, not merely struct cleanup."}
-{"time":"2026-07-26T21:21:29-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src","pattern":"pub (struct|enum)|fn (handle|process|try_|on_|finalize|root|vote)|Certificate|Finalized|Notar|Skip|BlockId","matches":529}
-{"time":"2026-07-26T21:21:29-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor-messages/src","pattern":"pub (struct|enum)|Certificate|Finalized|Notar|Skip|BlockId|WIRE","matches":239}
-{"time":"2026-07-26T21:21:39-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src","pattern":"fast_leader|handover|LeaderWindowInfo|window","matches":166}
-{"time":"2026-07-26T21:21:40-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"votor/src","pattern":"fast_leader|handover|LeaderWindowInfo|window","matches":138}
-{"time":"2026-07-26T21:21:46-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src/consensus_pool_service.rs","start":1,"end":130}
-{"time":"2026-07-26T21:21:46-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"votor/src/consensus_pool_service.rs","start":300,"end":630}
-{"time":"2026-07-26T21:21:46-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"votor/src/consensus_pool_service.rs","start":300,"end":465}
-{"time":"2026-07-26T21:22:07-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src","pattern":"has_existing_shreds_for_slot|already produced shreds|LeaderWindowInfo|parent_block|start_slot","matches":225}
-{"time":"2026-07-26T21:22:09-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"core/src","pattern":"has_existing_shreds_for_slot|already produced shreds|LeaderWindowInfo|parent_block|start_slot","matches":92}
-{"time":"2026-07-26T21:22:16-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":340,"end":650}
-{"time":"2026-07-26T21:22:16-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":760,"end":970}
-{"time":"2026-07-26T21:22:16-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@-","change":"moqlomltlmyvmkxxtxlxzouqkrmnxrpt","file":"core/src/block_creation_loop.rs","start":240,"end":455}
-{"time":"2026-07-26T21:22:25-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","start":4250,"end":4400}
-{"time":"2026-07-26T21:22:25-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","pattern":"alpenglow_fast_leader_handover|optimistic_parent","matches":28}
-{"time":"2026-07-26T21:22:25-03:00","op":"grep","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","pattern":"alpenglow_fast_leader_handover|optimistic_parent","matches":35}
-{"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/replay_stage.rs","start":1580,"end":1630}
-{"time":"2026-07-26T21:22:33-03:00","op":"read","session":"019fa0f1-49de-7e33-af62-6db956589080","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/00-solana/agave/02-upstream","rev":"@","change":"qwkyytssluqzzouynropxurvsokvnroq","file":"core/src/block_creation_loop.rs","start":630,"end":755}
-{"time":"2026-07-30T15:22:34-03:00","op":"note","session":"web:goblin","agent":"goblin","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"vsrmxqunvnxxzylkqlpkzpkmonulkzwn","file":"vr-log.jsonl","start":9,"end":9,"note_id":1,"text":"example note"}
-{"time":"2026-08-01T18:01:25-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":".","pattern":"type (Entry|Note|PageData|note)|func .*note|Op.*note|case \"note\"|/notes|frag-note|note-form|permalink","matches":83}
-{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":1,"end":491}
-{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":1,"end":540}
-{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main.go","start":1,"end":229}
-{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/render.go","start":1,"end":460}
-{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":1,"end":294}
-{"time":"2026-08-01T18:01:25-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":1,"end":756}
-{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":350,"end":460}
-{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"doc.txt","start":1,"end":19}
-{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":1,"end":190}
-{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":190,"end":380}
-{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":380,"end":480}
-{"time":"2026-08-01T18:01:34-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":1,"end":220}
-{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":63}
-{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-note.tmpl","start":1,"end":41}
-{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-notes.tmpl","start":1,"end":52}
-{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-file.tmpl","start":1,"end":20}
-{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/layout.tmpl","start":1,"end":88}
-{"time":"2026-08-01T18:01:39-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","pattern":"note-card|note-|comment|textarea|form|focus","matches":27}
-{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":280,"end":440}
-{"time":"2026-08-01T18:01:39-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/app.js","pattern":"note|textarea|reply|form","matches":32}
-{"time":"2026-08-01T18:01:39-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/app.js","start":1,"end":426}
-{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":250,"end":490}
-{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/render.go","start":1,"end":230}
-{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/static.go","start":160,"end":247}
-{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main_test.go","start":1,"end":57}
-{"time":"2026-08-01T18:01:44-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":1,"end":240}
-{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/static.go","start":1,"end":165}
-{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown.go","start":1,"end":173}
-{"time":"2026-08-01T18:02:37-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/permalink_test.go","start":1,"end":198}
-{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":187,"end":270}
-{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":1,"end":190}
-{"time":"2026-08-01T18:02:42-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-index.tmpl","start":1,"end":56}
-{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-change.tmpl","start":1,"end":37}
-{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-session.tmpl","start":1,"end":70}
-{"time":"2026-08-01T18:02:43-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite","pattern":"newTemplates|Funcs|dict|md|NoteHref","matches":70}
-{"time":"2026-08-01T18:02:43-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":400,"end":775}
-{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":103,"end":160,"note_id":1,"type":"invariant","text":"Public note permalinks use a log-wide ID counted over note entries, while note_id remains session-local; reply edges should therefore target the log-wide ID."}
-{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":417,"end":465,"note_id":2,"type":"flow","text":"The focused /note/{id} handler starts from the whole-log model before choosing source context, so it is the natural place to render cross-change reply and mention backlinks."}
-{"time":"2026-08-01T18:06:07-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":26,"note_id":3,"type":"ui","text":"Every note surface shares the note card template; a relation indicator added here will cover lists, file asides, conversations, and focused threads consistently."}
-{"time":"2026-08-01T18:08:23-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","pattern":"ReplyTo|reply_to|newNoteLinkParser"}
-{"time":"2026-08-01T18:08:23-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"doc.txt","start":8,"end":19}
-{"time":"2026-08-01T18:08:23-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"query-doc.txt","start":1,"end":10}
-{"time":"2026-08-01T18:11:16-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","start":490,"end":515}
-{"time":"2026-08-01T18:11:16-03:00","op":"grep","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/assets/style.css.tmpl","pattern":"@media","matches":2}
-{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown_test.go","start":1,"end":80}
-{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":240,"end":400}
-{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":617,"end":700}
-{"time":"2026-08-01T18:12:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/permalink_test.go","start":1,"end":60}
-{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":35,"end":130}
-{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main_test.go","start":1,"end":38}
-{"time":"2026-08-01T18:13:38-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown_test.go","start":55,"end":105}
-{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":32,"end":110}
-{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"main.go","start":350,"end":475}
-{"time":"2026-08-01T18:16:06-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":20,"end":190}
-{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":300,"end":435}
-{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/markdown.go","start":70,"end":175}
-{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/page.go","start":190,"end":285}
-{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":417,"end":605}
-{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":1,"end":105}
-{"time":"2026-08-01T18:16:07-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/page-note.tmpl","start":1,"end":54}
-{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/model.go","start":338,"end":408,"note_id":4,"type":"graph","text":"Reply edges come from reply_to while mention edges come only from local Markdown note links; recursive expansion uses a global seen set so arbitrary-depth DAG joins and cycles terminate as references."}
-{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/serve.go","start":524,"end":562,"note_id":5,"type":"validation","text":"Browser replies resolve their target against the whole-log model before append, while preserving the change currently being read and leaving mentions as ordinary note text."}
-{"time":"2026-08-01T18:19:45-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/templates/frag-note.tmpl","start":40,"end":76,"note_id":6,"type":"ui","text":"The focused thread recursively renders reply and mention edges at arbitrary depth, with one normal POST reply form under each full card and compact references for repeated DAG nodes."}
-{"time":"2026-08-01T18:20:12-03:00","op":"note","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/app_test.go","start":60,"end":94,"note_id":7,"reply_to":20,"type":"test","text":"The model test covers a four-edge reply chain plus a note reached through both a reply and a mention, proving arbitrary recursion and DAG join handling."}
-{"time":"2026-08-01T18:20:28-03:00","op":"read","session":"019fbf1e-a14a-76e3-8702-a110fb11b046","agent":"codex","model":"gpt-5.6-sol","reasoning":"xhigh","dir":"/home/goblin/00-projects/01-tools/vr-agent-logger","rev":"@","change":"nwlvyrqvolqnrlurrpupzxxzvkrrmptp","file":"vrsite/main.go","start":1,"end":130}
diff --git a/vrsite/app_test.go b/vrsite/app_test.go
index 403903a..1ee7209 100644
--- a/vrsite/app_test.go
+++ b/vrsite/app_test.go
@@ -13,6 +13,8 @@ import (
"strings"
"testing"
"time"
+
+ "0x4200.cafe/vr/sidecar"
)
func logLines(lines ...string) string { return strings.Join(lines, "\n") + "\n" }
@@ -369,7 +371,7 @@ func testServer(t *testing.T) (*server, string, string) {
t.Fatal(err)
}
dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light")
- sv := newServer(logPath, repo, "test", "tester", dark, light, testThemes(dark, light))
+ sv := newServer(newFileLog(logPath), repo, "test", "tester", dark, light, testThemes(dark, light))
return sv, logPath, change
}
@@ -586,6 +588,54 @@ func TestServerWritesOnlyTheLog(t *testing.T) {
}
}
+func TestServerWritesBrowserNotesToJJSidecar(t *testing.T) {
+ repo, change := testRepo(t)
+ t.Setenv("HOME", repo)
+ t.Setenv("JJ_USER", "test")
+ t.Setenv("JJ_EMAIL", "[email protected]")
+ _, commit, err := resolveRevision(repo, change)
+ if err != nil {
+ t.Fatal(err)
+ }
+ log, err := sidecar.Open(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light")
+ sv := newServer(log, repo, "test", "tester", dark, light, testThemes(dark, light))
+ before, err := jjRun(repo, "status")
+ if err != nil {
+ t.Fatal(err)
+ }
+ form := url.Values{"text": {"sidecar browser note"}, "file": {"hello.txt"}, "change": {change}}
+ r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ sv.handler().ServeHTTP(w, r)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("POST /notes = %d: %s", w.Code, w.Body)
+ }
+ after, err := jjRun(repo, "status")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after) != string(before) {
+ t.Fatalf("project working copy changed:\n--- before\n%s--- after\n%s", before, after)
+ }
+ if _, err := os.Stat(filepath.Join(repo, sidecar.LogFile)); !os.IsNotExist(err) {
+ t.Fatalf("sidecar materialized in project working copy: %v", err)
+ }
+ b, err := log.Read()
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries := parseLog(b)
+ last := entries[len(entries)-1]
+ if last.Text != "sidecar browser note" || last.Commit != commit || len(last.Commit) != 40 {
+ t.Fatalf("sidecar note = %+v", last)
+ }
+}
+
var agoRe = regexp.MustCompile(`\d+[smhd] ago|just now`)
func TestServerHoldsNoStateOfItsOwn(t *testing.T) {
@@ -598,7 +648,7 @@ func TestServerHoldsNoStateOfItsOwn(t *testing.T) {
h.ServeHTTP(httptest.NewRecorder(), r)
// a second server that has only ever seen the log file
- fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler()
+ fresh := newServer(newFileLog(logPath), sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler()
for _, path := range []string{"/", "/notes", "/notes?scope=all", "/code/hello.txt",
"/files", "/tree", "/change/" + change} {
was := agoRe.ReplaceAllString(get(t, h, path, false).Body.String(), "AGO")
diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js
index 696041c..74db8d2 100644
--- a/vrsite/assets/app.js
+++ b/vrsite/assets/app.js
@@ -22,6 +22,26 @@ setMode(root.dataset.mode || "trace");
const typing = e =>
/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
+// The project hub is fully server-derived; filtering is deliberately local so
+// choosing a project does not create another piece of server state.
+const projectFind = document.getElementById("projectfind");
+if (projectFind) {
+ const apply = () => {
+ const q = projectFind.value.trim().toLowerCase();
+ for (const card of document.querySelectorAll(".project-card"))
+ card.hidden = !!q && !(card.dataset.project || "").toLowerCase().includes(q);
+ };
+ projectFind.addEventListener("input", apply);
+ projectFind.addEventListener("search", apply);
+ projectFind.addEventListener("keydown", e => {
+ if (e.key === "Escape") { projectFind.value = ""; apply(); projectFind.blur(); }
+ if (e.key === "Enter") {
+ const first = document.querySelector(".project-card:not([hidden]) a.project-action");
+ if (first) first.click();
+ }
+ });
+}
+
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey || typing(e)) return;
if (e.key === "t") setMode(root.dataset.mode === "notes" ? "trace" : "notes");
diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl
index 9418087..60d2a2b 100644
--- a/vrsite/assets/style.css.tmpl
+++ b/vrsite/assets/style.css.tmpl
@@ -537,6 +537,31 @@ table.changed .st { color: var(--muted); font-size: 11px; width: 7ch; }
table.changed .st-added { color: var(--accent); }
table.changed .st-deleted { color: var(--note); }
+/* ── project hub ───────────────────────────────────────────── */
+.projects-main { padding-bottom: 20vh; }
+.projects-main > section { max-width: 1200px; }
+.project-refresh { margin: 10px 0; }
+.project-refresh button, .project-card button, .project-action {
+ display: inline-block; background: var(--panel); color: var(--fg);
+ border: 1px solid var(--border); border-radius: 5px;
+ font: inherit; font-size: 12px; padding: 5px 10px; cursor: pointer;
+}
+.project-refresh button:hover, .project-card button:hover, .project-action:hover {
+ background: var(--hover); text-decoration: none;
+}
+.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
+.project-card { min-width: 0; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); }
+.project-card > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.project-card h3 { min-width: 0; margin: 0; font: 600 14px/1.3 ui-monospace, Menlo, monospace; overflow: hidden; text-overflow: ellipsis; }
+.project-path { margin: 8px 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.project-state { flex: none; padding: 1px 6px; border: 1px solid var(--border); border-radius: 99px; color: var(--muted); font-size: 10px; text-transform: uppercase; }
+.project-state.active { border-color: var(--accent); color: var(--accent); }
+.project-state.git { color: var(--note); }
+.project-card form, .project-card .project-action { margin-top: 10px; }
+.project-message { max-width: 1168px; }
+.ok { margin: 12px 16px; padding: 8px 10px; border: 1px solid var(--accent); border-radius: 6px; color: var(--accent); }
+.project-card[hidden] { display: none; }
+
/* ── agent coverage colors ──────────────────────────────────
A bitmask is its agents' colors averaged. The blend is left to the
browser so these rules hold for any theme: a theme only has to name
diff --git a/vrsite/go.mod b/vrsite/go.mod
index e4c19c8..2175fcd 100644
--- a/vrsite/go.mod
+++ b/vrsite/go.mod
@@ -3,6 +3,7 @@ module 0x4200.cafe/vrsite
go 1.26.5
require (
+ 0x4200.cafe/vr v0.0.0
github.com/BurntSushi/toml v1.6.0
github.com/tree-sitter/go-tree-sitter v0.25.0
github.com/tree-sitter/tree-sitter-bash v0.25.1
@@ -18,3 +19,5 @@ require (
)
require github.com/mattn/go-pointer v0.0.1 // indirect
+
+replace 0x4200.cafe/vr => ..
diff --git a/vrsite/jj.go b/vrsite/jj.go
index bb95d83..0b5cbdf 100644
--- a/vrsite/jj.go
+++ b/vrsite/jj.go
@@ -1,9 +1,8 @@
// jj.go — every read of the repository goes through here.
//
-// Two rules hold everywhere: the repo is never modified (no edit, no new, no
-// checkout; --ignore-working-copy on every call so we never even snapshot),
-// and nothing is written to disk — templates are passed inline with -T, so a
-// query leaves no trace behind, it just renders once and is sent to the page.
+// These helpers never modify project history or its working copy
+// (--ignore-working-copy means they never even snapshot it). Sidecar mutation
+// is isolated in the shared sidecar package.
package main
import (
@@ -72,21 +71,27 @@ func checkRevset(r string) error {
}
func resolveChange(repo, rev string) (string, error) {
+ change, _, err := resolveRevision(repo, rev)
+ return change, err
+}
+
+func resolveRevision(repo, rev string) (change, commit string, err error) {
if rev == "" {
rev = "@"
}
if err := checkRevset(rev); err != nil {
- return "", err
+ return "", "", err
}
- out, err := jjRun(repo, "log", "-r", rev, "--no-graph", "-n", "1", "-T", "change_id")
+ out, err := jjRun(repo, "log", "-r", "exactly(("+rev+"), 1)", "--no-graph",
+ "-T", "change_id ++ \"\\n\" ++ commit_id ++ \"\\n\"")
if err != nil {
- return "", err
+ return "", "", err
}
- id := strings.TrimSpace(string(out))
- if id == "" {
- return "", fmt.Errorf("revset %q matches no change", rev)
+ ids := strings.Fields(string(out))
+ if len(ids) != 2 {
+ return "", "", fmt.Errorf("revset %q did not resolve to one change and commit", rev)
}
- return id, nil
+ return ids[0], ids[1], nil
}
// changeBrief resolves a revset to the one change it names, with everything a
diff --git a/vrsite/log.go b/vrsite/log.go
new file mode 100644
index 0000000..a0a4f3e
--- /dev/null
+++ b/vrsite/log.go
@@ -0,0 +1,56 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "sync"
+)
+
+// logStore is implemented by the production jj sidecar and by the file-backed
+// adapter used in tests. Append's callback makes session note numbering atomic
+// with the content update.
+type logStore interface {
+ Read() ([]byte, error)
+ Generation() (string, error)
+ Append(func(current []byte) ([]byte, error)) error
+ String() string
+}
+
+type fileLog struct {
+ path string
+ mu sync.Mutex
+}
+
+func newFileLog(path string) *fileLog { return &fileLog{path: path} }
+
+func (l *fileLog) String() string { return l.path }
+
+func (l *fileLog) Read() ([]byte, error) { return os.ReadFile(l.path) }
+
+func (l *fileLog) Generation() (string, error) {
+ info, err := os.Stat(l.path)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano()), nil
+}
+
+func (l *fileLog) Append(makePayload func(current []byte) ([]byte, error)) error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ current, err := os.ReadFile(l.path)
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ payload, err := makePayload(current)
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = f.Write(payload)
+ return err
+}
diff --git a/vrsite/main.go b/vrsite/main.go
index 69c90a8..e43a8eb 100644
--- a/vrsite/main.go
+++ b/vrsite/main.go
@@ -7,8 +7,8 @@
// both the ones agents left and the ones you write in the browser.
//
// It runs as a live web app (default) or writes a static export (-out).
-// Either way the log is the only state: notes are appended to it, and the
-// repository is only ever read, never modified.
+// Either way the log is the only state: notes are appended to a private jj
+// sidecar, never to the project working copy.
//
// Theme comes from a Zed theme JSON; per-agent colors come from the theme's
// player (collaborator) palette. Every other theme this machine has — Zed's
@@ -25,6 +25,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "0x4200.cafe/vr/sidecar"
)
//go:embed templates assets
@@ -43,30 +45,33 @@ tree with per-agent read coverage, syntax-highlighted sources, the notes agents
left, the conversation each note was written in, and jj queries over the repo.
usage:
- vrsite -log FILE -repo DIR [-addr HOST:PORT] [-user NAME]
- serve it, at http://127.0.0.1:4200 by default
- vrsite -log FILE -repo DIR -out DIR [-watch]
+ vrsite [-scan-root DIR] [-addr HOST:PORT] [-user NAME]
+ serve every discovered project from one dashboard; scans ~ by default
+ vrsite -repo DIR [-addr HOST:PORT] [-user NAME]
+ serve one repository directly, preserving the original single-project mode
+ vrsite [-repo DIR] -out DIR [-watch]
write a static copy instead — no server, so no comments, no live
activity, no jj queries; the focus toggle, filters and file finder
still work, and it holds the one change the log points at
starting on a new project:
- 1. the project must be a jj repo — in an existing git one:
- jj git init --colocate
- 2. give the agents vr and one log for the repo:
- export VR_LOG=$PWD/vr-log.jsonl
- and tell them to read with it; constrain/AGENTS.md in the vr repo is a
- drop-in brief that does, and constrain/ can also enforce it
- 3. let them work, then:
- vrsite -log $PWD/vr-log.jsonl -repo $PWD
+ 1. open the dashboard; it discovers jj and Git repositories below ~
+ 2. initialize colocated jj or start the private sidecar from its explicit
+ button — no project registry or scan result is written to disk
+ 3. give the agents vr and tell them to read with it; constrain/AGENTS.md in
+ the vr repo is a drop-in brief that does, and constrain/ can enforce it
+ 4. let them work, then refresh the dashboard when a new repo appears
A log belongs to one repository. Entries carry the jj change id they were
recorded at, and vrsite reads the code at those changes, so a log from another
repo has nothing to show here. Point -repo at the repository the agents ran in.
-Notes written in the browser are appended to the same log, under -user, at the
-change being read. Nothing else is ever written: the repository is only read,
-and the app keeps no state of its own.
+Notes written in the browser are appended to the same private jj sidecar,
+under -user, at the exact change and commit being read. The project working
+copy is never changed, and the app keeps no state of its own.
+
+The sidecar has no bookmark and is local-only. It is not included in a fresh
+clone, so back up the jj repository or export the site when the trace matters.
Session transcripts (the conversation behind a note) are looked up by session
id under -transcripts; Claude Code and Codex are found automatically.
@@ -80,8 +85,12 @@ flags:
`
func main() {
- logPath := flag.String("log", "", "vr trace log (jsonl)")
- repo := flag.String("repo", "", "jj repo the trace was recorded in")
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = "."
+ }
+ repo := flag.String("repo", "", "serve one jj repo directly (with -out, default: current directory)")
+ scanRoot := flag.String("scan-root", home, "live dashboard root to scan for jj and Git repositories")
addr := flag.String("addr", "127.0.0.1:4200", "address to serve on")
who := flag.String("user", "", "name your browser notes are logged under (default: $USER)")
out := flag.String("out", "", "write a static site here instead of serving")
@@ -99,42 +108,54 @@ func main() {
flag.PrintDefaults()
}
flag.Parse()
- if *logPath == "" || *repo == "" {
- missing := "-log and -repo are required"
- if *logPath != "" {
- missing = "-repo is required: the repository the log was recorded in"
- } else if *repo != "" {
- missing = "-log is required: the jsonl file vr appends to"
- }
- fmt.Fprintf(os.Stderr, "vrsite: %s\n\n", missing)
- flag.Usage()
- os.Exit(2)
- }
- repoAbs, err := filepath.Abs(*repo)
- if err != nil {
- fatal("%v", err)
- }
if *debounce < 0 {
fatal("debounce must not be negative")
}
theme := loadTheme(*themePath, *themeName, "dark")
light := loadTheme(*lightPath, *lightName, "light")
themes := newThemeSet(theme, light)
+ if *out == "" && *watch {
+ fatal("-watch is for -out builds; the served app already follows the log")
+ }
- if *out == "" {
- if *watch {
- fatal("-watch is for -out builds; the served app already follows the log")
+ // Explicit -repo and every static export retain the original one-repository
+ // path. A bare live invocation is the new, stateless project dashboard.
+ if *repo == "" && *out == "" {
+ hubTitle := *title
+ if hubTitle == "vr trace" {
+ hubTitle = "vr projects"
}
- serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes)
+ hub, err := newProjectHub(*scanRoot, hubTitle, username(*who), strings.Split(*roots, ","), theme, light, themes)
+ if err != nil {
+ fatal("discover projects: %v", err)
+ }
+ serveProjects(*addr, hub)
+ return
+ }
+
+ repoPath := *repo
+ if repoPath == "" {
+ repoPath = "."
+ }
+ repoAbs, err := filepath.Abs(repoPath)
+ if err != nil {
+ fatal("%v", err)
+ }
+ log, err := sidecar.Open(repoAbs)
+ if err != nil {
+ fatal("open jj sidecar: %v", err)
+ }
+ if *out == "" {
+ serve(*addr, log, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes)
return
}
- build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light, themes) }
+ build := func() { buildSite(log, repoAbs, *out, *title, theme, light, themes) }
build()
if !*watch {
return
}
- fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", *logPath, *debounce)
- watchLog(*logPath, *debounce, build)
+ fmt.Printf("vrsite: watching %s (rebuild after %s without changes)\n", log, *debounce)
+ watchLog(log, *debounce, build)
}
func username(flagValue string) string {
@@ -152,27 +173,14 @@ func username(flagValue string) string {
// ── watching the log (static export only) ─────────────────────────────────
-type logState struct {
- size int64
- modTime time.Time
-}
-
-func statLog(path string) (logState, error) {
- info, err := os.Stat(path)
- if err != nil {
- return logState{}, err
- }
- return logState{size: info.Size(), modTime: info.ModTime()}, nil
-}
-
-func watchLog(path string, debounce time.Duration, rebuild func()) {
- watchLogUntil(path, debounce, time.Second, nil, rebuild)
+func watchLog(log logStore, debounce time.Duration, rebuild func()) {
+ watchLogUntil(log, debounce, time.Second, nil, rebuild)
}
-func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) {
- state, err := statLog(path)
+func watchLogUntil(log logStore, debounce, poll time.Duration, done <-chan struct{}, rebuild func()) {
+ state, err := log.Generation()
if err != nil {
- fatal("watch %s: %v", path, err)
+ fatal("watch %s: %v", log, err)
}
ticker := time.NewTicker(poll)
@@ -192,10 +200,10 @@ func watchLogUntil(path string, debounce, poll time.Duration, done <-chan struct
return
case <-ticker.C:
- next, err := statLog(path)
+ next, err := log.Generation()
if err != nil {
if msg := err.Error(); msg != lastStatErr {
- fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", path, err)
+ fmt.Fprintf(os.Stderr, "vrsite: watch %s: %v\n", log, err)
lastStatErr = msg
}
continue
diff --git a/vrsite/main_test.go b/vrsite/main_test.go
index 7e715b9..b7c93c3 100644
--- a/vrsite/main_test.go
+++ b/vrsite/main_test.go
@@ -18,7 +18,7 @@ func TestWatchLogDebouncesChanges(t *testing.T) {
rebuilt := make(chan struct{}, 2)
go func() {
defer close(stopped)
- watchLogUntil(logPath, 60*time.Millisecond, 5*time.Millisecond, done, func() {
+ watchLogUntil(newFileLog(logPath), 60*time.Millisecond, 5*time.Millisecond, done, func() {
rebuilt <- struct{}{}
})
}()
diff --git a/vrsite/model.go b/vrsite/model.go
index b580b49..10f3f1d 100644
--- a/vrsite/model.go
+++ b/vrsite/model.go
@@ -2,14 +2,13 @@
//
// Everything the site shows is derived here from the vr log alone: agents and
// their colors, per-file coverage bitmasks, notes, and live session activity.
-// The repo is never written to; the log is the only state.
+// The private jj sidecar is the only state.
package main
import (
"encoding/json"
"fmt"
"html/template"
- "os"
"sort"
"strings"
"time"
@@ -29,6 +28,7 @@ type Entry struct {
Dir string `json:"dir,omitempty"`
Rev string `json:"rev,omitempty"`
Change string `json:"change,omitempty"`
+ Commit string `json:"commit,omitempty"`
File string `json:"file,omitempty"`
Start int `json:"start,omitempty"`
End int `json:"end,omitempty"`
@@ -47,16 +47,16 @@ type Agent struct {
}
type Note struct {
- ID int // position in the log, for anchors
- NoteID int // the agent's own per-session number
- Agent *Agent
- Session, Model, Reason string
- File, Kind, Text, Change, Rev string
- Start, End int
- ReplyTo int
- Replies, Mentions []*Note // notes that point back to this one
- At time.Time
- When, Ago string
+ ID int // position in the log, for anchors
+ NoteID int // the agent's own per-session number
+ Agent *Agent
+ Session, Model, Reason string
+ File, Kind, Text, Change, Commit, Rev string
+ Start, End int
+ ReplyTo int
+ Replies, Mentions []*Note // notes that point back to this one
+ At time.Time
+ When, Ago string
}
// ThreadNode is the tree-shaped rendering of the note DAG. A note reached by
@@ -164,7 +164,7 @@ func buildModel(title string, entries []Entry, only string) *Site {
at := parseTime(e.Time)
n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session,
Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change,
- Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo,
+ Commit: e.Commit, Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo,
Kind: e.Type, Text: e.Text,
At: at, When: stamp(at), Ago: ago(at)}
s.Notes = append(s.Notes, n)
@@ -547,7 +547,7 @@ func describe(e Entry) string {
return e.Op
}
-// ── log file ──────────────────────────────────────────────────────────────
+// ── sidecar JSONL ─────────────────────────────────────────────────────────
func parseLog(b []byte) []Entry {
var entries []Entry
@@ -563,28 +563,20 @@ func parseLog(b []byte) []Entry {
return entries
}
-func readLog(path string) []Entry {
- b, err := os.ReadFile(path)
+func readLog(log logStore) []Entry {
+ b, err := log.Read()
if err != nil {
fatal("%v", err)
}
return parseLog(b)
}
-// appendEntry adds one line to the log. O_APPEND plus a single write keeps it
-// interleaving safely with vr processes writing the same file.
-func appendEntry(path string, e Entry) error {
- b, err := json.Marshal(e)
- if err != nil {
- return err
- }
- f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
- if err != nil {
- return err
- }
- defer f.Close()
- _, err = f.Write(append(b, '\n'))
- return err
+func appendEntry(log logStore, e *Entry) error {
+ return log.Append(func(current []byte) ([]byte, error) {
+ e.NoteID = nextNoteID(parseLog(current), e.Session)
+ b, err := json.Marshal(e)
+ return append(b, '\n'), err
+ })
}
// ── time ──────────────────────────────────────────────────────────────────
diff --git a/vrsite/page.go b/vrsite/page.go
index cca0c58..1570d45 100644
--- a/vrsite/page.go
+++ b/vrsite/page.go
@@ -17,7 +17,7 @@ type page struct {
Site *Site
Title string
Kind string // index | file | files | notes | note | jj | change | session
- Root string // static: relative prefix; live: "/"
+ Root string // static: relative prefix; live: route prefix ending in "/"
Live bool
Rev string // revision being browsed ("" = the change the log points at)
ChangeID string // resolved change id for Rev — what pages name and link
@@ -63,6 +63,7 @@ type page struct {
// JJView is one rendered jj query: what was asked, and what came back.
type JJView struct {
+ Root string
Kind string // log | op
Revset string
AtOp string
@@ -95,7 +96,7 @@ func (p *page) paramsWith(scope string) string {
func (p *page) ScopeHref(scope string) string {
path := p.Path
if path == "" {
- path = "/"
+ path = p.liveRoot()
}
return path + p.paramsWith(scope)
}
@@ -118,7 +119,21 @@ func (p *page) JumpPath() string {
return p.Path
}
}
- return "/files"
+ return p.liveRoot() + "files"
+}
+
+func (p *page) liveRoot() string {
+ root := p.Root
+ if root == "" {
+ return "/"
+ }
+ if !strings.HasPrefix(root, "/") {
+ root = "/" + root
+ }
+ if !strings.HasSuffix(root, "/") {
+ root += "/"
+ }
+ return root
}
// Href maps a logical destination to a URL for this page's mode. Kinds the
@@ -129,29 +144,38 @@ func (p *page) Href(kind string, arg ...string) string {
a = arg[0]
}
if p.Live {
+ root := p.liveRoot()
switch kind {
case "":
- return "/" + p.params()
+ return root + p.params()
case "notes":
- return "/notes" + p.params()
+ return root + "notes" + p.params()
+ case "notes-list":
+ return root + "notes/list" + p.params()
case "files":
- return "/files" + p.params()
+ return root + "files" + p.params()
case "tree":
- return "/tree" + p.params()
+ return root + "tree" + p.params()
case "jj":
- return "/jj"
+ return root + "jj"
+ case "jj-log":
+ return root + "jj/log"
+ case "jj-op":
+ return root + "jj/op"
+ case "activity":
+ return root + "activity"
case "code":
- return "/code/" + escPath(a) + p.params()
+ return root + "code/" + escPath(a) + p.params()
case "note":
- return "/note/" + a
+ return root + "note/" + a
case "change":
- return "/change/" + a
+ return root + "change/" + a
case "session":
- return "/session/" + escPath(a)
+ return root + "session/" + escPath(a)
case "asset":
- return "/" + a
+ return root + a
}
- return "/"
+ return root
}
switch kind {
case "":
diff --git a/vrsite/projects.go b/vrsite/projects.go
new file mode 100644
index 0000000..5808a92
--- /dev/null
+++ b/vrsite/projects.go
@@ -0,0 +1,698 @@
+// projects.go — the stateless, multi-repository front door for vrsite.
+//
+// The hub is an index and dispatcher, not another database. It reconstructs
+// its project list from .jj/.git markers and vr sidecars at startup or on an
+// explicit refresh. Per-project pages are still served by server, and the only
+// writes are the two actions named by their buttons: initialize jj metadata or
+// create the normal private vr sidecar/config.
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "0x4200.cafe/vr/sidecar"
+)
+
+type projectKind string
+
+const (
+ projectActive projectKind = "active"
+ projectJJ projectKind = "jj"
+ projectGit projectKind = "git"
+)
+
+type Project struct {
+ ID, Key, Name, Path, Relative string
+ Kind projectKind
+ Workspaces []string
+ Entries, Notes, Agents int
+ Updated, Err string
+}
+
+type projectRuntime struct {
+ repo string
+ server *server
+ handler http.Handler
+}
+
+type projectHub struct {
+ scanRoot, title, user string
+ transcriptRoots []string
+ theme, light *Theme
+ themes *themeSet
+ tpl *template.Template
+
+ mu sync.RWMutex
+ projects map[string]*Project
+ order []*Project
+ runtimes map[string]*projectRuntime
+ scanned time.Time
+ scanErr string
+
+ refreshMu sync.Mutex
+ actionMu sync.Mutex
+}
+
+type projectsPage struct {
+ Title, ScanRoot, Scanned, Message, Err string
+ Active, Ready, Git []*Project
+ Theme, Light *Theme
+}
+
+func newProjectHub(scanRoot, title, user string, transcriptRoots []string, theme, light *Theme, themes *themeSet) (*projectHub, error) {
+ abs, err := filepath.Abs(scanRoot)
+ if err != nil {
+ return nil, err
+ }
+ tpl, err := template.New("projects.tmpl").ParseFS(tfs, "templates/projects.tmpl")
+ if err != nil {
+ return nil, err
+ }
+ h := &projectHub{
+ scanRoot: abs, title: title, user: user, transcriptRoots: transcriptRoots,
+ theme: theme, light: light, themes: themes, tpl: tpl,
+ projects: map[string]*Project{}, runtimes: map[string]*projectRuntime{},
+ }
+ if err := h.refresh(); err != nil {
+ return nil, err
+ }
+ return h, nil
+}
+
+func (h *projectHub) refresh() error {
+ h.refreshMu.Lock()
+ defer h.refreshMu.Unlock()
+ projects, scanErr, err := discoverProjects(h.scanRoot)
+ if err != nil {
+ return err
+ }
+ byID := make(map[string]*Project, len(projects))
+ for _, p := range projects {
+ byID[p.ID] = p
+ }
+ h.mu.Lock()
+ h.projects, h.order = byID, projects
+ h.runtimes = map[string]*projectRuntime{} // derived cache; rebuild lazily
+ h.scanned, h.scanErr = time.Now(), scanErr
+ h.mu.Unlock()
+ return nil
+}
+
+type repoCandidate struct {
+ path string
+ git bool
+}
+
+func discoverProjects(root string) ([]*Project, string, error) {
+ var candidates []repoCandidate
+ var walkErrs []string
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ if path == root {
+ return err
+ }
+ walkErrs = append(walkErrs, fmt.Sprintf("%s: %v", path, err))
+ if d != nil && d.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !d.IsDir() {
+ return nil
+ }
+ if path != root && skipScanDir(d.Name()) {
+ return filepath.SkipDir
+ }
+ if markerExists(filepath.Join(path, ".jj")) {
+ candidates = append(candidates, repoCandidate{path: path})
+ return filepath.SkipDir
+ }
+ if markerExists(filepath.Join(path, ".git")) {
+ candidates = append(candidates, repoCandidate{path: path, git: true})
+ return filepath.SkipDir
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, "", err
+ }
+
+ // A jj repository can have several workspaces. The repo-local config path is
+ // stable across them and gives the dashboard one project, not duplicate logs.
+ groups := map[string]*Project{}
+ for _, c := range candidates {
+ if c.git {
+ key := filepath.Clean(c.path)
+ p := &Project{ID: projectID("git", key), Key: key, Path: key, Kind: projectGit}
+ nameProject(p, root)
+ groups["git\x00"+key] = p
+ continue
+ }
+ out, err := jjRun(c.path, "config", "path", "--repo")
+ if err != nil {
+ walkErrs = append(walkErrs, fmt.Sprintf("%s: %v", c.path, err))
+ continue
+ }
+ key := filepath.Clean(strings.TrimSpace(string(out)))
+ gkey := "jj\x00" + key
+ p := groups[gkey]
+ if p == nil {
+ p = &Project{ID: projectID("jj", key), Key: key, Path: c.path, Kind: projectJJ}
+ groups[gkey] = p
+ }
+ p.Workspaces = append(p.Workspaces, c.path)
+ if preferredWorkspace(c.path, p.Path) {
+ p.Path = c.path
+ }
+ }
+
+ projects := make([]*Project, 0, len(groups))
+ for _, p := range groups {
+ if p.Kind != projectGit {
+ sort.Strings(p.Workspaces)
+ active, err := hasVRSidecar(p.Path)
+ if err != nil {
+ p.Err = err.Error()
+ } else if active {
+ p.Kind = projectActive
+ loadProjectStats(p)
+ }
+ }
+ nameProject(p, root)
+ projects = append(projects, p)
+ }
+ sort.Slice(projects, func(i, j int) bool {
+ if projects[i].Kind != projects[j].Kind {
+ return projects[i].Kind < projects[j].Kind
+ }
+ return strings.ToLower(projects[i].Relative) < strings.ToLower(projects[j].Relative)
+ })
+ return projects, strings.Join(walkErrs, "; "), nil
+}
+
+func skipScanDir(name string) bool {
+ switch name {
+ case ".git", ".jj", ".cache", ".cargo", ".local", ".npm", ".rustup", ".var",
+ "node_modules", "target", "vendor":
+ return true
+ }
+ return false
+}
+
+func markerExists(path string) bool {
+ _, err := os.Lstat(path)
+ return err == nil
+}
+
+func preferredWorkspace(candidate, current string) bool {
+ return current == "" || len(candidate) < len(current) || len(candidate) == len(current) && candidate < current
+}
+
+func projectID(kind, key string) string {
+ sum := sha256.Sum256([]byte(kind + "\x00" + key))
+ return fmt.Sprintf("%x", sum[:8])
+}
+
+func nameProject(p *Project, root string) {
+ p.Name = filepath.Base(p.Path)
+ rel, err := filepath.Rel(root, p.Path)
+ if err != nil || strings.HasPrefix(rel, "..") {
+ p.Relative = p.Path
+ } else if rel == "." {
+ p.Relative = "~"
+ } else {
+ p.Relative = "~/" + filepath.ToSlash(rel)
+ }
+}
+
+func sidecarChanges(repo string) ([]string, error) {
+ revset := fmt.Sprintf("root()+ & subject(exact:%s)", strconv.Quote(sidecar.Description))
+ out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", "change_id ++ \"\\n\"")
+ if err != nil {
+ return nil, err
+ }
+ return strings.Fields(string(out)), nil
+}
+
+func hasVRSidecar(repo string) (bool, error) {
+ ids, err := sidecarChanges(repo)
+ if err != nil {
+ return false, err
+ }
+ switch len(ids) {
+ case 0:
+ return false, nil
+ case 1:
+ return true, nil
+ default:
+ return false, fmt.Errorf("found %d private vr sidecars", len(ids))
+ }
+}
+
+func loadProjectStats(p *Project) {
+ log, err := sidecar.Open(p.Path)
+ if err != nil {
+ p.Err = err.Error()
+ return
+ }
+ b, err := log.Read()
+ if err != nil && !errors.Is(err, sidecar.ErrNoLog) {
+ p.Err = err.Error()
+ return
+ }
+ entries := parseLog(b)
+ p.Entries = len(entries)
+ agents := map[string]bool{}
+ var latest time.Time
+ for _, e := range entries {
+ if e.Op == "note" {
+ p.Notes++
+ }
+ if e.Agent != "" {
+ agents[e.Agent] = true
+ }
+ if at := parseTime(e.Time); at.After(latest) {
+ latest = at
+ }
+ }
+ p.Agents = len(agents)
+ if latest.IsZero() {
+ p.Updated = "no entries yet"
+ } else {
+ p.Updated = ago(latest)
+ }
+}
+
+func (h *projectHub) handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /{$}", h.dashboard)
+ mux.HandleFunc("POST /projects/refresh", h.refreshAction)
+ mux.HandleFunc("POST /projects/{id}/start", h.startAction)
+ mux.HandleFunc("POST /git/{id}/init", h.initGitAction)
+ mux.HandleFunc("GET /p/{id}", h.projectSlash)
+ mux.HandleFunc("/p/{id}/{rest...}", h.projectRoute)
+ mux.HandleFunc("GET /style.css", h.styleCSS)
+ mux.HandleFunc("GET /themes.css", h.themeAsset)
+ mux.HandleFunc("GET /themes.js", h.themeAsset)
+ mux.HandleFunc("GET /app.js", h.asset)
+ mux.HandleFunc("GET /htmx.min.js", h.asset)
+ return mux
+}
+
+func (h *projectHub) dashboard(w http.ResponseWriter, r *http.Request) {
+ h.mu.RLock()
+ p := projectsPage{Title: h.title, ScanRoot: h.scanRoot, Scanned: h.scanned.Format("2006-01-02 15:04:05"),
+ Message: r.URL.Query().Get("message"), Err: r.URL.Query().Get("error"), Theme: h.theme, Light: h.light}
+ if p.Err == "" {
+ p.Err = h.scanErr
+ }
+ for _, project := range h.order {
+ switch project.Kind {
+ case projectActive:
+ p.Active = append(p.Active, project)
+ case projectJJ:
+ p.Ready = append(p.Ready, project)
+ case projectGit:
+ p.Git = append(p.Git, project)
+ }
+ }
+ h.mu.RUnlock()
+ var b bytes.Buffer
+ if err := h.tpl.ExecuteTemplate(&b, "projects", p); err != nil {
+ http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ b.WriteTo(w)
+}
+
+func (h *projectHub) refreshAction(w http.ResponseWriter, r *http.Request) {
+ if !localPost(w, r) {
+ return
+ }
+ if err := h.refresh(); err != nil {
+ homeRedirect(w, r, "", err)
+ return
+ }
+ homeRedirect(w, r, "projects refreshed", nil)
+}
+
+func (h *projectHub) startAction(w http.ResponseWriter, r *http.Request) {
+ if !localPost(w, r) {
+ return
+ }
+ p := h.project(r.PathValue("id"))
+ if p == nil || p.Kind != projectJJ {
+ http.NotFound(w, r)
+ return
+ }
+ h.actionMu.Lock()
+ err := ensureVRSidecar(p.Path)
+ h.actionMu.Unlock()
+ if err != nil {
+ homeRedirect(w, r, "", err)
+ return
+ }
+ if err := h.refresh(); err != nil {
+ homeRedirect(w, r, "", err)
+ return
+ }
+ if active := h.projectByKey(p.Key, projectActive); active != nil {
+ http.Redirect(w, r, "/p/"+active.ID+"/", http.StatusSeeOther)
+ return
+ }
+ homeRedirect(w, r, "sidecar created", nil)
+}
+
+func (h *projectHub) initGitAction(w http.ResponseWriter, r *http.Request) {
+ if !localPost(w, r) {
+ return
+ }
+ p := h.project(r.PathValue("id"))
+ if p == nil || p.Kind != projectGit {
+ http.NotFound(w, r)
+ return
+ }
+ h.actionMu.Lock()
+ err := initColocated(p.Path)
+ h.actionMu.Unlock()
+ if err != nil {
+ homeRedirect(w, r, "", err)
+ return
+ }
+ if err := h.refresh(); err != nil {
+ homeRedirect(w, r, "", err)
+ return
+ }
+ homeRedirect(w, r, "jj initialized; the repository is ready for a vr sidecar", nil)
+}
+
+func localPost(w http.ResponseWriter, r *http.Request) bool {
+ if o := r.Header.Get("Origin"); o != "" {
+ if u, err := url.Parse(o); err != nil || u.Host != r.Host {
+ http.Error(w, "cross-origin post refused", http.StatusForbidden)
+ return false
+ }
+ }
+ return true
+}
+
+func homeRedirect(w http.ResponseWriter, r *http.Request, message string, err error) {
+ q := url.Values{}
+ if message != "" {
+ q.Set("message", message)
+ }
+ if err != nil {
+ q.Set("error", err.Error())
+ }
+ to := "/"
+ if encoded := q.Encode(); encoded != "" {
+ to += "?" + encoded
+ }
+ http.Redirect(w, r, to, http.StatusSeeOther)
+}
+
+func (h *projectHub) project(id string) *Project {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ return h.projects[id]
+}
+
+func (h *projectHub) projectByKey(key string, kind projectKind) *Project {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ for _, p := range h.order {
+ if p.Key == key && p.Kind == kind {
+ return p
+ }
+ }
+ return nil
+}
+
+func (h *projectHub) projectSlash(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if p := h.project(id); p == nil || p.Kind != projectActive {
+ http.NotFound(w, r)
+ return
+ }
+ http.Redirect(w, r, "/p/"+id+"/", http.StatusPermanentRedirect)
+}
+
+func (h *projectHub) projectRoute(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ runtime, err := h.runtime(id)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ }
+ rest := r.PathValue("rest")
+ clone := r.Clone(r.Context())
+ u := *r.URL
+ u.Path, u.RawPath = "/"+rest, ""
+ clone.URL = &u
+ clone.RequestURI = u.RequestURI()
+ runtime.handler.ServeHTTP(w, clone)
+}
+
+func (h *projectHub) runtime(id string) (*projectRuntime, error) {
+ h.mu.RLock()
+ if rt := h.runtimes[id]; rt != nil {
+ h.mu.RUnlock()
+ return rt, nil
+ }
+ p := h.projects[id]
+ h.mu.RUnlock()
+ if p == nil || p.Kind != projectActive {
+ return nil, fmt.Errorf("project not found")
+ }
+ log, err := sidecar.Open(p.Path)
+ if err != nil {
+ return nil, err
+ }
+ title := p.Name
+ if h.title != "" && h.title != "vr projects" {
+ title += " — " + h.title
+ }
+ sv := newServerAt(log, p.Path, title, h.user, "/p/"+id+"/", h.theme, h.light, h.themes)
+ sv.tx = newTranscripts(h.transcriptRoots)
+ rt := &projectRuntime{repo: p.Path, server: sv, handler: sv.handler()}
+ h.mu.Lock()
+ if existing := h.runtimes[id]; existing != nil {
+ rt = existing
+ } else {
+ h.runtimes[id] = rt
+ }
+ h.mu.Unlock()
+ return rt, nil
+}
+
+func ensureVRSidecar(repo string) error {
+ config, err := jjRun(repo, "config", "path", "--repo")
+ if err != nil {
+ return err
+ }
+ lockPath := filepath.Join(filepath.Dir(strings.TrimSpace(string(config))), "vr-agent-logger.lock")
+ if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
+ return err
+ }
+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
+ return err
+ }
+ defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:errcheck
+
+ configuredValue, configErr := jjRun(repo, "config", "get", "revset-aliases."+sidecar.Alias)
+ configured := configErr == nil
+ var id string
+ if configured {
+ resolved, err := jjLogValue(repo, "exactly("+sidecar.Alias+", 1)", "change_id")
+ if err != nil {
+ return fmt.Errorf("repo-local revset alias %s is invalid: %w", sidecar.Alias, err)
+ }
+ valid := fmt.Sprintf("exactly(%s & root()+ & subject(exact:%s), 1)", sidecar.Alias, strconv.Quote(sidecar.Description))
+ if _, err := jjLogValue(repo, valid, "change_id"); err != nil {
+ return fmt.Errorf("repo-local revset alias %s=%q does not name vr's sidecar: %w",
+ sidecar.Alias, strings.TrimSpace(string(configuredValue)), err)
+ }
+ id = resolved
+ } else {
+ ids, err := sidecarChanges(repo)
+ if err != nil {
+ return err
+ }
+ switch len(ids) {
+ case 0:
+ case 1:
+ id = ids[0]
+ default:
+ return fmt.Errorf("found %d root children described %q; cannot choose a vr sidecar", len(ids), sidecar.Description)
+ }
+ }
+ if id == "" {
+ if err := jjMutate(repo, "new", "--no-edit", "root()", "-m", sidecar.Description); err != nil {
+ return err
+ }
+ ids, err := sidecarChanges(repo)
+ if err != nil {
+ return err
+ }
+ if len(ids) != 1 {
+ return fmt.Errorf("created %q sidecar but found %d matching root children", sidecar.Description, len(ids))
+ }
+ id = ids[0]
+ }
+ if !configured {
+ if err := jjMutate(repo, "config", "set", "--repo", "revset-aliases."+sidecar.Alias, strconv.Quote(id)); err != nil {
+ return err
+ }
+ }
+ private, err := jjRun(repo, "config", "get", "git.private-commits")
+ if err != nil {
+ return err
+ }
+ expr := strings.TrimSpace(string(private))
+ if !mentionsRevset(expr, sidecar.Alias) {
+ expr = "(" + expr + ") | " + sidecar.Alias
+ if err := jjMutate(repo, "config", "set", "--repo", "git.private-commits", strconv.Quote(expr)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func jjLogValue(repo, revset, template string) (string, error) {
+ out, err := jjRun(repo, "log", "-G", "-r", revset, "-T", template)
+ if err != nil {
+ return "", err
+ }
+ value := strings.TrimSpace(string(out))
+ if value == "" {
+ return "", fmt.Errorf("revision %q produced no %s", revset, template)
+ }
+ return value, nil
+}
+
+func mentionsRevset(expr, name string) bool {
+ for i := 0; i+len(name) <= len(expr); i++ {
+ if expr[i:i+len(name)] != name {
+ continue
+ }
+ before := i == 0 || !revsetNameByte(expr[i-1])
+ after := i+len(name) == len(expr) || !revsetNameByte(expr[i+len(name)])
+ if before && after {
+ return true
+ }
+ }
+ return false
+}
+
+func revsetNameByte(b byte) bool {
+ return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-'
+}
+
+func jjMutate(repo string, args ...string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), jjTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "jj", append([]string{"--ignore-working-copy", "--no-pager"}, args...)...)
+ cmd.Dir = repo
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("jj %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+func initColocated(repo string) error {
+ if !markerExists(filepath.Join(repo, ".git")) {
+ return fmt.Errorf("%s is no longer a git repository", repo)
+ }
+ if markerExists(filepath.Join(repo, ".jj")) {
+ return fmt.Errorf("%s already has jj metadata", repo)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "jj", "git", "init", "--colocate")
+ cmd.Dir = repo
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("jj git init --colocate: %s", strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+func (h *projectHub) styleCSS(w http.ResponseWriter, _ *http.Request) {
+ site := buildModel(h.title, nil, "")
+ site.Theme, site.Light = h.theme, h.light
+ var b bytes.Buffer
+ if err := renderCSS(&b, site); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ b.WriteTo(w)
+}
+
+func (h *projectHub) themeAsset(w http.ResponseWriter, r *http.Request) {
+ body, ctype := h.themes.JS, "text/javascript; charset=utf-8"
+ if strings.HasSuffix(r.URL.Path, ".css") {
+ body, ctype = h.themes.CSS, "text/css; charset=utf-8"
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Header().Set("Cache-Control", "max-age=300")
+ w.Write(body)
+}
+
+func (h *projectHub) asset(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(r.URL.Path, "/")
+ b, err := tfs.ReadFile("assets/" + name)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ w.Header().Set("Cache-Control", "max-age=300")
+ w.Write(b)
+}
+
+func serveProjects(addr string, hub *projectHub) {
+ hub.mu.RLock()
+ active, ready, git := 0, 0, 0
+ for _, p := range hub.order {
+ switch p.Kind {
+ case projectActive:
+ active++
+ case projectJJ:
+ ready++
+ case projectGit:
+ git++
+ }
+ }
+ hub.mu.RUnlock()
+ fmt.Printf("vrsite: http://%s — %d projects, %d jj repos ready to start, %d git repos under %s\n",
+ addr, active, ready, git, hub.scanRoot)
+ fmt.Println("vrsite: project discovery is in memory; refresh rescans the filesystem")
+ if err := http.ListenAndServe(addr, hub.handler()); err != nil {
+ fatal("%v", err)
+ }
+}
diff --git a/vrsite/projects_test.go b/vrsite/projects_test.go
new file mode 100644
index 0000000..79fc6bc
--- /dev/null
+++ b/vrsite/projects_test.go
@@ -0,0 +1,315 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "0x4200.cafe/vr/sidecar"
+)
+
+func projectTestEnv(t *testing.T, home string) {
+ t.Helper()
+ t.Setenv("HOME", home)
+ t.Setenv("JJ_USER", "test")
+ t.Setenv("JJ_EMAIL", "[email protected]")
+}
+
+func runAt(t *testing.T, dir, name string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command(name, args...)
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("%s %s in %s: %v\n%s", name, strings.Join(args, " "), dir, err, out)
+ }
+ return string(out)
+}
+
+func initJJAt(t *testing.T, dir string) (change, commit string) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ runAt(t, dir, "jj", "git", "init")
+ if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runAt(t, dir, "jj", "describe", "-m", "test project")
+ change, commit, err := resolveRevision(dir, "@")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return change, commit
+}
+
+func addTestSidecar(t *testing.T, repo, change, commit string) {
+ t.Helper()
+ log, err := sidecar.Open(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ line, err := json.Marshal(Entry{
+ Time: "2026-08-02T12:00:00Z", Op: "note", Session: "s1", Agent: "codex",
+ Dir: repo, Rev: "@", Change: change, Commit: commit, File: "hello.txt",
+ Start: 1, End: 1, NoteID: 1, Text: "project note",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := log.Append(func([]byte) ([]byte, error) { return []byte(line), nil }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func initGitAt(t *testing.T, dir string) {
+ t.Helper()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ runAt(t, dir, "git", "init", "-q")
+ if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("git only\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func testProjectHub(t *testing.T, home string) *projectHub {
+ t.Helper()
+ dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light")
+ h, err := newProjectHub(home, "vr projects", "tester", nil, dark, light, testThemes(dark, light))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return h
+}
+
+func projectOfKind(t *testing.T, h *projectHub, kind projectKind, name string) *Project {
+ t.Helper()
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ for _, p := range h.order {
+ if p.Kind == kind && p.Name == name {
+ return p
+ }
+ }
+ t.Fatalf("no %s project named %s", kind, name)
+ return nil
+}
+
+func TestDiscoverProjectsClassifiesAndDeduplicates(t *testing.T) {
+ if _, err := exec.LookPath("jj"); err != nil {
+ t.Skip("jj not installed")
+ }
+ home := t.TempDir()
+ projectTestEnv(t, home)
+ active := filepath.Join(home, "src", "active")
+ change, commit := initJJAt(t, active)
+ addTestSidecar(t, active, change, commit)
+ workspace := filepath.Join(home, "src", "active-workspace")
+ runAt(t, active, "jj", "workspace", "add", workspace)
+ initJJAt(t, filepath.Join(home, "src", "ready"))
+ initGitAt(t, filepath.Join(home, "src", "git-only"))
+
+ projects, scanErr, err := discoverProjects(home)
+ if err != nil || scanErr != "" {
+ t.Fatalf("discover = %v, %q", err, scanErr)
+ }
+ if len(projects) != 3 {
+ t.Fatalf("projects = %d, want 3: %+v", len(projects), projects)
+ }
+ counts := map[projectKind]int{}
+ for _, p := range projects {
+ counts[p.Kind]++
+ if p.Kind == projectActive {
+ if p.Entries != 1 || p.Notes != 1 || p.Agents != 1 || len(p.Workspaces) != 2 {
+ t.Fatalf("active project = %+v", p)
+ }
+ }
+ }
+ if counts[projectActive] != 1 || counts[projectJJ] != 1 || counts[projectGit] != 1 {
+ t.Fatalf("classification = %+v", counts)
+ }
+}
+
+func TestProjectHubRoutesAnExistingLogUnderPrefix(t *testing.T) {
+ home := t.TempDir()
+ projectTestEnv(t, home)
+ repo := filepath.Join(home, "code", "alpha")
+ change, commit := initJJAt(t, repo)
+ addTestSidecar(t, repo, change, commit)
+ hub := testProjectHub(t, home)
+ p := projectOfKind(t, hub, projectActive, "alpha")
+ h := hub.handler()
+
+ for _, tc := range []struct{ path, want string }{
+ {"/", "alpha"},
+ {"/", `id="themepick"`},
+ {"/", `data-js="/themes.js"`},
+ {"/", `l.href = "/themes.css"`},
+ {"/p/" + p.ID + "/", "project note"},
+ {"/p/" + p.ID + "/code/hello.txt", "two"},
+ {"/p/" + p.ID + "/style.css", ".project-grid"},
+ {"/p/" + p.ID + "/jj", "/p/" + p.ID + "/jj/log"},
+ } {
+ w := get(t, h, tc.path, false)
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), tc.want) {
+ t.Errorf("GET %s = %d, want body containing %q\n%s", tc.path, w.Code, tc.want, w.Body.String())
+ }
+ }
+ body := get(t, h, "/p/"+p.ID+"/", false).Body.String()
+ for _, want := range []string{"/p/" + p.ID + "/style.css", "/p/" + p.ID + "/activity", ">projects</a>"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("prefixed page lacks %q", want)
+ }
+ }
+
+ form := url.Values{"text": {"written through the project route"}, "change": {change}}
+ r := httptest.NewRequest("POST", "/p/"+p.ID+"/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/p/"+p.ID+"/notes" {
+ t.Fatalf("prefixed POST = %d, location %q: %s", w.Code, w.Header().Get("Location"), w.Body.String())
+ }
+ log, err := sidecar.Open(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := log.Read()
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries := parseLog(b)
+ if last := entries[len(entries)-1]; last.Text != "written through the project route" || last.Change != change {
+ t.Fatalf("prefixed POST recorded %+v", last)
+ }
+}
+
+func TestProjectHubStartsAnEmptyPrivateSidecar(t *testing.T) {
+ home := t.TempDir()
+ projectTestEnv(t, home)
+ repo := filepath.Join(home, "code", "ready")
+ initJJAt(t, repo)
+ hub := testProjectHub(t, home)
+ p := projectOfKind(t, hub, projectJJ, "ready")
+ beforeStatus, _ := jjRun(repo, "status")
+ _, beforeCommit, err := resolveRevision(repo, "@")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ r := httptest.NewRequest("POST", "/projects/"+p.ID+"/start", nil)
+ w := httptest.NewRecorder()
+ hub.handler().ServeHTTP(w, r)
+ if w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/p/") {
+ t.Fatalf("start = %d, location %q: %s", w.Code, w.Header().Get("Location"), w.Body.String())
+ }
+ opened := get(t, hub.handler(), w.Header().Get("Location"), false)
+ if opened.Code != http.StatusOK || !strings.Contains(opened.Body.String(), "ready") {
+ t.Fatalf("new empty project page = %d: %s", opened.Code, opened.Body.String())
+ }
+ active, err := hasVRSidecar(repo)
+ if err != nil || !active {
+ t.Fatalf("sidecar = %v, %v", active, err)
+ }
+ afterStatus, _ := jjRun(repo, "status")
+ _, afterCommit, err := resolveRevision(repo, "@")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(afterStatus) != string(beforeStatus) || afterCommit != beforeCommit {
+ t.Fatal("starting the sidecar changed the project working copy")
+ }
+ log, err := sidecar.Open(repo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b, err := log.Read(); err != nil || len(b) != 0 {
+ t.Fatalf("new sidecar log = %q, %v", b, err)
+ }
+ if _, err := os.Stat(filepath.Join(repo, sidecar.LogFile)); !os.IsNotExist(err) {
+ t.Fatalf("sidecar materialized in the project: %v", err)
+ }
+ private, err := jjRun(repo, "config", "get", "git.private-commits")
+ if err != nil || !mentionsRevset(string(private), sidecar.Alias) {
+ t.Fatalf("private commits = %q, %v", private, err)
+ }
+}
+
+func TestEnsureVRSidecarRefusesAConflictingAlias(t *testing.T) {
+ home := t.TempDir()
+ projectTestEnv(t, home)
+ repo := filepath.Join(home, "code", "conflict")
+ initJJAt(t, repo)
+ runAt(t, repo, "jj", "config", "set", "--repo", "revset-aliases."+sidecar.Alias, `"root()"`)
+
+ err := ensureVRSidecar(repo)
+ if err == nil || !strings.Contains(err.Error(), "does not name vr's sidecar") {
+ t.Fatalf("ensure with conflicting alias = %v", err)
+ }
+ if active, err := hasVRSidecar(repo); err != nil || active {
+ t.Fatalf("conflicting alias created a sidecar: %v, %v", active, err)
+ }
+ alias := strings.TrimSpace(runAt(t, repo, "jj", "config", "get", "revset-aliases."+sidecar.Alias))
+ if alias != "root()" {
+ t.Fatalf("conflicting alias was overwritten: %q", alias)
+ }
+}
+
+func TestProjectHubInitializesGitThenRefreshes(t *testing.T) {
+ home := t.TempDir()
+ projectTestEnv(t, home)
+ repo := filepath.Join(home, "code", "git-only")
+ initGitAt(t, repo)
+ hub := testProjectHub(t, home)
+ p := projectOfKind(t, hub, projectGit, "git-only")
+
+ r := httptest.NewRequest("POST", "/git/"+p.ID+"/init", nil)
+ w := httptest.NewRecorder()
+ hub.handler().ServeHTTP(w, r)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("init = %d: %s", w.Code, w.Body.String())
+ }
+ if !markerExists(filepath.Join(repo, ".jj")) {
+ t.Fatal("colocated jj metadata was not created")
+ }
+ projectOfKind(t, hub, projectJJ, "git-only")
+ if got := string(mustRead(t, filepath.Join(repo, "README.md"))); got != "git only\n" {
+ t.Fatalf("working file changed: %q", got)
+ }
+
+ late := filepath.Join(home, "code", "appeared-later")
+ initJJAt(t, late)
+ r = httptest.NewRequest("POST", "/projects/refresh", nil)
+ w = httptest.NewRecorder()
+ hub.handler().ServeHTTP(w, r)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("refresh = %d", w.Code)
+ }
+ projectOfKind(t, hub, projectJJ, "appeared-later")
+}
+
+func TestProjectHubRefusesCrossOriginMutation(t *testing.T) {
+ home := t.TempDir()
+ projectTestEnv(t, home)
+ repo := filepath.Join(home, "code", "ready")
+ initJJAt(t, repo)
+ hub := testProjectHub(t, home)
+ p := projectOfKind(t, hub, projectJJ, "ready")
+ r := httptest.NewRequest("POST", "/projects/"+p.ID+"/start", nil)
+ r.Header.Set("Origin", "https://elsewhere.example")
+ w := httptest.NewRecorder()
+ hub.handler().ServeHTTP(w, r)
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("cross-origin start = %d", w.Code)
+ }
+ if active, err := hasVRSidecar(repo); err != nil || active {
+ t.Fatalf("cross-origin request created sidecar: %v, %v", active, err)
+ }
+}
diff --git a/vrsite/serve.go b/vrsite/serve.go
index 31217af..746abac 100644
--- a/vrsite/serve.go
+++ b/vrsite/serve.go
@@ -3,9 +3,9 @@
// The server owns nothing. Three rules keep it that way, and app_test.go holds
// each of them:
//
-// - The log is the only thing written. Notes typed in the browser are
-// appended to it exactly as vr appends them; the repository is only ever
-// read (see jj.go), and no file of the app's own is ever created.
+// - The log sidecar is the only thing written. Notes typed in the browser are
+// appended to it exactly as vr appends them; project history and the main
+// working copy are only ever read (see jj.go).
// - Memory is a cache, never a source. The model, the per-change
// projections, file lists and rendered pages are all derived from the log
// and the repo, so a restart changes nothing anyone can see.
@@ -27,7 +27,6 @@ import (
"html/template"
"net/http"
"net/url"
- "os"
"strconv"
"strings"
"sync"
@@ -43,18 +42,18 @@ const liveWindow = 5 * time.Minute
const renderCacheMax = 64
type server struct {
- logPath, repo, title, user, session string
- tpl *template.Template
- hl *highlighter
- theme, light *Theme
- themes *themeSet
- tx *transcripts
+ log logStore
+ repo, title, user, session, base string
+ tpl *template.Template
+ hl *highlighter
+ theme, light *Theme
+ themes *themeSet
+ tx *transcripts
mu sync.Mutex
site *Site
entries []Entry
- off int64
- state logState
+ state string
gen int
def string // resolved change the log points at
models map[string]*Site // scope key -> the log projected onto it
@@ -65,12 +64,17 @@ type server struct {
// scopeAll is the key of the unscoped model — the whole log, every change.
const scopeAll = "*"
-func newServer(logPath, repo, title, user string, theme, light *Theme, themes *themeSet) *server {
+func newServer(log logStore, repo, title, user string, theme, light *Theme, themes *themeSet) *server {
+ return newServerAt(log, repo, title, user, "/", theme, light, themes)
+}
+
+func newServerAt(log logStore, repo, title, user, base string, theme, light *Theme, themes *themeSet) *server {
if themes == nil {
themes = &themeSet{}
}
+ base = (&page{Root: base}).liveRoot()
hl := newHighlighter(theme, light)
- sv := &server{logPath: logPath, repo: repo, title: title, user: user,
+ sv := &server{log: log, repo: repo, title: title, user: user, base: base,
session: "web:" + user, theme: theme, light: light, themes: themes,
tpl: newTemplates(hl), models: map[string]*Site{}, trees: map[string][]*File{},
rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())}
@@ -79,11 +83,10 @@ func newServer(logPath, repo, title, user string, theme, light *Theme, themes *t
return sv
}
-// reload re-reads the log if it changed. It appends from the last offset when
-// the file only grew — the common case, since vr and the web app both append.
+// reload re-reads the log when the sidecar commit id changes.
func (sv *server) reload(force bool) {
- st, err := statLog(sv.logPath)
- if err == nil && st == sv.state && !force {
+ state, err := sv.log.Generation()
+ if err == nil && state == sv.state && !force {
return
}
if err != nil {
@@ -92,22 +95,17 @@ func (sv *server) reload(force bool) {
}
return
}
- if st.size < sv.off {
- sv.off, sv.entries = 0, nil // truncated or rewritten
- }
- f, err := os.Open(sv.logPath)
+ b, err := sv.log.Read()
if err != nil {
+ if sv.site == nil {
+ sv.state = state
+ sv.site = sv.dress(buildModel(sv.title, nil, ""))
+ sv.gen++
+ }
return
}
- defer f.Close()
- buf := make([]byte, st.size-sv.off)
- n, _ := f.ReadAt(buf, sv.off)
- buf = buf[:n]
- if i := bytes.LastIndexByte(buf, '\n'); i >= 0 { // ignore a partial last line
- sv.off += int64(i + 1)
- sv.entries = append(sv.entries, parseLog(buf[:i+1])...)
- }
- sv.state = st
+ sv.entries = parseLog(b)
+ sv.state = state
sv.site = sv.dress(buildModel(sv.title, sv.entries, ""))
sv.gen++
sv.def = ""
@@ -257,8 +255,9 @@ func (sv *server) fileAt(site *Site, gen int, commit, change, path string) *File
func (sv *server) newPage(r *http.Request, kind, title string) (*page, *Site, error) {
full, _, def := sv.snap()
q := r.URL.Query()
- p := &page{Site: full, Kind: kind, Title: title, Root: "/", Live: true,
- User: sv.user, ChangeID: def, Default: def, Path: r.URL.Path, Scope: "change"}
+ path := sv.base + strings.TrimPrefix(r.URL.Path, "/")
+ p := &page{Site: full, Kind: kind, Title: title, Root: sv.base, Live: true,
+ User: sv.user, ChangeID: def, Default: def, Path: path, Scope: "change"}
rev := strings.TrimSpace(q.Get("rev"))
target := rev
if target == "" {
@@ -540,7 +539,7 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
}
target = fallback
}
- change, err := resolveChange(sv.repo, target)
+ change, commit, err := resolveRevision(sv.repo, target)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -556,14 +555,13 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
e := Entry{
Time: time.Now().Format(time.RFC3339), Op: "note",
Session: sv.session, Agent: sv.user, Dir: sv.repo,
- Rev: rev, Change: change, File: strings.TrimSpace(r.FormValue("file")),
+ Rev: rev, Change: change, Commit: commit, File: strings.TrimSpace(r.FormValue("file")),
Start: start, End: end, ReplyTo: replyTo,
Type: strings.TrimSpace(r.FormValue("kind")), Text: text,
}
sv.mu.Lock()
- e.NoteID = nextNoteID(sv.entries, sv.session)
- err = appendEntry(sv.logPath, e)
+ err = appendEntry(sv.log, &e)
if err == nil {
sv.reload(true)
}
@@ -583,12 +581,12 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
if !htmxReq(r) {
back := r.FormValue("back")
if back == "" {
- back = "/notes"
+ back = sv.base + "notes"
}
http.Redirect(w, r, back, http.StatusSeeOther)
return
}
- p := &page{Site: site, Kind: "file", Root: "/", Live: true, User: sv.user, Facets: site.Facets()}
+ p := &page{Site: site, Kind: "file", Root: sv.base, Live: true, User: sv.user, Facets: site.Facets()}
if e.File != "" {
p.File = site.Files[e.File]
}
@@ -609,7 +607,7 @@ func (sv *server) activity(w http.ResponseWriter, r *http.Request) {
if !compact {
limit = 20
}
- p := &page{Site: site, Live: true, Compact: compact, Window: "5 min",
+ p := &page{Site: site, Root: sv.base, Live: true, Compact: compact, Window: "5 min",
Sessions: site.Sessions(time.Now(), liveWindow, limit)}
sv.frag(w, "activity", p)
}
@@ -638,7 +636,7 @@ const defaultLimit = 50
func (sv *server) jjView(r *http.Request) *JJView {
q := r.URL.Query()
- v := &JJView{Kind: q.Get("kind"), Revset: strings.TrimSpace(q.Get("revset")),
+ v := &JJView{Root: sv.base, Kind: q.Get("kind"), Revset: strings.TrimSpace(q.Get("revset")),
AtOp: strings.TrimSpace(q.Get("at_op")), Limit: atoiDefault(q.Get("limit"), defaultLimit)}
if v.Limit < 1 || v.Limit > 1000 {
v.Limit = defaultLimit
@@ -678,7 +676,7 @@ func (sv *server) jjLog(w http.ResponseWriter, r *http.Request) {
func (sv *server) jjOp(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
- v := &JJView{Kind: "op", Limit: atoiDefault(q.Get("limit"), defaultLimit)}
+ v := &JJView{Root: sv.base, Kind: "op", Limit: atoiDefault(q.Get("limit"), defaultLimit)}
if v.Limit < 1 || v.Limit > 1000 {
v.Limit = defaultLimit
}
@@ -751,15 +749,15 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) {
w.Write(b)
}
-func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) {
- sv := newServer(logPath, repo, title, user, theme, light, themes)
+func serve(addr string, log logStore, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) {
+ sv := newServer(log, repo, title, user, theme, light, themes)
sv.tx = newTranscripts(roots)
site, _, def := sv.snap()
fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n",
addr, len(site.Notes), len(site.Agents), repo, short(def))
fmt.Printf("vrsite: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n",
theme.Name, light.Name, len(sv.themes.List), sv.themes.count("zed"), sv.themes.count("helix"))
- fmt.Printf("vrsite: comments are appended to %s; the repo is never written to\n", logPath)
+ fmt.Printf("vrsite: comments are appended to %s; the project working copy is never written to\n", log)
fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", "))
if err := http.ListenAndServe(addr, sv.handler()); err != nil {
fatal("%v", err)
diff --git a/vrsite/static.go b/vrsite/static.go
index b299975..0a8eec0 100644
--- a/vrsite/static.go
+++ b/vrsite/static.go
@@ -32,9 +32,9 @@ func run(dir string, name string, args ...string) []byte {
return out
}
-func buildSite(logPath, repoAbs, out, title string, theme, light *Theme, themes *themeSet) {
+func buildSite(log logStore, repoAbs, out, title string, theme, light *Theme, themes *themeSet) {
start := time.Now()
- entries := readLog(logPath)
+ entries := readLog(log)
ranked := buildModel(title, entries, "").Ranked
if len(ranked) == 0 {
fatal("log has no jj change ids")
diff --git a/vrsite/templates/frag-jj.tmpl b/vrsite/templates/frag-jj.tmpl
index b31af53..61040dc 100644
--- a/vrsite/templates/frag-jj.tmpl
+++ b/vrsite/templates/frag-jj.tmpl
@@ -8,7 +8,7 @@
{{else if .Op}}
<div class="jjrow">
<span class="graph">{{.Graph}}</span>
- <a class="id" href="/jj?at_op={{.ID}}" title="run the commit log as of this operation">{{.Short}}</a>
+ <a class="id" href="{{$.Root}}jj?at_op={{.ID}}" title="run the commit log as of this operation">{{.Short}}</a>
<span class="when">{{.When}}</span>
<span class="who">{{.Author}}</span>
<span class="desc">{{.Desc}}</span>
@@ -17,17 +17,17 @@
{{else}}
<div class="jjrow">
<span class="graph">{{.Graph}}</span>
- <a class="id" href="/change/{{.ID}}" title="{{.ID}}">{{.Short}}</a>
+ <a class="id" href="{{$.Root}}change/{{.ID}}" title="{{.ID}}">{{.Short}}</a>
<code class="commit">{{.CommitShort}}</code>
<span class="when">{{.When}}</span>
<span class="who">{{.Author}}</span>
{{if .Refs}}<span class="refs">{{.Refs}}</span>{{end}}
{{if .Flags}}<span class="flags">{{.Flags}}</span>{{end}}
- <a class="desc" href="/files?rev={{.ID}}" title="browse the tree at this change">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</a>
+ <a class="desc" href="{{$.Root}}files?rev={{.ID}}" title="browse the tree at this change">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</a>
</div>
{{end}}
{{end}}
</div>
{{else if not .Err}}<p class="meta hint">no revisions matched</p>{{end}}
-{{if .AtOp}}<p class="meta">as of operation <code>{{short .AtOp}}</code> · <a href="/jj?kind=op">back to the op log</a></p>{{end}}
+{{if .AtOp}}<p class="meta">as of operation <code>{{short .AtOp}}</code> · <a href="{{.Root}}jj?kind=op">back to the op log</a></p>{{end}}
{{end}}
diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl
index 268b5b6..e83730a 100644
--- a/vrsite/templates/layout.tmpl
+++ b/vrsite/templates/layout.tmpl
@@ -33,12 +33,13 @@
</header>
{{template "changebar" .}}
{{if .Live}}
- <div id="activity" class="activity" hx-get="/activity?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div>
+ <div id="activity" class="activity" hx-get="{{.Href "activity"}}?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div>
{{end}}
<input id="filefind" class="find" type="search" placeholder="find file…" aria-label="find file" autocomplete="off" spellcheck="false">
<div id="tree"><p class="meta hint">loading tree…</p></div>
<footer>
<span>
+ {{if and .Live (ne .Root "/")}}<a href="/">projects</a> · {{end}}
<a href="{{.Href "notes"}}">notes ({{len .Site.Notes}})</a> ·
<a href="{{.Href "files"}}">files</a>{{if .Live}} ·
<a href="{{.Href "jj"}}">jj</a>{{end}}
diff --git a/vrsite/templates/page-change.tmpl b/vrsite/templates/page-change.tmpl
index 08310e5..8641da7 100644
--- a/vrsite/templates/page-change.tmpl
+++ b/vrsite/templates/page-change.tmpl
@@ -1,5 +1,6 @@
{{define "page-change"}}
{{$c := .Change}}
+{{$p := .}}
<header class="filehead">
<h1>{{$c.Row.Short}} <span class="meta">{{$c.Row.Flags}}</span></h1>
<p class="meta">
@@ -14,7 +15,7 @@
{{if $c.Parents}}
<p class="meta">parents:
- {{range $c.Parents}}<a href="/change/{{.ID}}"><code>{{.Short}}</code></a> {{.Desc}} {{end}}
+ {{range $c.Parents}}<a href="{{$p.Href "change" .ID}}"><code>{{.Short}}</code></a> {{.Desc}} {{end}}
</p>
{{end}}
@@ -24,7 +25,7 @@
<tbody>
{{range $c.Files}}
<tr><td class="st st-{{.Status}}">{{.Status}}</td>
- <td><a href="/code/{{.Path}}?rev={{$c.Row.ID}}">{{.Path}}</a></td></tr>
+ <td><a href="{{$p.Href "code" .Path}}">{{.Path}}</a></td></tr>
{{end}}
</tbody>
</table>
diff --git a/vrsite/templates/page-index.tmpl b/vrsite/templates/page-index.tmpl
index bd377ac..93cad6b 100644
--- a/vrsite/templates/page-index.tmpl
+++ b/vrsite/templates/page-index.tmpl
@@ -12,7 +12,7 @@
{{if .Live}}
<section>
<h2>Live <span class="meta">— agents reading this codebase</span></h2>
- <div id="activity-full" hx-get="/activity" hx-trigger="load, every 3s" hx-swap="innerHTML">
+ <div id="activity-full" hx-get="{{.Href "activity"}}" hx-trigger="load, every 3s" hx-swap="innerHTML">
<p class="meta hint">…</p>
</div>
</section>
diff --git a/vrsite/templates/page-jj.tmpl b/vrsite/templates/page-jj.tmpl
index 5c1381c..24df596 100644
--- a/vrsite/templates/page-jj.tmpl
+++ b/vrsite/templates/page-jj.tmpl
@@ -8,12 +8,12 @@
<section class="jjpage">
<form class="jjform" method="get" action="{{.Href "jj"}}"
- hx-get="/jj/log" hx-target="#jjout" hx-swap="innerHTML" hx-indicator="#jjbusy">
+ hx-get="{{.Href "jj-log"}}" hx-target="#jjout" hx-swap="innerHTML" hx-indicator="#jjbusy">
<input type="hidden" name="kind" value="log">
<input type="search" name="revset" value="{{.JJ.Revset}}" placeholder="revset — e.g. @ | ancestors(trunk(), 10) | description(glob:'*fix*')" autocomplete="off" spellcheck="false">
<input type="number" name="limit" value="{{.JJ.Limit}}" min="1" max="1000" aria-label="limit" title="limit">
<button type="submit">jj log</button>
- <button type="button" hx-get="/jj/op" hx-target="#jjout" hx-include="closest form" hx-indicator="#jjbusy" title="operation log">op log</button>
+ <button type="button" hx-get="{{.Href "jj-op"}}" hx-target="#jjout" hx-include="closest form" hx-indicator="#jjbusy" title="operation log">op log</button>
<span id="jjbusy" class="busy">·</span>
</form>
<p class="meta hint">
diff --git a/vrsite/templates/page-notes.tmpl b/vrsite/templates/page-notes.tmpl
index 242c137..ebd5865 100644
--- a/vrsite/templates/page-notes.tmpl
+++ b/vrsite/templates/page-notes.tmpl
@@ -16,7 +16,7 @@
{{if .Live}}<a href="{{.ScopeHref "all"}}">{{.Elsewhere}} more at other changes</a>
{{else}}{{.Elsewhere}} more at other changes are not in this export{{end}}{{end}}
{{end}}
- {{if .Live}} · anything you add here is appended to the log at this change — the repo is never touched{{end}}
+ {{if .Live}} · anything you add here is appended to the private log sidecar at this change — the project working copy is never touched{{end}}
</p>
</header>
@@ -29,7 +29,7 @@
{{end}}
<form class="filters" id="notefilters" method="get" action="{{.Href "notes"}}"
- {{if .Live}}hx-get="/notes/list" hx-target="#notelist" hx-swap="innerHTML"
+ {{if .Live}}hx-get="{{.Href "notes-list"}}" hx-target="#notelist" hx-swap="innerHTML"
hx-trigger="input delay:250ms, change, search" hx-indicator="#notesbusy"{{end}}>
<input type="search" name="text" value="{{.Filter.Text}}" placeholder="search note text…" autocomplete="off">
<input type="search" name="file" value="{{.Filter.File}}" placeholder="file path…" list="filelist" autocomplete="off">
diff --git a/vrsite/templates/projects.tmpl b/vrsite/templates/projects.tmpl
new file mode 100644
index 0000000..3e3c670
--- /dev/null
+++ b/vrsite/templates/projects.tmpl
@@ -0,0 +1,112 @@
+{{define "projects"}}<!doctype html>
+<html lang="en" data-live="1">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<link rel="stylesheet" href="/style.css">
+<script>
+// Match project pages: wear the saved theme before first paint, and load the
+// installed-theme stylesheet now only when that saved choice requires it.
+{const d = document.documentElement, t = localStorage.getItem("vrsite-theme");
+ if (t && t !== "system") d.dataset.theme = t;
+ if (t && !/^(system|dark|light)$/.test(t)) {
+ const l = document.createElement("link");
+ l.rel = "stylesheet"; l.id = "themescss"; l.href = "/themes.css";
+ document.head.append(l);
+ }
+ d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace";}
+</script>
+<script src="/app.js" defer></script>
+</head>
+<body class="projects">
+<nav aria-label="projects" id="nav">
+ <header><a class="brand" href="/">{{.Title}}</a></header>
+ <p class="meta">one process · every local vr sidecar</p>
+ <input id="projectfind" class="find" type="search" placeholder="find project…" aria-label="find project" autocomplete="off" spellcheck="false">
+ <form method="post" action="/projects/refresh" class="project-refresh">
+ <button type="submit">refresh repositories</button>
+ </form>
+ <p class="meta">scans <code>{{.ScanRoot}}</code></p>
+ <p class="meta">last refresh {{.Scanned}}</p>
+ <footer>
+ <span>selection and scan results stay in memory</span>
+ <div class="themepick" id="themepick"
+ data-js="/themes.js" data-css="/themes.css"
+ data-dark="{{.Theme.Name}}" data-light="{{.Light.Name}}"
+ data-darkid="{{.Theme.ID}}" data-lightid="{{.Light.ID}}">
+ <button type="button" id="themebtn" aria-haspopup="listbox" aria-expanded="false"
+ title="theme — every one installed here (T)">theme</button>
+ <div class="thememenu" id="thememenu" hidden>
+ <input id="themefind" type="search" placeholder="filter themes…" aria-label="filter themes"
+ role="combobox" aria-controls="themelist" aria-autocomplete="list"
+ autocomplete="off" spellcheck="false">
+ <ul id="themelist" role="listbox" aria-label="themes"></ul>
+ <p class="meta hint">↑↓ preview · enter keeps · esc puts it back</p>
+ </div>
+ </div>
+ </footer>
+</nav>
+
+<main class="projects-main">
+ <header class="filehead">
+ <h1>Projects</h1>
+ <p class="meta">Choose a vr log, start one in a jj repository, or colocate jj with an existing Git repository.</p>
+ </header>
+ {{if .Message}}<p class="ok project-message">{{.Message}}</p>{{end}}
+ {{if .Err}}<p class="err project-message">{{.Err}}</p>{{end}}
+
+ <section>
+ <h2>Logs <span class="meta">— {{len .Active}} repositories with a private sidecar</span></h2>
+ <div class="project-grid">
+ {{range .Active}}
+ <article class="project-card" data-project="{{.Name}} {{.Relative}}">
+ <header><h3><a href="/p/{{.ID}}/">{{.Name}}</a></h3><span class="project-state active">log</span></header>
+ <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p>
+ <p class="meta">{{.Entries}} entries · {{.Notes}} notes · {{.Agents}} agents · {{.Updated}}</p>
+ {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} jj workspaces share this log</p>{{end}}
+ {{if .Err}}<p class="err">{{.Err}}</p>{{end}}
+ <a class="project-action" href="/p/{{.ID}}/">open project</a>
+ </article>
+ {{end}}
+ {{if not .Active}}<p class="meta hint">no vr sidecars found yet</p>{{end}}
+ </div>
+ </section>
+
+ <section>
+ <h2>Ready to start <span class="meta">— {{len .Ready}} jj repositories without a vr sidecar</span></h2>
+ <div class="project-grid">
+ {{range .Ready}}
+ <article class="project-card" data-project="{{.Name}} {{.Relative}}">
+ <header><h3>{{.Name}}</h3><span class="project-state">jj</span></header>
+ <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p>
+ {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} workspaces</p>{{end}}
+ {{if .Err}}<p class="err">{{.Err}}</p>{{end}}
+ <form method="post" action="/projects/{{.ID}}/start">
+ <button type="submit">start private sidecar</button>
+ </form>
+ </article>
+ {{end}}
+ {{if not .Ready}}<p class="meta hint">every detected jj repository already has a sidecar</p>{{end}}
+ </div>
+ </section>
+
+ <section>
+ <h2>Git repositories <span class="meta">— {{len .Git}} without jj metadata</span></h2>
+ <p class="meta">Initializing adds colocated <code>.jj</code> metadata; it does not create a sidecar until you choose “start private sidecar”.</p>
+ <div class="project-grid">
+ {{range .Git}}
+ <article class="project-card" data-project="{{.Name}} {{.Relative}}">
+ <header><h3>{{.Name}}</h3><span class="project-state git">git</span></header>
+ <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p>
+ <form method="post" action="/git/{{.ID}}/init">
+ <button type="submit">initialize colocated jj</button>
+ </form>
+ </article>
+ {{end}}
+ {{if not .Git}}<p class="meta hint">no Git-only repositories detected</p>{{end}}
+ </div>
+ </section>
+</main>
+</body>
+</html>{{end}}