# 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, and (Phase 7c) role-typed BLOCKED tasks whose ask_user question has timed out; `StoryOrchestrator` (Phase 7b) — polls stories and drives Builder → Evaluators → Arbitration → REVIEW_READY, and (Phase 8) a `DONE` story through a retro stage proposing `role_configs` drafts | | `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, needs_review, 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. ### Epic-proposal tool (Phase 7c) A 5th `AgentChannel` method, `ProposeEpic(ctx, agentchannel.EpicProposal{Name, Description, StoryIDs})`, lets a discovery/planner-role agent that has been handed several story IDs act on its own judgment that they form a cohesive initiative. `storeChannel.ProposeEpic` (`internal/executor/channel.go`) matches an existing epic by exact `Name` (`Store.GetEpicByName`) or creates a new one, then sets `epic_id` on each resolved story via `Store.GetStory` + `Store.UpdateStory` — a story ID that doesn't resolve is skipped, not fatal to the call. Emits `event.KindEpicProposed` attached to the epic's own ID (payload `{epic_id, name, story_ids}`, `story_ids` being only the ones actually grouped), matching the story orchestrator's convention of attaching planning-layer ceremony events to the entity they're about. Exposed as a `propose_epic` tool on both transports — `internal/agentloop/tools.go` (native tool-use loop) and `internal/executor/agentmcp.go` (MCP) — with the same `{name, description?, story_ids}` schema Phase 6 used for `spawn_subtask`'s `role` parameter. This phase only builds the mechanism; judging *whether* stories belong together is the calling agent's job via its instructions/model. ### AskUser-timeout escalation (Phase 7c) `internal/scheduler.Scheduler` (the same retry-then-escalate watcher described above) also polls BLOCKED role-typed tasks whose `question_json` has been outstanding longer than `config.SchedulerConfig.AskUserTimeout()` (default 10 minutes; `[scheduler] ask_user_timeout_seconds` in TOML) — `tickAskUserTimeouts`, called from `Tick` alongside the existing FAILED-task pass. The "outstanding since" timestamp is `task.UpdatedAt` itself, not a new column: `storage.DB.UpdateTaskQuestion` (the last write made to a task's row on its way into BLOCKED) already stamps `updated_at` the moment the question was recorded, and nothing else touches the row while it sits BLOCKED. For each timed-out question, the scheduler resolves the role's active escalation ladder from the tier the task was dispatched at (`latest execution's EscalationRung`) and, if a higher tier exists, escalates to its first candidate (mirroring `processTask`'s existing "always `Candidates[0]`" simplification); if not (ladder exhausted, no active role config, etc.), it still resumes the task at its current tier rather than leaving it stuck forever, marking that escalation `final: true`. Either way it records the system-authored answer `"[auto-escalated: no human response within timeout; proceeding with best judgment]"` as a `task.Interaction` (mirroring `api.answerTaskQuestion`'s audit trail for a real human answer), clears `question_json`, sets `tasks.needs_review = true`, and resumes the task via `Pool.SubmitResume` — the same mechanism `POST /api/tasks/{id}/answer` uses, just at the escalated tier's provider/model instead of the one that asked. Emits `event.KindEscalated` with a `trigger` field (`"failure"` for the existing FAILED-task path, `"ask_user_timeout"` for this one) so the two are distinguishable in the event stream. `GET /api/tasks?needs_review=true` surfaces flagged tasks for a human to double-check the system's stand-in answer later. ### Retro & the self-improvement loop (Phase 8) Closes the loop the whole role-versioning system (Phase 5) was built for: when a story reaches `DONE`, `internal/scheduler.StoryOrchestrator` spawns a `retro`-role task that reflects on the story and proposes improved `role_configs` drafts for a human to review and activate. - **Trigger**: `StoryOrchestrator.Tick` now routes a story at status `DONE` to `processRetro` instead of `processStory` (`CANCELLED` stories are still skipped entirely, unchanged). `processRetro` never spawns or mutates a Builder/Evaluator/Arbitration task itself — it only *looks up* the already-settled pipeline via read-only `findEvaluators`/`findArbitration` (structurally identical to `ensureEvaluators`/`ensureArbitration` but without their spawn side effects) to find the Arbitration task the retro task should depend on. If the pipeline isn't fully settled yet (shouldn't happen for a real `DONE` story), it simply has nothing to do this tick. - **Idempotency**: structural, like the rest of this file — it looks for an existing `retro`-role dependent of the Arbitration task before spawning one, the same "does a role-matched dependent already exist" check `ensureArbitration` uses. - **Instructions**: `buildRetroInstructions` assembles the story's spec/ acceptance criteria, its full task tree (`taskTree`, the same parent/dependents BFS `GET /api/stories/{id}/task-tree` performs, walked directly against `StoryStore` rather than over HTTP), each task's accumulated cost and highest escalation rung (`ListExecutions`), the currently active `role_configs` for every role encountered (`GetActiveRoleConfig`), and the story's own event stream — evaluator verdicts, arbitration decision (`ListEvents`) — then instructs the retro agent to call `propose_role_config` for any role worth revising and `report_summary` with the qualitative lessons either way. - **Reporting mechanism**: a 6th `AgentChannel` method, `ProposeRoleConfig(ctx, role.RoleConfig) (version int, err error)`, added following `ProposeEpic`'s precedent (structured tool call over summary-parsing). `storeChannel.ProposeRoleConfig` (`internal/executor/channel.go`) calls the same `Store.CreateRoleConfig` `POST /api/roles/{role}/versions` already uses, hardcoding `proposed_by: "retro"`, and — never reading or touching whatever version is currently `active` for that role. It also records `event.KindRoleConfigProposed` (payload `{role, version}`) attached to the *calling task's own ID* (roles have no first-class row of their own the way epics/stories do). Exposed as a `propose_role_config` tool on both transports — `internal/agentloop/tools.go` and `internal/executor/agentmcp.go` — decoding directly into `role.RoleConfig`'s own JSON shape (no parallel input type). - **`event.KindRetroCaptured`**: once the retro task reaches `COMPLETED` (auto-accepted from `READY` exactly like every other task in this pipeline), `finalizeRetro` reads the retro task's own event stream for every `KindRoleConfigProposed` it recorded and aggregates them into a single event attached to the **story's** ID: payload `{task_id, proposals: [{role, version}, ...], summary}` — `summary` is the retro task's own reported summary, satisfying "capturing lessons" even when zero config changes are proposed. Guarded by an in-memory `handledRetro` set (keyed by retro task ID) so a story sitting at `DONE` forever doesn't re-emit the event every tick — the same accepted per-process-only simplification as `Scheduler.handled`/`handledVerdicts` above. - **Human activation, unchanged**: `POST /api/roles/{role}/activate` (Phase 5) is untouched — draft rows land via the exact same `CreateRoleConfig` path the human-facing `POST /api/roles/{role}/versions` endpoint uses, so they're structurally identical and immediately visible via the existing `GET /api/roles/{role}/versions` with no changes required. --- --- ## 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. ### AskUser-timeout escalation resumes at the same tier when the ladder is exhausted The phase description for `Scheduler.escalateAskUserTimeout` didn't specify behavior when a BLOCKED task is already at its role's top escalation tier (or has no active role config at all). Leaving the task stuck BLOCKED forever seemed worse than the alternative, so it still resumes the task (unblocking it is the priority) at its *current* provider/model — no `UpdateTaskAgent` call — and marks that `KindEscalated` event `final: true` so it's visible in the event stream as "resumed without escalating" rather than a genuine tier bump. A later phase could reconsider this (e.g. leaving the task BLOCKED and only flagging it) if it turns out best-effort auto-resumption at the same tier isn't the right call for every role. ### 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&needs_review=true` | | 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.