// notevi — read code, take notes, read the trace back; every call is logged. // // Named for the bem-te-vi, the kiskadee that announces itself all day and keeps // a bright crown patch hidden under plain feathers: the notes are there the // whole time, tucked out of the project's way in a private jj sidecar. // // Each operation appends one JSON line to that 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. // "notevi web" serves the same log as a browsable site. // // Agent identity comes from the caller's environment (Claude Code exports // CLAUDE_CODE_SESSION_ID, AI_AGENT, CLAUDE_EFFORT into tool subprocesses); when // absent, we walk the process tree with ps — the agent-id.py technique, macOS // edition — matching harness names in ancestor cmdlines. Unlike /proc on // Linux, macOS redacts other processes' env (KERN_PROCARGS2 is caller-only, // ps -E shows nothing even for your own children), so the session id can only // come from our own env, i.e. from harnesses that export it. package main import ( "bytes" _ "embed" "encoding/json" "errors" "flag" "fmt" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" "0x4200.cafe/notevi/sidecar" "0x4200.cafe/notevi/web" ) type Entry struct { Time string `json:"time"` Op string `json:"op"` Session string `json:"session,omitempty"` Agent string `json:"agent,omitempty"` Model string `json:"model,omitempty"` Reasoning string `json:"reasoning,omitempty"` 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"` Pattern string `json:"pattern,omitempty"` Matches int `json:"matches,omitempty"` NoteID int `json:"note_id,omitempty"` ReplyTo int `json:"reply_to,omitempty"` Type string `json:"type,omitempty"` Text string `json:"text,omitempty"` } var gSession, gAgent, gModel, gReasoning, gDir string var gStore *sidecar.Store //go:embed doc.txt var doc string //go:embed query-doc.txt var queryDoc string func main() { if len(os.Args) < 2 { usage() } gDir, _ = os.Getwd() switch strings.TrimLeft(os.Args[1], "-") { case "doc": fmt.Print(doc) return case "doc-query": fmt.Print(queryDoc) return } // web serves the log rather than appending to it, and brings its own flags. if os.Args[1] == "web" { web.Main(os.Args[2:]) return } detect() switch os.Args[1] { case "read": cmdRead(os.Args[2:]) case "grep": cmdGrep(os.Args[2:]) case "note": cmdNote(os.Args[2:]) case "query": cmdQuery(os.Args[2:]) case "migrate": cmdMigrate(os.Args[2:]) default: usage() } } func usage() { fmt.Fprint(os.Stderr, `notevi — read code and take notes; logs every call, hides them in a sidecar. usage: notevi read [-r REV] FILE[:START[-END]] print a file line-numbered, whole or a line range, at a jj revision notevi 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 notevi 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 notevi query [-session S] [-op OP] [-file SUBSTR] [-n N] print logged entries, oldest first notevi web [-repo DIR] [-out DIR] ... browse the trace: repo tree, coverage, notes; "notevi web -h" for more notevi migrate [DIR...] rename a pre-notevi sidecar in place; run once per old repository REV is any jj revset, default @ (working copy). The JSONL log lives in the private jj sidecar named by the repo-local revset alias notevi_log. "notevi -doc" prints the short agent-facing usage doc; "notevi -doc-query" the query one. `) os.Exit(2) } func fatal(format string, a ...any) { fmt.Fprintf(os.Stderr, "notevi: "+format+"\n", a...) os.Exit(1) } // ── agent / session / model detection ───────────────────────────────────── func detect() { envClaude := os.Getenv("CLAUDECODE") == "1" || os.Getenv("CLAUDE_CODE_SESSION_ID") != "" envCodex := os.Getenv("CODEX_THREAD_ID") != "" || os.Getenv("CODEX_SANDBOX") != "" envHermes := os.Getenv("HERMES_SESSION_ID") != "" switch { case envClaude && !envCodex && !envHermes: gAgent = "claude" case envCodex && !envClaude && !envHermes: gAgent = "codex" case envHermes && !envClaude && !envCodex: gAgent = "hermes" default: // none, or several — nested harnesses inherit each other's env // (codex launched from a claude session carries both sets of vars), // so let the nearest ancestor in the process tree decide walkProcessTree() } switch { case strings.Contains(gAgent, "claude"): if v := os.Getenv("AI_AGENT"); v != "" { gAgent = v // claude-code__agent } gSession = os.Getenv("CLAUDE_CODE_SESSION_ID") gModel = os.Getenv("ANTHROPIC_MODEL") gReasoning = os.Getenv("CLAUDE_EFFORT") // Claude Code doesn't put the model in the env; it lives in settings. if gModel == "" || gReasoning == "" { home, _ := os.UserHomeDir() b, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json")) if err == nil { var s struct { Model string `json:"model"` Effort string `json:"effortLevel"` } json.Unmarshal(b, &s) if gModel == "" { gModel = s.Model } if gReasoning == "" { gReasoning = s.Effort } } } case gAgent == "codex": gSession = os.Getenv("CODEX_THREAD_ID") gModel, gReasoning = codexConfig() case gAgent == "hermes": gSession = os.Getenv("HERMES_SESSION_ID") } } // codexConfig reads the top-level model and model_reasoning_effort from // ~/.codex/config.toml (keys before the first [section]; profiles ignored). func codexConfig() (model, effort string) { home, _ := os.UserHomeDir() b, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml")) if err != nil { return } modelRe := regexp.MustCompile(`^model\s*=\s*"([^"]+)"`) effortRe := regexp.MustCompile(`^model_reasoning_effort\s*=\s*"([^"]+)"`) for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "[") { break } if m := modelRe.FindStringSubmatch(line); m != nil { model = m[1] } if m := effortRe.FindStringSubmatch(line); m != nil { effort = m[1] } } return } var agentPatterns = []struct { name string re *regexp.Regexp }{ {"hermes", regexp.MustCompile(`(?i)hermes[_-]?(cli|gateway)`)}, {"codex", regexp.MustCompile(`(?i)codex`)}, {"claude", regexp.MustCompile(`(?i)claude`)}, } var shells = map[string]bool{"sh": true, "bash": true, "zsh": true, "fish": true, "dash": true, "ksh": true, "nu": true} func walkProcessTree() { pid := os.Getppid() for depth := 0; pid > 1 && depth < 20; depth++ { // a shell's cmdline embeds arbitrary user command text (which can // mention any harness by name), so never pattern-match shells comm := strings.TrimPrefix(ps(pid, "-o", "comm="), "-") if !shells[filepath.Base(comm)] { cmdline := ps(pid, "-ww", "-o", "command=") for _, p := range agentPatterns { if p.re.MatchString(cmdline) { gAgent = p.name return } } } ppid, err := strconv.Atoi(ps(pid, "-o", "ppid=")) if err != nil || ppid == pid { return } pid = ppid } } func ps(pid int, args ...string) string { out, _ := exec.Command("ps", append(args, "-p", strconv.Itoa(pid))...).Output() return strings.TrimSpace(string(out)) } // ── jj helpers ──────────────────────────────────────────────────────────── func jjOut(args ...string) string { out, err := exec.Command("jj", args...).Output() if err != nil { if ee, ok := err.(*exec.ExitError); ok { os.Stderr.Write(ee.Stderr) } fatal("jj %s failed: %v", strings.Join(args, " "), err) } return string(out) } // --ignore-working-copy skips the snapshot: change ids are stable across // snapshots, and this keeps `notevi note`/`notevi 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 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". func parseFileRange(s string) (file string, start, end int) { i := strings.LastIndex(s, ":") if i < 0 { return s, 0, 0 } m := regexp.MustCompile(`^(\d+)(?:-(\d+))?$`).FindStringSubmatch(s[i+1:]) if m == nil { return s, 0, 0 } start, _ = strconv.Atoi(m[1]) end = start if m[2] != "" { end, _ = strconv.Atoi(m[2]) } return s[:i], start, end } // ── subcommands ─────────────────────────────────────────────────────────── func cmdRead(args []string) { fs := flag.NewFlagSet("read", flag.ExitOnError) rev := fs.String("r", "@", "jj revision to read at") fs.Parse(args) if fs.NArg() != 1 { fatal("usage: notevi read [-r REV] FILE[:START[-END]]") } file, start, end := parseFileRange(fs.Arg(0)) content := jjOut("file", "show", "-r", *rev, "--", file) lines := strings.Split(content, "\n") if n := len(lines); n > 0 && lines[n-1] == "" { lines = lines[:n-1] } if start == 0 { start, end = 1, len(lines) } if start > len(lines) { fatal("%s has only %d lines", file, len(lines)) } if end > len(lines) { end = len(lines) } for i := start; i <= end; i++ { fmt.Printf("%d\t%s\n", i, lines[i-1]) } 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) rev := fs.String("r", "@", "jj revision to search at") fs.Parse(args) if fs.NArg() < 1 || fs.NArg() > 2 { fatal("usage: notevi grep [-r REV] PATTERN [PATH]") } pattern, path := fs.Arg(0), fs.Arg(1) matches := 0 if *rev == "@" { rgArgs := []string{"-n", "--no-heading", pattern} if path != "" { rgArgs = append(rgArgs, path) } out, err := exec.Command("rg", rgArgs...).Output() os.Stdout.Write(out) if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() != 1 { // 1 = no matches os.Stderr.Write(ee.Stderr) fatal("rg failed: %v", err) } matches = bytes.Count(out, []byte("\n")) } else { re, err := regexp.Compile(pattern) if err != nil { fatal("bad pattern: %v", err) } listArgs := []string{"file", "list", "-r", *rev} if path != "" { listArgs = append(listArgs, "--", path) } for _, f := range strings.Split(strings.TrimRight(jjOut(listArgs...), "\n"), "\n") { if f == "" { continue } content := strings.TrimSuffix(jjOut("file", "show", "-r", *rev, "--", f), "\n") for i, line := range strings.Split(content, "\n") { if re.MatchString(line) { fmt.Printf("%s:%d:%s\n", f, i+1, line) 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) 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, ...)") reply := fs.Int("reply", 0, "log-wide note number this note replies to") fs.Parse(args) text := strings.Join(fs.Args(), " ") if text == "" { fatal("usage: notevi note [-r REV] [-f FILE[:START[-END]]] [-t KIND] [-reply NOTE] TEXT...") } if *reply < 0 { fatal("reply note must be positive") } 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) } 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) } // hasNote uses the same numbering as the web permalinks: note operations count // from one over the append-only log, independent of their writers' sessions. func hasNote(entries []Entry, id int) bool { n := 0 for _, e := range entries { if e.Op == "note" { n++ if n == id { return true } } } return false } func nextNoteID(entries []Entry) int { max := 0 for _, e := range entries { if e.Op == "note" && e.Session == gSession && e.NoteID > max { max = e.NoteID } } return max + 1 } func cmdQuery(args []string) { fs := flag.NewFlagSet("query", flag.ExitOnError) 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() type result struct { Entry logNoteID int } var kept []result logNoteID := 0 for _, e := range entries { if e.Op == "note" { logNoteID++ } if *session != "" && !strings.Contains(e.Session, *session) { continue } if *op != "" && e.Op != *op { continue } if *file != "" && !strings.Contains(e.File, *file) { continue } kept = append(kept, result{Entry: e, logNoteID: logNoteID}) } if *n > 0 && len(kept) > *n { kept = kept[len(kept)-*n:] } for _, r := range kept { e := r.Entry ts := e.Time if t, err := time.Parse(time.RFC3339, e.Time); err == nil { ts = t.Format("01-02 15:04:05") } head := fmt.Sprintf("%s %-8.8s %-5s %-8.8s", ts, e.Session, e.Op, e.Change) switch e.Op { case "read": fmt.Printf("%s %s\n", head, fileRange(e)) case "grep": loc := "" if e.File != "" { loc = " in " + e.File } fmt.Printf("%s %q%s (%d matches)\n", head, e.Pattern, loc, e.Matches) case "note": tag := "" if e.Type != "" { tag = " (" + e.Type + ")" } loc := "" if e.File != "" { loc = " " + fileRange(e) } rel := "" if e.ReplyTo > 0 { rel = fmt.Sprintf(" reply to log #%d", e.ReplyTo) } fmt.Printf("%s #%d [log #%d]%s%s%s — %s\n", head, e.NoteID, r.logNoteID, tag, loc, rel, e.Text) default: fmt.Printf("%s\n", head) } } } // cmdMigrate renames pre-notevi sidecars. It takes directories rather than only // working on the current one so that a machine full of old repositories can be // brought over in one run. func cmdMigrate(args []string) { fs := flag.NewFlagSet("migrate", flag.ExitOnError) fs.Parse(args) dirs := fs.Args() if len(dirs) == 0 { dirs = []string{gDir} } failed := false for _, dir := range dirs { s, err := sidecar.Open(dir) if err != nil { fmt.Fprintf(os.Stderr, "notevi: %s: %v\n", dir, err) failed = true continue } migrated, err := s.Migrate() switch { case err != nil: fmt.Fprintf(os.Stderr, "notevi: %s: %v\n", s.Root(), err) failed = true case migrated: fmt.Printf("%s: renamed to %s\n", s.Root(), s) default: fmt.Printf("%s: nothing to rename\n", s.Root()) } } if failed { os.Exit(1) } } func fileRange(e Entry) string { s := e.File if e.Start > 0 { s += ":" + strconv.Itoa(e.Start) if e.End > e.Start { s += "-" + strconv.Itoa(e.End) } } return s } // ── jj sidecar log ──────────────────────────────────────────────────────── func parseLog(b []byte) []Entry { var entries []Entry for _, line := range strings.Split(string(b), "\n") { if line == "" { continue } var e Entry if json.Unmarshal([]byte(line), &e) == nil { entries = append(entries, e) } } return entries } 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("read %s: %v", store(), 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) } }