- Status: Draft v0.1 (2026-09-02); amended 2026-09-18 (Circulo pivot + outstanding list, below)
- Related: TRD · PRD
- Workflow: every phase = one branch
feature/<phase>offmain, granular commits (one logical change each), tests land with the code they cover. Never commit tomaindirectly (project rule).
docs/{prd,trd,ux,flow,ui,implement}.md + AGENTS.md. DoD: docs review-able, AGENTS.md
contains the adjusted rule set.
| # | Commit | Content | DoD |
|---|---|---|---|
| 1.1 | chore: git init + ignore |
.gitignore (Go, node, wails build, .scratch) | clean status |
| 1.2 | chore: wails3 react-ts scaffold |
wails3 init -n circulogo -t react output (the project was later renamed to circulo, see the 2026-09-29 rename amendment), go mod tidy, window options (1280×800, dark bg, mac hidden-inset titlebar) |
wails3 dev opens window with template page |
| 1.3 | feat(frontend): tailwind theme tokens + shadcn init |
shadcn init, theme vars from UI.md §1, dark default | Button renders themed |
| 1.4 | test: vitest + go test wiring |
vitest config + sample, make test target (go test ./... + vitest run) |
both suites green |
| 1.5 | docs: AGENTS.md guardrails in template |
ensure lint configs reflect rules | lint passes |
| # | Commit | Content |
|---|---|---|
| 2.1 | feat(protocol): neutral events + parts |
internal/agent/protocol: envelope, event types, Part union, JSON camelCase tags, decode-and-skip-unknown helpers |
| 2.2 | test(opencode): captured SSE fixtures |
internal/opencode/testdata/*.sse (real events from 1.18.25 smoke test: server.connected, session.created/updated/status busy+retry, message.updated, part.updated for text/reasoning/tool ×4 states/step-start/step-finish/patch, message.part.delta, permission.asked/replied, session.error) |
| 2.3 | feat(opencode): SSE frame parser |
frame reader (data-only lines, comments, multi-line safety) + tests |
| 2.4 | feat(opencode): wire types + decoder |
OC event/message/part structs (from GET /doc of 1.18.25), unknown-tolerant decoding + fixture tests |
| 2.5 | feat(opencode): translate to neutral |
translation table (TRD §5.1) + table-driven tests |
| 2.6 | feat(opencode): HTTP client |
create/list/rename/delete session, history, prompt_async, abort, permission reply, agents, providers, health; Basic-auth option; httptest server tests |
| 2.7 | feat(opencode): process manager |
spawn managed server (free port, cwd, health poll ≤10s), attach mode, kill on stop, stderr ring buffer; lifecycle test (skip if no opencode binary) |
| 2.8 | feat(agent): AgentAdapter interface |
internal/agent: interface (Start/Stop/Sessions/CreateSession/Prompt/Abort/ReplyPermission/Meta + Events() channel), compile-time check that opencode.Adapter satisfies it |
DoD Phase 2: go test ./... green with no opencode binary present (fixtures +
httptest); with binary present, lifecycle test green. No Wails imports in
internal/{agent,opencode} (assert via go list -deps in test or CI grep).
Phase 3 — Orchestrator + relay ✅ (merged; E2E verified with real opencode serve via CIRCULOGO_DEBUG_ADDR)
| # | Commit | Content |
|---|---|---|
| 3.1 | feat(store): settings persistence |
projects CRUD → settings.json (OS config dir), atomic write |
| 3.2 | feat(orchestrator): project registry |
AddProject (managed/attach), status fan-in, event hub (subscribers, per-client buffer), graceful Shutdown |
| 3.3 | feat(relay): /agent HTTP API |
endpoints per TRD §4, SSE writer (flush per event, heartbeat comment 15s), JSON errors |
| 3.4 | feat(main): wire services |
register orchestrator service + relay route in Wails app; single-instance; window options |
| 3.5 | test: relay integration |
httptest E2E: fake OC server → adapter → relay SSE → assert neutral events; manual smoke with real opencode serve |
DoD Phase 3: curl -N localhost…/agent/sse (from a debug listener or in-app test)
shows neutral events for a real prompt; wails3 dev window still loads; fallback to
app.Event.Emit decided/documented if route interception fails in dev.
| # | Commit | Content |
|---|---|---|
| 4.1 | feat(frontend): protocol.ts + sse client |
TS mirror of protocol, EventSource wrapper w/ backoff reconnect (Flow §7), zustand stores |
| 4.2 | feat(frontend): reducer + tests |
events→SessionState per TRD §6 + golden-stream vitest suite |
| 4.3 | feat(sidebar): projects + sessions |
ProjectSwitcher, SessionList (date groups, status icons, rename/delete), empty state (FR-1/3/5/6/7) |
| 4.4 | feat(composer): input + pickers |
textarea, send, model/agent pickers from /meta, optimistic user message (FR-10) |
| 4.5 | feat(app-shell): layout + states |
AppShell, TitleBar, adapter status dots + banners (FR-19) |
DoD Phase 4: add real project via dialog → server starts (dot green) → session list populates; creating a session works; no chat rendering yet.
Phase 5 — Chat: parts + live streaming ✅ (merged into feature/frontend; streaming verified via relay E2E, in-window prompt verification pending manual pass)
| # | Commit | Content |
|---|---|---|
| 5.1 | feat(chat): transcript + message list |
hydration on open (FR-8), pin-to-bottom + pill (UX §5) |
| 5.2 | feat(chat): markdown view |
MarkdownView per UI.md §4, code header + copy + wrap |
| 5.3 | feat(chat): part renderers |
ReasoningPart (live-open/collapse), ToolCard(+group fold), PatchCard, SubtaskPill, TurnFooter (FR-11/12/13) |
| 5.4 | feat(chat): live streaming polish |
15 Hz flush, working timer, reduced-motion, streaming memo (NFR-1/2) |
| 5.5 | test(chat): reducer→render golden cases |
hydration+live overlap, delta-then-full, out-of-order |
DoD Phase 5: real prompt streams text+reasoning+tools live; history renders identically to live; usage footer correct.
Phase 6 — Permissions, abort, errors ✅ (merged into feature/frontend; needs the manual E2E checklist pass)
| # | Commit | Content |
|---|---|---|
| 6.1 | feat(permissions): cards + replies |
PermissionCard stack, 3 actions, resolved-by-event removal (FR-16) |
| 6.2 | feat(control): abort + retry state |
Stop button/Esc, retry banner from session.status retry (FR-17/18) |
| 6.3 | feat(errors): inline error blocks + reconnect |
session.error rendering (FR-21), bridge reconnect resync (Flow §7) |
| 6.4 | feat(sessions): session switch race safety |
hydration/live interleave tests (Flow §5) |
DoD Phase 6: full PRD functional checklist passes (below).
After Phases 5–6 merged, the visual layer pivoted to replicate the Circulo
Paper design (feature/circulo-design + app-bar/polish branches, merged to
main): theme tokens, sidebar geometry, an app bar, and chat metrics.
- ui.md is partially superseded: the layout skeleton and component inventory still apply, but palette/geometry follow the Circulo replica. Re-sync ui.md before starting Phase 7 (it still says "neutral zinc, violet rejected" — no longer true of the code).
AppBar(session breadcrumb + close action) exists but was never specified in ui.md.- Behavior contracts (ux.md, flow.md) are unaffected.
Recorded retroactively; both shipped on the branch stack now pushed to origin
(canonical repo: github.com/soycanopa/circulo — the project that previously lived
there is preserved under legacy/*).
OpenCode v1 → v2 migration — code-complete. Phases 0–5 of
opencode-v2-migration.md: the adapter speaks only v2
(Basic auth captured from serve output, /openapi.json as the spec source, new
endpoint/event map) and the app runs against 2.0.8 via
CIRCULOGO_OPENCODE_BIN. Handoff state and owner gates live in
opencode-v2-phase6-e2e.md.
Feature wave on top of the Circulo replica (details in the commit history of
feature/opencode-v2):
- PTY terminals per project —
internal/term(creack/pty), one tabbed xterm.js surface below the chat/composer cards; I/O over SSE + write/resize POSTs. questiontool → ApprovalCard: one question at a time, 1/N odometer, radio auto-advance (v2 Forms events, not permissions).- Thinking trace: one collapsible per work type (Reasoning/Search/Coding/Tools) with pixel-loader headers.
- CodePanel: fenced code with line numbers + syntax coloring; unified
diffwith old/new gutters and word-level pairing. circulo-flowblocks render as the dotted flowchart canvas.- AssistantText streams word-by-word (55ms reveal) and settles to copyable text.
- Session targeting: always-visible strip; new-session mode selects project + branch up front and the session materializes on the first message.
- Resizable sidebar (200–480px, persisted), animated pixel-glow texture, transcript edge fades, white Send/Stop pair.
- Per-session markdown formatting instruction via the instructions-entries API
(
circulo-format); the v2instructionsconfig key is not read by the server.
The ui.md re-sync debt from the pivot amendment still stands and now also covers the terminal surface and app-bar actions.
Adapter #2, built on the owner's explicit go-ahead (the AGENTS.md adapter-#2 gate —
E2E checklist pass — is hereby overridden by owner decision for omp; the checklist
below still stands for a formal release pass). Target: omp
(can1357/oh-my-pi), which speaks omp --mode rpc over stdio NDJSON instead of
HTTP+SSE — proving the neutral protocol holds for a fundamentally different
transport, not just a second HTTP API.
| # | Commit | Content |
|---|---|---|
| 7.1 | feat(projects): plumb provider selection |
store.Project.Provider (empty = opencode, old settings valid), AddProject validation (opencode|omp; omp rejects attach), ProjectView/protocol.ts mirror, api.addProject provider arg |
| 7.2 | feat(omp): adapter |
stdio RPC client (ready handshake, protocol v2 chunk reassembly, id correlation), event translation (deltas, tool parts, finish accounting, retry status, extension-UI forms), ordinal message identity, disk session discovery, fixtures + tests (internal/omp/), provider switch in main.go (CIRCULOGO_OMP_BIN) |
| 7.3 | feat(ui): provider identity |
omp π mark, provider-aware banner copy, model-picker rail from the project provider, New-project provider menu, null-list hardening in loadMeta, gated live smoke (CIRCULOGO_OMP_LIVE=1) |
Capability map (omp → Circulo). Sessions (list/create/rename/delete), history
hydration with live-identical part ids, prompt/abort with steering when busy,
thinking-level variants, 264-model catalog with the composer default from omp's own
state, git VCS read locally, retry status, extension-UI forms (select/confirm/input).
Known gaps: no permission round-trip over RPC (approvals follow omp's
tools.approvalMode; headless prompts fail closed — ReplyPermission errors), no
named agents, no branch pinning (no instruction channel), compaction/subagent frames
have no neutral surface yet. Details in trd.md §3A.
Verification. go test ./internal/omp/ (fixtures from real omp 18.2.8 captures,
no binary needed) + CIRCULOGO_OMP_LIVE=1 go test ./internal/omp/ -run TestLiveSmoke
(real child: handshake → catalog → prompt stream → hydration → abort → clean stop);
browser E2E against the debug listener (add omp project → banner → streaming
transcript → model picker) run on 2026-09-22.
The adapters were verified against opencode 2.0.8 and omp 18.2.8 captures, but the binaries the app actually spawns had moved on. Ten opencode minors and two omp minors is enough to move an event or a field, and the adapters tolerate unknown input by design (NFR-3), so drift would surface as a silently empty transcript rather than an error. Audited on 2026-09-29: no drift.
- omp 18.4.2 —
CIRCULOGO_OMP_LIVE=1 go test ./internal/omp/ -run TestLiveSmokegreen end to end (276-model catalog, defaultzai/glm-5.3-flash, real turn, hydration, abort, clean stop). Doc said 264 models: only the catalog grew. - opencode 2.0.18 — all 19 endpoints the adapter calls are present with
identical method + path in the 2.0.18 spec (138 operations total); the streaming
path, the usage accounting and the hydration shape are unchanged. Additive only:
session.inbox.delivered,session.step.streamedandsession.instructions.updatedare new and correctly ignored, as is the usual mcp/model/provider noise. The prompt body is still{text, files}—parts[]is rejected — andfiles[]now carriesskills,delivery,resume,metadatathat the adapter does not use yet. - Guard added.
internal/opencode/live_test.gomirrors the omp smoke (CIRCULOGO_OPENCODE_LIVE=1): readiness, a real turn, and the §3.3 hydration-parity assertion, logging the server version it ran against. The managed integration test now resolves the binary from PATH (whatmain.gospawns) and skips with a reason when the major is not 2, instead of silently passing against the pinned 2.0.8 side-by-side install. - Spec URL corrected. On v2
GET /docserves the webview HTML; the machine spec isGET /openapi.json. AGENTS.md pointed at/doc.
Owner decision: nothing in the product may remain circulogo. The scaffold had
been named that way and it leaked into the module, the binary, the bundle, the
Wails bindings path, the localStorage keys and the config directory.
What moved, and the one thing that needed care:
- Go module
circulogo→circulo, with every import rewritten. - Binary, bundle, Taskfile
APP_NAME, and the Wails-generated platform templates for iOS, Linux and Windows — which also still carried the template metadata ("A circulogo application",com.example.circulogo) that the macOS and iOS plists were cleaned of earlier. - Bindings moved with
git mv, since they are tracked despite being generated:frontend/bindings/circulogo/→frontend/bindings/circulo/. circulogo-flow→circulo-flow. This one is a wire contract, not a label: the adapter tells the agent to emit the fence and the renderer recognises it, so both sides had to move together. A one-sided rename would have silently stopped rendering flowcharts.- Instruction entry keys
circulogo-format/circulogo-branch→circulo-*, written into the user's agent config per session. - The config directory
~/Library/Application Support/circulogo→circulo, with a migration. Renaming it without one would have looked like a fresh install and silently dropped every project the user had added. The store moves the file on first run and falls back to the old path if the move fails, because losing the projects matters far more than the directory name.
The two remaining circulogo strings are deliberate: the Phase 1 record of the
original wails3 init command, and the legacyAppDir constant the migration
reads from.
- v2 handoff: owner E2E checklist (§3 of
opencode-v2-phase6-e2e.md). Of the five §5
decisions, only §5.3 remains open (import v1 sessions or start clean):
§5.1 done (AGENTS.md rewritten for v2), §5.2 retracted (Wails already routes
SIGTERM to
ServiceShutdown— no handler needed), §5.4 moot (the global CLI is v2), §5.5 done (the stack is onmain). - Full E2E manual checklist pass (below) — the project gate in AGENTS.md for remote work; the adapter-#2 clause was consumed by the omp provider (Phase 7). The provider half of it is now covered by the two live smokes above; what remains is the window-level pass (cards, banners, terminal, reconnect).
Re-sync ui.md with the shipped Circulo replica, terminals and app-bar actions.Done (2026-09-29) — rewritten from the build as v0.2, split into durable contract (§2–§4) and dated inventory (§5). Debt from both earlier amendments is closed.
- Perf pass (NFR-2 profiling with 50+ tool-call session), a11y pass (UX §8 —
the open items are sidebar ↑↓/Enter session navigation and a WCAG AA contrast
audit of the dark theme), icon + app name,
wails3 build+wails3 package(.app), orphan-process audit (NFR-4). - No light theme in v0 (owner decision 2026-09-29): the build is deliberately dark-first against the Circulo Paper tokens and ux.md §8 was corrected to stop promising one. A light theme is a v1 candidate.
- v0 exit checklist: all FR-1…FR-21 demonstrated once in a recorded E2E session (prompt → stream → permission → abort → error → history).
mkdir /tmp/e2e-proj && cd /tmp/e2e-proj && git init→ add as project in app.- New session → prompt "list files, then reply DONE" → observe: tool card runs, text streams.
- Prompt "create notes.txt with 'hi'" → approve edit permission via card.
- Abort mid-turn on a long prompt → partial content stays, status idle.
- Kill
opencode serveexternally → project dot red with detail → Retry works. - Quit app →
pgrep opencodeempty (no orphans). ← ✅ verified 2026-09-29 on the packaged bundle for both SIGTERM and SIGINT, aftershutdownOnSignallanded; the check is what exposed the bug. - Reopen → history hydrates for the same session.
Circulo and waku sit in the same product category, so waku is a legitimate reference. Three constraints on that reference, decided by the owner on 2026-09-29:
- waku is GPL-3.0-only. Ideas and UX patterns yes, code never — copying a file would force Circulo under GPL-3.0. This is now a rule in AGENTS.md.
- waku's architecture is not portable. Its
waku-daemon/waku-protocol/waku-coresplit exists because it drives 9 providers plus a browser client and remote access. Circulo is one process with a route-mounted relay, and the neutral protocol already covers what the daemon covers there. A daemon would break the single-composition-root invariant (main.go) with no proven need. WebSocket RPC is likewise the wrong call for a one-way event stream. - The gaps below were each verified against the code, not inferred from waku's README. Where Circulo already has something, it says so.
Also checked and no action needed: waku's oversized-catalog bug (its omp probe
skipped chunk reassembly, PR #153). Circulo's Meta reads through the same
reassembling framed reader, and the 18.4.2 live smoke returns a 276-model catalog
against the real binary.
And a position worth keeping: waku 0.1.19 is broken against OpenCode GA —
issue #242, open 11 days, 0 reactions, unfixed in main. Its probe hits
GET /api/health, which does not exist in GA, so the model picker silently
empties. Verified against the live 2.0.18 spec: /api/health is absent,
/api/info is present, the command body requires {name, text} and rename is a
PATCH — Circulo already does the right thing on all four counts, and the live
smoke added in the version audit now keeps it that way.
Scope line set by the owner 2026-09-29: P0–P4 land before v0.1; P5–P7 are v1.
Sequenced P0 first because the later ones that touch the protocol ride on it.
P0–P4 are all done (branch docs/waku-gap-plan).
P0 — Sequenced envelopes with a replay cursor. ✅ done (branch
feature/stream-integrity). The reason this was not cosmetic: the only defense
against a dropped frame was that parts are full replacements, so the reducer
self-heals (FR-20) — but that covers part content only. It does not cover
permission.asked, session.status, adapter.status, retry or form requests,
and those are the events that strand a UI. What shipped:
protocol.Envelopecarriesseq+epoch;protocol.tsand the SSE client changed with it, per the AGENTS.md contract rule.- The orchestrator stamps both, and
Subscribe(protocol.Cursor)returns aReplayStatus(full/resumed/stale/gap) with the channel. - The relay sends that status as the first SSE control frame and writes an
id:per event, so a nativeEventSourcereconnect also carriesLast-Event-ID. - The client owns the cursor and rebuilds the
EventSourceon reconnect, dedupes on the(epoch, seq)pair rather thanseqalone, and refetches only when the status is notresumed. A one-second blip no longer costs four REST round trips including the model catalog. - A hole the cursor exposed, now closed: a subscriber that stopped reading had its events silently dropped. It is now dropped as a subscription — the channel closes, the relay ends the response, and the client reconnects with a cursor that recovers the exact tail.
- Verified end to end over HTTP, not just per-unit: connect, cut, emit three events while away, reconnect, receive exactly those three.
P1 — Checkpoints as per-turn git refs. ✅ done. Replaced backlog item 5
rather than adding to it: that item assumed OpenCode's revert/unrevert, which
ties checkpoints to one provider. Refs work identically for omp, where no such
support exists, and need nothing from the harness.
internal/checkpointowns the git plumbing. Refs live underrefs/circulo/, outsiderefs/headsandrefs/tags, namedsession-<id>-turn-<n>-<start|end>.- Snapshots go through an alternate index, not
git stash create: stash leaves everything alone but omits untracked files, and the files an agent creates are the whole point. The user's index, worktree and stash list are asserted unchanged after a capture. - Turn boundaries are detected in the orchestrator from
session.status(busy→idle), because the relay hands prompts straight to the adapter — the orchestrator never sees them — while status is the one event both backends translate identically. - Rewind restores the worktree and leaves HEAD alone. Moving the branch
would discard commits the user made during the session. The pre-rewind state
is checkpointed as a
guardfirst, so the action is undoable. - The relay splits preview from action:
rewind-previewreturns the diff and whether uncommitted work would be lost;rewindacts and requires an explicit turn number so it can never default to "the newest one". Both routes are on the project, not the adapter, so rewind keeps working when the backend is down. - 21 package tests against real git repositories, 9 wiring tests, 6 relay tests,
8 frontend tests. One bug the wiring test caught: files an agent creates are
untracked and
git read-treedoes not remove them, so a rewind that only restored tracked files would have undone almost nothing.
P2 — Declared capabilities on the runtime. ✅ done. protocol.Capabilities
on the Adapter interface, carried on Meta, read in the UI through one helper.
Flags: permissions, forms, steer, branch, agents, commands, accessMode — each one
a real code path rather than an aspiration.
The two gaps this actually fixes are the ones that are invisible until a user
hits them. omp has no permission round-trip, so permission cards simply never
appear with nothing to explain why; and its SetBranch is a documented no-op, so
the branch picker looked functional and did nothing. The first real UI use is
that picker, which now shows the checked-out branch read-only and says why.
Correction to how this was scoped: the plan said capabilities would "remove the provider name as crossing vocabulary entirely". That was overselling it and it is wrong. Identity is legitimately name-based — which product this is, its logo, which "add project" entry to show. Only behavior moves to capabilities, and the AGENTS.md invariant that permits the provider name still holds.
Not yet wired: steer is declared false for opencode because the adapter sends
a second prompt rather than using the inbox delivery GA offers
(PATCH /api/session/{id}/inbox/{id} {delivery: steer|queue}, per waku's #242
table). That is the real queue/steer feature and it is still ahead.
P3 — Composer drafts per target. ✅ done. Keyed by conversation target and restored on return, so switching sessions no longer discards what was being typed. The key includes the branch, because for a new session the same project plus "new session" can be aimed at different branches.
Two decisions that shape it:
- A draft stores segments, not text. The composer is a
contenteditablewhose @file and /command tags are DOM chips rather than characters, so storing the projected string would come back with@src/app.tspasted as plain text — which then reads as a command the user never picked. localStorage, not a server store. waku persists drafts in its daemon; Circulo has no daemon and a draft has no reason to sync between anything. The storage is injected so the module is testable in node, and every failure path (blocked storage, quota, corrupt entry) degrades instead of throwing: a draft is never worth failing a keystroke over. Save and restore are one effect on purpose — as two they race and can write the restored draft back over the one just restored. Sending clears the draft for that target.
P4 — Scope on slash commands. ✅ done. CommandInfo.Source carried the
backend's own word across the boundary (omp's skill, opencode's file) and
the UI branched on it. Replaced with the neutral CommandScope; NormalizeScope
maps the provider vocabulary and an unknown value becomes builtin rather than
crossing as a string nobody can render.
The substantive part is the two orderings, and conflating them breaks both. A
project command must outrank a backend builtin, or the builtin makes the
project's command unreachable — the opposite of what a project command is for.
But the picker still lists the builtin first. Backwards, you get either
unreachable commands or a picker where the shadowed entry looks like the winner.
A test asserts the two orderings still differ; if they ever agree, one has
collapsed into the other. ResolveCommands ignores input order, so two backends
listing the same thing differently produce the same picker, and it runs in the
relay because merging sources is a neutral concern an adapter cannot do.
- omp tool-call granularity. The OpenCode side is done — the adapter already
handles
session.tool.input.started/endedandsession.tool.progress, and the neutral protocol has apart.deltafast path behind a fullpart.updated. omp only emitstoolcall_end, so arguments appear when the call completes; incremental input depends on what omp's RPC actually exposes. @file mentions + image attachments in composer (protocol already has file parts).- Syntax highlighting behind MarkdownView; diff syntax tone.
- Third adapter (Claude CLI stream-json) — the omp adapter already proved the neutral protocol across transports (stdio vs HTTP+SSE).
Checkpoints/revert UI→ shipped as P1 in Phase 9: per-turn git refs instead of OpenCode's provider-specificrevert/unrevert. Session fork stays here (OpenCode now takes{before: msgID}onPOST /api/session/{id}/fork).- Remote access (Tailcat) — design frozen in remote.md; implementation blocked by project rule until local protocol is stable.
- Projectless tasks (waku P5): auto-create a workspace under a local dated directory for a quick question, instead of demanding a folder up front. Fits the local-first model; Circulo has no such path today.
- Search within session history (waku P6): message search, complementing the
existing
@file search. - Plan quota / usage history (waku P7): subscription-remaining display. Circulo shows per-turn usage only.
- Auto-update (waku ships signed .dmg + updater). Packaging exists; the updater is a separate concern, after v0.1.