From c1beacb703d178f21ca891ab5839708c22d86a53 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 19 Mar 2026 14:56:38 +0800 Subject: [PATCH] Add council review start command --- docs/council-review.md | 10 + docs/implementation-roadmap.md | 32 +- docs/roadmaps/archive/orch-council-start.md | 62 +++ internal/cli/orch/council.go | 13 + internal/cli/orch/council_start.go | 88 ++++ internal/cli/orch/integration_test.go | 144 +++++++ internal/cli/orch/root.go | 1 + internal/db/schema/006_council_inputs.sql | 9 + internal/store/council.go | 444 ++++++++++++++++++++ 9 files changed, 797 insertions(+), 6 deletions(-) create mode 100644 docs/roadmaps/archive/orch-council-start.md create mode 100644 internal/cli/orch/council.go create mode 100644 internal/cli/orch/council_start.go create mode 100644 internal/db/schema/006_council_inputs.sql create mode 100644 internal/store/council.go diff --git a/docs/council-review.md b/docs/council-review.md index 758f189..993cc8f 100644 --- a/docs/council-review.md +++ b/docs/council-review.md @@ -462,6 +462,16 @@ CREATE TABLE IF NOT EXISTS council_runs ( updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS council_inputs ( + run_id TEXT PRIMARY KEY, + prompt TEXT NOT NULL DEFAULT '', + target_file TEXT NOT NULL DEFAULT '', + repo_path TEXT NOT NULL DEFAULT '', + target_task_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS council_reviewers ( run_id TEXT NOT NULL, reviewer_role TEXT NOT NULL, diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 679001a..1d23c98 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -28,9 +28,10 @@ As of now: - `orch` can create runs, gate tasks through dependencies, dispatch work through `inbox`, reconcile worker thread state back into task state, answer blocked tasks, retry or reassign work, cancel tasks or runs, clean attempt worktrees, and create per-attempt Git worktrees during strict dispatch - `orch dispatch` now supports `--repo-path`, `--workspace-root`, and `--strict-worktree`, auto-enables strict worktree mode for code-like tasks inferred from task metadata, resolves committed base revisions, records workspace metadata on attempts, and writes that metadata into inbox task payloads - `orch wait` now blocks on run-scoped task events and reconciles inbox state while polling so leader waits can wake on worker progress without manual sleep loops -- automated integration tests now cover the main `orch` scheduler slice, including dependency gating, dispatch, blocked-answer flow, retry, reassign, cancel, cleanup, strict worktree creation, automatic code-task worktree enablement, dirty-repo rejection rules, and wait wake/timeout behavior +- `orch council start` now creates a dedicated council run, persists council target input metadata, and dispatches the three fixed reviewer roles through the existing scheduler +- automated integration tests now cover the main `orch` scheduler slice, including dependency gating, dispatch, blocked-answer flow, retry, reassign, cancel, cleanup, strict worktree creation, automatic code-task worktree enablement, dirty-repo rejection rules, wait wake/timeout behavior, and council start dispatch -This means the project now has a working `orch` core scheduler with automatic worktree selection for code-like tasks, strict worktree-backed dispatch, and the main leader-side control loop, and is ready for council workflows. +This means the project now has a working `orch` core scheduler with automatic worktree selection for code-like tasks, strict worktree-backed dispatch, the main leader-side control loop, and the first council workflow slice. ## Source Of Truth @@ -73,8 +74,9 @@ Current implementation status: - `Milestone 4: Orch Core Scheduling` is complete for the current non-worktree scheduler scope - `Milestone 5: Strict Worktree Support` is complete - `Milestone 6: Waiting Primitives` is complete +- `Milestone 7: Council Review` is partially complete through `orch council start` -The next practical coding target is `Milestone 7: Council Review`. +The next practical coding target is the next `Milestone 7` slice: `orch council wait`. ### Milestone 1: Go Skeleton @@ -331,15 +333,32 @@ Definition of done: - tally grouped recommendations into `consensus`, `majority`, and `minority` - produce stable JSON and a markdown report artifact +Status: + +- partially complete through `orch council start` + +Completed so far: + +- council-specific storage now includes run metadata, reviewer assignment rows, reviewer findings/groups tables, and persisted council input references +- `orch council start` +- council start creates a dedicated run, stores council target input metadata, creates reviewer tasks `CR1` through `CR3`, and dispatches the fixed reviewer roles `architecture-reviewer`, `implementation-reviewer`, and `risk-reviewer` +- CLI integration tests cover council start dispatch and metadata persistence + +Remaining: + +- `orch council wait` +- `orch council tally` +- `orch council report` + ## Immediate Next Task If a new agent is taking over now, the next concrete step should be: -1. start `Milestone 7: Council Review` -2. define the persisted storage shape for grouped reviewer recommendations and council wake/tally state +1. continue `Milestone 7: Council Review` with `orch council wait` +2. define the council completion check that determines when all reviewer outputs are ready for tally 3. keep the authored inbox test-plan set in `docs/tests/inbox/` synchronized if CLI behavior changes during further `orch` work -The inbox implementation and its human-readable test-plan set are already in place, and `orch` now supports the main scheduler loop plus automatic worktree selection for code-like tasks, so the next meaningful project step is the council workflow layer. +The inbox implementation and its human-readable test-plan set are already in place, and `orch` now supports the main scheduler loop plus the first council workflow slice, so the next meaningful project step is finishing council wait, tally, and report. ## Recommended Driver Choices @@ -365,6 +384,7 @@ Completed so far: - orch strict worktree creation and dirty-repo policy coverage - orch wait wake and timeout coverage - orch retry, reassign, cancel, and cleanup coverage +- orch council start dispatch and persistence coverage Still recommended before the codebase grows too much: diff --git a/docs/roadmaps/archive/orch-council-start.md b/docs/roadmaps/archive/orch-council-start.md new file mode 100644 index 0000000..36e233f --- /dev/null +++ b/docs/roadmaps/archive/orch-council-start.md @@ -0,0 +1,62 @@ +# Orch Council Start + +## Status + +- `completed` + +## Owner + +- codex + +## Started At + +- `2026-03-19` + +## Goal + +- implement the first Milestone 7 slice by adding council input storage and `orch council start` + +## Scope + +- persist council run metadata plus target input references +- add `orch council start` to create a council run and dispatch three reviewer tasks +- add integration tests for council start dispatch behavior +- update the implementation roadmap and archive this workstream when complete + +## Checklist + +- [x] inspect council docs, schema, and existing orch dispatch primitives +- [x] implement council storage and `orch council start` +- [x] add integration coverage for council start +- [x] run `go test ./...` +- [x] update `docs/implementation-roadmap.md` +- [x] archive this roadmap with a completion summary + +## Files + +- `docs/roadmaps/archive/orch-council-start.md` +- `docs/implementation-roadmap.md` +- `internal/db/schema/*.sql` +- `internal/store/council.go` +- `internal/cli/orch/root.go` +- `internal/cli/orch/council*.go` +- `internal/cli/orch/integration_test.go` + +## Decisions + +- treat `orch council start` as creating a dedicated run id rather than attaching to a pre-existing ordinary run +- persist target prompt/file/repo/task references in a separate council input table instead of overloading the existing `runs` table + +## Blockers + +- none + +## Next Step + +- continue Milestone 7 with `orch council wait`, then add tally and report on top of the persisted reviewer data + +## Completion Summary + +- council input references are now persisted separately from council run metadata +- `orch council start` creates a dedicated council run, dispatches the three fixed reviewer roles, and records reviewer assignment rows for downstream wait/tally/report steps +- integration tests now cover council start dispatch behavior and stored council metadata diff --git a/internal/cli/orch/council.go b/internal/cli/orch/council.go new file mode 100644 index 0000000..0bcd0f0 --- /dev/null +++ b/internal/cli/orch/council.go @@ -0,0 +1,13 @@ +package orch + +import "github.com/spf13/cobra" + +func newCouncilCmd(root *rootOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "council", + Short: "Council review workflow commands", + } + + cmd.AddCommand(newCouncilStartCmd(root)) + return cmd +} diff --git a/internal/cli/orch/council_start.go b/internal/cli/orch/council_start.go new file mode 100644 index 0000000..7dc59a4 --- /dev/null +++ b/internal/cli/orch/council_start.go @@ -0,0 +1,88 @@ +package orch + +import ( + "fmt" + + "ai-workflow-skill/internal/protocol" + "ai-workflow-skill/internal/store" + + "github.com/spf13/cobra" +) + +type councilStartOptions struct { + runID string + target string + targetFile string + repoPath string + targetTaskID string + targetType string + mode string + outputMode string + onlyUnanimous bool +} + +func newCouncilStartCmd(root *rootOptions) *cobra.Command { + opts := &councilStartOptions{} + + cmd := &cobra.Command{ + Use: "start", + Short: "Create and dispatch a three-reviewer council run", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + sqlDB, err := openOrchDB(ctx, root.dbPath) + if err != nil { + return err + } + defer sqlDB.Close() + + result, err := store.NewOrchStore(sqlDB).StartCouncil(ctx, store.CouncilStartInput{ + RunID: opts.runID, + Target: opts.target, + TargetFile: opts.targetFile, + RepoPath: opts.repoPath, + TargetTaskID: opts.targetTaskID, + TargetType: opts.targetType, + Mode: opts.mode, + OutputMode: opts.outputMode, + OnlyUnanimous: opts.onlyUnanimous, + }) + if err != nil { + return err + } + + resp := protocol.Success{ + OK: true, + Command: "council start", + Data: map[string]any{ + "run_id": result.Run.RunID, + "mode": result.Run.Mode, + "target_type": result.Run.TargetType, + "output": result.Run.OutputMode, + "only_unanimous": result.Run.OnlyUnanimous, + "target": result.Input, + "reviewers": result.Reviewers, + }, + } + if root.json { + return protocol.WriteJSON(cmd.OutOrStdout(), resp) + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "started council run %s with %d reviewers\n", result.Run.RunID, len(result.Reviewers)) + return err + }, + } + + cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID") + cmd.Flags().StringVar(&opts.target, "target", "", "Inline target prompt") + cmd.Flags().StringVar(&opts.targetFile, "target-file", "", "Optional target context file") + cmd.Flags().StringVar(&opts.repoPath, "repo-path", "", "Optional repository path for review context") + cmd.Flags().StringVar(&opts.targetTaskID, "task-id", "", "Optional related task ID") + cmd.Flags().StringVar(&opts.targetType, "target-type", "mixed", "Target type: text, repo, or mixed") + cmd.Flags().StringVar(&opts.mode, "mode", "brainstorm", "Council mode: brainstorm or review") + cmd.Flags().StringVar(&opts.outputMode, "output", "both", "Output mode: markdown, json, or both") + cmd.Flags().BoolVar(&opts.onlyUnanimous, "only-unanimous", false, "Show only unanimous recommendations in downstream report defaults") + _ = cmd.MarkFlagRequired("run") + + return cmd +} diff --git a/internal/cli/orch/integration_test.go b/internal/cli/orch/integration_test.go index ec7f1b9..836d849 100644 --- a/internal/cli/orch/integration_test.go +++ b/internal/cli/orch/integration_test.go @@ -1,6 +1,7 @@ package orch import ( + "database/sql" "os" "path/filepath" "testing" @@ -1396,3 +1397,146 @@ func TestOrchCleanupRemovesCompletedWorktree(t *testing.T) { t.Fatalf("expected cleaned worktree path to be removed, err=%v", err) } } + +func TestOrchCouncilStartDispatchesThreeReviewers(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "coord.db") + + startOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "council", "start", + "--run", "council_blog_001", + "--target", "Review the current blog architecture and propose optimizations.", + "--target-type", "mixed", + "--output", "both", + ) + + var startResp map[string]any + mustDecodeJSON(t, startOut, &startResp) + if got := nestedString(t, startResp, "data", "run_id"); got != "council_blog_001" { + t.Fatalf("expected council run id, got %q", got) + } + if got := nestedString(t, startResp, "data", "mode"); got != "brainstorm" { + t.Fatalf("expected default council mode brainstorm, got %q", got) + } + + reviewers := nestedArray(t, startResp, "data", "reviewers") + if len(reviewers) != 3 { + t.Fatalf("expected three council reviewers, got %#v", reviewers) + } + + sqlDB, err := openOrchDB(t.Context(), dbPath) + if err != nil { + t.Fatalf("open orch db: %v", err) + } + defer sqlDB.Close() + + var ( + mode string + targetType string + outputMode string + onlyUnanimous int + ) + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT mode, target_type, output_mode, only_unanimous + FROM council_runs + WHERE run_id = ?`, + "council_blog_001", + ).Scan(&mode, &targetType, &outputMode, &onlyUnanimous); err != nil { + t.Fatalf("query council_runs: %v", err) + } + if mode != "brainstorm" || targetType != "mixed" || outputMode != "both" || onlyUnanimous != 0 { + t.Fatalf("unexpected council run metadata: mode=%q targetType=%q outputMode=%q onlyUnanimous=%d", mode, targetType, outputMode, onlyUnanimous) + } + + var ( + prompt string + targetFile string + repoPath string + targetTaskID string + ) + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT prompt, target_file, repo_path, target_task_id + FROM council_inputs + WHERE run_id = ?`, + "council_blog_001", + ).Scan(&prompt, &targetFile, &repoPath, &targetTaskID); err != nil { + t.Fatalf("query council_inputs: %v", err) + } + if prompt == "" || targetFile != "" || repoPath != "" || targetTaskID != "" { + t.Fatalf("unexpected council input row: prompt=%q targetFile=%q repoPath=%q targetTaskID=%q", prompt, targetFile, repoPath, targetTaskID) + } + + rows, err := sqlDB.QueryContext( + t.Context(), + `SELECT reviewer_role, task_id, status + FROM council_reviewers + WHERE run_id = ? + ORDER BY reviewer_role ASC`, + "council_blog_001", + ) + if err != nil { + t.Fatalf("query council_reviewers: %v", err) + } + defer rows.Close() + + var reviewerRows int + for rows.Next() { + var ( + reviewerRole string + taskID string + status string + ) + if err := rows.Scan(&reviewerRole, &taskID, &status); err != nil { + t.Fatalf("scan council reviewer row: %v", err) + } + reviewerRows++ + if status != "dispatched" { + t.Fatalf("expected council reviewer status dispatched, got %q for %s", status, reviewerRole) + } + + var worktreePath sql.NullString + if err := sqlDB.QueryRowContext( + t.Context(), + `SELECT a.worktree_path + FROM task_attempts a + WHERE a.run_id = ? AND a.task_id = ? AND a.attempt_no = 1`, + "council_blog_001", + taskID, + ).Scan(&worktreePath); err != nil { + t.Fatalf("query council attempt worktree path for %s: %v", taskID, err) + } + if worktreePath.Valid && worktreePath.String != "" { + t.Fatalf("expected council reviewer task %s to avoid worktree allocation, got %q", taskID, worktreePath.String) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate council reviewers: %v", err) + } + if reviewerRows != 3 { + t.Fatalf("expected three stored council reviewers, got %d", reviewerRows) + } + + statusOut := runOrchCommand( + t, + "--db", dbPath, + "--json", + "status", + "--run", "council_blog_001", + ) + + var statusResp map[string]any + mustDecodeJSON(t, statusOut, &statusResp) + if got := nestedString(t, statusResp, "data", "run", "status"); got != "running" { + t.Fatalf("expected council run status running, got %q", got) + } + tasks := nestedArray(t, statusResp, "data", "tasks") + if len(tasks) != 3 { + t.Fatalf("expected three council tasks, got %#v", tasks) + } +} diff --git a/internal/cli/orch/root.go b/internal/cli/orch/root.go index 1243e04..912c5a3 100644 --- a/internal/cli/orch/root.go +++ b/internal/cli/orch/root.go @@ -33,6 +33,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newReassignCmd(opts)) cmd.AddCommand(newCancelCmd(opts)) cmd.AddCommand(newCleanupCmd(opts)) + cmd.AddCommand(newCouncilCmd(opts)) cmd.AddCommand(newBlockedCmd(opts)) cmd.AddCommand(newAnswerCmd(opts)) cmd.AddCommand(newStatusCmd(opts)) diff --git a/internal/db/schema/006_council_inputs.sql b/internal/db/schema/006_council_inputs.sql new file mode 100644 index 0000000..b1709b1 --- /dev/null +++ b/internal/db/schema/006_council_inputs.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS council_inputs ( + run_id TEXT PRIMARY KEY, + prompt TEXT NOT NULL DEFAULT '', + target_file TEXT NOT NULL DEFAULT '', + repo_path TEXT NOT NULL DEFAULT '', + target_task_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/internal/store/council.go b/internal/store/council.go new file mode 100644 index 0000000..08d5b2a --- /dev/null +++ b/internal/store/council.go @@ -0,0 +1,444 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var councilReviewerRoles = []string{ + "architecture-reviewer", + "implementation-reviewer", + "risk-reviewer", +} + +type CouncilRun struct { + RunID string `json:"run_id"` + Mode string `json:"mode"` + TargetType string `json:"target_type"` + OutputMode string `json:"output_mode"` + OnlyUnanimous bool `json:"only_unanimous"` +} + +type CouncilInput struct { + RunID string `json:"run_id"` + Prompt string `json:"prompt,omitempty"` + TargetFile string `json:"target_file,omitempty"` + RepoPath string `json:"repo_path,omitempty"` + TargetTaskID string `json:"task_id,omitempty"` +} + +type CouncilReviewer struct { + ReviewerRole string `json:"reviewer_role"` + TaskID string `json:"task_id"` + Status string `json:"status"` +} + +type CouncilStartInput struct { + RunID string + Target string + TargetFile string + RepoPath string + TargetTaskID string + TargetType string + Mode string + OutputMode string + OnlyUnanimous bool +} + +type CouncilStartResult struct { + Run CouncilRun `json:"run"` + Input CouncilInput `json:"input"` + Reviewers []CouncilReviewer `json:"reviewers"` +} + +func (s *OrchStore) StartCouncil(ctx context.Context, input CouncilStartInput) (CouncilStartResult, error) { + runID := strings.TrimSpace(input.RunID) + if runID == "" { + return CouncilStartResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput) + } + + councilInput, err := normalizeCouncilInput(input) + if err != nil { + return CouncilStartResult{}, err + } + + councilRun, err := normalizeCouncilRun(input) + if err != nil { + return CouncilStartResult{}, err + } + + now := nowUTC() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return CouncilStartResult{}, fmt.Errorf("begin council start transaction: %w", err) + } + defer tx.Rollback() + + if _, err := selectRun(ctx, tx, runID); err == nil { + return CouncilStartResult{}, fmt.Errorf("%w: run %s already exists", ErrInvalidState, runID) + } else if !errors.Is(err, ErrRunNotFound) { + return CouncilStartResult{}, err + } + + goal := buildCouncilRunGoal(councilInput) + summary := buildCouncilRunSummary(councilRun, councilInput) + + _, err = tx.ExecContext( + ctx, + `INSERT INTO runs (run_id, goal, summary, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + runID, + goal, + summary, + "active", + formatTime(now), + formatTime(now), + ) + if err != nil { + return CouncilStartResult{}, fmt.Errorf("insert council run into runs: %w", err) + } + + if err := insertEvent(ctx, tx, eventInput{ + RunID: runID, + Source: "orch", + EventType: "run_initialized", + Summary: summary, + PayloadJSON: marshalJSON(map[string]any{"goal": goal, "summary": summary}), + CreatedAt: now, + }); err != nil { + return CouncilStartResult{}, err + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO council_runs ( + run_id, mode, target_type, output_mode, only_unanimous, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + runID, + councilRun.Mode, + councilRun.TargetType, + councilRun.OutputMode, + boolToInt(councilRun.OnlyUnanimous), + formatTime(now), + formatTime(now), + ) + if err != nil { + return CouncilStartResult{}, fmt.Errorf("insert council run metadata: %w", err) + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO council_inputs ( + run_id, prompt, target_file, repo_path, target_task_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + runID, + councilInput.Prompt, + councilInput.TargetFile, + councilInput.RepoPath, + councilInput.TargetTaskID, + formatTime(now), + formatTime(now), + ) + if err != nil { + return CouncilStartResult{}, fmt.Errorf("insert council input metadata: %w", err) + } + + reviewers := make([]CouncilReviewer, 0, len(councilReviewerRoles)) + for i, reviewerRole := range councilReviewerRoles { + taskID := fmt.Sprintf("CR%d", i+1) + task := Task{ + RunID: runID, + TaskID: taskID, + Title: buildCouncilTaskTitle(reviewerRole), + Summary: buildCouncilTaskSummary(reviewerRole), + Status: "ready", + DefaultTo: reviewerRole, + Priority: "normal", + AcceptanceJSON: []byte(buildCouncilTaskAcceptanceJSON(councilRun, councilInput, reviewerRole)), + CreatedAt: now, + UpdatedAt: now, + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO tasks ( + run_id, task_id, title, summary, status, default_to, priority, + acceptance_json, latest_attempt_no, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`, + task.RunID, + task.TaskID, + task.Title, + task.Summary, + task.Status, + nullIfEmpty(task.DefaultTo), + task.Priority, + string(task.AcceptanceJSON), + formatTime(task.CreatedAt), + formatTime(task.UpdatedAt), + ) + if err != nil { + return CouncilStartResult{}, fmt.Errorf("insert council reviewer task: %w", err) + } + + if err := insertEvent(ctx, tx, eventInput{ + RunID: runID, + TaskID: taskID, + Source: "orch", + EventType: "task_added", + Summary: task.Title, + PayloadJSON: marshalJSON(map[string]any{"title": task.Title, "priority": task.Priority}), + CreatedAt: now, + }); err != nil { + return CouncilStartResult{}, err + } + if err := insertEvent(ctx, tx, eventInput{ + RunID: runID, + TaskID: taskID, + Source: "orch", + EventType: "task_ready", + Summary: task.Title, + PayloadJSON: marshalJSON(map[string]any{"task_id": taskID}), + CreatedAt: now, + }); err != nil { + return CouncilStartResult{}, err + } + + dispatchResult, finalizeWorkspace, err := s.dispatchTaskTx( + ctx, + tx, + task, + reviewerRole, + buildCouncilTaskBody(councilRun, councilInput, reviewerRole), + "", + nil, + now, + ) + if err != nil { + return CouncilStartResult{}, err + } + defer finalizeWorkspace(false) + + _, err = tx.ExecContext( + ctx, + `INSERT INTO council_reviewers (run_id, reviewer_role, task_id, status) + VALUES (?, ?, ?, ?)`, + runID, + reviewerRole, + taskID, + dispatchResult.Task.Status, + ) + if err != nil { + return CouncilStartResult{}, fmt.Errorf("insert council reviewer row: %w", err) + } + + reviewers = append(reviewers, CouncilReviewer{ + ReviewerRole: reviewerRole, + TaskID: taskID, + Status: dispatchResult.Task.Status, + }) + } + + if err := insertEvent(ctx, tx, eventInput{ + RunID: runID, + Source: "orch", + EventType: "council_started", + Summary: "council reviewers dispatched", + PayloadJSON: marshalJSON(map[string]any{ + "mode": councilRun.Mode, + "target_type": councilRun.TargetType, + "output_mode": councilRun.OutputMode, + "only_unanimous": councilRun.OnlyUnanimous, + "reviewers": reviewers, + }), + CreatedAt: now, + }); err != nil { + return CouncilStartResult{}, err + } + + if err := updateRunAggregateStatus(ctx, tx, runID, now); err != nil { + return CouncilStartResult{}, err + } + + if err := tx.Commit(); err != nil { + return CouncilStartResult{}, fmt.Errorf("commit council start transaction: %w", err) + } + + return CouncilStartResult{ + Run: councilRun, + Input: councilInput, + Reviewers: reviewers, + }, nil +} + +func normalizeCouncilRun(input CouncilStartInput) (CouncilRun, error) { + mode := defaultString(strings.TrimSpace(input.Mode), "brainstorm") + switch mode { + case "brainstorm", "review": + default: + return CouncilRun{}, fmt.Errorf("%w: mode must be brainstorm or review", ErrInvalidInput) + } + + targetType := defaultString(strings.TrimSpace(input.TargetType), "mixed") + switch targetType { + case "text", "repo", "mixed": + default: + return CouncilRun{}, fmt.Errorf("%w: target-type must be text, repo, or mixed", ErrInvalidInput) + } + + outputMode := defaultString(strings.TrimSpace(input.OutputMode), "both") + switch outputMode { + case "markdown", "json", "both": + default: + return CouncilRun{}, fmt.Errorf("%w: output must be markdown, json, or both", ErrInvalidInput) + } + + return CouncilRun{ + RunID: strings.TrimSpace(input.RunID), + Mode: mode, + TargetType: targetType, + OutputMode: outputMode, + OnlyUnanimous: input.OnlyUnanimous, + }, nil +} + +func normalizeCouncilInput(input CouncilStartInput) (CouncilInput, error) { + result := CouncilInput{ + RunID: strings.TrimSpace(input.RunID), + Prompt: strings.TrimSpace(input.Target), + TargetFile: strings.TrimSpace(input.TargetFile), + RepoPath: strings.TrimSpace(input.RepoPath), + TargetTaskID: strings.TrimSpace(input.TargetTaskID), + } + + if result.Prompt == "" && result.TargetFile == "" && result.RepoPath == "" && result.TargetTaskID == "" { + return CouncilInput{}, fmt.Errorf("%w: at least one of target, target-file, repo-path, or task-id is required", ErrInvalidInput) + } + + return result, nil +} + +func buildCouncilRunGoal(input CouncilInput) string { + switch { + case input.Prompt != "": + return "Council review: " + truncateSingleLine(input.Prompt, 80) + case input.TargetTaskID != "": + return "Council review for task " + input.TargetTaskID + case input.TargetFile != "": + return "Council review for " + input.TargetFile + case input.RepoPath != "": + return "Council review for repo " + input.RepoPath + default: + return "Council review" + } +} + +func buildCouncilRunSummary(run CouncilRun, input CouncilInput) string { + return fmt.Sprintf("%s council (%s)", run.Mode, run.TargetType) +} + +func buildCouncilTaskTitle(reviewerRole string) string { + switch reviewerRole { + case "architecture-reviewer": + return "Council architecture review" + case "implementation-reviewer": + return "Council implementation review" + case "risk-reviewer": + return "Council risk review" + default: + return "Council review" + } +} + +func buildCouncilTaskSummary(reviewerRole string) string { + switch reviewerRole { + case "architecture-reviewer": + return "Review the target for architecture, boundaries, and interfaces" + case "implementation-reviewer": + return "Review the target for simplicity, maintainability, and practicality" + case "risk-reviewer": + return "Review the target for regressions, correctness, and operability risks" + default: + return "Review the target" + } +} + +func buildCouncilTaskAcceptanceJSON(run CouncilRun, input CouncilInput, reviewerRole string) string { + return marshalJSON(map[string]any{ + "mode": "analysis", + "council": map[string]any{ + "reviewer_role": reviewerRole, + "council_mode": run.Mode, + "target_type": run.TargetType, + "output_mode": run.OutputMode, + "only_unanimous": run.OnlyUnanimous, + "target": map[string]any{ + "prompt": input.Prompt, + "target_file": input.TargetFile, + "repo_path": input.RepoPath, + "task_id": input.TargetTaskID, + }, + "response_format": map[string]any{ + "reviewer_role": reviewerRole, + "findings": []map[string]any{ + { + "title": "string", + "summary": "string", + "proposal": "string", + "rationale": "string", + "confidence": "low|medium|high", + "tags": []string{}, + "target_refs": map[string]any{}, + }, + }, + }, + }, + }) +} + +func buildCouncilTaskBody(run CouncilRun, input CouncilInput, reviewerRole string) string { + parts := []string{ + fmt.Sprintf("Reviewer role: %s", reviewerRole), + fmt.Sprintf("Council mode: %s", run.Mode), + fmt.Sprintf("Target type: %s", run.TargetType), + "Analyze the target from your assigned reviewer perspective.", + "Return structured findings with title, summary, proposal, rationale, confidence, tags, and optional target references.", + } + + if input.Prompt != "" { + parts = append(parts, "", "Prompt:", input.Prompt) + } + if input.TargetFile != "" { + parts = append(parts, "", "Target file:", input.TargetFile) + } + if input.RepoPath != "" { + parts = append(parts, "", "Repo path:", input.RepoPath) + } + if input.TargetTaskID != "" { + parts = append(parts, "", "Related task id:", input.TargetTaskID) + } + + return strings.Join(parts, "\n") +} + +func truncateSingleLine(value string, maxLen int) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + value = strings.Join(strings.Fields(value), " ") + if maxLen <= 0 || len(value) <= maxLen { + return value + } + if maxLen <= 3 { + return value[:maxLen] + } + return value[:maxLen-3] + "..." +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +}