diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-07-08-subtask-ordering-and-verdict-reporting.md | 978 |
1 files changed, 978 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-08-subtask-ordering-and-verdict-reporting.md b/docs/superpowers/plans/2026-07-08-subtask-ordering-and-verdict-reporting.md new file mode 100644 index 0000000..511a68d --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-subtask-ordering-and-verdict-reporting.md @@ -0,0 +1,978 @@ +# Subtask Ordering + Structured Verdict Reporting — 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:** Give `spawn_subtask` the ability to express ordering between the subtasks it creates, and give arbitration a structured, mechanically-parseable pass/fail signal instead of always routing to `REVIEW_READY` regardless of what the arbitration task actually decided. + +**Architecture:** Two small, independent additions to existing extension points. `SubtaskSpec` gains an optional `DependsOn []string` field, wired straight into the `task.DependsOn` the executor already gates dispatch on — no new scheduling logic. A new `AgentChannel.ReportVerdict` method, exposed as a `report_verdict` tool on both transports (native tool-use loop and MCP), mirrors `ProposeRoleConfig` exactly: it records a `KindVerdictReported` event on the calling task's own ID. `StoryOrchestrator.finalizeArbitration` is extended to look for that event on the arbitration task before deciding `REVIEW_READY` vs `NEEDS_FIX`, falling back to today's unconditional `REVIEW_READY` when no verdict event exists (backward compatible with any agent that doesn't call the new tool). + +**Tech Stack:** Go 1.25, SQLite (existing `events` table, additive — no migration needed since events already tolerate any `Kind` string), the existing `mcp` package for MCP tool registration. + +## Global Constraints + +- This plan is Plan 1 of a larger recursive-story-decomposition design + (`docs/superpowers/specs/2026-07-08-recursive-story-decomposition-design.md`). + It does not implement recursion, the `builder` role's decompose-or-implement + judgment, or the story-creation entry point — those are deliberately + out of scope here, planned separately once `internal/scheduler/story_orchestrator_test.go` + (1023 lines of existing convention) has been read in full. +- Both new features must be backward compatible: an agent that never calls + `report_verdict`, and a `spawn_subtask` call that never sets `depends_on`, + must behave exactly as they do today. No existing test may need to change + its expected behavior — only new tests are added. +- Follow existing patterns exactly: `ReportVerdict`/`report_verdict` mirrors + `ProposeRoleConfig`/`propose_role_config` field-for-field, comment-style + for comment-style. `SubtaskSpec.DependsOn` mirrors how `Role` was added to + the same struct ("Backward-compatible default... every pre-existing + caller unchanged"). + +--- + +## File Map + +| File | Change | +|---|---| +| `internal/agentchannel/agentchannel.go` | Add `DependsOn` to `SubtaskSpec`; add `ReportVerdict` to `AgentChannel` interface | +| `internal/event/event.go` | Add `KindVerdictReported` | +| `internal/executor/channel.go` | `storeChannel.SpawnSubtask` sets `child.DependsOn`; add `storeChannel.ReportVerdict` | +| `internal/executor/channel_test.go` | Tests for both | +| `internal/agentloop/tools.go` | Add `depends_on` to `spawn_subtask`'s schema; add `report_verdict` tool + case handler | +| `internal/executor/agentmcp.go` | Add `DependsOn` to `spawnSubtaskInput`; add `reportVerdictInput` + `report_verdict` MCP tool | +| `internal/scheduler/story_orchestrator.go` | `finalizeArbitration` consults `KindVerdictReported` before deciding `REVIEW_READY` vs `NEEDS_FIX` | +| `internal/scheduler/story_orchestrator_test.go` | Tests for the new branch | + +--- + +## Task 1: `SubtaskSpec.DependsOn` + +**Files:** +- Modify: `internal/agentchannel/agentchannel.go:33-43` (`SubtaskSpec`) +- Modify: `internal/executor/channel.go:136-164` (`storeChannel.SpawnSubtask`) +- Test: `internal/executor/channel_test.go` (append) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `SubtaskSpec.DependsOn []string` — when non-empty, the created child task's `task.DependsOn` is set to it, and the existing executor dispatch-gating/cascade-fail-on-dependency-failure machinery (already built for top-level tasks) applies unchanged. Consumed by Task 2's tool-surface wiring (this task only touches the channel layer, not the tools that call it). + +- [ ] **Step 1: Write the failing test** + +Add to `internal/executor/channel_test.go` (find the existing `TestStoreChannel_SpawnSubtask...` tests and add this alongside them — check the file for the exact existing test names first so this doesn't duplicate a helper): + +```go +// TestStoreChannel_SpawnSubtask_SetsDependsOn proves a caller can chain a +// new subtask after a sibling it already spawned, by passing the sibling's +// returned ID back in DependsOn -- the mechanism a decomposing task uses to +// express "this step must wait for that one", not just fan-out-and-wait. +func TestStoreChannel_SpawnSubtask_SetsDependsOn(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "parent-task-1") + + firstID, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ + Name: "step 1", + Instructions: "do the first thing", + }) + if err != nil { + t.Fatalf("spawn first subtask: %v", err) + } + + secondID, err := ch.SpawnSubtask(context.Background(), agentchannel.SubtaskSpec{ + Name: "step 2", + Instructions: "do the second thing", + DependsOn: []string{firstID}, + }) + if err != nil { + t.Fatalf("spawn second subtask: %v", err) + } + + var second *task.Task + for _, ct := range store.createdTasks { + if ct.ID == secondID { + second = ct + } + } + if second == nil { + t.Fatal("second subtask not found in store.createdTasks") + } + if len(second.DependsOn) != 1 || second.DependsOn[0] != firstID { + t.Errorf("expected DependsOn=[%q], got %v", firstID, second.DependsOn) + } +} + +// TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty proves the +// backward-compatible default: a caller that never sets DependsOn (every +// pre-existing caller) gets a child task with no dependencies, exactly as +// before this change. +func TestStoreChannel_SpawnSubtask_DependsOnDefaultsEmpty(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.DependsOn) != 0 { + t.Errorf("expected empty DependsOn by default, got %v", got.DependsOn) + } +} +``` + +`fakeChannelStore.createdTasks` is a `[]*task.Task` slice (confirmed by reading `internal/executor/channel_test.go:18-38` directly), appended to by the fake's `CreateTask` implementation — both tests above search it by ID rather than indexing a map, since that's the actual shape. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/executor/ -run TestStoreChannel_SpawnSubtask_DependsOn -v` +Expected: FAIL — `SubtaskSpec` has no field `DependsOn` (compile error). + +- [ ] **Step 3: Add the field to `SubtaskSpec`** + +In `internal/agentchannel/agentchannel.go`, change: + +```go + // Role, when non-empty, names an internal/role.RoleConfig the child task + // should dispatch through (task.AgentConfig.Role — see Phase 5's + // role-based dispatch in internal/executor.Pool.execute()). When set, the + // storeChannel implementation leaves Agent.Type/Model empty on the child + // so tier 0 of the role's escalation ladder resolves them, exactly like + // any other role-typed task. Empty (the default) preserves the prior + // hardcoded "claude" behavior for backward compatibility. + Role string +} +``` + +to: + +```go + // Role, when non-empty, names an internal/role.RoleConfig the child task + // should dispatch through (task.AgentConfig.Role — see Phase 5's + // role-based dispatch in internal/executor.Pool.execute()). When set, the + // storeChannel implementation leaves Agent.Type/Model empty on the child + // so tier 0 of the role's escalation ladder resolves them, exactly like + // any other role-typed task. Empty (the default) preserves the prior + // hardcoded "claude" behavior for backward compatibility. + Role string + // 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 +} +``` + +- [ ] **Step 4: Wire it through in `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, + State: task.StatePending, + CreatedAt: now, + UpdatedAt: now, + } +``` + +and change it to: + +```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, + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/executor/ -run TestStoreChannel_SpawnSubtask_DependsOn -v` +Expected: PASS, both tests. + +- [ ] **Step 6: Run the full package suite to check for regressions** + +Run: `go test ./internal/executor/... ./internal/agentchannel/...` +Expected: PASS for everything — this change is purely additive to a struct and one assignment line. + +- [ ] **Step 7: Commit** + +```bash +git add internal/agentchannel/agentchannel.go internal/executor/channel.go internal/executor/channel_test.go +git commit -m "feat(agentchannel): add DependsOn to SubtaskSpec so a decomposing task can order its own subtasks" +``` + +--- + +## Task 2: Expose `depends_on` on the `spawn_subtask` tool (both transports) + +**Files:** +- Modify: `internal/agentloop/tools.go` (schema + case handler) +- Modify: `internal/executor/agentmcp.go` (`spawnSubtaskInput` + tool call) + +**Interfaces:** +- Consumes: `agentchannel.SubtaskSpec.DependsOn` (Task 1). +- Produces: nothing new for later tasks — this is the last mile connecting the primitive to what an agent can actually call. + +- [ ] **Step 1: Add `depends_on` to the native tool-use loop's schema** + +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"), + }, + "required": []string{"name", "instructions"}, + }, + }, +``` + +Change to (adding `depends_on`): + +```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"}, + }, + }, +``` + +- [ ] **Step 2: Wire the field through the case handler** + +In the same file, find: + +```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"` + } + _ = 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, + }) +``` + +Change to: + +```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, + }) +``` + +- [ ] **Step 3: Add `DependsOn` to the MCP transport's input struct** + +In `internal/executor/agentmcp.go`, find: + +```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"` +} +``` + +Change to: + +```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"` +} +``` + +- [ ] **Step 4: Wire it through the MCP tool call** + +In the same file, find: + +```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, + }) +``` + +Change to: + +```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, + }) +``` + +- [ ] **Step 5: Build and run the full suite** + +Run: `go build ./... && go test ./internal/agentloop/... ./internal/executor/...` +Expected: PASS — no new tests in this task (the underlying behavior is already covered by Task 1's tests; this task only threads a field through two already-tested call sites, matching how `Role` itself was threaded through without its own dedicated schema-plumbing test). + +- [ ] **Step 6: Commit** + +```bash +git add internal/agentloop/tools.go internal/executor/agentmcp.go +git commit -m "feat(agentloop,executor): expose depends_on on the spawn_subtask tool" +``` + +--- + +## Task 3: `KindVerdictReported` event kind + +**Files:** +- Modify: `internal/event/event.go` + +**Interfaces:** +- Produces: `event.KindVerdictReported`. Consumed by Task 4 (`storeChannel.ReportVerdict`) and Task 6 (`finalizeArbitration`). + +- [ ] **Step 1: Add the constant** + +In `internal/event/event.go`, find: + +```go + // KindRoleConfigProposed records a single AgentChannel.ProposeRoleConfig + // call (Phase 8): a retro-role agent proposing a new draft role_configs + // version for a role. Attached to the *calling task's* own ID (the retro + // task), payload {role, version} — internal/scheduler.StoryOrchestrator + // reads a retro task's own event stream for these once the task + // completes, to assemble the aggregate KindRetroCaptured payload it + // attaches to the story's ID. Mirrors KindEpicProposed's "one event per + // proposal call" convention. + KindRoleConfigProposed Kind = "role_config_proposed" +) +``` + +Change to: + +```go + // KindRoleConfigProposed records a single AgentChannel.ProposeRoleConfig + // call (Phase 8): a retro-role agent proposing a new draft role_configs + // version for a role. Attached to the *calling task's* own ID (the retro + // task), payload {role, version} — internal/scheduler.StoryOrchestrator + // reads a retro task's own event stream for these once the task + // completes, to assemble the aggregate KindRetroCaptured payload it + // attaches to the story's ID. Mirrors KindEpicProposed's "one event per + // proposal call" convention. + KindRoleConfigProposed Kind = "role_config_proposed" + + // KindVerdictReported records a single AgentChannel.ReportVerdict call: + // an evaluating agent (e.g. an arbitration-role task) reporting a + // structured approve/reject decision instead of leaving it to be + // inferred from free-text summary parsing. Attached to the *calling + // task's own* ID, payload {approved, reasoning} — + // internal/scheduler.StoryOrchestrator's finalizeArbitration reads the + // arbitration task's own event stream for this once the task completes, + // to decide REVIEW_READY vs NEEDS_FIX. Mirrors KindRoleConfigProposed's + // "one event per call, attached to the calling task" convention. + KindVerdictReported Kind = "verdict_reported" +) +``` + +- [ ] **Step 2: Build** + +Run: `go build ./...` +Expected: succeeds (this is a pure addition, nothing consumes it yet). + +- [ ] **Step 3: Commit** + +```bash +git add internal/event/event.go +git commit -m "feat(event): add KindVerdictReported" +``` + +--- + +## Task 4: `AgentChannel.ReportVerdict` + `storeChannel` implementation + +**Files:** +- Modify: `internal/agentchannel/agentchannel.go` (interface) +- Modify: `internal/executor/channel.go` (implementation) +- Test: `internal/executor/channel_test.go` (append) + +**Interfaces:** +- Consumes: `event.KindVerdictReported` (Task 3). +- Produces: `AgentChannel.ReportVerdict(ctx context.Context, approved bool, reasoning string) error`. Consumed by Task 5 (tool exposure). + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/executor/channel_test.go`, mirroring the three `ProposeRoleConfig` tests already in that file (`TestStoreChannel_ProposeRoleConfig_CreatesDraftRow` etc. — read them again if needed, this mirrors their structure, not their content): + +```go +// TestStoreChannel_ReportVerdict_EmitsVerdictReportedEvent proves calling +// ReportVerdict records a KindVerdictReported event attached to the +// *calling task's* own ID, payload {approved, reasoning} — +// internal/scheduler.StoryOrchestrator's finalizeArbitration later reads an +// arbitration task's own event stream for this to decide REVIEW_READY vs +// NEEDS_FIX. Mirrors TestStoreChannel_ProposeRoleConfig_EmitsRoleConfigProposedEvent. +func TestStoreChannel_ReportVerdict_EmitsVerdictReportedEvent(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "arbitration-task-1") + + if err := ch.ReportVerdict(context.Background(), false, "the CSS change breaks the running-log panel"); err != nil { + t.Fatalf("ReportVerdict: %v", err) + } + + if len(store.createdEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(store.createdEvents)) + } + ev := store.createdEvents[0] + if ev.Kind != event.KindVerdictReported { + t.Errorf("expected KindVerdictReported, got %q", ev.Kind) + } + if ev.Actor != event.ActorAgent { + t.Errorf("expected ActorAgent, got %q", ev.Actor) + } + if ev.TaskID != "arbitration-task-1" { + t.Errorf("expected event attached to calling task ID, got %q", ev.TaskID) + } + + var payload struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + } + if err := json.Unmarshal(ev.Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if payload.Approved != false || payload.Reasoning != "the CSS change breaks the running-log panel" { + t.Errorf("unexpected payload: %+v", payload) + } +} + +// TestStoreChannel_ReportVerdict_Approved proves the approved=true case +// round-trips correctly too — not just the rejection path exercised above. +func TestStoreChannel_ReportVerdict_Approved(t *testing.T) { + store := &fakeChannelStore{} + ch := newStoreChannel(store, "arbitration-task-2") + + if err := ch.ReportVerdict(context.Background(), true, "meets all acceptance criteria"); err != nil { + t.Fatalf("ReportVerdict: %v", err) + } + + var payload struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + } + if err := json.Unmarshal(store.createdEvents[0].Payload, &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if !payload.Approved { + t.Error("expected approved=true to round-trip as true") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/executor/ -run TestStoreChannel_ReportVerdict -v` +Expected: FAIL — `ReportVerdict` is not a method on `*storeChannel` yet (compile error). + +- [ ] **Step 3: Add `ReportVerdict` to the `AgentChannel` interface** + +In `internal/agentchannel/agentchannel.go`, find: + +```go + // ProposeRoleConfig lets a retro-role agent (internal/scheduler. + // StoryOrchestrator's Phase 8 retro stage) propose a new draft + // role_configs version for a role, after reflecting on a completed + // story's history (task tree, escalations, cost, evaluator/arbitration + // feedback). Implementations create a new "draft"-status role_configs + // row and return its version number — they never touch whatever version + // is currently "active" for that role; promoting a draft to active stays + // a human action via the existing POST /api/roles/{role}/activate. + ProposeRoleConfig(ctx context.Context, config role.RoleConfig) (version int, err error) +} +``` + +Change to: + +```go + // ProposeRoleConfig lets a retro-role agent (internal/scheduler. + // StoryOrchestrator's Phase 8 retro stage) propose a new draft + // role_configs version for a role, after reflecting on a completed + // story's history (task tree, escalations, cost, evaluator/arbitration + // feedback). Implementations create a new "draft"-status role_configs + // row and return its version number — they never touch whatever version + // is currently "active" for that role; promoting a draft to active stays + // a human action via the existing POST /api/roles/{role}/activate. + ProposeRoleConfig(ctx context.Context, config role.RoleConfig) (version int, err error) + // ReportVerdict lets an evaluating agent (e.g. an arbitration-role task) + // report a structured approve/reject decision, instead of leaving it to + // be inferred by parsing the task's free-text summary later. + // Implementations record this as an event attached to the calling task's + // own ID; internal/scheduler.StoryOrchestrator's finalizeArbitration + // reads it back once the reporting task completes to decide REVIEW_READY + // vs NEEDS_FIX. A task that never calls this leaves no such event — + // callers reading it back must treat "no event found" as a distinct, + // backward-compatible case, not an error. + ReportVerdict(ctx context.Context, approved bool, reasoning string) error +} +``` + +- [ ] **Step 4: Implement `storeChannel.ReportVerdict`** + +In `internal/executor/channel.go`, add directly after the existing `ProposeRoleConfig` method (find it — it ends with the closing brace after `return row.Version, nil` / `}`): + +```go +func (c *storeChannel) ReportVerdict(_ context.Context, approved bool, reasoning string) error { + payload, _ := json.Marshal(struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + }{Approved: approved, Reasoning: reasoning}) + return c.store.CreateEvent(&event.Event{ + TaskID: c.taskID, + Kind: event.KindVerdictReported, + Actor: event.ActorAgent, + Payload: payload, + }) +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/executor/ -run TestStoreChannel_ReportVerdict -v` +Expected: PASS, both tests. + +- [ ] **Step 6: Run the full package suite** + +Run: `go test ./internal/executor/... ./internal/agentchannel/...` +Expected: PASS for everything. + +- [ ] **Step 7: Commit** + +```bash +git add internal/agentchannel/agentchannel.go internal/executor/channel.go internal/executor/channel_test.go +git commit -m "feat(agentchannel,executor): add ReportVerdict for structured approve/reject signals" +``` + +--- + +## Task 5: Expose `report_verdict` as a tool (both transports) + +**Files:** +- Modify: `internal/agentloop/tools.go` (schema + case handler) +- Modify: `internal/executor/agentmcp.go` (input struct + MCP tool) + +**Interfaces:** +- Consumes: `AgentChannel.ReportVerdict` (Task 4). +- Produces: nothing new for later tasks — last mile connecting the primitive to what an agent can call, mirroring `propose_role_config`'s exposure exactly. + +- [ ] **Step 1: Add the tool definition to the native tool-use loop** + +In `internal/agentloop/tools.go`, add directly after the `propose_role_config` tool definition (find it — it's the last entry before the closing of the tool-spec slice, ending with the `escalation_ladder` property's closing braces): + +```go + { + Name: "report_verdict", + Description: "Report a structured approve/reject decision after evaluating another task's work (e.g. as an arbitration-role task). Call this before report_summary when your job is to decide whether the work is acceptable — this is read by the orchestrator to decide the outcome, not just logged for a human to read later.", + ParametersJSONSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "approved": map[string]any{"type": "boolean", "description": "true if the work meets its acceptance criteria and should proceed; false if it needs to be sent back for a fix"}, + "reasoning": strProp("a concise explanation of the decision"), + }, + "required": []string{"approved", "reasoning"}, + }, + }, +``` + +- [ ] **Step 2: Add the case handler** + +In the same file, add directly after the `propose_role_config` case (find `case "propose_role_config":` and its block, ending `return fmt.Sprintf("Proposed role config %s v%d (draft)", a.Role, version), false, nil`): + +```go + case "report_verdict": + var a struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + } + _ = json.Unmarshal([]byte(argsJSON), &a) + if rvErr := l.Channel.ReportVerdict(ctx, a.Approved, a.Reasoning); rvErr != nil { + return "", false, rvErr + } + return "Verdict recorded.", false, nil +``` + +- [ ] **Step 3: Add the MCP input struct** + +In `internal/executor/agentmcp.go`, add directly after `proposeRoleConfigInput`'s `toRoleConfig()` method (find where that method's closing brace is): + +```go +type reportVerdictInput struct { + Approved bool `json:"approved" jsonschema:"true if the work meets its acceptance criteria and should proceed; false if it needs to be sent back for a fix"` + Reasoning string `json:"reasoning" jsonschema:"a concise explanation of the decision"` +} +``` + +- [ ] **Step 4: Add the MCP tool registration** + +In the same file, add directly after the `propose_role_config` `mcp.AddTool` call (find it — ends with `return textResult(fmt.Sprintf("Proposed role config %s v%d (draft)", in.Role, version)), nil, nil\n\t})`): + +```go + mcp.AddTool(s, &mcp.Tool{ + Name: "report_verdict", + Description: "Report a structured approve/reject decision after evaluating another task's work (e.g. as an arbitration-role task). Call this before report_summary when your job is to decide whether the work is acceptable -- this is read by the orchestrator to decide the outcome, not just logged for a human to read later.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in reportVerdictInput) (*mcp.CallToolResult, any, error) { + if err := ch.ReportVerdict(ctx, in.Approved, in.Reasoning); err != nil { + return nil, nil, err + } + return textResult("Verdict recorded."), nil, nil + }) +``` + +- [ ] **Step 5: Build and run the full suite** + +Run: `go build ./... && go test ./internal/agentloop/... ./internal/executor/...` +Expected: PASS — no new tests in this task, same reasoning as Task 2 (underlying behavior already covered by Task 4's tests). + +- [ ] **Step 6: Commit** + +```bash +git add internal/agentloop/tools.go internal/executor/agentmcp.go +git commit -m "feat(agentloop,executor): expose report_verdict as a tool on both transports" +``` + +--- + +## Task 6: `finalizeArbitration` consults the structured verdict + +**Files:** +- Modify: `internal/scheduler/story_orchestrator.go:386-427` (`finalizeArbitration`) +- Test: `internal/scheduler/story_orchestrator_test.go` (append) + +**Interfaces:** +- Consumes: `event.KindVerdictReported` (Task 3), `StoryStore.ListEvents` (already exists in the `StoryStore` interface — see `internal/scheduler/story_orchestrator.go:113`). +- Produces: nothing new for later tasks — this is the final consumer in this plan. + +**Why this task is safe to do without reading all 1023 lines of the test file:** `finalizeArbitration` is a single, self-contained function with one existing test (`TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady`, read in full during this plan's research) that already exercises exactly the scenario being extended — the fake store's `eventsOfKind`/`setTaskSummary`/`seedStoryWithEvaluators` helpers this task's new tests need are the same ones that existing test already uses. This task adds new tests alongside it using the identical helpers; it does not need to touch or understand the other ~950 lines covering `ensureEvaluators`/`ensureArbitration`/retro, which are untouched by this change. + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/scheduler/story_orchestrator_test.go`, directly after `TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady` (the test you read confirms `seedStoryWithEvaluators(t, task.StateCompleted)` returns `(store, st, evaluators)`, and that after `orch.Tick` spawns the arbitration task, `store.dependentsWithRole(evaluators[0].ID, "planner")` finds it, `store.setTaskState(arb.ID, task.StateCompleted)` completes it, and a second `orch.Tick` triggers `finalizeArbitration` — this task's new tests follow the identical shape, only adding a `ReportVerdict`-equivalent event before the second tick): + +```go +// TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix proves that +// when the arbitration task reports a structured approved=false verdict +// (via a KindVerdictReported event, the same one AgentChannel.ReportVerdict +// records), finalizeArbitration routes the story to NEEDS_FIX instead of +// unconditionally REVIEW_READY. +func TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix(t *testing.T) { + store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) + + pool := &fakePool{} + orch := &StoryOrchestrator{Store: store, Pool: pool} + orch.Tick(context.Background()) // spawns arbitration + + arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") + if len(arbitrations) != 1 { + t.Fatalf("expected 1 arbitration task, got %d", len(arbitrations)) + } + arb := arbitrations[0] + + // Simulate the arbitration agent calling report_verdict with a rejection + // before finishing, the same event storeChannel.ReportVerdict records. + payload, _ := json.Marshal(struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + }{Approved: false, Reasoning: "breaks the running-log panel styling"}) + if err := store.CreateEvent(&event.Event{ + TaskID: arb.ID, + Kind: event.KindVerdictReported, + Actor: event.ActorAgent, + Payload: payload, + }); err != nil { + t.Fatalf("seed verdict event: %v", err) + } + + store.setTaskState(arb.ID, task.StateCompleted) + store.setTaskSummary(arb.ID, "found a real problem") + + orch.Tick(context.Background()) // finalizeArbitration runs + + stories, _ := store.ListStories(storage.StoryFilter{}) + var got *story.Story + for _, s := range stories { + if s.ID == st.ID { + got = s + } + } + if got == nil { + t.Fatal("story not found") + } + if got.Status != "NEEDS_FIX" { + t.Errorf("story status: want NEEDS_FIX, got %q", got.Status) + } +} + +// TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady proves the +// mirror case: an explicit approved=true verdict still routes to +// REVIEW_READY, same as today's unconditional behavior -- this isn't just +// "absence of a verdict defaults to proceed", a real approval also proceeds. +func TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady(t *testing.T) { + store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) + + pool := &fakePool{} + orch := &StoryOrchestrator{Store: store, Pool: pool} + orch.Tick(context.Background()) + + arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") + arb := arbitrations[0] + + payload, _ := json.Marshal(struct { + Approved bool `json:"approved"` + Reasoning string `json:"reasoning"` + }{Approved: true, Reasoning: "meets all acceptance criteria"}) + if err := store.CreateEvent(&event.Event{ + TaskID: arb.ID, + Kind: event.KindVerdictReported, + Actor: event.ActorAgent, + Payload: payload, + }); err != nil { + t.Fatalf("seed verdict event: %v", err) + } + + store.setTaskState(arb.ID, task.StateCompleted) + store.setTaskSummary(arb.ID, "ship it") + + orch.Tick(context.Background()) + + stories, _ := store.ListStories(storage.StoryFilter{}) + var got *story.Story + for _, s := range stories { + if s.ID == st.ID { + got = s + } + } + if got == nil { + t.Fatal("story not found") + } + if got.Status != "REVIEW_READY" { + t.Errorf("story status: want REVIEW_READY, got %q", got.Status) + } +} +``` + +Note: `TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady` (the existing test, unmodified by this task) never seeds a `KindVerdictReported` event, so it exercises the "no verdict reported" backward-compatible path — after this task's change, it must still pass unchanged, proving the no-verdict-found case still defaults to `REVIEW_READY` exactly as before. + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `go test ./internal/scheduler/ -run TestStoryOrchestrator_Arbitration -v` +Expected: `TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix` FAILS (story status is `REVIEW_READY`, not `NEEDS_FIX` — `finalizeArbitration` doesn't check for a verdict yet). `TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady` and the pre-existing `TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady` PASS already (current unconditional behavior happens to satisfy both). + +- [ ] **Step 3: Implement the verdict check** + +In `internal/scheduler/story_orchestrator.go`, find `finalizeArbitration`: + +```go +func (o *StoryOrchestrator) finalizeArbitration(st *story.Story, arbitration *task.Task) { + if st.Status != "VALIDATING" { + return + } + + payload, _ := json.Marshal(struct { + TaskID string `json:"task_id"` + Summary string `json:"summary"` + }{TaskID: arbitration.ID, Summary: arbitration.Summary}) + if err := o.Store.CreateEvent(&event.Event{ + TaskID: st.ID, + Kind: event.KindArbitrationDecided, + Actor: event.ActorSystem, + Payload: payload, + }); err != nil { + o.logf("story orchestrator: emit arbitration_decided", "storyID", st.ID, "error", err) + } + + st.Status = "REVIEW_READY" + if err := o.Store.UpdateStory(st); err != nil { + o.logf("story orchestrator: update story to REVIEW_READY", "storyID", st.ID, "error", err) + } +} +``` + +Replace with: + +```go +func (o *StoryOrchestrator) finalizeArbitration(st *story.Story, arbitration *task.Task) { + if st.Status != "VALIDATING" { + return + } + + approved, hasVerdict := o.arbitrationVerdict(st, arbitration) + + payload, _ := json.Marshal(struct { + TaskID string `json:"task_id"` + Summary string `json:"summary"` + Approved *bool `json:"approved,omitempty"` + }{TaskID: arbitration.ID, Summary: arbitration.Summary, Approved: optionalBool(approved, hasVerdict)}) + if err := o.Store.CreateEvent(&event.Event{ + TaskID: st.ID, + Kind: event.KindArbitrationDecided, + Actor: event.ActorSystem, + Payload: payload, + }); err != nil { + o.logf("story orchestrator: emit arbitration_decided", "storyID", st.ID, "error", err) + } + + // Documented simplification (Phase 7b) closed: a structured + // KindVerdictReported event (AgentChannel.ReportVerdict) on the + // arbitration task, if present, now decides REVIEW_READY vs NEEDS_FIX. + // An arbitration task that never calls report_verdict (hasVerdict == + // false) preserves the prior unconditional REVIEW_READY behavior — a + // human or chatbot can still manually set NEEDS_FIX via the existing + // PUT /api/stories/{id} for a story arbitrated by an agent that doesn't + // report a structured verdict. + if hasVerdict && !approved { + st.Status = "NEEDS_FIX" + } else { + st.Status = "REVIEW_READY" + } + if err := o.Store.UpdateStory(st); err != nil { + o.logf("story orchestrator: update story status after arbitration", "storyID", st.ID, "status", st.Status, "error", err) + } +} + +// arbitrationVerdict reads the arbitration task's own event stream for a +// KindVerdictReported event (AgentChannel.ReportVerdict) and returns its +// approved value. hasVerdict is false if no such event was ever recorded — +// callers must treat that as "no structured verdict was reported", not as +// an implicit rejection. +func (o *StoryOrchestrator) arbitrationVerdict(st *story.Story, arbitration *task.Task) (approved bool, hasVerdict bool) { + events, err := o.Store.ListEvents(arbitration.ID, 0) + if err != nil { + o.logf("story orchestrator: list arbitration task events", "storyID", st.ID, "taskID", arbitration.ID, "error", err) + return false, false + } + for _, e := range events { + if e.Kind != event.KindVerdictReported { + continue + } + var payload struct { + Approved bool `json:"approved"` + } + if json.Unmarshal(e.Payload, &payload) == nil { + approved = payload.Approved + hasVerdict = true + } + } + return approved, hasVerdict +} + +// optionalBool returns a pointer to v if present is true, nil otherwise — +// used so KindArbitrationDecided's payload omits "approved" entirely +// (rather than encoding a misleading false) when no structured verdict was +// ever reported. +func optionalBool(v bool, present bool) *bool { + if !present { + return nil + } + return &v +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/scheduler/ -run TestStoryOrchestrator_Arbitration -v` +Expected: PASS, all three (the two new ones plus the pre-existing unmodified one). + +- [ ] **Step 5: Run the full package suite to check for regressions** + +Run: `go test ./internal/scheduler/...` +Expected: PASS for everything — this change only touches `finalizeArbitration` and adds two new helper functions; nothing else in the 1023-line test file exercises that function differently than before. + +- [ ] **Step 6: Commit** + +```bash +git add internal/scheduler/story_orchestrator.go internal/scheduler/story_orchestrator_test.go +git commit -m "feat(scheduler): finalizeArbitration consults a structured verdict instead of always routing to REVIEW_READY" +``` + +--- + +## Final Verification + +- [ ] Run `go build ./...` — passes. +- [ ] Run `go test ./...` — passes, full repo. +- [ ] Run `grep -rn "ReportVerdict\|report_verdict\|KindVerdictReported\|DependsOn.*SubtaskSpec\|depends_on.*spawn_subtask" internal/` to visually confirm every file in the File Map was actually touched — a missed file in a multi-file feature like this is easy to overlook. |
