Add council review start command

This commit is contained in:
2026-03-19 14:56:38 +08:00
parent 10ffb13f75
commit c1beacb703
9 changed files with 797 additions and 6 deletions
+13
View File
@@ -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
}
+88
View File
@@ -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
}
+144
View File
@@ -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)
}
}
+1
View File
@@ -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))
@@ -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
);
+444
View File
@@ -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
}