# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Also check `~/.claude/CLAUDE.md` for user-level development standards (TDD workflow, git practices, session state management, etc.) that apply globally across all projects. ## Canonical Repository **The canonical source of truth is `/workspace/claudomator`.** All development must happen here. Do not work in any other directory unless explicitly instructed. Do not explore `/site/doot.terst.org/` for source files. ## Build & Test Commands ```bash # Build go build ./... # Run all tests go test ./... # Run a single package's tests go test ./internal/executor/... # Run a single test by name go test ./internal/api/ -run TestServer_CreateTask_MissingName # Run with race detector (important for executor/pool tests) go test -race ./... # Build the binary go build -o claudomator ./cmd/claudomator/ ``` > **Note:** `go-sqlite3` uses CGo. A C compiler (`gcc`) must be present for builds and tests. ## Running the Server ```bash # Initialize data directory ./claudomator init # Start API server (default :8484) ./claudomator serve # Run a task file directly (bypasses server) ./claudomator run ./test/fixtures/tasks/simple-task.yaml # List tasks via CLI ./claudomator list ``` Config defaults to `~/.claudomator/config.toml`. Data is stored in `~/.claudomator/` (SQLite DB + execution logs). --- ## Architecture **Pipeline:** CLI/API → `executor.Pool` → `executor.ContainerRunner` → `claude -p` subprocess → SQLite + log files ### Package Overview | Package | Role | |---|---| | `internal/task` | `Task` struct, YAML/JSON parsing, state machine constants, validation | | `internal/executor` | `Pool` (bounded goroutine dispatcher) + `ContainerRunner` (Docker; runs `claude`/`gemini`) + `LocalRunner` (OpenAI-compatible HTTP) + `Classifier` + `AgentChannel` + per-task agent MCP server (`Registry`) + planning preamble | | `internal/storage` | SQLite wrapper; additive migrations; tasks + executions + events tables | | `internal/event` | `Event` type, kind/actor constants — the per-task observability stream | | `internal/budget` | `Accountant` — rolling per-provider spend windows; gates the dispatcher | | `internal/llm` | OpenAI-compatible chat client (used by LocalRunner + Classifier), incl. tool-use | | `internal/api` | HTTP/WebSocket server — REST endpoints, webhook handler, validate, script runner, agent MCP (`/mcp`) + chatbot MCP (`/chatbot/mcp`), budget + events endpoints | | `internal/notify` | `Notifier` interface; webhook, multi, log implementations | | `internal/reporter` | Console/JSON/HTML report generation | | `internal/deployment` | Deployment-status checking (polls URL for expected version) | | `internal/config` | TOML config loading + data-dir layout helpers | | `internal/cli` | Cobra commands: `run`, `serve`, `list`, `status`, `start`, `logs`, `create`, `report`, `init` | | `internal/version` | VCS version detection (`debug.ReadBuildInfo`) | | `internal/provider` | Provider-neutral chat/tool-use interface (`Provider`); adapters: `anthropic`, `google`, `openaicompat` (wraps `internal/llm`, also backs the `groq`/`openrouter`/`openai` runners) | | `internal/agentloop` | Provider-neutral tool-use control-flow loop shared by all `NativeRunner`s | | `internal/sandbox` | `Sandbox` interface (`HostSandbox`/`DockerSandbox`) + pre-tool-use guardrail hooks | | `internal/role` | `RoleConfig`/`Tier`/`Rung` — per-role system prompt + provider/model escalation ladder | | `internal/scheduler` | `Scheduler` — polls role-typed FAILED tasks and retries/escalates them per their role's ladder; `StoryOrchestrator` (Phase 7b) — polls stories and drives Builder → Evaluators → Arbitration → REVIEW_READY | | `internal/story` | `Epic`/`Story` — planning layer above the flat task tree; data model + CRUD only (see `internal/scheduler.StoryOrchestrator` for the orchestration that drives it) | | `web` | Embedded static UI (`embed.go`) | ### Key Data Flows **Task execution:** 1. Task created via `POST /api/tasks` or YAML file (`task.ParseFile`) 2. `POST /api/tasks/{id}/run` → `executor.Pool.Submit()` → buffered work queue 3. `dispatch()` goroutine picks from queue, waits for slot, launches `execute()` 4. `execute()` calls `ContainerRunner.Run()` → `claude -p --output-format stream-json` 5. stdout piped through `parseStream()` to `~/.claudomator/executions//stdout.log` 6. Execution result written to SQLite, broadcast via WebSocket to connected clients **Task state machine** (enforced in `storage.UpdateTaskState` via `task.ValidTransition`): ``` PENDING ──→ QUEUED ──→ RUNNING ──→ READY ──→ COMPLETED ↑ │ └──→ PENDING (rejected) │ │ │ ├──→ BLOCKED ──→ READY (all subtasks done) │ │ └──→ QUEUED (question answered) │ │ └──────────────├──→ FAILED ├──→ TIMED_OUT ├──→ CANCELLED └──→ BUDGET_EXCEEDED ``` - **BLOCKED**: Parent task completed but has subtasks that are not yet COMPLETED, OR the agent called `ask_user`. Unblocked by `maybeUnblockParent()` or a user/chatbot answer via `/api/tasks/{id}/answer` (or the chatbot MCP `answer_question` tool). - **READY**: Execution succeeded; awaits manual accept/reject via `/api/tasks/{id}/accept` or `/api/tasks/{id}/reject`. Exception: a Builder/Evaluator/Arbitration task belonging to a story's pipeline is auto-accepted straight to `COMPLETED` by `internal/scheduler.StoryOrchestrator` (Phase 7b) instead of waiting for a manual accept — see "Story orchestrator" below. - **COMPLETED**: Terminal — entered via user accept (top-level), `StoryOrchestrator` auto-accept (story-pipeline top-level tasks), or automatic subtask completion. - `FAILED/TIMED_OUT/CANCELLED/BUDGET_EXCEEDED` all re-enter at `QUEUED` for retry/resume. **WebSocket:** `Hub` fans out task completion events to all connected clients. `Server.StartHub()` must be called before `ListenAndServe`. ### Execution model (ContainerRunner, Docker-based) The production runner for both `claude` and `gemini` agent types is `ContainerRunner`. (The old subprocess `ClaudeRunner`/`GeminiRunner` and the `/tmp` git-sandbox helpers were removed; the standalone Gemini stub is gone.) 1. The repo is cloned into a per-run workspace bind-mounted at `/workspace`; a writable `$HOME` is staged at `/home/agent`. The agent runs as the host uid (`--user`), with `IS_SANDBOX=1` set so `claude --permission-mode bypassPermissions` is honored even under root. 2. Instructions are written to a file and passed to the CLI; when the MCP back-channel is active and `skip_planning` is false, the planning preamble is prepended. 3. After a successful run, new commits are pushed from the workspace. 4. On BLOCKED (the agent called `ask_user`), the workspace path is stored in `executions.sandbox_dir` so the resume execution reuses it. ### Agent back-channel (MCP) Runners expose a normalized `AgentChannel` (`AskUser`, `ReportSummary`, `SpawnSubtask`, `RecordProgress`). Transport is per-runner: - **ContainerRunner** mints a per-task bearer token (`executor.Registry`) and points the agent at the host MCP server: claude via `--mcp-config`, gemini via `~/.gemini/settings.json`. The server (`/mcp`) resolves the task from the token and never trusts an agent-supplied ID. - **LocalRunner** declares the four tools as OpenAI function-calling defs and runs a tool-use loop, re-feeding tool results as message history. `ask_user` records a clarification and returns `ErrAgentBlocked`; the runner turns the buffered question into a `*BlockedError` and the task blocks. ### Task YAML Format ```yaml name: "My Task" description: "Optional longer description" agent: type: "claude" # "claude" (default) or "gemini" (stub, not production-ready) model: "sonnet" # optional; auto-classified by Classifier if omitted instructions: | Do something useful. project_dir: "/path/to/project" # optional; triggers sandbox isolation max_budget_usd: 1.00 permission_mode: "bypassPermissions" # default; or "default", "acceptEdits" allowed_tools: ["Bash", "Read", "Edit"] disallowed_tools: [] context_files: ["/extra/context/path"] system_prompt_append: "Extra instructions appended to system prompt." skip_planning: false # if false, prepends planning/orchestration preamble additional_args: [] # extra flags forwarded verbatim to claude CLI timeout: "15m" priority: "normal" # "high" | "normal" | "low" (stored but not yet used for scheduling) tags: ["ci"] depends_on: ["other-task-id"] retry: max_attempts: 1 # stored but retry is currently manual via /resume backoff: "exponential" ``` > **Note:** The YAML key is `agent:`, not `claude:`. Earlier docs showed `claude:` which was wrong. Batch files wrap multiple tasks under a `tasks:` key. ### Storage Schema Schema is auto-migrated additively on `storage.Open()` — new columns are `ALTER TABLE ... ADD COLUMN` statements that silently succeed if the column already exists. ``` tasks: id, name, description, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, rejection_comment, question_json, summary, elaboration_input, interactions_json, created_at, updated_at executions: id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, sandbox_dir, changestats_json, commits_json, tokens_in, tokens_out, agent, escalation_rung events: id, task_id, seq, ts, kind, actor, payload_json role_configs: id, role, version, status, config_json, created_at, activated_at, retired_at, proposed_by (UNIQUE(role, version)) epics: id, name, description, status, discovery_source, created_at, updated_at stories: id, epic_id, project_id, name, branch_name, status, discovery_source, spec, acceptance_criteria_json, validation_json, deploy_config, priority, root_task_id, created_at, updated_at ``` The `events` table is the per-task observability stream (`internal/event`); see the REST `GET /api/tasks/{id}/events` and the chatbot MCP `get_events` tool. `executions.agent` records which provider ran each execution, for budget accounting (`SpendByProviderSince`). `executions.escalation_rung` records the 0-based index into the active role's `EscalationLadder` a role-typed task's execution ran at (0 for non-role tasks); see "Role-based dispatch & escalation" below. `role_configs` holds versioned `internal/role.RoleConfig` blobs (`config_json`) per role, `status` one of `draft`/`active`/`retired`. `ActivateRoleConfigVersion` enforces "at most one active row per role" transactionally (retire-then-activate in one transaction), the same way `UpdateTaskState` enforces the task state machine in a transaction rather than in schema — see `internal/storage/roleconfig.go`. JSON blobs: `config_json` (AgentConfig on `tasks`; RoleConfig on `role_configs`), `retry_json`, `tags_json`, `depends_on_json`, `interactions_json`, `changestats_json`, `commits_json`, `acceptance_criteria_json`. `epics`/`stories` (`internal/story.Epic`/`internal/story.Story`) are the planning layer that sits above the flat task tree — see "Planning layer: epics & stories" below. This phase (7a) is pure data model + CRUD: no orchestration reads or writes these tables yet. --- ## Features ### Planning Preamble & Orchestration When `agent.skip_planning` is false (the default), `withPlanningPreamble()` prepends a system-level prompt that points the agent at its MCP/tool-use back-channel rather than file/REST conventions: - Break work taking more than ~3 minutes into pieces via the `spawn_subtask` tool, then stop - Call `ask_user` (and end the turn) when it needs a decision - Commit all changes before finishing - Call `report_summary` before finishing The four tools — `ask_user`, `report_summary`, `spawn_subtask`, `record_progress` — are served over MCP (claude/gemini) or OpenAI tool-use (local); the old `CLAUDOMATOR_QUESTION_FILE`/`CLAUDOMATOR_SUMMARY_FILE` conventions were removed. ### Changestats After each execution, changestats (files changed, lines added/removed) are parsed from git `diff --stat` output in `stdout.log` and stored in `executions.changestats_json`. > **Duplication debt:** Changestats are extracted in two places: `executor.Pool.handleRunResult()` and `api.Server.processResult()`. Both write the same value to the same row (idempotent), but the double-extraction is confusing and should be consolidated. See [Design Debt](#design-debt). **Parser:** `internal/task/changestats.go` — `ParseChangestatFromOutput`, `ParseChangestatFromFile`. **Frontend:** `web/app.js` renders a `.changestats-badge` on COMPLETED/READY task cards. ### GitHub Webhook Integration `POST /api/webhooks/github` accepts `check_run` and `workflow_run` events. Returns `{"task_id": "..."}` (200) on task creation or 204 if ignored. #### Config (`~/.claudomator/config.toml`) ```toml webhook_secret = "your-github-webhook-secret" # HMAC-SHA256; skip validation if omitted [[projects]] name = "myrepo" dir = "/workspace/myrepo" ``` #### Matching logic Repository name matched case-insensitively against each project's `name` and the basename of its `dir`. Falls back to the only configured project if no match found. #### Task creation Tasks created for: - `check_run` with `action: completed` and `conclusion: failure` - `workflow_run` with `action: completed` and `conclusion: failure` or `timed_out` Tagged `["ci", "auto"]`, capped at $3 USD, allowed tools: Read, Edit, Bash, Glob, Grep. ### Task creation (chatbot-driven) The natural-language `POST /api/tasks/elaborate` endpoint and the web create form were removed. Chatbots now create and drive tasks through the chatbot MCP server (`/chatbot/mcp`, shared bearer token): `submit_task`, `list_tasks`, `get_task`, `get_events`, `answer_question`, `accept_task`, `reject_task`, `cancel_task`. The web UI is observability-only (task tree, live logs, event timeline, accept/reject, budget headroom). ### Budget gating `internal/budget.Accountant` tracks per-provider spend in a rolling window (default 5h, `[budget]` config). Before running, the dispatcher reroutes paid work to the free local runner when a cap would be breached, else blocks the task (`BUDGET_EXCEEDED`). `GET /api/budget` surfaces headroom. Local runners are free. ### Auth / binding The server binds `127.0.0.1` by default and refuses a non-loopback bind unless `external_bind_allowed = true` (front external access with a reverse proxy). The shared `api_token` guards the web UI + chatbot MCP; agent MCP uses per-task tokens; GitHub webhooks use HMAC. ### Model Classifier `executor.Classifier` calls the Gemini CLI (`gemini-2.5-flash-lite`) to pick the best Claude or Gemini model for a task. Falls back to the default model (`sonnet`) if Gemini fails. Agent type is selected first by load balancer; classifier only picks the model within that agent. > **Implementation gap:** Output parsing is brittle — strips `"Loaded cached credentials."` lines and markdown fences by string matching. No fallback if Gemini CLI isn't installed. Classification results are not cached or logged for learning. ### Role-based dispatch & escalation A task opts into role-based dispatch by setting `agent.role` (`task.AgentConfig.Role`) to a role name with an **active** `role_configs` version (see the REST endpoints below). Tasks with `agent.role` unset (every existing YAML/chatbot task shape) are completely unaffected — this is purely additive. - **Initial dispatch** (`internal/executor.Pool.execute()`): when `Agent.Role != ""` and `Agent.Type == ""`, the pool resolves tier 0 of the active `role_configs` row's `EscalationLadder`, setting `Agent.Type`/`Agent.Model` on a copy of the task (mirroring the `withFailureHistory` copy-before-mutate pattern) and applying `RoleConfig.SystemPrompt` to `Agent.SystemPromptAppend`. Multi-candidate `round_robin` tiers rotate via `Pool.selectRung`, skipping any provider currently in `Pool.rateLimited` (falling back to whichever clears soonest if all are limited). - **Retry/escalation** (`internal/scheduler.Scheduler`, started by `serve` only — not the one-shot `run` command): polls for role-typed tasks whose latest execution is `FAILED` and, per the ladder tier at that execution's `escalation_rung`: retries at the same rung while under `tier.MaxRetries`, or escalates to the next tier's first candidate if `budget.Accountant.Allow` permits it (recording an `event.KindEscalated` event), or leaves the task `FAILED` for human attention — recording a `final: true` `KindEscalated` event — if the budget denies it or the ladder is exhausted. An in-memory (per-process) "already handled" set keyed by execution ID keeps the poll loop convergent: a task stuck at the end of its ladder gets exactly one `final` event, not one per poll tick. This resets on restart (by design — it's idempotent bookkeeping, not orchestration state, so re-deriving it once is harmless). - **REST**: `POST /api/roles/{role}/versions` (create draft), `GET /api/roles/{role}/versions` (list), `POST /api/roles/{role}/activate?version=N` (activate, atomically retiring whatever was active). > **Stored but not yet enforced:** `RoleConfig.Tools`/`SandboxKind` are round-tripped through storage/API but don't affect tool availability or sandbox selection — those are still applied uniformly by `NativeRunner` regardless of role. `RoleConfig.DefaultBudgetUSD` is read narrowly by the scheduler as the estimated cost passed to `Allow()` when considering an escalation — it is not enforced at initial-dispatch time the way `task.AgentConfig.MaxBudgetUSD` is. See `internal/role/role.go`'s field docs. > **Known simplification:** the scheduler always escalates to `nextTier.Candidates[0]` — it does not round-robin across multiple candidates in a tier the way `Pool.execute()`'s initial-dispatch resolution does. A later phase could extend this if multi-candidate escalation tiers turn out to matter in practice. ### Cascade-fail & role-typed subtask spawning Two prerequisites for fan-out patterns (e.g. a Builder task with several role-typed Evaluator subtasks depending on it, followed by an Arbitration task depending on all of them): - **Auto-cascade-fail** (`internal/executor.Pool.cascadeFail`): the moment a task lands in a terminal failure state (`FAILED`/`TIMED_OUT`/`CANCELLED`/`BUDGET_EXCEEDED` — checked in `handleRunResult` plus the two direct-cancel paths in `execute()` for budget-gate and dependency-failure), the pool looks up `Store.ListDependents(taskID)` (a full-table scan over `depends_on_json` — no reverse index, same tradeoff already accepted elsewhere for JSON-blob columns) and cancels every direct dependent still waiting (`PENDING`/`QUEUED`), recording a `CANCELLED` execution whose `error_msg` references the upstream task and writing the ordinary `KindStateChange` event via `UpdateTaskState` (no new event kind). It recurses into each cancelled dependent's own dependents (visited-set guarded against a pathological dependency cycle — task creation does not itself reject cycles), so a multi-level chain cascades all the way down. `execute()`'s dependency-wait requeue loop re-checks the task's fresh DB state before dispatching so a task cascade-cancelled out from under it is never actually run. - **Role-typed subtask spawning** (`agentchannel.SubtaskSpec.Role`, threaded through `storeChannel.SpawnSubtask` in `internal/executor/channel.go`): when a spawning agent sets `role` (available as an optional `spawn_subtask` tool parameter on both transports — `internal/agentloop/tools.go` for the native tool-use loop, `internal/executor/agentmcp.go` for the MCP transport), the child task gets `Agent.Role` set and `Agent.Type`/`Agent.Model` left empty, so Phase 5's role-based dispatch resolves them from the role's escalation ladder on the child's own first dispatch. Omitting `role` (every pre-Phase-6 caller) preserves the exact prior behavior (`Agent.Type: "claude"`, `Agent.Model` from the `model` argument). ### Planning layer: epics & stories `internal/story` defines `Epic` and `Story` — a planning layer above the flat task tree (`epics`/`stories` tables, see Storage Schema above). Phase 7a built pure data model + CRUD. Phase 7b (`internal/scheduler.StoryOrchestrator`) adds the first slice of automated orchestration on top of it: driving a story through Builder → 4 parallel Evaluators → Arbitration → a single human accept-gate **at the story level**. Every task along the way (Builder, Evaluators, Arbitration) is auto-accepted by the orchestrator itself — see "Story orchestrator" below — so `POST /api/stories/{id}/accept` is the *only* manual accept a human/chatbot ever has to make for the whole chain. Planner→Builder chain automation, epic-proposal tooling, and deploy-gating are still out of scope — see that type's non-goals. - **Epic** (`epics` table): a loosely-scoped initiative that decomposes into Stories. `status` is `OPEN`/`CLOSED`, unvalidated pass-through (`UpdateEpic` writes whatever it's given, same trust level as `UpdateProject`). - **Story** (`stories` table): a shippable slice of work realized by a tree of `internal/task` Tasks rooted at `root_task_id`. `epic_id`/`project_id`/ `root_task_id` are loose references (plain strings, no FK enforcement) — same tolerance the codebase already has for `tasks.project`. `status` is one of `DISCOVERY|FRAMING|BACKLOG|PRIORITIZED|IN_PROGRESS|SHIPPABLE| DEPLOYED|VALIDATING|REVIEW_READY|NEEDS_FIX|DONE|CANCELLED`, still unvalidated pass-through at the storage layer (no `task.ValidTransition`- style enforcement in `UpdateStory`/`PUT /api/stories/{id}`) — `StoryOrchestrator` itself only ever writes `VALIDATING` and `REVIEW_READY`, and only after checking the story's current status/dependents first (see below). - **`GET /api/stories/{id}/task-tree`** walks the task graph realizing a story: starting at `root_task_id`, it follows both `parent_task_id` children (`ListSubtasks`) and `depends_on_json` edges in either direction (`ListDependents`), so a task that merely depends on something already in the tree is discovered even if it isn't a literal `parent_task_id` child. Returns a flat node list (`{story_id, root_task_id, nodes: [{id, name, state, agent_type, role, parent_task_id, depends_on}]}`); clients reconstruct the graph from each node's `parent_task_id`/`depends_on`. An unset `root_task_id` returns an empty node list, not an error. - REST endpoints are not gated by `api_token`, consistent with `/api/projects`/`/api/tasks` (only chatbot MCP, agent MCP, and WebSocket require it). #### Story orchestrator (Phase 7b) `internal/scheduler.StoryOrchestrator` (same file group as `Scheduler`, see `internal/scheduler/story_orchestrator.go`) is a poll-based watcher (`Tick`/`Run`, `DefaultStoryPollInterval` = 15s), not a hook into `executor.Pool.handleRunResult`. This is a deliberate choice, not the default: every task the orchestrator reacts to (the story's Builder/root task, the 4 Evaluators, the Arbitration task) is a **top-level** task (`parent_task_id == ""`), and per the task state machine above, the *only* way one of them would ever reach `COMPLETED` on its own is via a human/chatbot `POST /api/tasks/{id}/accept` (`READY → COMPLETED`, in `internal/api`'s `acceptTask`) — `handleRunResult` only ever lands a top-level task at `READY` or `BLOCKED`, never `COMPLETED` directly. Rather than requiring a human to make that call 6 times per story (builder + 4 evaluators + arbitration), **the orchestrator auto-accepts these specific tasks itself** (`autoAccept`, see below) — so the *only* manual accept anywhere in this chain is the final story-level `POST /api/stories/{id}/accept`. A `handleRunResult` hook would never observe either transition (the human-driven one or this orchestrator's own auto-accept) cleanly, since both happen outside `executor`; polling sidesteps the question of *how*/*by whom* a task reached `COMPLETED` entirely — the same way `Scheduler` doesn't care how a task got to `FAILED`. Stage-by-stage: 1. **Builder → Evaluators**: once a story's `root_task_id` task is `READY`, the orchestrator auto-accepts it to `COMPLETED` itself (see "Auto-accept" below); once it's `COMPLETED`, spawns 4 new top-level tasks (`Store.CreateTask`, not `SpawnSubtask` — no `parent_task_id`, `depends_on: [root_task_id]`) with `agent.role` = `evaluator_quality`/`evaluator_security`/`evaluator_correctness`/ `evaluator_performance`, queues and submits each to the pool, and sets the story's `status` to `VALIDATING`. Idempotency is **structural**, not a marker: it inspects `Store.ListDependents(root_task_id)` for existing tasks whose role already matches one of the four, and only creates the missing ones — safe to call every tick forever, and safe across a process restart (unlike an in-memory guard would be). 2. **Per-evaluator verdicts**: each Evaluator task is likewise auto-accepted from `READY` to `COMPLETED`. As each is observed `COMPLETED`, emits `event.KindEvalVerdict` — payload `{task_id, role, summary}` — attached to the **story's** ID (not the evaluator task's ID), via `GET /api/stories/{id}/events` (new in this phase, mirrors `GET /api/tasks/{id}/events` but resolves existence via `GetStory`). Attaching to the story ID means one call surfaces every verdict for a story; `events.task_id` has no enforced FK, which is exactly the tolerance 7a's `event.Kind` doc comments anticipated. De-duplication here is an in-memory, per-process "already emitted" set keyed by evaluator task ID (mirrors `Scheduler.handled` exactly) — the only place in this type that isn't structurally idempotent, because there's no persisted marker for "this specific verdict was already emitted"; a restart can produce at most one duplicate `eval_verdict` event per evaluator, never more. 3. **Evaluators → Arbitration**: once all 4 Evaluators for a story are `COMPLETED`, spawns one more top-level task, `agent.role: "planner"`, `depends_on` = all 4 evaluator task IDs. Idempotency is again structural: looks for an existing `planner`-role dependent of the first evaluator task whose `depends_on` already contains all 4 IDs, rather than trusting `story.status` (a human can freely rewrite it via `PUT /api/stories/{id}`). 4. **Arbitration → REVIEW_READY**: the Arbitration task is likewise auto-accepted from `READY` to `COMPLETED`. When it reaches `COMPLETED`, emits `event.KindArbitrationDecided` (payload `{task_id, summary}`, attached to the story ID) and sets `status` to `REVIEW_READY`. **Gated on `status == "VALIDATING"`** — the one place this type's idempotency check is the story's own status field rather than a structural "does a downstream task exist" check, because the Arbitration task is the last task in the chain, so there's no further task existence to check against. **Documented simplification:** this does **not** parse the Arbitration task's summary for an approve/reject verdict — it always routes to `REVIEW_READY`. A human or chatbot who reads the summary and disagrees sets `NEEDS_FIX` manually via `PUT /api/stories/{id}`. A later phase could close this gap with a dedicated verdict-reporting tool for the Arbitration task (a new `AgentChannel` method) instead of parsing free text. 5. **Human accept-gate (the only one)**: `POST /api/stories/{id}/accept` (new in this phase) mirrors `handleAcceptTask`'s pattern: only valid from `REVIEW_READY` (409 otherwise), transitions to `DONE`, emits `event.KindHumanAccepted` (payload `{story_id, from, to}`, attached to the story ID). Because of auto-accept (below), this is the *only* story-status transition — indeed the only task/story state transition at all — in this whole chain that requires a human/chatbot to act. **Auto-accept** (`StoryOrchestrator.autoAccept`): on every tick, for the story's root/Builder task and each structurally-discovered Evaluator/Arbitration dependent (i.e. only tasks the orchestrator has already established are part of *this* story's pipeline — never a blanket "auto- accept every READY task" sweep), if that task is `READY`, the orchestrator transitions it straight to `COMPLETED` itself, using the exact same state-machine-respecting write `internal/api`'s `acceptTask` uses (`Store.UpdateTaskState`, which wraps `storage.DB.UpdateTaskStateBy` — validates `task.ValidTransition` and writes the `state_change` event atomically, not a raw/unchecked write). This is what makes the story-level accept-gate the system's only required human/chatbot touchpoint: without it, a human would have to separately `POST /api/tasks/{id}/accept` the Builder, each of the 4 Evaluators, and the Arbitration task, since none of those top-level tasks can reach `COMPLETED` any other way. `FAILED`/`TIMED_OUT`/ `CANCELLED`/`BUDGET_EXCEEDED` outcomes for these same tasks are untouched by this — they still go through `Scheduler`'s existing retry-then-escalate path exactly as before; auto-accept only ever fires on the `READY` (success) path. `evaluator_quality`/`evaluator_security`/`evaluator_correctness`/ `evaluator_performance`/`planner`/`builder` `role_configs` rows are assumed to be seeded separately (this phase doesn't create them); a role-typed task spawned here with no matching active `role_configs` row dispatches in the same degraded (no role resolution) mode `Pool.execute()` already logs a warning and falls back to for any other role-typed task. --- --- ## Design Debt ### RoleConfig.Tools/SandboxKind/DefaultBudgetUSD are stored but not fully enforced Same pattern as `task.Priority`/`RetryConfig` below: `internal/role.RoleConfig.Tools` and `.SandboxKind` round-trip through `role_configs`/the REST endpoints but have no effect on dispatch — tool availability and sandbox selection are still applied uniformly by `NativeRunner` regardless of role. `.DefaultBudgetUSD` is read only narrowly, as the scheduler's estimated escalation cost, not enforced generally at dispatch time. ### Scheduler's escalation candidate selection doesn't round-robin `internal/scheduler.Scheduler` always escalates to `nextTier.Candidates[0]`, unlike `Pool.execute()`'s initial-dispatch tier-0 resolution, which round-robins across multi-candidate tiers. Same-rung retries intentionally reuse the exact same provider/model rather than re-resolving. ### Scheduler's double-processing guard is in-memory only `Scheduler.handled` (keyed by execution ID) prevents re-emitting a `final: true` `KindEscalated` event on every poll tick once a role-typed task's ladder is exhausted, but it's per-process and resets on restart. A restart can produce one extra "reconsideration" (and, if still exhausted/denied, one more `KindEscalated` event) — not an infinite loop, just not persisted. A future phase could persist this via a `tasks` column if that turns out to matter. ### StoryOrchestrator's Arbitration outcome always routes to REVIEW_READY `internal/scheduler.StoryOrchestrator.finalizeArbitration` does not parse the Arbitration task's summary for an approve/reject verdict — every completed Arbitration moves the story to `REVIEW_READY`, never directly to `NEEDS_FIX`. A human or chatbot who reads the Arbitration summary and disagrees must manually set the story to `NEEDS_FIX` via `PUT /api/stories/{id}`. A later phase could close this by giving the Arbitration task a dedicated verdict-reporting tool (a new `AgentChannel` method) instead of parsing free text. ### StoryOrchestrator's per-evaluator verdict de-dup is in-memory only `StoryOrchestrator.handledVerdicts` (keyed by evaluator task ID) prevents re-emitting `KindEvalVerdict` on every poll tick while sibling evaluators are still running, but — like `Scheduler.handled` above — it's per-process and resets on restart. A restart can produce at most one duplicate `eval_verdict` event per evaluator; the structural idempotency checks that actually prevent duplicate task creation and duplicate story-status transitions (`ensureEvaluators`/`ensureArbitration`'s dependents-based checks, and `finalizeArbitration`'s `status == "VALIDATING"` gate) are unaffected by a restart. ### Deprecated task columns not yet dropped (Phase 8 follow-up) `tasks.question_json`, `summary`, `interactions_json`, `elaboration_input` are superseded by the `events` table but still read by the answer flow and task panel. Dropping them requires migrating those reads to events first. ### Gemini tool-use unverified The gemini MCP wiring (`~/.gemini/settings.json`) is in place but was never run against a real `gemini` binary — see `cmd/spike` (`go run ./cmd/spike gemini`). ### Priority field is stored but never used `task.Priority` (`high`, `normal`, `low`) is persisted in SQLite and surfaced in the API. The executor `dispatch()` goroutine uses a simple FIFO channel (`workCh`) with no priority ordering. ### RetryConfig is stored but retry is manual `task.RetryConfig.MaxAttempts` and `Backoff` are parsed and stored. No code reads them during execution. Retries must be triggered manually via `POST /api/tasks/{id}/resume`. ### Changestats extracted in two places `executor.Pool.handleRunResult()` and `api.Server.processResult()` both call `task.ParseChangestatFromFile()` and write to `executions.changestats_json`. The second write is idempotent but wasteful and confusing. One of the two should be removed. ### context.Background() in resume path `api.Server.handleAnswerQuestion()` calls `p.SubmitResume(context.Background(), ...)`. If the HTTP request context is cancelled, the resume still runs. Inversely, if the server shuts down, in-flight resumes using the server's root context would be cancelled while this one would not. Should use a long-lived server-level context, not `Background()`. ### Non-transactional execution creation `pool.execute()` calls `store.CreateExecution(exec)` followed by `store.UpdateTaskState(t.ID, task.StateRunning)` as separate statements. If the server crashes between them, the task stays PENDING while an execution record exists with status RUNNING. Recovery (`RecoverStaleRunning`) partially handles this but the root cause is the missing transaction. ### Elaborate/validate cmd path indirection `Server` has two separate fields `elaborateCmdPath` and `validateCmdPath` that override `claudeBinPath` only for tests. This is a testing-time seam that leaks into the production struct. A cleaner approach would be to inject an `Elaborator` interface. ### `withFailureHistory` mutates a shallow copy In `executor.go`, `withFailureHistory` creates a copy of the task struct (`copy := *t`) but `copy.Agent = t.Agent` copies the struct value — slices inside AgentConfig (`AllowedTools`, `DisallowedTools`, etc.) share the backing array. Appending to `SystemPromptAppend` is safe but any mutation of slices would affect the original. ### Additive migration strategy is fragile `storage.migrate()` lists every `ALTER TABLE ADD COLUMN` statement in code order. The only idempotency guard is catching "column already exists" errors. There is no migration version tracking. Columns dropped in `CREATE TABLE IF NOT EXISTS` and added back via ALTER are indistinguishable from new columns. Concurrent server instances running migrations simultaneously have no protection. --- ## REST API Reference | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/tasks` | List tasks; `?state=RUNNING&since=&limit=50` | | POST | `/api/tasks` | Create task (JSON body) | | GET | `/api/tasks/{id}` | Get task | | DELETE | `/api/tasks/{id}` | Delete task + subtasks + executions | | POST | `/api/tasks/{id}/run` | Submit PENDING task to executor | | POST | `/api/tasks/{id}/cancel` | Cancel RUNNING/QUEUED task | | POST | `/api/tasks/{id}/accept` | Accept READY task → COMPLETED | | POST | `/api/tasks/{id}/reject` | Reject READY task → PENDING | | POST | `/api/tasks/{id}/answer` | Answer BLOCKED task question → QUEUED | | POST | `/api/tasks/{id}/resume` | Resume FAILED/TIMED_OUT/CANCELLED task | | GET | `/api/tasks/{id}/subtasks` | List subtasks | | GET | `/api/tasks/{id}/executions` | List execution history | | GET | `/api/executions/{id}` | Get execution | | GET | `/api/executions/{id}/log` | Get execution log (`?tail=100`) | | GET | `/api/executions/{id}/logs/stream` | Stream logs as SSE | | GET | `/api/tasks/{id}/logs/stream` | Stream latest execution logs | | GET | `/api/executions` | List recent executions across all tasks | | GET | `/api/tasks/{id}/deployment-status` | Poll deployment readiness | | GET | `/api/tasks/{id}/events` | Task observability event stream (`?since_seq=N`) | | GET | `/api/budget` | Per-provider rolling-window spend headroom | | POST | `/api/tasks/validate` | Validate task JSON | | POST/GET/DELETE | `/mcp` | Per-task agent MCP back-channel (bearer = minted token) | | POST/GET/DELETE | `/chatbot/mcp` | Chatbot MCP server (bearer = `api_token`) | | POST | `/api/scripts/{name}` | Run named script with task context | | GET | `/api/ws` | WebSocket upgrade (live task updates) | | GET | `/api/workspaces` | List directories under `workspace_root` | | GET | `/api/health` | Server health | | POST | `/api/webhooks/github` | GitHub CI webhook | | POST | `/api/roles/{role}/versions` | Create a new draft `role_configs` version | | GET | `/api/roles/{role}/versions` | List all versions for a role | | POST | `/api/roles/{role}/activate?version=N` | Activate a version (atomically retires the prior active one) | | GET | `/api/epics` | List epics; `?status=OPEN` | | POST | `/api/epics` | Create epic | | GET | `/api/epics/{id}` | Get epic | | PUT | `/api/epics/{id}` | Update epic (name/description/status; unvalidated pass-through) | | GET | `/api/epics/{id}/stories` | List stories whose `epic_id` matches `{id}` | | GET | `/api/stories` | List stories; `?status=X&epic_id=Y` | | POST | `/api/stories` | Create story | | GET | `/api/stories/{id}` | Get story | | PUT | `/api/stories/{id}` | Update story (all fields but id/created_at; unvalidated status pass-through) | | GET | `/api/stories/{id}/task-tree` | Walk the task graph realizing a story (parent_task_id + depends_on edges) | | GET | `/api/stories/{id}/events` | Story observability event stream (`?since_seq=N`); surfaces `eval_verdict`/`arbitration_decided`/`human_accepted` events the story orchestrator/accept-gate attach to the story's own ID | | POST | `/api/stories/{id}/accept` | Accept a `REVIEW_READY` story → `DONE`; emits `KindHumanAccepted` | --- ## ADRs See `docs/adr/001-language-and-architecture.md` for the Go + SQLite + WebSocket rationale.