From 8e07b5dbcdd80d74a3b220b730c1b36a5de33aae Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 8 Jul 2026 21:56:35 +0000 Subject: docs: add implementation plan for story-level chatbot MCP surface Scopes the "MCP surface: story-level tools, additive to what exists" piece of the recursive-story-decomposition design spec on its own, independent of the larger deferred per-node arbitrated-review generalization and builder role system-prompt work. --- .../plans/2026-07-08-story-mcp-surface.md | 597 +++++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-08-story-mcp-surface.md (limited to 'docs/superpowers') diff --git a/docs/superpowers/plans/2026-07-08-story-mcp-surface.md b/docs/superpowers/plans/2026-07-08-story-mcp-surface.md new file mode 100644 index 0000000..676b80d --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-story-mcp-surface.md @@ -0,0 +1,597 @@ +# Story-Level Chatbot MCP Surface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `create_story`, `get_story`, `list_stories`, and `accept_story` to the chatbot-facing MCP server (`/chatbot/mcp`), so a chatbot can create and drive a Story the same way it already creates and drives a plain Task — closing the "MCP surface: story-level tools, additive to what exists" gap from the recursive-story-decomposition design spec. + +**Architecture:** Both new tools and the existing REST endpoints (`internal/api/stories.go`) must share one service-layer implementation, mirroring how `internal/api/taskops.go`'s `acceptTask`/`rejectTask`/`cancelTask` already back both `internal/api/server.go`'s REST handlers and `internal/api/chatbotmcp.go`'s `accept_task`/`reject_task`/`cancel_task` tools — so the two surfaces cannot drift. `create_story`/`get_story`/`list_stories` are thin wrappers around the existing `storage.DB.CreateStory`/`GetStory`/`ListStories` (no new service-layer method needed, since there's no shared state-transition logic to protect — unlike accept, which does need one, exactly like `acceptTask`). + +**Tech Stack:** Go, `github.com/modelcontextprotocol/go-sdk/mcp`, existing `internal/story`/`internal/storage` packages. + +## Global Constraints + +- Every new MCP tool must mirror an existing sibling tool's exact code shape in `internal/api/chatbotmcp.go` (input struct with `jsonschema` tags, `mcp.AddTool` registration, `mcpJSON`/`mcpText` for output) — this file has zero deviation from that shape today and this plan must not introduce any. +- `story.Story` has **no `repository_url` field** — only the loose-reference `ProjectID` (mirrors `tasks.project`, unenforced, no automatic resolution to a repo). The design spec's prose lists `create_story`'s inputs as "(name, spec, acceptance_criteria, repository_url → story_id)", but that field does not exist on the data model and nothing yet resolves a repo from a story (that requires the deferred root-task-spawning piece of the recursive-decomposition design). This plan's `create_story` accepts `project_id` instead — do not add a `repository_url` field to `story.Story` or to storage as part of this plan; that would be schema scope well beyond an additive MCP wrapper. +- Do not touch `internal/scheduler/story_orchestrator.go` or anything else in the recursive-decomposition design's larger, still-deferred pieces (per-node arbitrated review generalization, `builder` role system-prompt authoring). This plan is scoped to the MCP surface only. + +--- + +## Task 1: Extract `acceptStory` service-layer helper + +**Files:** +- Modify: `internal/api/stories.go` (add `errStoryNotFound`, add `acceptStory` method, refactor `handleAcceptStory` to call it) +- Modify: `internal/api/taskops.go` (map `errStoryNotFound` to 404 in `writeOpError`) +- Test: `internal/api/stories_test.go` (existing `TestServer_AcceptStory_*` tests must still pass unmodified — this task's own verification, no new tests needed) + +**Interfaces:** +- Produces: `func (s *Server) acceptStory(id string, actor event.Actor) error` — Task 2 calls this directly from the new `accept_story` MCP tool. + +- [ ] **Step 1: Add `errStoryNotFound` and the `acceptStory` helper** + +In `internal/api/stories.go`, add `"errors"` to the import block (it currently imports `encoding/json`, `net/http`, `strconv`, `github.com/google/uuid`, `.../internal/event`, `.../internal/storage`, `.../internal/story`): + +```go +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/google/uuid" + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" +) +``` + +Directly after the imports, add: + +```go +// errStoryNotFound is returned when a story ID does not resolve. REST maps +// it to 404 (via writeOpError, mirroring errTaskNotFound in taskops.go); MCP +// surfaces it as a tool error. +var errStoryNotFound = errors.New("story not found") +``` + +Directly before `handleAcceptStory` (which you'll replace in Step 2), add this new method: + +```go +// acceptStory is the shared service-layer accept-gate for a story: both +// handleAcceptStory (REST) and the accept_story chatbot MCP tool call this, +// mirroring taskops.go's shared task-operation pattern (see acceptTask) so +// the two surfaces cannot drift. +func (s *Server) acceptStory(id string, actor event.Actor) error { + st, err := s.store.GetStory(id) + if err != nil { + return errStoryNotFound + } + if st.Status != "REVIEW_READY" { + return conflictf("story cannot be accepted from status %s", st.Status) + } + + from := st.Status + st.Status = "DONE" + if err := s.store.UpdateStory(st); err != nil { + return err + } + + payload, _ := json.Marshal(struct { + StoryID string `json:"story_id"` + From string `json:"from"` + To string `json:"to"` + }{StoryID: id, From: from, To: "DONE"}) + if err := s.store.CreateEvent(&event.Event{ + TaskID: id, + Kind: event.KindHumanAccepted, + Actor: actor, + Payload: payload, + }); err != nil { + s.logger.Error("failed to record human_accepted event", "storyID", id, "error", err) + } + return nil +} +``` + +- [ ] **Step 2: Refactor `handleAcceptStory` to call it** + +Find the existing `handleAcceptStory` function (its whole body, including the doc comment above it): + +```go +// handleAcceptStory is the human accept-gate for a story: it mirrors +// handleAcceptTask's pattern exactly (validate current state, transition, +// emit an event) but operates on story.Story, which (unlike task.Task) has +// no enforced state machine (story.UpdateStory is unvalidated pass-through — +// see internal/story's doc comments) — so the "only valid from REVIEW_READY" +// check is done here, not in the storage layer. +func (s *Server) handleAcceptStory(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 + } + if st.Status != "REVIEW_READY" { + writeJSON(w, http.StatusConflict, map[string]string{"error": "story cannot be accepted from status " + st.Status}) + return + } + + from := st.Status + st.Status = "DONE" + if err := s.store.UpdateStory(st); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + payload, _ := json.Marshal(struct { + StoryID string `json:"story_id"` + From string `json:"from"` + To string `json:"to"` + }{StoryID: id, From: from, To: "DONE"}) + if err := s.store.CreateEvent(&event.Event{ + TaskID: id, + Kind: event.KindHumanAccepted, + Actor: event.ActorUser, + Payload: payload, + }); err != nil { + s.logger.Error("failed to record human_accepted event", "storyID", id, "error", err) + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "story accepted", "story_id": id}) +} +``` + +Replace it with: + +```go +// handleAcceptStory is the human accept-gate for a story: it mirrors +// handleAcceptTask's pattern exactly (validate current state, transition, +// emit an event), delegating to the shared acceptStory service-layer helper +// so the chatbot MCP accept_story tool cannot drift from this REST behavior. +func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.acceptStory(id, event.ActorUser); err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "story accepted", "story_id": id}) +} +``` + +- [ ] **Step 3: Map `errStoryNotFound` to 404 in `writeOpError`** + +In `internal/api/taskops.go`, find `writeOpError`: + +```go +// writeOpError maps a service-layer error to the appropriate REST status. +func writeOpError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, errTaskNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + var ce *conflictError + if errors.As(err, &ce) { + writeJSON(w, http.StatusConflict, map[string]string{"error": ce.Error()}) + return + } + var be *badRequestError + if errors.As(err, &be) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": be.Error()}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) +} +``` + +Replace the `switch` block with: + +```go + switch { + case errors.Is(err, errTaskNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + case errors.Is(err, errStoryNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"}) + return + } +``` + +- [ ] **Step 4: Run the existing story tests to confirm the refactor is behavior-preserving** + +Run: `go test ./internal/api/ -run TestServer_AcceptStory -v` +Expected: `TestServer_AcceptStory_SucceedsFromReviewReady`, `TestServer_AcceptStory_FailsFromOtherStatuses`, and `TestServer_AcceptStory_NotFound` all PASS unmodified — this task is a pure refactor, so these pre-existing tests are the proof it preserves REST behavior exactly. No new tests are added in this task. + +- [ ] **Step 5: Run the full package suite, then the full repo suite** + +Run: `go test ./internal/api/...` +Expected: PASS for everything. + +Then run: `go test ./...` (the entire repo). A prior unrelated plan in this repo (`docs/superpowers/plans/2026-07-08-subtask-ordering-and-verdict-reporting.md`, Task 4) found that task-scoped tests passing is not sufficient — a separate package's code can fail to compile due to a change here. This task doesn't change any shared interface, but verify the full suite anyway rather than assuming. Paste the actual output. + +- [ ] **Step 6: Commit** + +```bash +git add internal/api/stories.go internal/api/taskops.go +git commit -m "refactor(api): extract acceptStory service-layer helper, mirroring acceptTask" +``` + +--- + +## Task 2: Add `create_story`/`get_story`/`list_stories`/`accept_story` chatbot MCP tools + +**Files:** +- Modify: `internal/api/chatbotmcp.go` (new input structs + 4 new `mcp.AddTool` registrations) +- Test: `internal/api/chatbotmcp_test.go` (append) + +**Interfaces:** +- Consumes: `s.acceptStory` (Task 1), `s.store.CreateStory`/`GetStory`/`ListStories` (existing, `internal/storage/story.go`), `storage.StoryFilter{Status, EpicID}` (existing). +- Produces: nothing new for later work — this is the last piece of the story-level MCP surface described in the design spec. + +- [ ] **Step 1: Add the new input structs and imports** + +In `internal/api/chatbotmcp.go`, the import block currently reads: + +```go +import ( + "context" + "crypto/subtle" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) +``` + +Replace it with (adding `github.com/google/uuid` and `.../internal/story`): + +```go +import ( + "context" + "crypto/subtle" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" + "github.com/thepeterstone/claudomator/internal/task" +) +``` + +Directly after the existing `rejectTaskMCPInput` struct (find it — ends with its closing `}`, directly before `type compactTask struct`), add these three new input structs: + +```go +type createStoryMCPInput struct { + Name string `json:"name" jsonschema:"short human-readable story name"` + Spec string `json:"spec,omitempty" jsonschema:"prose description of the intent -- what a human is asking for and why"` + AcceptanceCriteria []string `json:"acceptance_criteria,omitempty" jsonschema:"optional list of concrete criteria the finished work must satisfy"` + EpicID string `json:"epic_id,omitempty" jsonschema:"optional epic ID to group this story under"` + ProjectID string `json:"project_id,omitempty" jsonschema:"optional loose-reference project ID; not yet used to automatically resolve a repository"` + Priority string `json:"priority,omitempty" jsonschema:"optional priority, e.g. high, normal, low"` +} + +type listStoriesMCPInput struct { + Status string `json:"status,omitempty" jsonschema:"optional status filter, e.g. DISCOVERY, IN_PROGRESS, REVIEW_READY, DONE"` + EpicID string `json:"epic_id,omitempty" jsonschema:"optional epic ID filter"` +} + +type storyIDInput struct { + StoryID string `json:"story_id" jsonschema:"the story ID"` +} +``` + +- [ ] **Step 2: Register the four new tools** + +In the same file, find the `cancel_task` tool registration (the last `mcp.AddTool` call before `return srv`): + +```go + mcp.AddTool(srv, &mcp.Tool{ + Name: "cancel_task", + Description: "Cancel a RUNNING or QUEUED task.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) { + msg, err := s.cancelTask(in.TaskID, event.ActorChatbot) + if err != nil { + return nil, nil, err + } + return mcpText(msg), nil, nil + }) + + return srv +``` + +Replace it with (adding the four new registrations directly after `cancel_task`, before `return srv`): + +```go + mcp.AddTool(srv, &mcp.Tool{ + Name: "cancel_task", + Description: "Cancel a RUNNING or QUEUED task.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) { + msg, err := s.cancelTask(in.TaskID, event.ActorChatbot) + if err != nil { + return nil, nil, err + } + return mcpText(msg), nil, nil + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "create_story", + Description: "Create a story representing a piece of user intent -- a shippable slice of work realized by a tree of tasks. Use this instead of submit_task when the ask needs to be broken down rather than run as a single step. Returns the new story.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in createStoryMCPInput) (*mcp.CallToolResult, any, error) { + if strings.TrimSpace(in.Name) == "" { + return nil, nil, badRequestf("name is required") + } + st := &story.Story{ + ID: uuid.New().String(), + Name: in.Name, + Status: "DISCOVERY", + DiscoverySource: "agent", + Spec: in.Spec, + AcceptanceCriteria: in.AcceptanceCriteria, + EpicID: in.EpicID, + ProjectID: in.ProjectID, + Priority: in.Priority, + } + if st.AcceptanceCriteria == nil { + st.AcceptanceCriteria = []string{} + } + if err := s.store.CreateStory(st); err != nil { + return nil, nil, err + } + return mcpJSON(st) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "get_story", + Description: "Get one story by ID.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in storyIDInput) (*mcp.CallToolResult, any, error) { + st, err := s.store.GetStory(in.StoryID) + if err != nil { + return nil, nil, errStoryNotFound + } + return mcpJSON(st) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "list_stories", + Description: "List stories, optionally filtered by status and/or epic_id.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in listStoriesMCPInput) (*mcp.CallToolResult, any, error) { + stories, err := s.store.ListStories(storage.StoryFilter{Status: in.Status, EpicID: in.EpicID}) + if err != nil { + return nil, nil, err + } + if stories == nil { + stories = []*story.Story{} + } + return mcpJSON(stories) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "accept_story", + Description: "Accept a REVIEW_READY story, marking it DONE. This is the single human/chatbot touchpoint for a whole story's pipeline -- the underlying builder/evaluator/arbitration tasks are auto-accepted by the story orchestrator.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in storyIDInput) (*mcp.CallToolResult, any, error) { + if err := s.acceptStory(in.StoryID, event.ActorChatbot); err != nil { + return nil, nil, err + } + return mcpText("Story accepted."), nil, nil + }) + + return srv +``` + +- [ ] **Step 3: Write the failing tests** + +First, update the import block at the top of `internal/api/chatbotmcp_test.go`. It currently reads: + +```go +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) +``` + +Replace it with (adding `.../internal/event` and `.../internal/story`): + +```go +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/event" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" + "github.com/thepeterstone/claudomator/internal/task" +) +``` + +Then append the following tests to the end of the file: + +```go +func TestChatbotMCP_CreateStory_AndGetStory(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + out := callText(t, cs, "create_story", map[string]any{ + "name": "Add login page", + "spec": "Users should be able to log in with email + password", + "acceptance_criteria": []string{"User can log in", "Invalid credentials show an error"}, + }) + var created story.Story + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatalf("unmarshal create_story result %q: %v", out, err) + } + if created.ID == "" { + t.Fatal("create_story returned empty story ID") + } + if created.Status != "DISCOVERY" { + t.Errorf("Status: want DISCOVERY, got %q", created.Status) + } + + out = callText(t, cs, "get_story", map[string]any{"story_id": created.ID}) + var got story.Story + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal get_story result %q: %v", out, err) + } + if got.Name != "Add login page" { + t.Errorf("Name: want %q, got %q", "Add login page", got.Name) + } + if len(got.AcceptanceCriteria) != 2 { + t.Errorf("AcceptanceCriteria: want 2 items, got %+v", got.AcceptanceCriteria) + } +} + +func TestChatbotMCP_CreateStory_MissingName_ReturnsToolError(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "create_story", Arguments: map[string]any{}}) + if err != nil { + t.Fatalf("CallTool transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected create_story with no name to return a tool error") + } +} + +func TestChatbotMCP_GetStory_NotFound_ReturnsToolError(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_story", Arguments: map[string]any{"story_id": "nope"}}) + if err != nil { + t.Fatalf("CallTool transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected get_story with unknown ID to return a tool error") + } +} + +func TestChatbotMCP_ListStories_FiltersByStatus(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "DISCOVERY", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story 1: %v", err) + } + if err := store.CreateStory(&story.Story{ID: "s2", Name: "Two", Status: "DONE", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story 2: %v", err) + } + + out := callText(t, cs, "list_stories", map[string]any{"status": "DONE"}) + var got []story.Story + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal list_stories result %q: %v", out, err) + } + if len(got) != 1 || got[0].ID != "s2" { + t.Errorf("expected only story s2, got %+v", got) + } +} + +func TestChatbotMCP_AcceptStory_SucceedsFromReviewReady(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "REVIEW_READY", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story: %v", err) + } + + callText(t, cs, "accept_story", map[string]any{"story_id": "s1"}) + + got, err := store.GetStory("s1") + if err != nil { + t.Fatalf("get story after accept: %v", err) + } + if got.Status != "DONE" { + t.Errorf("Status: want DONE, got %q", got.Status) + } + + events, err := store.ListEvents("s1", 0) + if err != nil { + t.Fatalf("list events: %v", err) + } + found := false + for _, e := range events { + if e.Kind == event.KindHumanAccepted && e.Actor == event.ActorChatbot { + found = true + } + } + if !found { + t.Error("expected a KindHumanAccepted event with ActorChatbot") + } +} + +func TestChatbotMCP_AcceptStory_FailsFromOtherStatus(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "IN_PROGRESS", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story: %v", err) + } + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "accept_story", Arguments: map[string]any{"story_id": "s1"}}) + if err != nil { + t.Fatalf("CallTool transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected accept_story from IN_PROGRESS to return a tool error") + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `go test ./internal/api/ -run 'TestChatbotMCP_(CreateStory|GetStory|ListStories|AcceptStory)' -v` + +Expected: compile failure — `create_story`/`get_story`/`list_stories`/`accept_story` tools don't exist yet. Paste the actual output. + +- [ ] **Step 5: Run tests to verify they pass** + +Run the same command as Step 4. +Expected: PASS, all 6 new tests. Paste the actual output. + +- [ ] **Step 6: Run the full package suite, then the full repo suite** + +Run: `go test ./internal/api/...` +Expected: PASS for everything, including the pre-existing `TestChatbotMCP_*` and `TestServer_*Story*` tests. + +Then run: `go test ./...` (the entire repo). This must also pass — paste the actual output. + +- [ ] **Step 7: Commit** + +```bash +git add internal/api/chatbotmcp.go internal/api/chatbotmcp_test.go +git commit -m "feat(api): add create_story/get_story/list_stories/accept_story to chatbot MCP" +``` + +--- + +## Final Verification + +- [ ] Run `go build ./...` — passes. +- [ ] Run `go test ./...` — passes, full repo. +- [ ] Run `grep -rn "create_story\|get_story\|list_stories\|accept_story\|acceptStory\|errStoryNotFound" internal/` to visually confirm every file in both tasks' File Maps was actually touched. -- cgit v1.2.3