If ../hub-private/CLAUDE.md exists, read it before writing or editing any files — it lists terms that must never appear in committed hub files.
Every change must build on devices with and without each hub-private module, not only on the
device it was written on. just lint checks every configuration the checkout can build. See
the invariant.
Hub is a personal command center that aggregates signals from multiple sources — GitHub PRs, CI status, Loki alerts, Linear issues, and more via the private feature — into a single urgency-ranked terminal view, and delegates action on those signals to agents via filesystem-based investigation sessions.
Its three binaries serve three distinct audiences:
hub-tui(Ratatui dashboard) — the human-facing surface. Read signals, launch investigation sessions, watch session progress, review results.hub(CLI) — the agent's toolkit (stub). The task subcommand (hub task *) was removed with the task model (ADR 019). Future agent-facing subcommands will be added here as the filesystem session model is built out.hub-daemon— the unattended surface (ADR 020). Refreshes the signal cache with nobody present. It refreshes every 15 minutes (--interval), one daemon per profile, and--onceruns a single pass. Each pass is recorded in a health record in the profile's database, and each pull request that becomes my turn gets a macOS notification throughterminal-notifier.just daemon-startruns the installed binary under launchd at every login. The TUI's status bar warns when the daemon's refreshes stop. The TUI becoming a client of the daemon and the PR attention model are the rest of #327.
The core value is cross-domain triage plus agent delegation: signals from different systems are ranked together in one list, and any signal can be investigated by pressing i to launch a Claude Code session with injected context. That session opens in its own tmux window, named after the signal by domain::InvestigationWindow, so several investigations run side by side and each stays reachable from tmux's window list.
See README.md for the full feature list and value proposition.
Any parent issue is an epic, and one with at least one closed sub-issue has started. The next
skill finds them and ranks their sub-issues against everything else in the tracker:
gh issue list -R ooloth/hub --state open --limit 300 --json number,title,subIssuesBefore starting any sub-issue of an epic, read the epic's plan of record:
gh issue view <sub-issue> --json parent # finds the epic
gh issue view <epic> --commentsThe plan lives in a comment. gh issue view <epic> on its own prints the body and stops, and
the body can predate the plan, so reading it alone can give you a superseded design. The comment
carries the settled architecture, the measured spike findings, and the full phase sequence.
Only issues on an epic's critical path carry a phase number. An issue is on the critical path
when the epic's "Done when" cannot be reached without it. Those are titled Phase N.M — ..., and
the numbering is their work order. Every other sub-issue of the epic has a plain title and is taken
whenever it is convenient. Hard dependencies are recorded separately as GitHub blockedBy
relationships, which gh issue view N --json blockedBy prints. Everything else the numbering
implies is sequence, not blocking. An issue body does not name its parent or its phase: read the
parent with gh issue view N --json parent, as above.
clients/ # external API wrappers — one file (or subdirectory) per external service
config/ # reads hub.toml and resolves credentials into typed domain structs
daemon/ # hub-daemon binary — refreshes the cache with nobody present
domain/ # types + pure logic; no I/O; no imports from other hub crates
store/ # local SQLite reads/writes
workflows/ # orchestrated operations; the "what hub does"
ui/
cli/ # hub binary — bootstraps config, wires deps, calls workflows
tui/ # hub-tui binary
prompts/ # investigation prompts loaded by the TUI
scripts/ # the `scripts` crate: dev/ops tooling, ships in no binary (Decision 025)
docs/ # architecture, decisions, playbooks
daemon/ sits beside ui/ rather than inside it because ui/ means user
interface and the daemon has no user. Both are entry points: they bootstrap
config, wire deps, and call workflows. Decision 020 settles that the daemon
is its own binary; the directory is the only part this repo chose.
Import direction (never import rightward's left neighbor):
ui/ → config/ → domain/
daemon/ → workflows/ → clients/ → domain/
→ store/ → domain/
config/ is a direct dependency of ui/cli, ui/tui and daemon/. Config
values are passed as function arguments into workflows and clients — those
crates do not depend on config/ directly.
| Concern | Choice |
|---|---|
| Language | Rust |
| Async runtime | tokio |
| CLI | clap (derive) |
| TUI | ratatui |
| HTTP clients | reqwest |
| SQLite | rusqlite (bundled) or sqlx |
| Serialization | serde |
| Secrets | 1Password CLI (secrecy + op read) |
| Error handling | anyhow |
See ~/.agents/standards/rust.md and ~/.agents/standards/type-design.md.
Hard rules for agents:
- Error handling:
anyhowonly. Nothiserror.?everywhere..context("msg")for human-readable chains. - Owned types: structs hold
String/Vec<T>. Functions that only read take&str/&[T]. Return owned values, not references. - No lifetime annotations: if you're writing
'a, stop and restructure. Return owned types instead. - Clone freely: don't fight the borrow checker. Clone across
.awaitpoints. Optimize only if profiling shows it matters. - Async:
#[tokio::main],features = ["full"]. Usetokio::join!for parallel work. Usetokio::fs/tokio::timenot std equivalents inside async. - Secrets: wrapped in
Secret<String>(secrecy crate) throughoutConfig,LokiEnv, andStatusParams. Sourced fromhub.toml's[credentials]table;op://references are resolved at startup viaop read..expose_secret()is called only at client call sites. - CLI:
clapwith derive macros. Annotate structs; don't use the builder API. - Newtypes over primitives: IDs, status values, and domain-meaningful strings are
wrapped in newtypes defined in
domain/, not passed as bareu64orString.RepoSlug(already indomain/) is the model: one construction path, validation baked in, impossible to substitute for an unrelated string. New domain concepts follow the same pattern — the type is proof of validity, not a comment.
just check # fmt + lint (autofixes where possible)
just test # run all tests
just build # build all crates
just cli # run the CLI
just tui # run the TUI
just daemon # refresh every 15 minutes with nobody present (--once for one pass)
just daemon-start # run the installed daemon under launchd at every login (also -stop, -restart, -uninstall, -logs)Every just recipe that runs hub from source uses the dev profile (~/.hub/dev/hub.db).
HUB_PROFILE=default just … reaches the installed hub's state. The daemon-* recipes manage the
installed daemon, which always uses default.
A Claude Code hook guards gh posts against hub-private's banned terms. It runs the built
scripts binary, so it needs just build once per checkout (see scripts/README.md).
Every change that alters what the TUI shows or how it behaves is driven live in tmux before it is reported as working. That includes rendering and layout changes, not only interaction ones. The point of the live run is to find behaviour the tests did not predict, so it happens even when every test and every snapshot is green — especially then.
A live run resolves credentials through op read, which raises 1Password
prompts the user answers with a fingerprint. That is normal and expected, and
it is never a reason to skip a run, defer one, or ask permission first.
Snapshots — necessary, never sufficient
Full-screen insta snapshots cover all major screen states (see
ui/tui/README.md for the full list and conventions). If a rendering
change causes a visual regression, a snapshot diff will show exactly what
changed. Run just test and review any failures.
If the diff is intentional, accept it:
just test-updateWhen adding a new screen state or item type, add a snapshot for it — don't rely on the existing snapshots to catch regressions in new code paths.
What a snapshot cannot show is anything outside the buffer this code produces: the real terminal's width and wrapping, real signal text, the startup path, timing, and how the screen behaves once keys arrive. Those need the live run.
Driving it live
Run the TUI in tmux, drive the interaction, and read the screen back. Look past the thing you changed: check the rows either side of it, resize the window, move the selection, open and close a detail pane. Report what you observed, not that it rendered.
Choosing the signal you test against. Signals come from live APIs, so a live run covers whichever signals exist at the time. When the change concerns a kind of signal that has none right now, the report says that path went unobserved live.
Seeing how hub behaves when sources fail. just sources-unreachable <recipe>
runs any recipe with every source refused, as a network failure would refuse it,
while 1Password still answers: just sources-unreachable daemon --once, or
just sources-unreachable tui. It points the proxy variables at a closed local
port and exempts 1Password's domains, so it does not cut 1Password, DNS, or
anything that ignores proxy variables, such as git over SSH. The GitHub sources
and the private workflows have been seen to fail through it; Linear, Loki and GCP
have not been tried. A TUI refresh through it leaves the cache and the screen as
they were, and the status bar names the sources that failed.
The loop.
just build # send-keys races a cargo build
tmux new-session -d -s qa -x 200 -y 50 -c "$PWD"
tmux send-keys -t qa:1 "just tui" Enter
sleep 12 # wait for the first render
tmux capture-pane -t qa:1 -p
tmux send-keys -t qa:1 "i"
sleep 12
tmux list-panes -s -t qa -F '#{window_name} :: #{pane_start_command}'Then clean up every time: tmux kill-session -t qa, remove any
investigation-* worktrees left under ~/.hub/repos/<project>/, and delete
/tmp/hub-supporting-data-*.json.
Gotchas that cost time
- Windows are 1-indexed here.
qa:0fails withcan't find window: 0. capture-paneshows the shell until the TUI enters the alternate screen. An empty-looking capture usually means it has not rendered yet, not that it crashed. Polltmux display-message -p -t qa:1 '#{alternate_on}'for1.op whoamiis not the credentials check. It reports the CLI session, which saysaccount is not signed inon a working machine, because the 1Password desktop app's CLI integration authorises eachop readon its own instead of creating a session. A signed-outop whoamiis never a reason to skip running hub. See the findings behind decision 027.op readraises 1Password prompts, and that is normal.Config::loadresolves everyop://reference inhub.tomlbefore the first fetch, so one run raises several prompts, each answered with a fingerprint. Expect them. They are never a reason to skip a live run or to ask before starting one.couldn't connect to the 1Password desktop appmeans the app is not running.op readfails within a second with that message, before any fetch starts. Start the app and run again. The first run after starting it can fail the same way while the app finishes launching (observed 2026-10-02).- A hang with nothing in the terminal is a different state.
op readis waiting on an approval prompt nobody has answered, and it waits with no limit rather than failing (measured 2026-10-10). Look for a Touch ID prompt, or a macOS "would like to access data from other apps" dialog, waiting on screen. Diagnose on this symptom, not on anythingop whoamisays. - You cannot
echoinside a launched investigation window — it is running Claude Code. Read the prompts off the process instead:ps -p $(tmux display-message -p -t qa:2.0 '#{pane_pid}') -wwE -o command=. #{pane_start_command}is the exact command tmux was handed, which is what to assert against when the change affects how an investigation is launched.
tmux send-keys pitfalls
- Use named keys for special keys:
"Enter","Escape","Backspace","Up","Down","Tab". An empty string""sends nothing — it is not a shorthand for Enter. - When testing the filter query flow: commit the query with
"Enter"before pressing"/"again. If the query is not committed the TUI stays in query mode and the second"/"is treated asAppendQuery('/'), notStartQuery.
If E2E validation cannot be run, explicitly state why and what weaker validation was run instead. Before concluding it cannot be run, verify by inspection: read the config, check the relevant directories, confirm what is actually available. Never assume a prerequisite is missing.
The organizing rule: architecture/ describes what is built; vision.md
and the decisions/ ADRs describe where we are going. One concept, one home.
An accepted ADR is not a built system. An ADR whose subject has not been
built carries _Status: accepted; not yet implemented._ under its title, naming
the file or symbol that proves it. So:
rg "not yet implemented" docs/decisions/is the list of designs that are settled and pending. Read it before writing code against anything an ADR describes, and delete an ADR's line in the change that builds it.
| Doc | Covers |
|---|---|
docs/vision.md |
Read this first — the why; the trust-building flywheel; what we are and are not building |
docs/architecture/worktrees.md |
PR investigation worktrees — read before touching fetch.rs |
docs/architecture/secrets.md |
1Password → op read → Secret model |
docs/architecture/private-workflows.md |
Two-repo model for private workflows |
docs/questions/ |
Open design questions, one per file |
docs/decisions/ |
ADRs (rationale). Unbuilt ones say so under the title — see the note above |
docs/invariants/ |
What must always hold, and the check that holds it up. No exceptions, unlike a standard |
clients/README.md |
reqwest pattern for HTTP clients |
store/README.md |
rusqlite pattern, db path, Connection threading notes |
ui/cli/README.md |
clap derive API for CLI commands |
ui/tui/README.md |
TUI architecture, cache/schema version, keybindings |
daemon/README.md |
The unattended refresh surface: what lives in daemon/ and its rules |
scripts/README.md |
The scripts crate: repository checks, Claude Code hooks, local setup |
| Doc | Covers |
|---|---|
docs/playbooks/add-a-workflow.md |
Adding a new workflow end to end |
docs/playbooks/add-a-project.md |
Adding a project to a device config |
docs/playbooks/add-a-private-workflow.md |
Adding a workflow to hub-private |
docs/playbooks/set-up-private-workflows-repository.md |
First-time or recovery setup of hub-private |
AGENTS.mdandCLAUDE.mdare symlinked.agents/skills/and.claude/skills/are symlinked
Every lib.rs and mod.rs is navigation only — module declarations and re-exports, nothing else. Every concept lives in a file named after it; ls src/ should read as a glossary. Files over ~400 lines are a signal that a boundary exists and wants a name.
.agents/ is the agent harness tracking directory — it holds skill files and
session state for agent runs. It is not a Rust crate. The agents/ Rust crate
for background automation is described in Decision 005 but has not been built yet.