1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
|
# Claudomator OSS Replacement — Architectural Redesign
## Context
Claudomator today is a useful but overgrown harness: ~10 task states, a stories layer, an elaborate-via-REST endpoint, file-based agent escape hatches, a manual task-creation web form, and a stub GeminiRunner that silently returns canned output. The primary mode of interaction we actually want is now: **a chatbot drives task creation and orchestration on the user's behalf; the human only intervenes when an agent asks a question; the web UI is for observability, not for primary input.**
This plan locks the architectural shape of that redesign. Implementation phasing is sketched at the end but the focus here is decision capture so subsequent work can proceed without re-litigating.
**Mission:**
- Reduce custom code we maintain.
- Support chatbot-as-primary-driver (us, or any chatbot with MCP).
- Push-notify the user when human input is needed.
- Web UI = observability layer (task tree, live logs, event stream, accept/reject).
- Multi-runner (Claude cloud, Gemini cloud, OSS local) with budget-aware routing.
**Non-goals (deferred):**
- Cross-model resume — each runner is sovereign over its own continuity.
- Non-code-task UX optimization — shape must remain *possible* (project_dir is optional) but no code or docs accommodate it yet.
- Autonomous chatbot responding to agent questions without human — the subscription model must not foreclose it, but it's not built.
---
## Locked Decisions
### 1. SQLite is source of truth; external trackers are input feeds
Tasks live in our `tasks` table. GitHub/GitLab/whatever-tracker integrations create and update tasks through the existing webhook surface — they are not the data model. Each task carries an `external_refs` slice (`[{tracker, url}]`) for opportunistic back-syncing of state, but nothing load-bearing depends on the external tracker being available.
Rejected: GitHub Issues as source of truth (Option B). Too much lock-in, non-code tasks fit poorly, and we'd still need our own DB for execution data.
### 2. Events table replaces ad-hoc task-state columns
A single `events` table replaces `question_json`, `interactions_json`, `summary`, `elaboration_input` on `tasks`. Schema:
```
events: id, task_id, seq, ts, kind, actor, payload_json
```
- `seq` is monotonic per task.
- `actor ∈ {agent, user, chatbot, system, webhook}`.
- `kind ∈ {state_change, agent_message, clarification_request, clarification_answer, human_message, summary, commit_made, subtask_spawned, cost_report, execution_started, execution_ended}`.
- Granularity: **milestones, not raw stream chunks.** Raw Claude stream-json stays in `stdout.log` on disk, linked from the `execution_started` event.
- Replay: read events in `seq` order, fold state transitions, reconstruct UI. **For humans, not for re-prompting the agent.** Cross-model agent replay is explicitly out of scope.
### 3. Per-runner structured agent channel (no more file conventions)
Today the agent uses `CLAUDOMATOR_QUESTION_FILE`, `CLAUDOMATOR_SUMMARY_FILE`, and `POST /api/tasks` for subtasks — orchestrated by a ~1KB prompt preamble. We replace this with a `Runner` interface that exposes normalized callbacks:
```go
type AgentChannel interface {
OnAskUser(question string, schema any) (answer string, err error)
OnReportSummary(text string)
OnSpawnSubtask(spec TaskSpec) (taskID string, err error)
OnRecordProgress(message string)
}
```
Each runner picks its own wire:
- **ClaudeRunner:** registers an in-process MCP server via `--mcp-config`. Tools (`ask_user`, `report_summary`, `spawn_subtask`, `record_progress`) bound to a per-task scoped token minted at subprocess spawn. Server derives `task_id` from token; never trusts agent-supplied IDs.
- **GeminiRunner:** uses Gemini CLI tool-use registration (or direct API if CLI tool-use proves unworkable — to determine during build).
- **LocalRunner:** uses Ollama-style tool-use API. Falls back to file conventions only if a future runner lacks tool-use plumbing.
**Safety tradeoffs accepted:**
- Atomicity at run boundary: MCP calls land mid-run, file-based intent lands at exit. Mitigated by designing tools idempotent (`report_summary` upserts, `ask_user` creates a clarification event which is fine mid-run by design).
- Blast radius: per-task token scopes the agent's access to one task's data.
- Portability: per-runner transport means non-MCP runners stay supported via callback abstraction.
### 4. Web UI = observability
**In:** task tree view, live log tailing, accept/reject buttons, event-stream timeline per task, budget headroom display.
**Out:** manual task creation form, elaborate textbox, stories dashboard.
Chatbots create tasks via the MCP server. Humans visit the web UI to watch, intervene on `READY`, or answer a `BLOCKED` clarification when push notification pings them.
### 5. Three runners + Classifier
| Runner | Status | Transport | Resume mechanism |
|---|---|---|---|
| ClaudeRunner | exists, works | `claude -p` subprocess + MCP | provider-side via `--resume <session_id>` |
| GeminiRunner | scratch rewrite (current stub deleted) | `gemini` CLI subprocess + tool-use registration | message-history re-feed |
| LocalRunner | exists (Phase 1 of `local-oss-runner.md`), extend with agent-channel | OpenAI-compat HTTP + tool-use | message-history re-feed |
Classifier stays. Its job becomes: given a task, choose Claude-cloud vs Gemini-cloud vs Local, and within Claude/Gemini, the specific model. **Budget state feeds this decision** (#7).
### 6. Auth: loopback default + reverse proxy + scoped tokens
- Server binds to `127.0.0.1` by default. Refuses to bind non-loopback unless `external_bind_allowed = true` is set in config — fail loud on misconfiguration.
- External network access: user fronts with Tailscale / Cloudflare Access / Caddy. Out of our code, battle-tested.
- Internal tokens, each scoped:
- **GitHub webhooks:** HMAC-SHA256 (already exists).
- **Chatbot MCP + web UI:** one shared bearer token from config; user pastes into MCP client config. One user, one credential.
- **Agent MCP back-channel:** per-task token minted at subprocess spawn; bound to single task ID; expires at task termination. Server resolves `task_id` from token — never accepts agent-supplied `task_id`.
- **Web push:** standard VAPID subscriptions.
### 7. Budget — multi-dimensional
- Per-task `max_budget_usd` (exists).
- **Rolling 5-hour budget per provider** (Claude, Gemini) — track spend in a window. Dispatcher refuses to start a paid task that would breach the window. Classifier prefers local when headroom is thin.
- Optional per-day / per-week hard ceiling in config.
- Local runners bypass entirely — free.
- UI surfaces headroom: e.g. "Claude: 67% remaining in current 5h window."
This is genuinely new code: budget accountant, classifier hook, UI surface. Not throwaway.
### 8. Resume = per-runner sovereign opaque state
Each runner persists its own resume state as opaque JSON in `executions.runner_state_json`. The Runner interface:
```go
Resume(ctx context.Context, opaqueState []byte) (*Result, error)
```
- **ClaudeRunner** stores `{session_id}`, calls `--resume`. **Prompt caching survives** — this is the killer feature that justifies the special case. Cost can differ 10x on long resumed tasks.
- **GeminiRunner** stores `{messages: [...]}`, re-feeds on resume.
- **LocalRunner** stores `{messages: [...]}`, re-feeds on resume.
The "specialness" of Claude is entirely internal to ClaudeRunner — the dispatcher and event store don't know. Cross-model resume is **not** a goal; if we ever want it, revisit then.
### 9. Subscription model: push, agent-return-friendly
Server broadcasts events on WebSocket (live UI) + web push (high-signal subset: `clarification_request`, terminal state changes, `summary`). Subscription record:
```
subscriptions: id, endpoint, kind_filter, task_filter, actor_label
```
Today's only subscribers are browser WebSocket clients and web-push endpoints. The model is flexible enough that adding an autonomous-chatbot subscriber later is one row: `{endpoint: chatbot_url, kinds: ["clarification_request"], actor_label: "auto-responder"}`. The `POST /api/tasks/{id}/answer` endpoint already accepts any caller; the `actor` field on the resulting `clarification_answer` event records who answered.
---
## What Gets Deleted
| Surface | Reason |
|---|---|
| `stories` table + handlers + UI dashboard | Folded into events + task tree view |
| `tasks.question_json`, `interactions_json`, `summary`, `elaboration_input` | Replaced by events table |
| `POST /api/tasks/elaborate` endpoint | Chatbots elaborate in-conversation; no REST shell-out path |
| `sanitizeElaboratedTask` keyword heuristics | Same |
| Web UI: create-task form, elaborate textbox, stories dashboard | Out of scope for observability UI |
| `CLAUDOMATOR_QUESTION_FILE`, `CLAUDOMATOR_SUMMARY_FILE` env conventions | Replaced by per-runner agent channel |
| GeminiRunner stub (`internal/executor/gemini.go` simulated output) | Scratch rewrite as real runner |
| Planning preamble's file-protocol section | Shrunk to "use your tools" — runners register tools per-transport |
## What Gets Built
| Surface | Notes |
|---|---|
| `internal/event` package | `Event` struct, kind constants, `EventStore` interface |
| `events` table + scan/CRUD methods | Additive migration; indexed on `(task_id, seq)` |
| Per-task scoped MCP server | Hosted inside claudomator process; tokens minted per subprocess spawn |
| Chatbot-facing MCP server | Tools: `submit_task`, `list_tasks`, `get_task`, `get_events`, `answer_question`, `accept_task`, `reject_task`, `cancel_task` |
| `AgentChannel` interface + per-runner implementations | Normalized callbacks; transport hidden in runner |
| Real GeminiRunner | Subprocess + stream parser + sandbox integration; mirrors ClaudeRunner shape |
| Budget accountant | Rolling 5h window per provider; classifier hook; UI surface |
| Loopback-default binding + `external_bind_allowed` flag | Hard refusal on misconfig |
| Per-task token minting + scoping | Auth context for agent MCP calls |
| Web UI event-stream timeline component | Replaces ad-hoc question/summary panels |
| `external_refs` field on tasks | For opportunistic tracker back-sync |
---
## Phasing
These are sequencing notes, not commitments to PR boundaries. Each phase should be independently shippable.
**Phase 1 — Events table + Runner interface refinement.** Add `events` table, `EventStore`, write paths for state changes and existing question/summary flows. Refactor `Runner` to expose `AgentChannel` callbacks; ClaudeRunner keeps file-based wire as transitional default. No user-visible behavior change yet.
**Phase 2 — ClaudeRunner MCP agent channel.** Per-task MCP server, token minting, four agent tools. Delete file-based escape hatches. Shrink preamble. Migration: existing in-flight tasks finish on the old wire; new tasks use MCP.
**Phase 3 — Chatbot MCP server + UI rebuild.** Chatbot-facing MCP server. Web UI gets event-stream timeline, drops create form / elaborate / stories. Elaborate REST endpoint removed.
**Phase 4 — GeminiRunner scratch rewrite.** Real subprocess + stream parser + sandbox + tool-use registration. Plug into AgentChannel.
**Phase 5 — LocalRunner agent channel.** Extend the existing LocalRunner (from `local-oss-runner.md`) to participate in AgentChannel via Ollama tool-use. Was previously fire-and-forget; now first-class for orchestration tasks.
**Phase 6 — Budget accountant.** Rolling 5h window tracking, classifier integration, UI headroom display, dispatcher gating.
**Phase 7 — Auth tightening.** Loopback default, `external_bind_allowed` flag, refuse-bind enforcement, chatbot bearer rotation flow.
**Phase 8 — Cleanup pass.** Delete `stories`, deprecated columns, dead handlers, dead UI. Update CLAUDE.md and ADRs.
---
## Open Design Questions (resolve at phase entry)
- **MCP tool schemas.** Exact arg shapes for `ask_user` (free text? JSON schema for typed answers?), `spawn_subtask` (how much of `TaskSpec` is exposed?), `submit_task` (how to handle `project_dir` resolution from chatbot side?). Defer to Phase 1/3 detailed design.
- **Gemini transport for tool-use.** Whether `gemini` CLI's tool-use is reliable enough or we need to drop to the SDK. Decide in Phase 4 spike.
- **Web push subset.** Final list of event kinds that trigger push vs WebSocket-only. Default: `clarification_request`, `state_change → READY/FAILED/BLOCKED`, `summary`. Tune in Phase 3.
- **Single shared bearer token vs per-client.** If multi-device gets painful, revisit. Defer.
- **Budget hard-ceiling defaults.** What's the user's actual daily Claude spend tolerance? Surface in config with no default; require explicit value.
## Deferred (revisit only if pressure appears)
- Cross-model resume.
- Non-code-task UX (event types for "agent produced text", task-with-conversation-as-primary view). Shape must remain possible; no code or docs accommodate it.
- Autonomous chatbot answering clarifications. Subscription model supports it; no implementation.
- GitLab webhook handler (parallel to GitHub). Add when needed.
- Todoist or similar personal-task-tracker bridges. Same shape as GitHub webhook; add when needed.
|