summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-07-09-task-acceptance-criteria.md863
1 files changed, 863 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-09-task-acceptance-criteria.md b/docs/superpowers/plans/2026-07-09-task-acceptance-criteria.md
new file mode 100644
index 0000000..967185f
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-task-acceptance-criteria.md
@@ -0,0 +1,863 @@
+# Task AcceptanceCriteria 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 `AcceptanceCriteria []string` to `task.Task`, mirroring `story.Story`'s existing pattern exactly, and expose it as a `spawn_subtask` parameter so a decomposing builder can state what each subtask must satisfy. This is piece 1 of `docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md`'s implementation order — additive, no behavior change to anything existing.
+
+**Architecture:** `task.Task` gains one new field, stored as `acceptance_criteria_json` (same column-naming/JSON-marshal convention as `depends_on_json`/`tags_json`). Touching this column means touching every one of the 5 places `internal/storage/db.go` repeats the tasks table's `SELECT` column list verbatim — this plan extracts a `taskSelectColumns()` helper (mirroring the already-established `storySelectColumns()` in `internal/storage/story.go`) at the same time, so a future column addition only requires one edit, not five kept in sync by hand.
+
+**Tech Stack:** Go, SQLite (additive `ALTER TABLE`, no data migration needed — new column defaults to `'[]'`).
+
+## Global Constraints
+
+- `AcceptanceCriteria` is **only settable at subtask creation** via `spawn_subtask` — it is intentionally not added to `storage.TaskUpdate`/`UpdateTask` (which already excludes several other `Task` fields, e.g. `ElaborationInput`, `ParentTaskID` — it is a curated subset for the "edit and reset to PENDING" REST use case, not an exhaustive field list).
+- Do not touch anything about arbitrated review's trigger, `maybeUnblockParent`, or the story-level fix loop's `RootTaskID` mechanism — those are separate, later pieces of the same design spec's implementation order.
+
+---
+
+## Task 1: Storage layer
+
+**Files:**
+- Modify: `internal/task/task.go` (`Task` struct)
+- Modify: `internal/storage/db.go` (schema, migration, new `taskSelectColumns()` helper, `CreateTask`, `GetTask`, `ListTasks`, `ListSubtasks`, `ListDependents`, `ResetTaskForRetry`, `scanTask`)
+- Test: `internal/storage/db_test.go` (append)
+
+**Interfaces:**
+- Produces: `task.Task.AcceptanceCriteria []string`, round-tripped through `storage.DB.CreateTask`/`GetTask` (and every other read path). Task 2 consumes this field directly when wiring `spawn_subtask`.
+
+- [ ] **Step 1: Add the field to `task.Task`**
+
+In `internal/task/task.go`, find the `Task` struct's `DependsOn` field:
+
+```go
+ DependsOn []string `yaml:"depends_on" json:"depends_on"`
+```
+
+Add directly after it:
+
+```go
+ DependsOn []string `yaml:"depends_on" json:"depends_on"`
+ // AcceptanceCriteria, when non-empty, states what this task's work must
+ // satisfy — set by a decomposing parent via spawn_subtask's
+ // acceptance_criteria parameter (mirrors story.Story.AcceptanceCriteria
+ // exactly). Unused by execution itself; a later phase's arbitrated-review
+ // generalization reads it when evaluating this task's work, falling back
+ // to the enclosing story's own AcceptanceCriteria when empty.
+ AcceptanceCriteria []string `yaml:"acceptance_criteria" json:"acceptance_criteria"`
+```
+
+- [ ] **Step 2: Write the failing storage test**
+
+Append to `internal/storage/db_test.go`, directly after `TestCreateTask_Project_RoundTrip`:
+
+```go
+func TestCreateTask_AcceptanceCriteria_RoundTrip(t *testing.T) {
+ db := testDB(t)
+ now := time.Now().UTC().Truncate(time.Second)
+
+ tk := &task.Task{
+ ID: "ac-1",
+ Name: "Task With Criteria",
+ Agent: task.AgentConfig{Type: "claude", Instructions: "do it"},
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ DependsOn: []string{},
+ AcceptanceCriteria: []string{"tests pass", "no new lint warnings"},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("creating task: %v", err)
+ }
+
+ got, err := db.GetTask("ac-1")
+ if err != nil {
+ t.Fatalf("getting task: %v", err)
+ }
+ if len(got.AcceptanceCriteria) != 2 || got.AcceptanceCriteria[0] != "tests pass" || got.AcceptanceCriteria[1] != "no new lint warnings" {
+ t.Errorf("AcceptanceCriteria: want [tests pass, no new lint warnings], got %+v", got.AcceptanceCriteria)
+ }
+}
+
+// TestCreateTask_AcceptanceCriteria_DefaultsEmpty proves a task created
+// without AcceptanceCriteria round-trips as an empty (not nil) slice, the
+// same "never null" convention story.Story.AcceptanceCriteria already uses.
+func TestCreateTask_AcceptanceCriteria_DefaultsEmpty(t *testing.T) {
+ db := testDB(t)
+ now := time.Now().UTC().Truncate(time.Second)
+
+ tk := &task.Task{
+ ID: "ac-2",
+ Name: "Task Without Criteria",
+ Agent: task.AgentConfig{Type: "claude", Instructions: "do it"},
+ Priority: task.PriorityNormal,
+ Tags: []string{},
+ DependsOn: []string{},
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("creating task: %v", err)
+ }
+
+ got, err := db.GetTask("ac-2")
+ if err != nil {
+ t.Fatalf("getting task: %v", err)
+ }
+ if got.AcceptanceCriteria == nil {
+ t.Error("AcceptanceCriteria: want empty slice, got nil")
+ }
+ if len(got.AcceptanceCriteria) != 0 {
+ t.Errorf("AcceptanceCriteria: want empty, got %+v", got.AcceptanceCriteria)
+ }
+}
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run: `go test ./internal/storage/ -run TestCreateTask_AcceptanceCriteria -v`
+Expected: compile failure — `task.Task` has no `AcceptanceCriteria` field yet (Step 1 alone doesn't make this pass; the storage layer doesn't read/write it yet). Paste the actual output.
+
+- [ ] **Step 4: Add the column to the schema and migrations**
+
+In `internal/storage/db.go`, find the `CREATE TABLE IF NOT EXISTS tasks` block:
+
+```go
+ CREATE TABLE IF NOT EXISTS tasks (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ config_json TEXT NOT NULL,
+ priority TEXT NOT NULL DEFAULT 'normal',
+ timeout_ns INTEGER NOT NULL DEFAULT 0,
+ retry_json TEXT NOT NULL DEFAULT '{}',
+ tags_json TEXT NOT NULL DEFAULT '[]',
+ depends_on_json TEXT NOT NULL DEFAULT '[]',
+ parent_task_id TEXT,
+ state TEXT NOT NULL DEFAULT 'PENDING',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ );
+```
+
+Replace it with:
+
+```go
+ CREATE TABLE IF NOT EXISTS tasks (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT,
+ config_json TEXT NOT NULL,
+ priority TEXT NOT NULL DEFAULT 'normal',
+ timeout_ns INTEGER NOT NULL DEFAULT 0,
+ retry_json TEXT NOT NULL DEFAULT '{}',
+ tags_json TEXT NOT NULL DEFAULT '[]',
+ depends_on_json TEXT NOT NULL DEFAULT '[]',
+ acceptance_criteria_json TEXT NOT NULL DEFAULT '[]',
+ parent_task_id TEXT,
+ state TEXT NOT NULL DEFAULT 'PENDING',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+ );
+```
+
+Find the last entry in the `migrations` slice:
+
+```go
+ `ALTER TABLE tasks ADD COLUMN needs_review BOOLEAN NOT NULL DEFAULT 0`,
+ }
+```
+
+Replace it with:
+
+```go
+ `ALTER TABLE tasks ADD COLUMN needs_review BOOLEAN NOT NULL DEFAULT 0`,
+ `ALTER TABLE tasks ADD COLUMN acceptance_criteria_json TEXT NOT NULL DEFAULT '[]'`,
+ }
+```
+
+- [ ] **Step 5: Add `taskSelectColumns()` and replace all 5 duplicated SELECT column lists**
+
+In `internal/storage/db.go`, find `GetTask`:
+
+```go
+// GetTask retrieves a task by ID.
+func (s *DB) GetTask(id string) (*task.Task, error) {
+ row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE id = ?`, id)
+ return scanTask(row)
+}
+```
+
+Replace it with (adding the new helper directly above `GetTask`):
+
+```go
+// taskSelectColumns is the shared SELECT column list every task-reading
+// query in this file uses — extracted so a future column addition is one
+// edit instead of five kept in sync by hand. Mirrors storySelectColumns in
+// internal/storage/story.go.
+func taskSelectColumns() string {
+ return `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, acceptance_criteria_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review`
+}
+
+// GetTask retrieves a task by ID.
+func (s *DB) GetTask(id string) (*task.Task, error) {
+ row := s.db.QueryRow(taskSelectColumns()+` FROM tasks WHERE id = ?`, id)
+ return scanTask(row)
+}
+```
+
+In the same file, find `ListTasks`'s query assignment:
+
+```go
+ query := `SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE 1=1`
+```
+
+Replace it with:
+
+```go
+ query := taskSelectColumns() + ` FROM tasks WHERE 1=1`
+```
+
+Find `ListSubtasks`:
+
+```go
+// ListSubtasks returns all tasks whose parent_task_id matches the given ID.
+func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) {
+ rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID)
+```
+
+Replace the query line with:
+
+```go
+ rows, err := s.db.Query(taskSelectColumns()+` FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID)
+```
+
+Find `ListDependents`:
+
+```go
+ rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks`)
+```
+
+Replace it with:
+
+```go
+ rows, err := s.db.Query(taskSelectColumns() + ` FROM tasks`)
+```
+
+Find `ResetTaskForRetry`:
+
+```go
+ t, err := scanTask(tx.QueryRow(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json, needs_review FROM tasks WHERE id = ?`, id))
+```
+
+Replace it with:
+
+```go
+ t, err := scanTask(tx.QueryRow(taskSelectColumns()+` FROM tasks WHERE id = ?`, id))
+```
+
+- [ ] **Step 6: Update `CreateTask` to write the new column**
+
+In the same file, find `CreateTask`:
+
+```go
+func (s *DB) CreateTask(t *task.Task) error {
+ configJSON, err := json.Marshal(t.Agent)
+ if err != nil {
+ return fmt.Errorf("marshaling config: %w", err)
+ }
+ retryJSON, err := json.Marshal(t.Retry)
+ if err != nil {
+ return fmt.Errorf("marshaling retry: %w", err)
+ }
+ tagsJSON, err := json.Marshal(t.Tags)
+ if err != nil {
+ return fmt.Errorf("marshaling tags: %w", err)
+ }
+ depsJSON, err := json.Marshal(t.DependsOn)
+ if err != nil {
+ return fmt.Errorf("marshaling depends_on: %w", err)
+ }
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ if _, err = tx.Exec(`
+ INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, t.RepositoryURL, string(configJSON), string(t.Priority),
+ t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON),
+ t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(),
+ ); err != nil {
+ return err
+ }
+```
+
+Replace it with:
+
+```go
+func (s *DB) CreateTask(t *task.Task) error {
+ configJSON, err := json.Marshal(t.Agent)
+ if err != nil {
+ return fmt.Errorf("marshaling config: %w", err)
+ }
+ retryJSON, err := json.Marshal(t.Retry)
+ if err != nil {
+ return fmt.Errorf("marshaling retry: %w", err)
+ }
+ tagsJSON, err := json.Marshal(t.Tags)
+ if err != nil {
+ return fmt.Errorf("marshaling tags: %w", err)
+ }
+ depsJSON, err := json.Marshal(t.DependsOn)
+ if err != nil {
+ return fmt.Errorf("marshaling depends_on: %w", err)
+ }
+ acceptanceCriteria := t.AcceptanceCriteria
+ if acceptanceCriteria == nil {
+ acceptanceCriteria = []string{}
+ }
+ acceptanceCriteriaJSON, err := json.Marshal(acceptanceCriteria)
+ if err != nil {
+ return fmt.Errorf("marshaling acceptance_criteria: %w", err)
+ }
+
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback() //nolint:errcheck
+
+ if _, err = tx.Exec(`
+ INSERT INTO tasks (id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, acceptance_criteria_json, parent_task_id, state, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ t.ID, t.Name, t.Description, t.ElaborationInput, t.Project, t.RepositoryURL, string(configJSON), string(t.Priority),
+ t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON), string(acceptanceCriteriaJSON),
+ t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(),
+ ); err != nil {
+ return err
+ }
+```
+
+- [ ] **Step 7: Update `scanTask` to read the new column**
+
+In the same file, find `scanTask`:
+
+```go
+func scanTask(row scanner) (*task.Task, error) {
+ var (
+ t task.Task
+ configJSON string
+ retryJSON string
+ tagsJSON string
+ depsJSON string
+ state string
+ priority string
+ timeoutNS int64
+ parentTaskID sql.NullString
+ elaborationInput sql.NullString
+ project sql.NullString
+ repositoryURL sql.NullString
+ rejectionComment sql.NullString
+ questionJSON sql.NullString
+ summary sql.NullString
+ interactionsJSON sql.NullString
+ )
+ err := row.Scan(
+ &t.ID, &t.Name, &t.Description, &elaborationInput, &project, &repositoryURL,
+ &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON,
+ &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt,
+ &rejectionComment, &questionJSON, &summary, &interactionsJSON, &t.NeedsReview,
+ )
+ t.ParentTaskID = parentTaskID.String
+ t.ElaborationInput = elaborationInput.String
+ t.Project = project.String
+ t.RepositoryURL = repositoryURL.String
+ t.RejectionComment = rejectionComment.String
+ t.QuestionJSON = questionJSON.String
+ t.Summary = summary.String
+ if err != nil {
+ return nil, err
+ }
+ t.State = task.State(state)
+ t.Priority = task.Priority(priority)
+ t.Timeout.Duration = time.Duration(timeoutNS)
+ if err := json.Unmarshal([]byte(configJSON), &t.Agent); err != nil {
+ return nil, fmt.Errorf("unmarshaling agent config: %w", err)
+ }
+ if err := json.Unmarshal([]byte(retryJSON), &t.Retry); err != nil {
+ return nil, fmt.Errorf("unmarshaling retry: %w", err)
+ }
+ if err := json.Unmarshal([]byte(tagsJSON), &t.Tags); err != nil {
+ return nil, fmt.Errorf("unmarshaling tags: %w", err)
+ }
+ if err := json.Unmarshal([]byte(depsJSON), &t.DependsOn); err != nil {
+ return nil, fmt.Errorf("unmarshaling depends_on: %w", err)
+ }
+ raw := interactionsJSON.String
+ if raw == "" {
+ raw = "[]"
+ }
+ if err := json.Unmarshal([]byte(raw), &t.Interactions); err != nil {
+ return nil, fmt.Errorf("unmarshaling interactions: %w", err)
+ }
+ return &t, nil
+}
+```
+
+Replace it with:
+
+```go
+func scanTask(row scanner) (*task.Task, error) {
+ var (
+ t task.Task
+ configJSON string
+ retryJSON string
+ tagsJSON string
+ depsJSON string
+ acceptanceCriteriaJSON string
+ state string
+ priority string
+ timeoutNS int64
+ parentTaskID sql.NullString
+ elaborationInput sql.NullString
+ project sql.NullString
+ repositoryURL sql.NullString
+ rejectionComment sql.NullString
+ questionJSON sql.NullString
+ summary sql.NullString
+ interactionsJSON sql.NullString
+ )
+ err := row.Scan(
+ &t.ID, &t.Name, &t.Description, &elaborationInput, &project, &repositoryURL,
+ &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, &acceptanceCriteriaJSON,
+ &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt,
+ &rejectionComment, &questionJSON, &summary, &interactionsJSON, &t.NeedsReview,
+ )
+ t.ParentTaskID = parentTaskID.String
+ t.ElaborationInput = elaborationInput.String
+ t.Project = project.String
+ t.RepositoryURL = repositoryURL.String
+ t.RejectionComment = rejectionComment.String
+ t.QuestionJSON = questionJSON.String
+ t.Summary = summary.String
+ if err != nil {
+ return nil, err
+ }
+ t.State = task.State(state)
+ t.Priority = task.Priority(priority)
+ t.Timeout.Duration = time.Duration(timeoutNS)
+ if err := json.Unmarshal([]byte(configJSON), &t.Agent); err != nil {
+ return nil, fmt.Errorf("unmarshaling agent config: %w", err)
+ }
+ if err := json.Unmarshal([]byte(retryJSON), &t.Retry); err != nil {
+ return nil, fmt.Errorf("unmarshaling retry: %w", err)
+ }
+ if err := json.Unmarshal([]byte(tagsJSON), &t.Tags); err != nil {
+ return nil, fmt.Errorf("unmarshaling tags: %w", err)
+ }
+ if err := json.Unmarshal([]byte(depsJSON), &t.DependsOn); err != nil {
+ return nil, fmt.Errorf("unmarshaling depends_on: %w", err)
+ }
+ acRaw := acceptanceCriteriaJSON
+ if acRaw == "" {
+ acRaw = "[]"
+ }
+ if err := json.Unmarshal([]byte(acRaw), &t.AcceptanceCriteria); err != nil {
+ return nil, fmt.Errorf("unmarshaling acceptance_criteria: %w", err)
+ }
+ raw := interactionsJSON.String
+ if raw == "" {
+ raw = "[]"
+ }
+ if err := json.Unmarshal([]byte(raw), &t.Interactions); err != nil {
+ return nil, fmt.Errorf("unmarshaling interactions: %w", err)
+ }
+ return &t, nil
+}
+```
+
+- [ ] **Step 8: Run tests to verify they pass**
+
+Run: `go test ./internal/storage/ -run TestCreateTask -v`
+Expected: PASS, all `TestCreateTask_*` tests, including the two new ones and every pre-existing one (`TestCreateTask_AndGetTask`, `TestCreateTask_Project_RoundTrip`, `TestCreateTask_Subtask_EmitsSubtaskSpawnedOnParent`, `TestCreateTask_TopLevel_EmitsNoEvent`) — this proves `taskSelectColumns()`'s column order matches `scanTask`'s scan order exactly, and that no existing behavior broke. Paste the actual output.
+
+- [ ] **Step 9: Run the full package suite, then the full repo suite**
+
+Run: `go test ./internal/storage/...`
+Expected: PASS for everything — `ListTasks`/`ListSubtasks`/`ListDependents`/`ResetTaskForRetry` all have their own existing tests elsewhere in this package; if `taskSelectColumns()`'s column list or `scanTask`'s scan order is wrong, one of them will fail with a scan-type-mismatch error, not silently return wrong data.
+
+Then run: `go test ./...` (the entire repo). This must also pass — paste the actual output. If `internal/api` flakes under the race detector on a one-off "sql: database is closed" teardown error unrelated to this task's actual changes, rerun once before treating it as real.
+
+- [ ] **Step 10: Commit and push to `main` directly, matching this repo's existing workflow**
+
+```bash
+git add internal/task/task.go internal/storage/db.go internal/storage/db_test.go
+git commit -m "feat(task,storage): add Task.AcceptanceCriteria, mirroring story.Story's pattern"
+```
+
+## Mandatory verification disclosure
+
+When you call report_summary, paste the actual terminal output of every test command above — literal pass/fail counts, not a claim of success, including the full-repo `go test ./...` run. If anything here is ambiguous or conflicts with what you find in the actual repo, call ask_user and describe the specific conflict — don't guess or silently decide.
+
+---
+
+## Task 2: Expose `acceptance_criteria` on `spawn_subtask`
+
+**Files:**
+- Modify: `internal/agentchannel/agentchannel.go` (`SubtaskSpec`)
+- Modify: `internal/executor/channel.go` (`storeChannel.SpawnSubtask`)
+- Modify: `internal/agentloop/tools.go` (schema + case handler)
+- Modify: `internal/executor/agentmcp.go` (input struct + MCP tool)
+- Test: `internal/executor/channel_test.go` (append)
+
+**Interfaces:**
+- Consumes: `task.Task.AcceptanceCriteria` (Task 1).
+- Produces: nothing new for later work — this closes the `spawn_subtask` half of this plan's scope. The arbitrated-review generalization that actually *reads* a task's `AcceptanceCriteria` when evaluating it is a later, separate piece of the design spec's implementation order.
+
+- [ ] **Step 1: Add `AcceptanceCriteria` to `SubtaskSpec`**
+
+In `internal/agentchannel/agentchannel.go`, find the end of `SubtaskSpec`:
+
+```go
+ // DependsOn, when non-empty, names sibling task IDs (returned by a prior
+ // SpawnSubtask call in the same decomposition) this new subtask must wait
+ // for — set directly on the created child's task.DependsOn, so the
+ // existing dispatch-gating and cascade-fail-on-dependency-failure
+ // machinery (already built for top-level tasks) applies unchanged. Empty
+ // (the default) preserves today's behavior: every spawned subtask is
+ // structurally independent/parallel.
+ DependsOn []string
+}
+```
+
+Replace it with:
+
+```go
+ // DependsOn, when non-empty, names sibling task IDs (returned by a prior
+ // SpawnSubtask call in the same decomposition) this new subtask must wait
+ // for — set directly on the created child's task.DependsOn, so the
+ // existing dispatch-gating and cascade-fail-on-dependency-failure
+ // machinery (already built for top-level tasks) applies unchanged. Empty
+ // (the default) preserves today's behavior: every spawned subtask is
+ // structurally independent/parallel.
+ DependsOn []string
+ // AcceptanceCriteria, when non-empty, states what this subtask's work
+ // must satisfy — set directly on the created child's
+ // task.Task.AcceptanceCriteria. Empty (the default) leaves the child with
+ // no criteria of its own; a later phase's arbitrated-review
+ // generalization falls back to the enclosing story's AcceptanceCriteria
+ // in that case.
+ AcceptanceCriteria []string
+}
+```
+
+- [ ] **Step 2: Wire it through `storeChannel.SpawnSubtask`**
+
+In `internal/executor/channel.go`, find:
+
+```go
+ child := &task.Task{
+ ID: uuid.NewString(),
+ Name: spec.Name,
+ ParentTaskID: c.taskID,
+ Agent: agent,
+ Priority: task.PriorityNormal,
+ DependsOn: spec.DependsOn,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+```
+
+Replace it with:
+
+```go
+ child := &task.Task{
+ ID: uuid.NewString(),
+ Name: spec.Name,
+ ParentTaskID: c.taskID,
+ Agent: agent,
+ Priority: task.PriorityNormal,
+ DependsOn: spec.DependsOn,
+ AcceptanceCriteria: spec.AcceptanceCriteria,
+ State: task.StatePending,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+```
+
+- [ ] **Step 3: Write the failing tests**
+
+Append to `internal/executor/channel_test.go`, directly after `TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty`:
+
+```go
+// TestStoreChannel_SpawnSubtask_SetsAcceptanceCriteria proves a caller can
+// state what a spawned subtask's work must satisfy.
+func TestStoreChannel_SpawnSubtask_SetsAcceptanceCriteria(t *testing.T) {
+ store := &fakeChannelStore{}
+ ch := newStoreChannel(store, "parent-task-1")
+
+ id, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{
+ Name: "step 1",
+ Instructions: "do the thing",
+ AcceptanceCriteria: []string{"tests pass", "no new lint warnings"},
+ })
+ if err != nil {
+ t.Fatalf("spawn subtask: %v", err)
+ }
+
+ var got *task.Task
+ for _, ct := range store.createdTasks {
+ if ct.ID == id {
+ got = ct
+ }
+ }
+ if got == nil {
+ t.Fatal("subtask not found in store.createdTasks")
+ }
+ if len(got.AcceptanceCriteria) != 2 || got.AcceptanceCriteria[0] != "tests pass" || got.AcceptanceCriteria[1] != "no new lint warnings" {
+ t.Errorf("AcceptanceCriteria: want [tests pass, no new lint warnings], got %+v", got.AcceptanceCriteria)
+ }
+}
+
+// TestStoreChannel_SpawnSubtask_AcceptanceCriteriaDefaultsEmpty proves the
+// backward-compatible default: a caller that never sets AcceptanceCriteria
+// (every pre-existing caller) gets a child task with none, exactly as before
+// this change.
+func TestStoreChannel_SpawnSubtask_AcceptanceCriteriaDefaultsEmpty(t *testing.T) {
+ store := &fakeChannelStore{}
+ ch := newStoreChannel(store, "parent-task-1")
+
+ id, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{
+ Name: "solo step",
+ Instructions: "do the thing",
+ })
+ if err != nil {
+ t.Fatalf("spawn subtask: %v", err)
+ }
+
+ var got *task.Task
+ for _, ct := range store.createdTasks {
+ if ct.ID == id {
+ got = ct
+ }
+ }
+ if got == nil {
+ t.Fatal("subtask not found in store.createdTasks")
+ }
+ if len(got.AcceptanceCriteria) != 0 {
+ t.Errorf("expected empty AcceptanceCriteria by default, got %v", got.AcceptanceCriteria)
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they fail, then implement, then verify they pass**
+
+Run: `go test ./internal/executor/ -run TestStoreChannel_SpawnSubtask_.*AcceptanceCriteria -v`
+Expected: FAIL (compile error, `SubtaskSpec` has no `AcceptanceCriteria` field) — this is the point where you actually make Steps 1-2's edits above if you haven't yet, then re-run and expect PASS. Paste both the failing and passing output.
+
+- [ ] **Step 5: Add the tool definition and case handler to the native tool-use loop**
+
+In `internal/agentloop/tools.go`, find the `spawn_subtask` tool definition:
+
+```go
+ {
+ Name: "spawn_subtask",
+ Description: "Create a child task to be executed separately. Use this to break large work into focused pieces.",
+ ParametersJSONSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "name": strProp("short descriptive name for the subtask"),
+ "instructions": strProp("complete instructions for the subtask agent"),
+ "model": strProp("optional model override"),
+ "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"},
+ "role": strProp("optional role name to dispatch the subtask through instead of a fixed model (e.g. an evaluator role); when set, model is ignored and the role's escalation ladder picks the provider/model"),
+ "depends_on": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional list of sibling subtask IDs (returned by prior spawn_subtask calls in this same decomposition) this subtask must wait for before it can run"},
+ },
+ "required": []string{"name", "instructions"},
+ },
+ },
+```
+
+Replace it with:
+
+```go
+ {
+ Name: "spawn_subtask",
+ Description: "Create a child task to be executed separately. Use this to break large work into focused pieces.",
+ ParametersJSONSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "name": strProp("short descriptive name for the subtask"),
+ "instructions": strProp("complete instructions for the subtask agent"),
+ "model": strProp("optional model override"),
+ "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"},
+ "role": strProp("optional role name to dispatch the subtask through instead of a fixed model (e.g. an evaluator role); when set, model is ignored and the role's escalation ladder picks the provider/model"),
+ "depends_on": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional list of sibling subtask IDs (returned by prior spawn_subtask calls in this same decomposition) this subtask must wait for before it can run"},
+ "acceptance_criteria": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional list of concrete criteria this subtask's work must satisfy"},
+ },
+ "required": []string{"name", "instructions"},
+ },
+ },
+```
+
+In the same file, find the `spawn_subtask` case handler:
+
+```go
+ case "spawn_subtask":
+ var a struct {
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+ Model string `json:"model"`
+ MaxBudgetUSD float64 `json:"max_budget_usd"`
+ Role string `json:"role"`
+ DependsOn []string `json:"depends_on"`
+ }
+ _ = json.Unmarshal([]byte(argsJSON), &a)
+ id, ssErr := l.Channel.SpawnSubtask(ctx, agentchannel.SubtaskSpec{
+ Name: a.Name,
+ Instructions: a.Instructions,
+ Model: a.Model,
+ MaxBudgetUSD: a.MaxBudgetUSD,
+ Role: a.Role,
+ DependsOn: a.DependsOn,
+ })
+```
+
+Replace it with:
+
+```go
+ case "spawn_subtask":
+ var a struct {
+ Name string `json:"name"`
+ Instructions string `json:"instructions"`
+ Model string `json:"model"`
+ MaxBudgetUSD float64 `json:"max_budget_usd"`
+ Role string `json:"role"`
+ DependsOn []string `json:"depends_on"`
+ AcceptanceCriteria []string `json:"acceptance_criteria"`
+ }
+ _ = json.Unmarshal([]byte(argsJSON), &a)
+ id, ssErr := l.Channel.SpawnSubtask(ctx, agentchannel.SubtaskSpec{
+ Name: a.Name,
+ Instructions: a.Instructions,
+ Model: a.Model,
+ MaxBudgetUSD: a.MaxBudgetUSD,
+ Role: a.Role,
+ DependsOn: a.DependsOn,
+ AcceptanceCriteria: a.AcceptanceCriteria,
+ })
+```
+
+- [ ] **Step 6: Add the MCP input field and wire the registration**
+
+In `internal/executor/agentmcp.go`, find `spawnSubtaskInput`:
+
+```go
+type spawnSubtaskInput struct {
+ Name string `json:"name" jsonschema:"short descriptive name for the subtask"`
+ Instructions string `json:"instructions" jsonschema:"complete instructions for the subtask agent"`
+ Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"`
+ MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional budget cap in USD"`
+ Role string `json:"role,omitempty" jsonschema:"optional role name to dispatch the subtask through instead of a fixed model (e.g. an evaluator role); when set, model is ignored and the role's escalation ladder picks the provider/model"`
+ DependsOn []string `json:"depends_on,omitempty" jsonschema:"optional list of sibling subtask IDs (returned by prior spawn_subtask calls in this same decomposition) this subtask must wait for before it can run"`
+}
+```
+
+Replace it with:
+
+```go
+type spawnSubtaskInput struct {
+ Name string `json:"name" jsonschema:"short descriptive name for the subtask"`
+ Instructions string `json:"instructions" jsonschema:"complete instructions for the subtask agent"`
+ Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"`
+ MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional budget cap in USD"`
+ Role string `json:"role,omitempty" jsonschema:"optional role name to dispatch the subtask through instead of a fixed model (e.g. an evaluator role); when set, model is ignored and the role's escalation ladder picks the provider/model"`
+ DependsOn []string `json:"depends_on,omitempty" jsonschema:"optional list of sibling subtask IDs (returned by prior spawn_subtask calls in this same decomposition) this subtask must wait for before it can run"`
+ AcceptanceCriteria []string `json:"acceptance_criteria,omitempty" jsonschema:"optional list of concrete criteria this subtask's work must satisfy"`
+}
+```
+
+In the same file, find the `spawn_subtask` `mcp.AddTool` call:
+
+```go
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "spawn_subtask",
+ Description: "Create a child task to be executed separately. Use this to break large work into focused pieces, then finish your turn.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in spawnSubtaskInput) (*mcp.CallToolResult, any, error) {
+ id, err := ch.SpawnSubtask(ctx, SubtaskSpec{
+ Name: in.Name,
+ Instructions: in.Instructions,
+ Model: in.Model,
+ MaxBudgetUSD: in.MaxBudgetUSD,
+ Role: in.Role,
+ DependsOn: in.DependsOn,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult("Created subtask " + id), nil, nil
+ })
+```
+
+Replace it with:
+
+```go
+ mcp.AddTool(s, &mcp.Tool{
+ Name: "spawn_subtask",
+ Description: "Create a child task to be executed separately. Use this to break large work into focused pieces, then finish your turn.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in spawnSubtaskInput) (*mcp.CallToolResult, any, error) {
+ id, err := ch.SpawnSubtask(ctx, SubtaskSpec{
+ Name: in.Name,
+ Instructions: in.Instructions,
+ Model: in.Model,
+ MaxBudgetUSD: in.MaxBudgetUSD,
+ Role: in.Role,
+ DependsOn: in.DependsOn,
+ AcceptanceCriteria: in.AcceptanceCriteria,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ return textResult("Created subtask " + id), nil, nil
+ })
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `go test ./internal/agentloop/... ./internal/executor/...`
+Expected: PASS for everything, no new tests needed for `tools.go`/`agentmcp.go` themselves (the underlying `SpawnSubtask` behavior is already covered by Task 2's own `channel_test.go` tests — this mirrors how `depends_on`'s exposure on both transports needed no new tests of its own in the earlier plan that added it).
+
+- [ ] **Step 8: Run the full package suite, then the full repo suite**
+
+Run: `go test ./internal/agentchannel/... ./internal/executor/... ./internal/agentloop/...`
+Expected: PASS for everything.
+
+Then run: `go test ./...` (the entire repo). This must also pass before you commit — paste the actual output. If `internal/api` flakes under the race detector on a one-off "sql: database is closed" teardown error unrelated to this task's actual changes, rerun once before treating it as real.
+
+- [ ] **Step 9: Commit and push to `main` directly, matching this repo's existing workflow**
+
+```bash
+git add internal/agentchannel/agentchannel.go internal/executor/channel.go internal/executor/channel_test.go internal/agentloop/tools.go internal/executor/agentmcp.go
+git commit -m "feat(agentchannel,executor,agentloop): expose acceptance_criteria on spawn_subtask"
+```
+
+## Mandatory verification disclosure
+
+When you call report_summary, paste the actual terminal output of every test command above — literal pass/fail counts, not a claim of success, including the full-repo `go test ./...` run. If anything here is ambiguous or conflicts with what you find in the actual repo, call ask_user and describe the specific conflict — don't guess or silently decide.
+
+---
+
+## Final Verification
+
+- [ ] Run `go build ./...` — passes.
+- [ ] Run `go test ./...` — passes, full repo.
+- [ ] Run `grep -rn "AcceptanceCriteria" internal/` to visually confirm every file in both tasks' File Maps was actually touched.