summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 23:46:26 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 23:46:26 +0000
commit392c7c1ada310b2f928dca89b75ba628478f7694 (patch)
treec2b32420ce5df0da79d6d4b014fe2c784f2bd4be
parent997cd8b56bc086a02b9c7c006dd62b07b9fcd2f3 (diff)
feat(story): add Epic/Story data model + REST CRUD (Phase 7a)
Pure data model + CRUD -- no orchestration behavior yet (that's Phase 7b). Revives ADR-007's stories concept (adapted; ADR-007 itself was deleted as "more machinery than the usage pattern needed") while staying lean: reuses the existing events/projects/task-tree machinery rather than building a parallel hierarchy. - internal/story: Epic/Story types. Epic is a loosely-scoped initiative (OPEN/CLOSED, no execution semantics). Story is a shippable slice of work realized by a task tree rooted at root_task_id; epic_id/project_id/ root_task_id are loose references (no FK enforcement, matching the codebase's existing tolerance for unmatched reference strings like tasks.project). - storage: new epics/stories tables (additive migrations) + CRUD methods. Story status is intentionally unvalidated pass-through in this phase -- no task.ValidTransition-style state machine, since there's no orchestrator yet to make transitions meaningful. - event: 9 new Kind constants for the ceremony this layer will eventually drive (epic_proposed, discovery_proposed, framing_decided, groomed, prioritized, eval_verdict, arbitration_decided, retro_captured, human_accepted) -- defined but nothing emits them yet. - api: GET/POST /api/epics, GET/PUT /api/epics/{id}, GET /api/epics/{id}/stories, GET/POST /api/stories, GET/PUT /api/stories/{id}, and GET /api/stories/{id}/task-tree -- BFS walk from root_task_id following both parent_task_id children and depends_on-edge dependents (visited-set guarded), returning a flat node list for a later UI phase to render as a graph. Unauthenticated, matching the existing projects/tasks endpoints' posture. go build/vet/test -race -count=1 all pass, full suite. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
-rw-r--r--CLAUDE.md63
-rw-r--r--internal/api/epics.go90
-rw-r--r--internal/api/epics_test.go202
-rw-r--r--internal/api/server.go10
-rw-r--r--internal/api/stories.go170
-rw-r--r--internal/api/stories_test.go278
-rw-r--r--internal/event/event.go15
-rw-r--r--internal/storage/db.go30
-rw-r--r--internal/storage/epic.go88
-rw-r--r--internal/storage/epic_test.go117
-rw-r--r--internal/storage/story.go160
-rw-r--r--internal/storage/story_test.go211
-rw-r--r--internal/story/story.go73
13 files changed, 1506 insertions, 1 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 6313f1d..90356e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -79,6 +79,7 @@ Config defaults to `~/.claudomator/config.toml`. Data is stored in `~/.claudomat
| `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 |
+| `internal/story` | `Epic`/`Story` — planning layer above the flat task tree; data model only (Phase 7a), no orchestration |
| `web` | Embedded static UI (`embed.go`) |
### Key Data Flows
@@ -196,6 +197,12 @@ 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
@@ -212,7 +219,12 @@ 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`.
+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.
---
@@ -312,6 +324,45 @@ Two prerequisites for fan-out patterns (e.g. a Builder task with several role-ty
- **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 (data model only)
+
+`internal/story` defines `Epic` and `Story` — a planning layer above the flat
+task tree (`epics`/`stories` tables, see Storage Schema above). This is
+Phase 7a: **pure data model + CRUD**. Nothing creates, transitions, or reads
+these rows automatically yet — no watcher spawns Builder/Evaluator/
+Arbitration tasks from a story, no deploy-gating, no epic-proposal agent
+tooling. A later phase builds that orchestration on top of what's stored
+here.
+
+- **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`, also
+ unvalidated pass-through in this phase (no `task.ValidTransition`-style
+ enforcement — there's no orchestrator yet to make transitions meaningful).
+- **`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.
+- New `event.Kind` constants for the ceremony this layer will eventually
+ drive (`epic_proposed`, `discovery_proposed`, `framing_decided`,
+ `groomed`, `prioritized`, `eval_verdict`, `arbitration_decided`,
+ `retro_captured`, `human_accepted`) are defined in `internal/event` but
+ nothing emits them yet.
+- REST endpoints are not gated by `api_token`, consistent with
+ `/api/projects`/`/api/tasks` (only chatbot MCP, agent MCP, and WebSocket
+ require it).
+
---
@@ -411,6 +462,16 @@ In `executor.go`, `withFailureHistory` creates a copy of the task struct (`copy
| 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) |
---
diff --git a/internal/api/epics.go b/internal/api/epics.go
new file mode 100644
index 0000000..e22a377
--- /dev/null
+++ b/internal/api/epics.go
@@ -0,0 +1,90 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+func (s *Server) handleListEpics(w http.ResponseWriter, r *http.Request) {
+ status := r.URL.Query().Get("status")
+ epics, err := s.store.ListEpics(status)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if epics == nil {
+ epics = []*story.Epic{}
+ }
+ writeJSON(w, http.StatusOK, epics)
+}
+
+func (s *Server) handleCreateEpic(w http.ResponseWriter, r *http.Request) {
+ var e story.Epic
+ if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if e.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+ return
+ }
+ if e.ID == "" {
+ e.ID = uuid.New().String()
+ }
+ if err := s.store.CreateEpic(&e); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, e)
+}
+
+func (s *Server) handleGetEpic(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ e, err := s.store.GetEpic(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic not found"})
+ return
+ }
+ writeJSON(w, http.StatusOK, e)
+}
+
+func (s *Server) handleUpdateEpic(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ existing, err := s.store.GetEpic(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "epic not found"})
+ return
+ }
+ createdAt := existing.CreatedAt
+ if err := json.NewDecoder(r.Body).Decode(existing); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ existing.ID = id // ensure ID cannot be changed via body
+ existing.CreatedAt = createdAt
+ if err := s.store.UpdateEpic(existing); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, existing)
+}
+
+// handleListEpicStories lists stories whose epic_id matches the path {id}.
+// Mirrors handleListSubtasks: no existence check on the epic itself, just
+// returns an empty list if there are no matching stories.
+func (s *Server) handleListEpicStories(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ stories, err := s.store.ListStories(storage.StoryFilter{EpicID: id})
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if stories == nil {
+ stories = []*story.Story{}
+ }
+ writeJSON(w, http.StatusOK, stories)
+}
diff --git a/internal/api/epics_test.go b/internal/api/epics_test.go
new file mode 100644
index 0000000..bae7468
--- /dev/null
+++ b/internal/api/epics_test.go
@@ -0,0 +1,202 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+// TestServer_CreateEpic_AndGetEpic mirrors the projects endpoint test
+// pattern: POST creates, GET retrieves what was created.
+func TestServer_CreateEpic_AndGetEpic(t *testing.T) {
+ srv, _ := testServer(t)
+
+ body, _ := json.Marshal(map[string]any{
+ "name": "Bigger Auth Rework",
+ "description": "Overhaul the auth flow.",
+ "discovery_source": "human",
+ })
+ req := httptest.NewRequest("POST", "/api/epics", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var created story.Epic
+ if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
+ t.Fatalf("decode create response: %v", err)
+ }
+ if created.ID == "" {
+ t.Fatal("expected server-assigned ID")
+ }
+ if created.Status != "OPEN" {
+ t.Errorf("expected default status OPEN, got %q", created.Status)
+ }
+
+ req = httptest.NewRequest("GET", "/api/epics/"+created.ID, nil)
+ w = httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("get: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got story.Epic
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode get response: %v", err)
+ }
+ if got.Name != "Bigger Auth Rework" {
+ t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name)
+ }
+ if got.DiscoverySource != "human" {
+ t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource)
+ }
+}
+
+func TestServer_CreateEpic_MissingName_Returns400(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("POST", "/api/epics", bytes.NewReader([]byte(`{}`)))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestServer_GetEpic_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/epics/does-not-exist", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestServer_ListEpics_FiltersByStatus confirms ?status=OPEN narrows results.
+func TestServer_ListEpics_FiltersByStatus(t *testing.T) {
+ srv, store := testServer(t)
+
+ if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "one", Status: "OPEN"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateEpic(&story.Epic{ID: "e2", Name: "two", Status: "CLOSED"}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/epics", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var all []story.Epic
+ if err := json.Unmarshal(w.Body.Bytes(), &all); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(all) != 2 {
+ t.Fatalf("want 2 epics, got %d", len(all))
+ }
+
+ req = httptest.NewRequest("GET", "/api/epics?status=OPEN", nil)
+ w = httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var open []story.Epic
+ if err := json.Unmarshal(w.Body.Bytes(), &open); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(open) != 1 || open[0].ID != "e1" {
+ t.Fatalf("want exactly [e1], got %+v", open)
+ }
+}
+
+func TestServer_UpdateEpic(t *testing.T) {
+ srv, store := testServer(t)
+ if err := store.CreateEpic(&story.Epic{ID: "e1", Name: "original", Status: "OPEN"}); err != nil {
+ t.Fatal(err)
+ }
+
+ body, _ := json.Marshal(map[string]any{
+ "name": "updated",
+ "status": "CLOSED",
+ })
+ req := httptest.NewRequest("PUT", "/api/epics/e1", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got story.Epic
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Name != "updated" || got.Status != "CLOSED" {
+ t.Errorf("expected updated fields, got %+v", got)
+ }
+ if got.ID != "e1" {
+ t.Errorf("ID must not change via body: got %q", got.ID)
+ }
+}
+
+func TestServer_UpdateEpic_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("PUT", "/api/epics/does-not-exist", bytes.NewReader([]byte(`{"name":"x"}`)))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestServer_ListEpicStories confirms GET /api/epics/{id}/stories returns
+// only stories whose epic_id matches the path {id}.
+func TestServer_ListEpicStories(t *testing.T) {
+ srv, store := testServer(t)
+ if err := store.CreateEpic(&story.Epic{ID: "epic-a", Name: "a"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateStory(&story.Story{ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateStory(&story.Story{ID: "s2", EpicID: "epic-b", Name: "two", Status: "DISCOVERY"}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/epics/epic-a/stories", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got []story.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(got) != 1 || got[0].ID != "s1" {
+ t.Fatalf("want exactly [s1], got %+v", got)
+ }
+}
+
+// TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList mirrors
+// handleListSubtasks: no existence check on the parent, just an empty list.
+func TestServer_ListEpicStories_UnknownEpic_ReturnsEmptyList(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/epics/does-not-exist/stories", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got []story.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("want empty list, got %+v", got)
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 0e5b185..9f0eb7c 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -172,6 +172,16 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /api/projects", s.handleCreateProject)
s.mux.HandleFunc("GET /api/projects/{id}", s.handleGetProject)
s.mux.HandleFunc("PUT /api/projects/{id}", s.handleUpdateProject)
+ s.mux.HandleFunc("GET /api/epics", s.handleListEpics)
+ s.mux.HandleFunc("POST /api/epics", s.handleCreateEpic)
+ s.mux.HandleFunc("GET /api/epics/{id}", s.handleGetEpic)
+ s.mux.HandleFunc("PUT /api/epics/{id}", s.handleUpdateEpic)
+ s.mux.HandleFunc("GET /api/epics/{id}/stories", s.handleListEpicStories)
+ s.mux.HandleFunc("GET /api/stories", s.handleListStories)
+ s.mux.HandleFunc("POST /api/stories", s.handleCreateStory)
+ s.mux.HandleFunc("GET /api/stories/{id}", s.handleGetStory)
+ s.mux.HandleFunc("PUT /api/stories/{id}", s.handleUpdateStory)
+ s.mux.HandleFunc("GET /api/stories/{id}/task-tree", s.handleStoryTaskTree)
s.mux.HandleFunc("POST /api/roles/{role}/versions", s.handleCreateRoleVersion)
s.mux.HandleFunc("GET /api/roles/{role}/versions", s.handleListRoleVersions)
s.mux.HandleFunc("POST /api/roles/{role}/activate", s.handleActivateRoleVersion)
diff --git a/internal/api/stories.go b/internal/api/stories.go
new file mode 100644
index 0000000..337da21
--- /dev/null
+++ b/internal/api/stories.go
@@ -0,0 +1,170 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+func (s *Server) handleListStories(w http.ResponseWriter, r *http.Request) {
+ filter := storage.StoryFilter{
+ Status: r.URL.Query().Get("status"),
+ EpicID: r.URL.Query().Get("epic_id"),
+ }
+ stories, err := s.store.ListStories(filter)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if stories == nil {
+ stories = []*story.Story{}
+ }
+ writeJSON(w, http.StatusOK, stories)
+}
+
+func (s *Server) handleCreateStory(w http.ResponseWriter, r *http.Request) {
+ var st story.Story
+ if err := json.NewDecoder(r.Body).Decode(&st); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ if st.Name == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+ return
+ }
+ if st.ID == "" {
+ st.ID = uuid.New().String()
+ }
+ if err := s.store.CreateStory(&st); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusCreated, st)
+}
+
+func (s *Server) handleGetStory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ st, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+ writeJSON(w, http.StatusOK, st)
+}
+
+// handleUpdateStory replaces all editable fields on a story (everything
+// except id/created_at). No status-machine validation is performed in this
+// phase — the status string is written as given, same trust level as
+// handleUpdateProject.
+func (s *Server) handleUpdateStory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ existing, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+ createdAt := existing.CreatedAt
+ if err := json.NewDecoder(r.Body).Decode(existing); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+ return
+ }
+ existing.ID = id // ensure ID cannot be changed via body
+ existing.CreatedAt = createdAt
+ if err := s.store.UpdateStory(existing); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, existing)
+}
+
+// taskTreeNode is one task in a story's task-tree/graph response.
+type taskTreeNode struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ State string `json:"state"`
+ AgentType string `json:"agent_type"`
+ Role string `json:"role,omitempty"`
+ ParentTaskID string `json:"parent_task_id,omitempty"`
+ DependsOn []string `json:"depends_on"`
+}
+
+// taskTreeResponse is the payload for GET /api/stories/{id}/task-tree.
+type taskTreeResponse struct {
+ StoryID string `json:"story_id"`
+ RootTaskID string `json:"root_task_id"`
+ Nodes []taskTreeNode `json:"nodes"`
+}
+
+// handleStoryTaskTree walks the task graph realizing a story, starting at
+// its root_task_id and following both parent_task_id children (via
+// ListSubtasks) and depends_on edges in either direction (via
+// ListDependents, so a sibling task that merely depends on something already
+// in the tree is still discovered even if it isn't a literal
+// parent_task_id child). Returns one flat node list; clients reconstruct
+// the graph from each node's parent_task_id/depends_on. If the story has no
+// root_task_id set, returns an empty node list, not an error.
+func (s *Server) handleStoryTaskTree(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ st, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+
+ resp := taskTreeResponse{StoryID: id, RootTaskID: st.RootTaskID, Nodes: []taskTreeNode{}}
+ if st.RootTaskID == "" {
+ writeJSON(w, http.StatusOK, resp)
+ return
+ }
+
+ visited := map[string]bool{}
+ queue := []string{st.RootTaskID}
+ for len(queue) > 0 {
+ tid := queue[0]
+ queue = queue[1:]
+ if visited[tid] {
+ continue
+ }
+ visited[tid] = true
+
+ t, err := s.store.GetTask(tid)
+ if err != nil {
+ // A referenced task ID no longer resolves (e.g. deleted); skip it
+ // rather than failing the whole tree.
+ continue
+ }
+ dependsOn := t.DependsOn
+ if dependsOn == nil {
+ dependsOn = []string{}
+ }
+ resp.Nodes = append(resp.Nodes, taskTreeNode{
+ ID: t.ID,
+ Name: t.Name,
+ State: string(t.State),
+ AgentType: t.Agent.Type,
+ Role: t.Agent.Role,
+ ParentTaskID: t.ParentTaskID,
+ DependsOn: dependsOn,
+ })
+
+ if children, err := s.store.ListSubtasks(tid); err == nil {
+ for _, c := range children {
+ if !visited[c.ID] {
+ queue = append(queue, c.ID)
+ }
+ }
+ }
+ if dependents, err := s.store.ListDependents(tid); err == nil {
+ for _, d := range dependents {
+ if !visited[d.ID] {
+ queue = append(queue, d.ID)
+ }
+ }
+ }
+ }
+
+ writeJSON(w, http.StatusOK, resp)
+}
diff --git a/internal/api/stories_test.go b/internal/api/stories_test.go
new file mode 100644
index 0000000..b346af5
--- /dev/null
+++ b/internal/api/stories_test.go
@@ -0,0 +1,278 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+func TestServer_CreateStory_AndGetStory(t *testing.T) {
+ srv, _ := testServer(t)
+
+ body, _ := json.Marshal(map[string]any{
+ "name": "Add login page",
+ "status": "DISCOVERY",
+ "epic_id": "epic-1",
+ "acceptance_criteria": []string{"User can log in"},
+ })
+ req := httptest.NewRequest("POST", "/api/stories", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
+ }
+ var created story.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
+ t.Fatalf("decode create response: %v", err)
+ }
+ if created.ID == "" {
+ t.Fatal("expected server-assigned ID")
+ }
+
+ req = httptest.NewRequest("GET", "/api/stories/"+created.ID, nil)
+ w = httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("get: expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got story.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode get response: %v", err)
+ }
+ if got.Name != "Add login page" {
+ t.Errorf("Name: want %q, got %q", "Add login page", got.Name)
+ }
+ if got.EpicID != "epic-1" {
+ t.Errorf("EpicID: want epic-1, got %q", got.EpicID)
+ }
+ if len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "User can log in" {
+ t.Errorf("AcceptanceCriteria: want [User can log in], got %+v", got.AcceptanceCriteria)
+ }
+}
+
+func TestServer_CreateStory_MissingName_Returns400(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("POST", "/api/stories", bytes.NewReader([]byte(`{}`)))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestServer_GetStory_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/stories/does-not-exist", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestServer_ListStories_FiltersByStatusAndEpic(t *testing.T) {
+ srv, store := testServer(t)
+ for _, st := range []*story.Story{
+ {ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"},
+ {ID: "s2", EpicID: "epic-a", Name: "two", Status: "IN_PROGRESS"},
+ {ID: "s3", EpicID: "epic-b", Name: "three", Status: "DISCOVERY"},
+ } {
+ if err := store.CreateStory(st); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ req := httptest.NewRequest("GET", "/api/stories?epic_id=epic-a&status=DISCOVERY", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got []story.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(got) != 1 || got[0].ID != "s1" {
+ t.Fatalf("want exactly [s1], got %+v", got)
+ }
+}
+
+func TestServer_UpdateStory_UnvalidatedStatusPassthrough(t *testing.T) {
+ srv, store := testServer(t)
+ if err := store.CreateStory(&story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"}); err != nil {
+ t.Fatal(err)
+ }
+
+ // A nonsensical status jump must still be accepted — this phase performs
+ // no status-machine validation on stories.
+ body, _ := json.Marshal(map[string]any{
+ "name": "updated",
+ "status": "DONE",
+ "root_task_id": "task-x",
+ })
+ req := httptest.NewRequest("PUT", "/api/stories/s1", bytes.NewReader(body))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got story.Story
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Status != "DONE" {
+ t.Errorf("Status: want DONE (unvalidated passthrough), got %q", got.Status)
+ }
+ if got.RootTaskID != "task-x" {
+ t.Errorf("RootTaskID: want task-x, got %q", got.RootTaskID)
+ }
+ if got.ID != "s1" {
+ t.Errorf("ID must not change via body: got %q", got.ID)
+ }
+}
+
+func TestServer_UpdateStory_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("PUT", "/api/stories/does-not-exist", bytes.NewReader([]byte(`{"name":"x"}`)))
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func makeTreeTestTask(id, parentID string, dependsOn []string) *task.Task {
+ now := time.Now().UTC()
+ if dependsOn == nil {
+ dependsOn = []string{}
+ }
+ return &task.Task{
+ ID: id,
+ Name: "Task " + id,
+ Agent: task.AgentConfig{Type: "claude", Instructions: "x"},
+ Priority: task.PriorityNormal,
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
+ Tags: []string{},
+ DependsOn: dependsOn,
+ ParentTaskID: parentID,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+}
+
+// TestServer_StoryTaskTree_WalksParentAndDependsOnEdges constructs a small
+// task tree by hand: a root task, a parent_task_id child of the root, and a
+// separate task (not a parent_task_id child of anything) whose
+// depends_on_json points at the root. Confirms the task-tree endpoint
+// discovers all three via the combination of ListSubtasks (parent_task_id)
+// and ListDependents (depends_on_json) traversal.
+func TestServer_StoryTaskTree_WalksParentAndDependsOnEdges(t *testing.T) {
+ srv, store := testServer(t)
+
+ root := makeTreeTestTask("tt-root", "", nil)
+ child := makeTreeTestTask("tt-child", "tt-root", nil)
+ sibling := makeTreeTestTask("tt-sibling", "", []string{"tt-root"})
+ for _, tk := range []*task.Task{root, child, sibling} {
+ if err := store.CreateTask(tk); err != nil {
+ t.Fatalf("CreateTask(%s): %v", tk.ID, err)
+ }
+ }
+
+ if err := store.CreateStory(&story.Story{ID: "story-1", Name: "story", Status: "IN_PROGRESS", RootTaskID: "tt-root"}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/stories/story-1/task-tree", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var got taskTreeResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.StoryID != "story-1" {
+ t.Errorf("StoryID: want story-1, got %q", got.StoryID)
+ }
+ if got.RootTaskID != "tt-root" {
+ t.Errorf("RootTaskID: want tt-root, got %q", got.RootTaskID)
+ }
+ if len(got.Nodes) != 3 {
+ t.Fatalf("want 3 nodes, got %d: %+v", len(got.Nodes), got.Nodes)
+ }
+
+ byID := map[string]taskTreeNode{}
+ for _, n := range got.Nodes {
+ byID[n.ID] = n
+ }
+ if _, ok := byID["tt-root"]; !ok {
+ t.Error("missing tt-root node")
+ }
+ childNode, ok := byID["tt-child"]
+ if !ok {
+ t.Fatal("missing tt-child node")
+ }
+ if childNode.ParentTaskID != "tt-root" {
+ t.Errorf("tt-child.ParentTaskID: want tt-root, got %q", childNode.ParentTaskID)
+ }
+ siblingNode, ok := byID["tt-sibling"]
+ if !ok {
+ t.Fatal("missing tt-sibling node")
+ }
+ if len(siblingNode.DependsOn) != 1 || siblingNode.DependsOn[0] != "tt-root" {
+ t.Errorf("tt-sibling.DependsOn: want [tt-root], got %+v", siblingNode.DependsOn)
+ }
+ for _, n := range got.Nodes {
+ if n.State == "" {
+ t.Errorf("node %s: expected non-empty state", n.ID)
+ }
+ if n.AgentType != "claude" {
+ t.Errorf("node %s: AgentType = %q, want claude", n.ID, n.AgentType)
+ }
+ }
+}
+
+// TestServer_StoryTaskTree_EmptyWhenNoRootTask confirms an unset
+// root_task_id returns an empty node list, not an error.
+func TestServer_StoryTaskTree_EmptyWhenNoRootTask(t *testing.T) {
+ srv, store := testServer(t)
+ if err := store.CreateStory(&story.Story{ID: "story-empty", Name: "story", Status: "DISCOVERY"}); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/stories/story-empty/task-tree", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ var got taskTreeResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.RootTaskID != "" {
+ t.Errorf("RootTaskID: want empty, got %q", got.RootTaskID)
+ }
+ if len(got.Nodes) != 0 {
+ t.Fatalf("want empty node list, got %+v", got.Nodes)
+ }
+}
+
+func TestServer_StoryTaskTree_UnknownStory_Returns404(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/stories/does-not-exist/task-tree", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
diff --git a/internal/event/event.go b/internal/event/event.go
index 202df63..f31092a 100644
--- a/internal/event/event.go
+++ b/internal/event/event.go
@@ -29,6 +29,21 @@ const (
// includes "final": true — the task stays FAILED for human attention).
// Payload shape: from_rung, to_rung, from_provider, to_provider, final.
KindEscalated Kind = "escalated"
+
+ // The following Kinds are part of the Phase 7 planning-layer/story-model
+ // ceremony (epics/stories, internal/story). This phase (7a) only adds the
+ // constants — nothing emits them yet; a later phase's orchestration logic
+ // writes them against a story or epic ID (events.task_id has no enforced
+ // FK, so it tolerates carrying a non-task ID).
+ KindEpicProposed Kind = "epic_proposed"
+ KindDiscoveryProposed Kind = "discovery_proposed"
+ KindFramingDecided Kind = "framing_decided"
+ KindGroomed Kind = "groomed"
+ KindPrioritized Kind = "prioritized"
+ KindEvalVerdict Kind = "eval_verdict"
+ KindArbitrationDecided Kind = "arbitration_decided"
+ KindRetroCaptured Kind = "retro_captured"
+ KindHumanAccepted Kind = "human_accepted"
)
// Actor identifies the originator of an event.
diff --git a/internal/storage/db.go b/internal/storage/db.go
index d571c2e..8b563a6 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -154,6 +154,36 @@ func (s *DB) migrate() error {
UNIQUE(role, version)
)`,
`CREATE INDEX IF NOT EXISTS idx_role_configs_role_status ON role_configs(role, status)`,
+ `CREATE TABLE IF NOT EXISTS epics (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ status TEXT NOT NULL DEFAULT 'OPEN',
+ discovery_source TEXT,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ )`,
+ `CREATE TABLE IF NOT EXISTS stories (
+ id TEXT PRIMARY KEY,
+ epic_id TEXT,
+ project_id TEXT,
+ name TEXT NOT NULL,
+ branch_name TEXT,
+ status TEXT NOT NULL,
+ discovery_source TEXT,
+ spec TEXT,
+ acceptance_criteria_json TEXT NOT NULL DEFAULT '[]',
+ validation_json TEXT,
+ deploy_config TEXT,
+ priority TEXT,
+ root_task_id TEXT,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_epics_status ON epics(status)`,
+ `CREATE INDEX IF NOT EXISTS idx_stories_status ON stories(status)`,
+ `CREATE INDEX IF NOT EXISTS idx_stories_epic_id ON stories(epic_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_stories_root_task_id ON stories(root_task_id)`,
}
for _, m := range migrations {
if _, err := s.db.Exec(m); err != nil {
diff --git a/internal/storage/epic.go b/internal/storage/epic.go
new file mode 100644
index 0000000..9f3863c
--- /dev/null
+++ b/internal/storage/epic.go
@@ -0,0 +1,88 @@
+package storage
+
+import (
+ "database/sql"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+// CreateEpic inserts a new epic. Defaults Status to "OPEN" if unset.
+func (s *DB) CreateEpic(e *story.Epic) error {
+ if e.Status == "" {
+ e.Status = "OPEN"
+ }
+ now := time.Now().UTC()
+ e.CreatedAt = now
+ e.UpdatedAt = now
+ _, err := s.db.Exec(
+ `INSERT INTO epics (id, name, description, status, discovery_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ e.ID, e.Name, e.Description, e.Status, e.DiscoverySource, now, now,
+ )
+ return err
+}
+
+// GetEpic retrieves an epic by ID.
+func (s *DB) GetEpic(id string) (*story.Epic, error) {
+ row := s.db.QueryRow(`SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE id = ?`, id)
+ return scanEpic(row)
+}
+
+// ListEpics returns epics, optionally filtered by status. Pass an empty
+// string to list all epics.
+func (s *DB) ListEpics(status string) ([]*story.Epic, error) {
+ query := `SELECT id, name, description, status, discovery_source, created_at, updated_at FROM epics WHERE 1=1`
+ var args []interface{}
+ if status != "" {
+ query += " AND status = ?"
+ args = append(args, status)
+ }
+ query += " ORDER BY created_at DESC"
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var epics []*story.Epic
+ for rows.Next() {
+ e, err := scanEpicRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ epics = append(epics, e)
+ }
+ return epics, rows.Err()
+}
+
+// UpdateEpic updates an existing epic's name/description/status/discovery_source.
+// Mirrors UpdateProject's shape: no existence check, no status-machine validation.
+func (s *DB) UpdateEpic(e *story.Epic) error {
+ now := time.Now().UTC()
+ _, err := s.db.Exec(
+ `UPDATE epics SET name = ?, description = ?, status = ?, discovery_source = ?, updated_at = ? WHERE id = ?`,
+ e.Name, e.Description, e.Status, e.DiscoverySource, now, e.ID,
+ )
+ if err != nil {
+ return err
+ }
+ e.UpdatedAt = now
+ return nil
+}
+
+func scanEpic(row scanner) (*story.Epic, error) {
+ var e story.Epic
+ var description sql.NullString
+ var discoverySource sql.NullString
+ if err := row.Scan(&e.ID, &e.Name, &description, &e.Status, &discoverySource, &e.CreatedAt, &e.UpdatedAt); err != nil {
+ return nil, err
+ }
+ e.Description = description.String
+ e.DiscoverySource = discoverySource.String
+ return &e, nil
+}
+
+func scanEpicRows(rows *sql.Rows) (*story.Epic, error) {
+ return scanEpic(rows)
+}
diff --git a/internal/storage/epic_test.go b/internal/storage/epic_test.go
new file mode 100644
index 0000000..dfd5be1
--- /dev/null
+++ b/internal/storage/epic_test.go
@@ -0,0 +1,117 @@
+package storage
+
+import (
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+func TestCreateEpic_AndGetEpic(t *testing.T) {
+ db := testDB(t)
+
+ e := &story.Epic{
+ ID: "epic-1",
+ Name: "Bigger Auth Rework",
+ Description: "Overhaul the auth flow.",
+ DiscoverySource: "human",
+ }
+ if err := db.CreateEpic(e); err != nil {
+ t.Fatalf("CreateEpic: %v", err)
+ }
+ // CreateEpic defaults Status to OPEN when unset.
+ if e.Status != "OPEN" {
+ t.Errorf("Status after create: want OPEN, got %q", e.Status)
+ }
+
+ got, err := db.GetEpic("epic-1")
+ if err != nil {
+ t.Fatalf("GetEpic: %v", err)
+ }
+ if got.Name != "Bigger Auth Rework" {
+ t.Errorf("Name: want %q, got %q", "Bigger Auth Rework", got.Name)
+ }
+ if got.Description != "Overhaul the auth flow." {
+ t.Errorf("Description: want %q, got %q", "Overhaul the auth flow.", got.Description)
+ }
+ if got.Status != "OPEN" {
+ t.Errorf("Status: want OPEN, got %q", got.Status)
+ }
+ if got.DiscoverySource != "human" {
+ t.Errorf("DiscoverySource: want human, got %q", got.DiscoverySource)
+ }
+ if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
+ t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got)
+ }
+}
+
+func TestGetEpic_NotFound(t *testing.T) {
+ db := testDB(t)
+ if _, err := db.GetEpic("nope"); err == nil {
+ t.Fatal("expected error for missing epic, got nil")
+ }
+}
+
+func TestListEpics(t *testing.T) {
+ db := testDB(t)
+
+ for _, e := range []*story.Epic{
+ {ID: "e1", Name: "alpha", Status: "OPEN"},
+ {ID: "e2", Name: "beta", Status: "CLOSED"},
+ {ID: "e3", Name: "gamma", Status: "OPEN"},
+ } {
+ if err := db.CreateEpic(e); err != nil {
+ t.Fatalf("CreateEpic(%s): %v", e.ID, err)
+ }
+ }
+
+ all, err := db.ListEpics("")
+ if err != nil {
+ t.Fatalf("ListEpics: %v", err)
+ }
+ if len(all) != 3 {
+ t.Errorf("want 3 epics, got %d", len(all))
+ }
+
+ open, err := db.ListEpics("OPEN")
+ if err != nil {
+ t.Fatalf("ListEpics(OPEN): %v", err)
+ }
+ if len(open) != 2 {
+ t.Errorf("want 2 OPEN epics, got %d", len(open))
+ }
+ for _, e := range open {
+ if e.Status != "OPEN" {
+ t.Errorf("expected only OPEN epics, got %q", e.Status)
+ }
+ }
+}
+
+func TestUpdateEpic(t *testing.T) {
+ db := testDB(t)
+
+ e := &story.Epic{ID: "e1", Name: "original", Status: "OPEN"}
+ if err := db.CreateEpic(e); err != nil {
+ t.Fatalf("CreateEpic: %v", err)
+ }
+ e.Name = "updated"
+ e.Description = "now with a description"
+ e.Status = "CLOSED"
+ if err := db.UpdateEpic(e); err != nil {
+ t.Fatalf("UpdateEpic: %v", err)
+ }
+ got, err := db.GetEpic("e1")
+ if err != nil {
+ t.Fatalf("GetEpic: %v", err)
+ }
+ if got.Name != "updated" {
+ t.Errorf("Name after update: want updated, got %q", got.Name)
+ }
+ if got.Description != "now with a description" {
+ t.Errorf("Description after update: want %q, got %q", "now with a description", got.Description)
+ }
+ // UpdateEpic performs no status-machine validation — CLOSED is accepted
+ // as a plain pass-through write, matching UpdateProject's trust level.
+ if got.Status != "CLOSED" {
+ t.Errorf("Status after update: want CLOSED, got %q", got.Status)
+ }
+}
diff --git a/internal/storage/story.go b/internal/storage/story.go
new file mode 100644
index 0000000..7570dfc
--- /dev/null
+++ b/internal/storage/story.go
@@ -0,0 +1,160 @@
+package storage
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+// StoryFilter specifies criteria for listing stories. Zero-value fields are
+// not applied as filters.
+type StoryFilter struct {
+ Status string
+ EpicID string
+}
+
+// CreateStory inserts a new story.
+func (s *DB) CreateStory(st *story.Story) error {
+ now := time.Now().UTC()
+ st.CreatedAt = now
+ st.UpdatedAt = now
+
+ ac := st.AcceptanceCriteria
+ if ac == nil {
+ ac = []string{}
+ }
+ acJSON, err := json.Marshal(ac)
+ if err != nil {
+ return fmt.Errorf("marshaling acceptance_criteria: %w", err)
+ }
+
+ _, err = s.db.Exec(`
+ INSERT INTO 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)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ st.ID, st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource, st.Spec,
+ string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now, now,
+ )
+ return err
+}
+
+// GetStory retrieves a story by ID.
+func (s *DB) GetStory(id string) (*story.Story, error) {
+ row := s.db.QueryRow(storySelectColumns()+` FROM stories WHERE id = ?`, id)
+ return scanStory(row)
+}
+
+// ListStories returns stories matching the given filter.
+func (s *DB) ListStories(filter StoryFilter) ([]*story.Story, error) {
+ query := storySelectColumns() + ` FROM stories WHERE 1=1`
+ var args []interface{}
+ if filter.Status != "" {
+ query += " AND status = ?"
+ args = append(args, filter.Status)
+ }
+ if filter.EpicID != "" {
+ query += " AND epic_id = ?"
+ args = append(args, filter.EpicID)
+ }
+ query += " ORDER BY created_at DESC"
+
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var stories []*story.Story
+ for rows.Next() {
+ st, err := scanStoryRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ stories = append(stories, st)
+ }
+ return stories, rows.Err()
+}
+
+// UpdateStory replaces all editable fields (everything except id/created_at).
+// This phase enforces no state-machine validation on Status transitions —
+// it writes whatever status string it is given, the same trust level as
+// UpdateProject/UpdateTask's field replacement (not UpdateTaskState).
+func (s *DB) UpdateStory(st *story.Story) error {
+ now := time.Now().UTC()
+
+ ac := st.AcceptanceCriteria
+ if ac == nil {
+ ac = []string{}
+ }
+ acJSON, err := json.Marshal(ac)
+ if err != nil {
+ return fmt.Errorf("marshaling acceptance_criteria: %w", err)
+ }
+
+ _, err = s.db.Exec(`
+ UPDATE stories SET epic_id = ?, project_id = ?, name = ?, branch_name = ?, status = ?, discovery_source = ?,
+ spec = ?, acceptance_criteria_json = ?, validation_json = ?, deploy_config = ?, priority = ?, root_task_id = ?, updated_at = ?
+ WHERE id = ?`,
+ st.EpicID, st.ProjectID, st.Name, st.BranchName, st.Status, st.DiscoverySource,
+ st.Spec, string(acJSON), rawJSONOrNull(st.Validation), rawJSONOrNull(st.DeployConfig), st.Priority, st.RootTaskID, now,
+ st.ID,
+ )
+ if err != nil {
+ return err
+ }
+ st.UpdatedAt = now
+ return nil
+}
+
+func storySelectColumns() string {
+ return `SELECT 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`
+}
+
+func scanStory(row scanner) (*story.Story, error) {
+ var st story.Story
+ var epicID, projectID, branchName, discoverySource, spec, validationJSON, deployConfig, priority, rootTaskID sql.NullString
+ var acJSON string
+ if err := row.Scan(
+ &st.ID, &epicID, &projectID, &st.Name, &branchName, &st.Status, &discoverySource, &spec,
+ &acJSON, &validationJSON, &deployConfig, &priority, &rootTaskID, &st.CreatedAt, &st.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ st.EpicID = epicID.String
+ st.ProjectID = projectID.String
+ st.BranchName = branchName.String
+ st.DiscoverySource = discoverySource.String
+ st.Spec = spec.String
+ st.Priority = priority.String
+ st.RootTaskID = rootTaskID.String
+
+ if acJSON == "" {
+ acJSON = "[]"
+ }
+ if err := json.Unmarshal([]byte(acJSON), &st.AcceptanceCriteria); err != nil {
+ return nil, fmt.Errorf("unmarshaling acceptance_criteria: %w", err)
+ }
+ if validationJSON.Valid && validationJSON.String != "" {
+ st.Validation = json.RawMessage(validationJSON.String)
+ }
+ if deployConfig.Valid && deployConfig.String != "" {
+ st.DeployConfig = json.RawMessage(deployConfig.String)
+ }
+ return &st, nil
+}
+
+func scanStoryRows(rows *sql.Rows) (*story.Story, error) {
+ return scanStory(rows)
+}
+
+// rawJSONOrNull returns nil (SQL NULL) for an empty json.RawMessage, or the
+// raw JSON text otherwise. Used for the nullable validation_json/deploy_config
+// columns, which carry freeform JSON blobs, not a fixed schema.
+func rawJSONOrNull(raw json.RawMessage) interface{} {
+ if len(raw) == 0 {
+ return nil
+ }
+ return string(raw)
+}
diff --git a/internal/storage/story_test.go b/internal/storage/story_test.go
new file mode 100644
index 0000000..9f2c723
--- /dev/null
+++ b/internal/storage/story_test.go
@@ -0,0 +1,211 @@
+package storage
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+
+func TestCreateStory_AndGetStory(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{
+ ID: "story-1",
+ EpicID: "epic-1",
+ ProjectID: "proj-1",
+ Name: "Add login page",
+ BranchName: "story/add-login-page",
+ Status: "DISCOVERY",
+ DiscoverySource: "agent",
+ Spec: "Product: users need to log in. Arch: reuse existing session middleware.",
+ AcceptanceCriteria: []string{"User can log in with email/password", "Invalid creds show an error"},
+ Validation: json.RawMessage(`{"type":"curl","steps":["GET /login returns 200"],"success_criteria":"all steps pass"}`),
+ Priority: "high",
+ RootTaskID: "task-1",
+ }
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+
+ got, err := db.GetStory("story-1")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.Name != "Add login page" {
+ t.Errorf("Name: want %q, got %q", "Add login page", got.Name)
+ }
+ if got.EpicID != "epic-1" {
+ t.Errorf("EpicID: want epic-1, got %q", got.EpicID)
+ }
+ if got.ProjectID != "proj-1" {
+ t.Errorf("ProjectID: want proj-1, got %q", got.ProjectID)
+ }
+ if got.BranchName != "story/add-login-page" {
+ t.Errorf("BranchName: want story/add-login-page, got %q", got.BranchName)
+ }
+ if got.Status != "DISCOVERY" {
+ t.Errorf("Status: want DISCOVERY, got %q", got.Status)
+ }
+ if len(got.AcceptanceCriteria) != 2 {
+ t.Errorf("AcceptanceCriteria: want 2 items, got %d: %+v", len(got.AcceptanceCriteria), got.AcceptanceCriteria)
+ }
+ if string(got.Validation) == "" {
+ t.Errorf("expected Validation to round-trip, got empty")
+ }
+ if got.RootTaskID != "task-1" {
+ t.Errorf("RootTaskID: want task-1, got %q", got.RootTaskID)
+ }
+ if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
+ t.Errorf("expected CreatedAt/UpdatedAt to be set, got %+v", got)
+ }
+}
+
+// TestCreateStory_LooseRefsToleratesUnmatchedIDs confirms epic_id,
+// project_id, and root_task_id are plain nullable-loose-ref strings with no
+// FK enforcement — a story referencing IDs that don't exist anywhere else
+// must still be created successfully, matching how tasks.project already
+// tolerates an unmatched string.
+func TestCreateStory_LooseRefsToleratesUnmatchedIDs(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{
+ ID: "story-loose",
+ EpicID: "epic-does-not-exist",
+ ProjectID: "project-does-not-exist",
+ Name: "Loose ref story",
+ Status: "DISCOVERY",
+ RootTaskID: "task-does-not-exist",
+ }
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory with unmatched loose refs should succeed, got: %v", err)
+ }
+ got, err := db.GetStory("story-loose")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.EpicID != "epic-does-not-exist" || got.ProjectID != "project-does-not-exist" || got.RootTaskID != "task-does-not-exist" {
+ t.Errorf("expected loose refs stored verbatim, got %+v", got)
+ }
+}
+
+func TestCreateStory_DefaultsAcceptanceCriteriaToEmptyList(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{ID: "story-nil-ac", Name: "No AC set", Status: "DISCOVERY"}
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+ got, err := db.GetStory("story-nil-ac")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.AcceptanceCriteria == nil {
+ t.Fatal("expected AcceptanceCriteria to be an empty slice, got nil")
+ }
+ if len(got.AcceptanceCriteria) != 0 {
+ t.Errorf("expected empty AcceptanceCriteria, got %+v", got.AcceptanceCriteria)
+ }
+}
+
+func TestGetStory_NotFound(t *testing.T) {
+ db := testDB(t)
+ if _, err := db.GetStory("nope"); err == nil {
+ t.Fatal("expected error for missing story, got nil")
+ }
+}
+
+func TestListStories_FilterByStatusAndEpic(t *testing.T) {
+ db := testDB(t)
+
+ stories := []*story.Story{
+ {ID: "s1", EpicID: "epic-a", Name: "one", Status: "DISCOVERY"},
+ {ID: "s2", EpicID: "epic-a", Name: "two", Status: "IN_PROGRESS"},
+ {ID: "s3", EpicID: "epic-b", Name: "three", Status: "DISCOVERY"},
+ }
+ for _, st := range stories {
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory(%s): %v", st.ID, err)
+ }
+ }
+
+ all, err := db.ListStories(StoryFilter{})
+ if err != nil {
+ t.Fatalf("ListStories: %v", err)
+ }
+ if len(all) != 3 {
+ t.Errorf("want 3 stories, got %d", len(all))
+ }
+
+ byEpic, err := db.ListStories(StoryFilter{EpicID: "epic-a"})
+ if err != nil {
+ t.Fatalf("ListStories(epic-a): %v", err)
+ }
+ if len(byEpic) != 2 {
+ t.Errorf("want 2 stories for epic-a, got %d", len(byEpic))
+ }
+
+ byStatus, err := db.ListStories(StoryFilter{Status: "DISCOVERY"})
+ if err != nil {
+ t.Fatalf("ListStories(DISCOVERY): %v", err)
+ }
+ if len(byStatus) != 2 {
+ t.Errorf("want 2 DISCOVERY stories, got %d", len(byStatus))
+ }
+
+ byBoth, err := db.ListStories(StoryFilter{EpicID: "epic-a", Status: "DISCOVERY"})
+ if err != nil {
+ t.Fatalf("ListStories(epic-a, DISCOVERY): %v", err)
+ }
+ if len(byBoth) != 1 || byBoth[0].ID != "s1" {
+ t.Errorf("want exactly [s1], got %+v", byBoth)
+ }
+}
+
+// TestUpdateStory_UnvalidatedStatusPassthrough confirms UpdateStory writes
+// whatever status string it is given with no state-machine enforcement
+// (unlike task.ValidTransition/UpdateTaskState) — this phase explicitly
+// defers that validation to a later orchestration phase.
+func TestUpdateStory_UnvalidatedStatusPassthrough(t *testing.T) {
+ db := testDB(t)
+
+ st := &story.Story{ID: "s1", Name: "original", Status: "DISCOVERY"}
+ if err := db.CreateStory(st); err != nil {
+ t.Fatalf("CreateStory: %v", err)
+ }
+ origCreatedAt := st.CreatedAt
+
+ // A nonsensical jump (DISCOVERY -> DONE, skipping every intermediate
+ // status) must still be accepted verbatim.
+ st.Name = "updated"
+ st.Status = "DONE"
+ st.EpicID = "epic-x"
+ st.RootTaskID = "task-x"
+ st.AcceptanceCriteria = []string{"criterion one"}
+ if err := db.UpdateStory(st); err != nil {
+ t.Fatalf("UpdateStory: %v", err)
+ }
+
+ got, err := db.GetStory("s1")
+ if err != nil {
+ t.Fatalf("GetStory: %v", err)
+ }
+ if got.Status != "DONE" {
+ t.Errorf("Status after update: want DONE (unvalidated passthrough), got %q", got.Status)
+ }
+ if got.Name != "updated" {
+ t.Errorf("Name after update: want updated, got %q", got.Name)
+ }
+ if got.EpicID != "epic-x" {
+ t.Errorf("EpicID after update: want epic-x, got %q", got.EpicID)
+ }
+ if got.RootTaskID != "task-x" {
+ t.Errorf("RootTaskID after update: want task-x, got %q", got.RootTaskID)
+ }
+ if len(got.AcceptanceCriteria) != 1 || got.AcceptanceCriteria[0] != "criterion one" {
+ t.Errorf("AcceptanceCriteria after update: want [criterion one], got %+v", got.AcceptanceCriteria)
+ }
+ if !got.CreatedAt.Equal(origCreatedAt) {
+ t.Errorf("CreatedAt must not change on update: want %v, got %v", origCreatedAt, got.CreatedAt)
+ }
+}
diff --git a/internal/story/story.go b/internal/story/story.go
new file mode 100644
index 0000000..dc1764f
--- /dev/null
+++ b/internal/story/story.go
@@ -0,0 +1,73 @@
+// Package story defines the Epic and Story data types that sit above
+// internal/task's flat task tree in the planning hierarchy. This phase
+// (7a) is pure data model + CRUD: no orchestration reads or writes these
+// types yet. A later phase builds fan-out/orchestration logic (creating
+// Builder/Evaluator/Arbitration tasks, gating deploys, etc.) on top of what
+// is stored here.
+package story
+
+import (
+ "encoding/json"
+ "time"
+)
+
+// Epic is a large, loosely-scoped initiative that decomposes into one or
+// more Stories. Epics are not execution units — they carry no root_task_id
+// and are never dispatched.
+type Epic struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ // Status is "OPEN" or "CLOSED". This phase does not validate transitions
+ // — storage writes whatever status string it is given, the same trust
+ // level as task.Project/UpdateProject.
+ Status string `json:"status"`
+ // DiscoverySource is "human" or "agent" — how the epic came to exist.
+ DiscoverySource string `json:"discovery_source,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Story is a shippable slice of work realized by a tree of internal/task
+// Tasks rooted at RootTaskID. EpicID and ProjectID are loose references
+// (plain strings, no FK enforcement) to epics.id / projects.id, matching
+// the codebase's existing tolerance for unmatched reference strings (e.g.
+// tasks.project).
+//
+// Status is one of DISCOVERY|FRAMING|BACKLOG|PRIORITIZED|IN_PROGRESS|
+// SHIPPABLE|DEPLOYED|VALIDATING|REVIEW_READY|NEEDS_FIX|DONE|CANCELLED, but
+// this phase enforces no state machine on it (unlike task.ValidTransition)
+// — a later phase adds that once there is an orchestrator to make
+// transitions meaningful. UpdateStory writes whatever status string it is
+// given.
+type Story struct {
+ ID string `json:"id"`
+ EpicID string `json:"epic_id,omitempty"`
+ ProjectID string `json:"project_id,omitempty"`
+ Name string `json:"name"`
+ BranchName string `json:"branch_name,omitempty"`
+ Status string `json:"status"`
+ DiscoverySource string `json:"discovery_source,omitempty"`
+ // Spec is the Planner's framing-pass output: product + arch context.
+ // Freeform text, not JSON.
+ Spec string `json:"spec,omitempty"`
+ // AcceptanceCriteria is stored as acceptance_criteria_json; defaults to
+ // an empty list, never null in API responses.
+ AcceptanceCriteria []string `json:"acceptance_criteria"`
+ // Validation is the freeform {type, steps, success_criteria} blob a
+ // later phase's validation agent will consume (curl|tests|playwright|
+ // gradle). Stored verbatim as validation_json; nil/omitted if unset.
+ Validation json.RawMessage `json:"validation,omitempty"`
+ // DeployConfig is optional, freeform JSON — only populated if a story
+ // should deploy-gate. Stored verbatim as deploy_config; nil/omitted if
+ // unset.
+ DeployConfig json.RawMessage `json:"deploy_config,omitempty"`
+ Priority string `json:"priority,omitempty"`
+ // RootTaskID is the top-level task realizing this story (usually a
+ // Planner-role task). Empty/unset means the story has no execution tree
+ // yet — GET .../task-tree returns an empty tree, not an error, in that
+ // case.
+ RootTaskID string `json:"root_task_id,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}