From c59c3416715562da6dd57269d75be4a86a3771b7 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 16 Apr 2026 10:01:42 +0200 Subject: [PATCH] feat: productize configurable flows --- README.md | 172 ++-- cmd/coop/flows.go | 444 ++++++++++ cmd/coop/flows_test.go | 205 +++++ cmd/coop/main.go | 110 +-- cmd/coop/root.go | 89 ++ docs/STATUS.md | 30 +- docs/USAGE.md | 183 ++++- examples/configurable-flows/README.md | 26 + .../incident_triage.seeded-approval.task.yaml | 14 + .../incident_triage.task.yaml | 9 + .../local-overrides/README.md | 24 + .../cooperations/flows/software_patch.yaml | 38 + .../cooperations/profiles/workspace_lite.yaml | 14 + .../cooperations/project-policy.yaml | 44 + .../local-overrides/task.yaml | 5 + .../recon_assessment.task.yaml | 9 + .../software_patch.seeded-approval.task.yaml | 9 + .../software_patch.task.yaml | 5 + internal/orchestrator/flow_loader.go | 57 +- internal/orchestrator/flow_productize.go | 765 ++++++++++++++++++ internal/orchestrator/flow_schema.go | 12 + internal/types/types.go | 2 +- 22 files changed, 2054 insertions(+), 212 deletions(-) create mode 100644 cmd/coop/flows.go create mode 100644 cmd/coop/flows_test.go create mode 100644 cmd/coop/root.go create mode 100644 examples/configurable-flows/README.md create mode 100644 examples/configurable-flows/incident_triage.seeded-approval.task.yaml create mode 100644 examples/configurable-flows/incident_triage.task.yaml create mode 100644 examples/configurable-flows/local-overrides/README.md create mode 100644 examples/configurable-flows/local-overrides/cooperations/flows/software_patch.yaml create mode 100644 examples/configurable-flows/local-overrides/cooperations/profiles/workspace_lite.yaml create mode 100644 examples/configurable-flows/local-overrides/cooperations/project-policy.yaml create mode 100644 examples/configurable-flows/local-overrides/task.yaml create mode 100644 examples/configurable-flows/recon_assessment.task.yaml create mode 100644 examples/configurable-flows/software_patch.seeded-approval.task.yaml create mode 100644 examples/configurable-flows/software_patch.task.yaml create mode 100644 internal/orchestrator/flow_productize.go diff --git a/README.md b/README.md index 18aad4b..f70d194 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # Cooperations -Local-first research toolkit for orchestrating specialized AI agents in software workflows. +Local-first configurable flow runner for inspectable AI, shell, and human-in-the-loop workflows. -Cooperations explores a role-based workflow built around explicit handoffs between `architect`, `implementer`, `reviewer`, and `navigator` agents. The implemented runtime is intentionally narrow and truthful: it is **Codex CLI-first**, **Linux/bash-oriented**, and stores task history, handoffs, generated artifacts, sandbox mode, and activated task capabilities on disk. +Cooperations is intentionally narrow and truthful. It is **Codex CLI-first**, **Linux/bash-oriented**, and built around explicit workflow state instead of opaque autonomous runs. Every task resolves to a declarative `flow`, an `execution profile`, and a `project policy`, then records handoffs, artifacts, sandbox posture, and task-scoped capabilities on disk. -## Why Inspectable Workflow +## Why Inspectable Flows -Cooperations is not trying to hide orchestration behind a single opaque agent run. The core idea is that software work is easier to trust when the execution contract stays visible: +The core idea is that software and operator work becomes easier to trust when the execution contract stays visible: -- each task declares its sandbox up front -- capabilities are explicit and task-scoped -- handoffs and artifacts are stored locally -- the TUI shows live workflow state and ends in a completion/error view -- when an interactive TTY is unavailable, `coop tui` falls back to plain workflow output instead of crashing +- the selected flow is explicit before execution starts +- sandbox, capabilities, and runtime posture are part of the task input +- project-local policy decides which roles can use which providers and models +- approvals, reports, and artifacts are stored locally +- `run --dry-run` and `flows validate` show what will happen before model tooling runs +- `coop tui` falls back to plain workflow output instead of crashing when no real TTY is available This keeps the runtime honest about what happened, what was allowed, and where the result ended up. @@ -20,9 +21,9 @@ This keeps the runtime honest about what happened, what was allowed, and where t See [docs/STATUS.md](docs/STATUS.md) for the source-of-truth status matrix. In short: -- Implemented core: Codex CLI orchestration, heuristic routing, per-task sandbox contract, optional task-scoped capabilities, local task/handoff storage +- Implemented core: configurable flow engine, built-in flows/profiles/policies, `flows list|show|validate|generate-task`, Codex CLI execution, local task/handoff/artifact storage - Secondary interfaces: Bubble Tea TUI and an opt-in Gio GUI build -- Experimental/reference: RVR, meta-cognitive reasoning notes, benchmark and planning documents +- Experimental/reference: RVR, long-form planning documents, benchmark/research notes ## Quick Start @@ -31,7 +32,7 @@ Requirements: - Linux with bash - Go 1.24+ - Installed `codex` CLI -- Optional local overrides from `.env.example` +- Optional `.env` overrides copied from `.env.example` The repository does not use the OpenAI API directly. Authenticate `codex` through its own CLI flow before running real tasks. @@ -42,117 +43,138 @@ cp .env.example .env go build -o coop ./cmd/coop ``` -Inspect routing without running model tooling: +Inspect built-ins and resolve a flow without executing anything: ```bash -./coop run --dry-run "implement a health check endpoint" -./coop history +./coop flows list +./coop flows show software_patch +./coop flows validate --task-file examples/configurable-flows/software_patch.task.yaml +./coop run --dry-run --task-file examples/configurable-flows/software_patch.task.yaml ``` -Run a real task with an explicit sandbox: +Generate or run a real task: ```bash +./coop flows generate-task --workflow software_patch > /tmp/software_patch.task.yaml +./coop run --task-file examples/configurable-flows/software_patch.task.yaml ./coop run --sandbox workspace-write "implement a health check endpoint" -./coop run --task-file examples/feature-request.json -./coop run --task-file examples/feature-request.yaml --capability skill:playwright ``` Optional interfaces: ```bash -./coop tui --sandbox workspace-write "implement a health check endpoint" +./coop tui --task-file examples/configurable-flows/software_patch.task.yaml go build -tags gio -o coop ./cmd/coop ./coop gui --demo "review the workflow" ``` -`coop tui` is the first-class interactive path when a real TTY is available. In non-interactive environments it automatically falls back to plain workflow output and still runs the task instead of failing with a terminal panic. +## Built-In Presets -## What Works Now +Cooperations currently ships three built-in flows: -- Keyword/heuristic routing into specialized roles -- Task-scoped sandbox enforcement for real runs -- Optional task-scoped capability references: - - `plugin:` - - `plugin_skill::` - - `skill:` -- Local task persistence under `.cooperations/` -- Generated artifacts and task summaries under `generated/` -- Shared workflow event model used by the orchestrator, TUI, and GUI -- TUI completion/error views with summary-first result rendering -- Plain CLI fallback for `coop tui` when no controlling TTY is available +- `software_patch`: plan, implement, review, approval, and final report +- `recon_assessment`: validate targets, run an `mcp`-typed recon step, wait/review, and report +- `incident_triage`: classify, gather context in parallel, approval gate, dispatch artifact, and report -## Runtime Contract +Built-in execution profiles: -- Real workflow execution requires an explicit sandbox on every task. -- Supported sandbox modes are `read-only`, `workspace-write`, and `danger-full-access`. -- Capability references are optional; when omitted, the task runs as a regular Codex model task. -- If a capability is explicitly requested but not available locally, the task fails before execution. -- `run --dry-run`, `status`, and `history` do not require Codex CLI initialization. -- `tui` uses the same task contract as `run`; when interactive terminal startup fails, it falls back to plain workflow output with progress, summary, and artifact paths. +- `default_workspace_write` +- `recon_conservative` +- `incident_conservative` -## Example Transcript +Built-in project policy: -The intended proof shape is a real `read-only` repository task, not a polished screenshot. This is a real non-interactive `tui` fallback run from this repository: +- `default_local` -```text -$ ./coop tui --sandbox read-only "Read this repository and summarize what it does in one short paragraph. Do not modify any files." -[fallback] interactive TUI unavailable: interactive TUI requires a controlling TTY: stdin and stdout must be interactive terminals -[fallback] continuing with plain workflow output -[workflow] plain fallback mode -Task: Read this repository and summarize what it does in one short paragraph. Do not modify any files. -Sandbox: read-only -Capabilities: none +`recon_assessment` is intentionally truthful today: its config can be inspected and validated, but default CLI runs still fail closed unless an MCP executor is wired into the runtime. --> [ 0%] Starting: Running task: Read this repository and summarize what it does in one short paragraph. Do not modify any files. --> Task completed successfully! +## Config Model -[COMPLETE] Read this repository and summarize what it does in one short paragraph. Do not modify any files. -Task ID: 1776299534231852928 -Status: completed -Sandbox: read-only -Capabilities: none -Summary: generated/1776299534231852928/README.md -``` +Every task resolves through four pieces: -The exact roles, progress lines, and artifacts vary by task, but the contract stays the same: progress is visible, the result is inspectable, and the generated summary path is printed at the end. +- `flow`: the workflow graph and step types +- `execution profile`: sandbox posture, capabilities, retry/parallel policy, and runtime defaults +- `project policy`: role-to-provider/model rules and privileged execution policy +- `task file`: the concrete task description, inputs, and optional seeded approvals -## Stability Tiers +Local overrides are discovered in both of these roots: -### Implemented Core +- `cooperations/` +- `.cooperations/` -- `coop run`, `coop status`, `coop history` -- Codex-only role/profile runtime wiring -- Per-task sandbox and capability contract -- Local handoff and artifact persistence -- TUI stream/event model +Supported local override paths: -### Secondary Interfaces +- `cooperations/flows/.yaml` +- `cooperations/profiles/.yaml` +- `cooperations/project-policy.yaml` +- `.cooperations/flows/.yaml` +- `.cooperations/profiles/.yaml` +- `.cooperations/project-policy.yaml` -- `coop tui` for interactive workflow execution -- `coop gui` as an exploratory interface for demo and playback flows when built with `-tags gio` +## Examples -### Experimental / Reference +Runnable examples live under [examples/configurable-flows](examples/configurable-flows): -- RVR and confidence-scoring documents in `docs/` -- Long-form planning and design documents -- Historical evaluation notes in `docs/WORK_JOURNAL.md` +- `software_patch.task.yaml` +- `software_patch.seeded-approval.task.yaml` +- `recon_assessment.task.yaml` +- `incident_triage.task.yaml` +- `incident_triage.seeded-approval.task.yaml` +- `local-overrides/` cookbook showing project-local config overrides + +These files are meant to pair with the new CLI surface: + +```bash +./coop flows validate --task-file examples/configurable-flows/incident_triage.task.yaml +./coop flows generate-task --workflow incident_triage --seed-approval approve +./coop run --task-file examples/configurable-flows/software_patch.seeded-approval.task.yaml +``` + +## Runtime Contract + +- Real workflow execution requires an explicit sandbox on every task, either in the task file or via `--sandbox`. +- Supported sandbox modes are `read-only`, `workspace-write`, and `danger-full-access`. +- Capability references are optional; when omitted, the task runs with the selected flow/profile/policy defaults. +- If a capability is explicitly requested but not available locally, validation and execution fail closed. +- `run --dry-run`, `flows list`, `flows show`, `flows validate`, `status`, and `history` do not require Codex CLI initialization. +- `flows validate --probe` performs deeper local executor checks, but it does not fake backend readiness when no executor is wired. +- `tui` uses the same task contract as `run`; when interactive terminal startup fails, it falls back to plain workflow output with progress, summary, and artifact paths. + +## What Works Now + +- Declarative flows with typed states such as `agent`, `shell`, `mcp`, `approval`, `choice`, `wait`, `parallel`, `report`, `artifact`, `succeed`, and `fail` +- Flow/profile/policy discovery through `coop flows list` +- Resolved config inspection through `coop flows show` +- Safe preflight validation through `coop flows validate` +- Starter task-file generation through `coop flows generate-task` +- Task-scoped sandbox enforcement for real runs +- Optional task-scoped capability references: + - `plugin:` + - `plugin_skill::` + - `skill:` +- Local task persistence under `.cooperations/` +- Generated artifacts and task summaries under `generated/` +- Shared workflow event model used by the orchestrator, TUI, and GUI +- Plain CLI fallback for `coop tui` when no controlling TTY is available ## Project Layout -- `cmd/coop/`: CLI entrypoint -- `internal/orchestrator/`: workflow engine, task parsing, config, hooks, and routing +- `cmd/coop/`: CLI entrypoint and flow-oriented command surface +- `internal/orchestrator/`: flow engine, config loading, validation, and execution - `internal/agents/`: role-specific agent wrappers - `internal/adapters/`: Codex CLI adapter and legacy/reference adapters - `internal/capabilities/`: local capability resolution for plugins and skills - `internal/context/`: local task, handoff, and artifact storage - `internal/tui/`: Bubble Tea interface and shared workflow stream types - `internal/gui/`: Gio-based exploratory interface +- `examples/configurable-flows/`: runnable task files and local override cookbook - `docs/`: status, usage, plans, and research/reference material ## Documentation - [docs/STATUS.md](docs/STATUS.md): current implemented vs experimental behavior - [docs/USAGE.md](docs/USAGE.md): command usage and runtime expectations +- [examples/configurable-flows](examples/configurable-flows): runnable task-file cookbook - [docs/STRATEGY.md](docs/STRATEGY.md): research/strategy direction - [docs/WORK_JOURNAL.md](docs/WORK_JOURNAL.md): historical evaluation notes diff --git a/cmd/coop/flows.go b/cmd/coop/flows.go new file mode 100644 index 0000000..ce0deb1 --- /dev/null +++ b/cmd/coop/flows.go @@ -0,0 +1,444 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "cooperations/internal/orchestrator" + "cooperations/internal/types" + + "github.com/spf13/cobra" + yaml "go.yaml.in/yaml/v3" +) + +func newFlowsCmd() *cobra.Command { + flowsCmd := &cobra.Command{ + Use: "flows", + Short: "Inspect, validate, and prepare configurable flows", + } + + var listJSON bool + listCmd := &cobra.Command{ + Use: "list", + Short: "List built-in flows, profiles, and detected local overrides", + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve repo root: %w", err) + } + catalog, err := orchestrator.DiscoverConfigCatalog(repoRoot) + if err != nil { + return err + } + if listJSON { + return writeJSON(cmd.OutOrStdout(), catalog) + } + printCatalog(cmd.OutOrStdout(), repoRoot, catalog) + return nil + }, + } + listCmd.Flags().BoolVar(&listJSON, "json", false, "Render the catalog as JSON") + + var showJSON bool + var showProfile string + var showPolicy string + showCmd := &cobra.Command{ + Use: "show [flow-id]", + Short: "Show a resolved flow, profile, or project policy", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve repo root: %w", err) + } + + switch { + case strings.TrimSpace(showProfile) != "" && strings.TrimSpace(showPolicy) != "": + return fmt.Errorf("choose only one of --profile or --policy") + case strings.TrimSpace(showProfile) != "": + profile, err := orchestrator.DescribeExecutionProfile(repoRoot, showProfile) + if err != nil { + return err + } + if showJSON { + return writeJSON(cmd.OutOrStdout(), profile) + } + printProfile(cmd.OutOrStdout(), repoRoot, profile) + return nil + case strings.TrimSpace(showPolicy) != "": + policy, err := orchestrator.DescribeProjectPolicy(repoRoot, showPolicy) + if err != nil { + return err + } + if showJSON { + return writeJSON(cmd.OutOrStdout(), policy) + } + printPolicy(cmd.OutOrStdout(), repoRoot, policy) + return nil + default: + if len(args) != 1 { + return fmt.Errorf("flow id is required unless --profile or --policy is used") + } + flow, err := orchestrator.DescribeFlow(repoRoot, args[0]) + if err != nil { + return err + } + template, err := orchestrator.GenerateTaskTemplate(repoRoot, orchestrator.TaskTemplateOptions{Flow: flow.ID}) + if err != nil { + return err + } + if showJSON { + return writeJSON(cmd.OutOrStdout(), map[string]any{ + "flow": flow, + "default_profile": template.Profile, + "default_policy": template.Policy, + "example_task": template, + }) + } + printFlow(cmd.OutOrStdout(), repoRoot, flow, template) + return nil + } + }, + } + showCmd.Flags().BoolVar(&showJSON, "json", false, "Render the resolved definition as JSON") + showCmd.Flags().StringVar(&showProfile, "profile", "", "Show an execution profile instead of a flow") + showCmd.Flags().StringVar(&showPolicy, "policy", "", "Show a project policy instead of a flow") + + var validateFlow string + var validateProfile string + var validatePolicy string + var validateTaskFile string + var validateSandbox string + var validateCapabilities []string + var validateJSON bool + var validateProbe bool + validateCmd := &cobra.Command{ + Use: "validate [task-description]", + Short: "Validate a flow/profile/policy selection and current runtime readiness", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve repo root: %w", err) + } + + req, err := buildFlowTaskRequest(flowTaskOptions{ + description: firstArg(args), + flow: validateFlow, + profile: validateProfile, + policy: validatePolicy, + taskFile: validateTaskFile, + sandbox: validateSandbox, + capabilities: validateCapabilities, + }) + if err != nil { + return err + } + + report, err := orchestrator.ValidateTaskConfiguration(repoRoot, req, orchestrator.ValidationOptions{Probe: validateProbe}) + if err != nil { + return err + } + if validateJSON { + if err := writeJSON(cmd.OutOrStdout(), report); err != nil { + return err + } + } else { + printValidationReport(cmd.OutOrStdout(), repoRoot, report, validateProbe) + } + if report.Overall == orchestrator.ValidationFail { + return errors.New("validation failed") + } + return nil + }, + } + validateCmd.Flags().StringVar(&validateFlow, "workflow", "", "Select flow preset or flow YAML path") + validateCmd.Flags().StringVar(&validateProfile, "profile", "", "Select execution profile or profile YAML path") + validateCmd.Flags().StringVar(&validatePolicy, "policy", "", "Select project policy or project-policy YAML path") + validateCmd.Flags().StringVar(&validateTaskFile, "task-file", "", "Load task input from a JSON or YAML file") + validateCmd.Flags().StringVar(&validateSandbox, "sandbox", "", "Override sandbox during validation") + validateCmd.Flags().StringArrayVar(&validateCapabilities, "capability", nil, "Task capability reference (repeatable)") + validateCmd.Flags().BoolVar(&validateJSON, "json", false, "Render the validation report as JSON") + validateCmd.Flags().BoolVar(&validateProbe, "probe", false, "Run deeper local executor probes in addition to config validation") + + var generateFlow string + var generateProfile string + var generatePolicy string + var generateDescription string + var generateSeedApproval string + var generateApprovalNote string + var generateOutput string + generateCmd := &cobra.Command{ + Use: "generate-task", + Short: "Generate a starter YAML task file for a flow", + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve repo root: %w", err) + } + + template, err := orchestrator.GenerateTaskTemplate(repoRoot, orchestrator.TaskTemplateOptions{ + Flow: generateFlow, + Profile: generateProfile, + Policy: generatePolicy, + Description: generateDescription, + SeedApproval: generateSeedApproval, + ApprovalNote: generateApprovalNote, + }) + if err != nil { + return err + } + + data, err := yaml.Marshal(template) + if err != nil { + return fmt.Errorf("marshal task template: %w", err) + } + + if strings.TrimSpace(generateOutput) != "" { + if err := os.WriteFile(generateOutput, data, 0644); err != nil { + return fmt.Errorf("write task template: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Wrote task template to %s\n", generateOutput) + return nil + } + + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } + generateCmd.Flags().StringVar(&generateFlow, "workflow", "software_patch", "Select flow preset or flow YAML path") + generateCmd.Flags().StringVar(&generateProfile, "profile", "", "Select execution profile or profile YAML path") + generateCmd.Flags().StringVar(&generatePolicy, "policy", "", "Select project policy or project-policy YAML path") + generateCmd.Flags().StringVar(&generateDescription, "description", "", "Override the template task description") + generateCmd.Flags().StringVar(&generateSeedApproval, "seed-approval", "", "Seed all approval states with one decision: approve, return-to-step, or reject") + generateCmd.Flags().StringVar(&generateApprovalNote, "approval-note", "", "Optional note attached to seeded approval decisions") + generateCmd.Flags().StringVarP(&generateOutput, "output", "o", "", "Write the generated YAML to a file instead of stdout") + + flowsCmd.AddCommand(listCmd, showCmd, validateCmd, generateCmd) + return flowsCmd +} + +type flowTaskOptions struct { + description string + flow string + profile string + policy string + taskFile string + sandbox string + capabilities []string +} + +func buildFlowTaskRequest(opts flowTaskOptions) (types.TaskRequest, error) { + var req types.TaskRequest + var err error + + if strings.TrimSpace(opts.taskFile) != "" { + req, err = orchestrator.LoadTaskRequestFile(opts.taskFile) + if err != nil { + return types.TaskRequest{}, err + } + } + if strings.TrimSpace(opts.description) != "" { + req.Description = opts.description + } + if strings.TrimSpace(opts.flow) != "" { + req.Flow = opts.flow + } + if strings.TrimSpace(opts.profile) != "" { + req.Profile = opts.profile + } + if strings.TrimSpace(opts.policy) != "" { + req.Policy = opts.policy + } + if strings.TrimSpace(opts.sandbox) != "" { + req.Sandbox = types.SandboxMode(opts.sandbox) + } + if len(opts.capabilities) > 0 { + req.Capabilities = append([]string(nil), opts.capabilities...) + } + return orchestrator.NormalizeTaskRequest(req) +} + +func printCatalog(out io.Writer, repoRoot string, catalog orchestrator.ConfigCatalog) { + printCatalogSection(out, repoRoot, "Flows", catalog.Flows) + printCatalogSection(out, repoRoot, "Execution Profiles", catalog.Profiles) + printCatalogSection(out, repoRoot, "Project Policies", catalog.Policies) +} + +func printCatalogSection(out io.Writer, repoRoot string, title string, entries []orchestrator.ConfigCatalogEntry) { + fmt.Fprintf(out, "%s\n", title) + if len(entries) == 0 { + fmt.Fprintln(out, " (none)") + fmt.Fprintln(out) + return + } + for _, entry := range entries { + line := fmt.Sprintf(" - %s", entry.ID) + if strings.TrimSpace(entry.Name) != "" { + line += ": " + entry.Name + } + line += fmt.Sprintf(" [%s]", entry.Source) + if entry.OverridesBuiltin { + line += " overrides built-in" + } + if strings.TrimSpace(entry.Error) != "" { + line += " INVALID" + } + fmt.Fprintln(out, line) + fmt.Fprintf(out, " %s\n", displayPath(repoRoot, entry.Path)) + if strings.TrimSpace(entry.Error) != "" { + fmt.Fprintf(out, " error: %s\n", entry.Error) + } + } + fmt.Fprintln(out) +} + +func printFlow(out io.Writer, repoRoot string, flow orchestrator.ResolvedFlowDefinition, example types.TaskRequest) { + fmt.Fprintf(out, "Flow: %s\n", flow.ID) + if strings.TrimSpace(flow.Flow.Name) != "" { + fmt.Fprintf(out, "Name: %s\n", flow.Flow.Name) + } + fmt.Fprintf(out, "Source: %s\n", flow.Source) + fmt.Fprintf(out, "Path: %s\n", displayPath(repoRoot, flow.Path)) + fmt.Fprintf(out, "Start state: %s\n", flow.Flow.StartAt) + fmt.Fprintf(out, "Suggested profile: %s\n", example.Profile) + fmt.Fprintf(out, "Suggested policy: %s\n", example.Policy) + fmt.Fprintln(out, "States:") + + stateIDs := make([]string, 0, len(flow.Flow.States)) + for stateID := range flow.Flow.States { + stateIDs = append(stateIDs, stateID) + } + sortStrings(stateIDs) + for _, stateID := range stateIDs { + state := flow.Flow.States[stateID] + line := fmt.Sprintf(" - %s: %s", stateID, state.Type) + if strings.TrimSpace(state.Role) != "" { + line += " role=" + state.Role + } + if strings.TrimSpace(state.Next) != "" { + line += " next=" + state.Next + } + if state.Type == orchestrator.FlowStateApproval && strings.TrimSpace(state.ReturnTo) != "" { + line += " return_to=" + state.ReturnTo + } + fmt.Fprintln(out, line) + } + fmt.Fprintln(out) + fmt.Fprintln(out, "Example") + fmt.Fprintf(out, " coop run --workflow %s --profile %s --policy %s --sandbox %s %q\n", flow.ID, example.Profile, example.Policy, example.Sandbox, example.Description) +} + +func printProfile(out io.Writer, repoRoot string, profile orchestrator.ResolvedExecutionProfile) { + fmt.Fprintf(out, "Execution Profile: %s\n", profile.ID) + if strings.TrimSpace(profile.Profile.Name) != "" { + fmt.Fprintf(out, "Name: %s\n", profile.Profile.Name) + } + fmt.Fprintf(out, "Source: %s\n", profile.Source) + fmt.Fprintf(out, "Path: %s\n", displayPath(repoRoot, profile.Path)) + fmt.Fprintf(out, "Sandbox: %s\n", profile.Profile.Sandbox) + if len(profile.Profile.Capabilities) > 0 { + fmt.Fprintf(out, "Capabilities: %s\n", strings.Join(profile.Profile.Capabilities, ", ")) + } else { + fmt.Fprintln(out, "Capabilities: none") + } + fmt.Fprintf(out, "Max review cycles: %d\n", profile.Profile.Defaults.MaxReviewCycles) + if strings.TrimSpace(profile.Profile.Defaults.Timeout) != "" { + fmt.Fprintf(out, "Timeout: %s\n", profile.Profile.Defaults.Timeout) + } + fmt.Fprintf(out, "Allow parallel: %t\n", profile.Profile.Policy.AllowParallel) + fmt.Fprintf(out, "Allow MCP steps: %t\n", profile.Profile.Policy.AllowMCPSteps) + fmt.Fprintf(out, "Allow shell steps: %t\n", profile.Profile.Policy.AllowShellSteps) + fmt.Fprintf(out, "Allow privileged shell: %t\n", profile.Profile.Policy.AllowPrivilegedShell) +} + +func printPolicy(out io.Writer, repoRoot string, policy orchestrator.ResolvedProjectPolicy) { + fmt.Fprintf(out, "Project Policy: %s\n", policy.ID) + fmt.Fprintf(out, "Source: %s\n", policy.Source) + fmt.Fprintf(out, "Path: %s\n", displayPath(repoRoot, policy.Path)) + fmt.Fprintf(out, "Allow privileged shell: %t\n", policy.Policy.Policy.AllowPrivilegedShell) + fmt.Fprintf(out, "Require explicit choice when default missing: %t\n", policy.Policy.Policy.RequireExplicitChoiceWhenDefaultMissing) + fmt.Fprintln(out, "Roles:") + + roleNames := make([]string, 0, len(policy.Policy.Roles)) + for role := range policy.Policy.Roles { + roleNames = append(roleNames, role) + } + sortStrings(roleNames) + for _, role := range roleNames { + rolePolicy := policy.Policy.Roles[role] + line := fmt.Sprintf(" - %s", role) + if rolePolicy.ManualOnly { + line += " manual-only" + } else { + line += fmt.Sprintf(" default=%s/%s", rolePolicy.DefaultAssignment.Provider, rolePolicy.DefaultAssignment.Model) + } + fmt.Fprintln(out, line) + } +} + +func printValidationReport(out io.Writer, repoRoot string, report orchestrator.ValidationReport, probe bool) { + fmt.Fprintf(out, "Validation: %s\n", strings.ToUpper(string(report.Overall))) + fmt.Fprintf(out, "Flow: %s\n", report.Preview.Flow.ID) + fmt.Fprintf(out, "Profile: %s\n", report.Preview.Profile.ID) + fmt.Fprintf(out, "Policy: %s\n", report.Preview.Policy.ID) + fmt.Fprintf(out, "Sandbox: %s\n", report.Preview.EffectiveRequest.Sandbox) + if probe { + fmt.Fprintln(out, "Probe mode: enabled") + } + fmt.Fprintf(out, "Start state: %s (%s)\n", report.Preview.StartState, report.Preview.StartRole) + if len(report.Preview.StateTypes) > 0 { + typesList := make([]string, 0, len(report.Preview.StateTypes)) + for _, stateType := range report.Preview.StateTypes { + typesList = append(typesList, string(stateType)) + } + fmt.Fprintf(out, "State types: %s\n", strings.Join(typesList, ", ")) + } + fmt.Fprintln(out) + for _, check := range report.Checks { + fmt.Fprintf(out, "- [%s] %s: %s\n", strings.ToUpper(string(check.Status)), check.Name, check.Summary) + if strings.TrimSpace(check.Detail) != "" { + fmt.Fprintf(out, " %s\n", strings.TrimSpace(check.Detail)) + } + } + fmt.Fprintln(out) + fmt.Fprintf(out, "Resolved flow path: %s\n", displayPath(repoRoot, report.Preview.Flow.Path)) + fmt.Fprintf(out, "Resolved profile path: %s\n", displayPath(repoRoot, report.Preview.Profile.Path)) + fmt.Fprintf(out, "Resolved policy path: %s\n", displayPath(repoRoot, report.Preview.Policy.Path)) +} + +func writeJSON(out io.Writer, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + _, err = out.Write(append(data, '\n')) + return err +} + +func displayPath(repoRoot string, path string) string { + if strings.HasPrefix(path, "builtins/") { + return path + } + if rel, err := filepath.Rel(repoRoot, path); err == nil && !strings.HasPrefix(rel, "..") { + return rel + } + return path +} + +func sortStrings(values []string) { + slices.Sort(values) +} + +func firstArg(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} diff --git a/cmd/coop/flows_test.go b/cmd/coop/flows_test.go new file mode 100644 index 0000000..7406d18 --- /dev/null +++ b/cmd/coop/flows_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "cooperations/internal/types" + yaml "go.yaml.in/yaml/v3" +) + +func TestFlowsList_ShowsBuiltins(t *testing.T) { + output, err := executeRootCommand(t, "flows", "list") + if err != nil { + t.Fatalf("executeRootCommand() error = %v\n%s", err, output) + } + for _, want := range []string{ + "Flows", + "software_patch", + "recon_assessment", + "incident_triage", + "Execution Profiles", + "Project Policies", + } { + if !strings.Contains(output, want) { + t.Fatalf("output missing %q\n%s", want, output) + } + } +} + +func TestFlowsShow_JSON(t *testing.T) { + output, err := executeRootCommand(t, "flows", "show", "software_patch", "--json") + if err != nil { + t.Fatalf("executeRootCommand() error = %v\n%s", err, output) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("json.Unmarshal() error = %v\n%s", err, output) + } + + flow, ok := payload["flow"].(map[string]any) + if !ok { + t.Fatalf("missing flow payload: %#v", payload) + } + if flow["id"] != "software_patch" { + t.Fatalf("flow.id = %#v, want software_patch", flow["id"]) + } + if payload["default_profile"] != "default_workspace_write" { + t.Fatalf("default_profile = %#v, want default_workspace_write", payload["default_profile"]) + } +} + +func TestFlowsValidate_PassesForSoftwarePatch(t *testing.T) { + t.Setenv("CODEX_CLI_PATH", "/bin/echo") + + output, err := executeRootCommand(t, "flows", "validate", "--workflow", "software_patch", "--profile", "default_workspace_write", "--policy", "default_local", "Implement the endpoint") + if err != nil { + t.Fatalf("executeRootCommand() error = %v\n%s", err, output) + } + if !strings.Contains(output, "Validation: PASS") { + t.Fatalf("expected PASS output\n%s", output) + } +} + +func TestFlowsValidate_FailsForMCPFlow(t *testing.T) { + t.Setenv("CODEX_CLI_PATH", "/bin/echo") + + output, err := executeRootCommand(t, "flows", "validate", "--workflow", "recon_assessment", "--profile", "recon_conservative", "--policy", "default_local", "Assess the targets") + if err == nil { + t.Fatalf("expected validation failure\n%s", output) + } + if !strings.Contains(output, "mcp") { + t.Fatalf("expected MCP failure details\n%s", output) + } +} + +func TestFlowsGenerateTask_EmitsYAML(t *testing.T) { + output, err := executeRootCommand(t, "flows", "generate-task", "--workflow", "incident_triage", "--seed-approval", "approve") + if err != nil { + t.Fatalf("executeRootCommand() error = %v\n%s", err, output) + } + + var req types.TaskRequest + if err := yaml.Unmarshal([]byte(output), &req); err != nil { + t.Fatalf("yaml.Unmarshal() error = %v\n%s", err, output) + } + + if req.Flow != "incident_triage" { + t.Fatalf("flow = %q, want incident_triage", req.Flow) + } + if req.Profile != "incident_conservative" { + t.Fatalf("profile = %q, want incident_conservative", req.Profile) + } + if _, ok := req.Approvals["approval"]; !ok { + t.Fatalf("expected seeded approval in task template: %#v", req.Approvals) + } +} + +func executeRootCommand(t *testing.T, args ...string) (string, error) { + t.Helper() + + cmd := newRootCmd() + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + + err := cmd.Execute() + combined := strings.TrimSpace(stdout.String()) + if errText := strings.TrimSpace(stderr.String()); errText != "" { + if combined != "" { + combined += "\n" + } + combined += errText + } + return combined, err +} + +func TestExampleTaskFiles_ParseAndLocalOverrideValidates(t *testing.T) { + exampleDir := filepath.Clean(filepath.Join("..", "..", "examples", "configurable-flows")) + paths := []string{ + filepath.Join(exampleDir, "software_patch.task.yaml"), + filepath.Join(exampleDir, "software_patch.seeded-approval.task.yaml"), + filepath.Join(exampleDir, "recon_assessment.task.yaml"), + filepath.Join(exampleDir, "incident_triage.task.yaml"), + filepath.Join(exampleDir, "incident_triage.seeded-approval.task.yaml"), + } + for _, path := range paths { + if _, err := os.Stat(path); err != nil { + t.Fatalf("example %s missing: %v", path, err) + } + var req types.TaskRequest + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read example %s: %v", path, err) + } + if err := yaml.Unmarshal(data, &req); err != nil { + t.Fatalf("parse example %s: %v", path, err) + } + } + + overrideRoot := filepath.Join(exampleDir, "local-overrides") + repoRoot := t.TempDir() + copyDir(t, filepath.Join(overrideRoot, "cooperations"), filepath.Join(repoRoot, "cooperations")) + copyFile(t, filepath.Join(overrideRoot, "task.yaml"), filepath.Join(repoRoot, "task.yaml")) + + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + if err := os.Chdir(repoRoot); err != nil { + t.Fatalf("Chdir() error = %v", err) + } + + t.Setenv("CODEX_CLI_PATH", "/bin/echo") + output, err := executeRootCommand(t, "flows", "validate", "--task-file", "task.yaml") + if err != nil { + t.Fatalf("override validate error = %v\n%s", err, output) + } + if !strings.Contains(output, "workspace_lite") { + t.Fatalf("expected local profile in validation output\n%s", output) + } +} + +func copyDir(t *testing.T, src string, dst string) { + t.Helper() + + entries, err := os.ReadDir(src) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", src, err) + } + if err := os.MkdirAll(dst, 0755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", dst, err) + } + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + if entry.IsDir() { + copyDir(t, srcPath, dstPath) + continue + } + copyFile(t, srcPath, dstPath) + } +} + +func copyFile(t *testing.T, src string, dst string) { + t.Helper() + + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", src, err) + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(dst), err) + } + if err := os.WriteFile(dst, data, 0644); err != nil { + t.Fatalf("WriteFile(%s) error = %v", dst, err) + } +} diff --git a/cmd/coop/main.go b/cmd/coop/main.go index 1ad39ee..d5e22ac 100644 --- a/cmd/coop/main.go +++ b/cmd/coop/main.go @@ -46,85 +46,7 @@ func main() { } logging.Setup(logLevel) - // Root command - rootCmd := &cobra.Command{ - Use: "coop", - Short: "Cooperations - local-first AI workflow orchestration toolkit", - Long: "Coordinates specialized CLI-backed agents for local software workflow orchestration.", - } - - // Run command - runCmd := &cobra.Command{ - Use: "run [task]", - Short: "Execute a task through the mob programming workflow", - Args: cobra.MaximumNArgs(1), - RunE: runTask, - } - runCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed output") - runCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show routing decision without executing") - runCmd.Flags().IntVar(&maxCycles, "max-cycles", 0, "Override max review cycles") - runCmd.Flags().StringVar(&workflowType, "workflow", "", "Select flow preset or flow YAML path") - runCmd.Flags().StringVar(&profileName, "profile", "", "Select execution profile or profile YAML path") - runCmd.Flags().StringVar(&policyName, "policy", "", "Select project policy or policy YAML path") - runCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Write generated code to file") - runCmd.Flags().StringVar(&taskFile, "task-file", "", "Load task input from a JSON or YAML file") - runCmd.Flags().StringVar(&sandboxMode, "sandbox", "", "Sandbox mode for the task: read-only, workspace-write, danger-full-access") - runCmd.Flags().StringArrayVar(&capabilities, "capability", nil, "Task capability reference (repeatable): plugin:, plugin_skill::, skill:") - - // Status command - statusCmd := &cobra.Command{ - Use: "status [task_id]", - Short: "Show task status", - Args: cobra.MaximumNArgs(1), - RunE: showStatus, - } - - // History command - historyCmd := &cobra.Command{ - Use: "history", - Short: "List past tasks", - RunE: showHistory, - } - var historyLimit int - historyCmd.Flags().IntVar(&historyLimit, "limit", 10, "Number of tasks to show") - - // GUI command - guiCmd := &cobra.Command{ - Use: "gui [task]", - Short: "Launch the graphical interface for a task", - Long: "Opens the exploratory Gio-based GUI for interactive workflow playback and review.", - Args: cobra.MaximumNArgs(1), - RunE: runGUI, - } - var guiDemoMode bool - guiCmd.Flags().BoolVar(&guiDemoMode, "demo", false, "Run in demo mode with stub progress") - guiCmd.Flags().StringVar(&taskFile, "task-file", "", "Load task input from a JSON or YAML file") - guiCmd.Flags().StringVar(&workflowType, "workflow", "", "Select flow preset or flow YAML path") - guiCmd.Flags().StringVar(&profileName, "profile", "", "Select execution profile or profile YAML path") - guiCmd.Flags().StringVar(&policyName, "policy", "", "Select project policy or policy YAML path") - guiCmd.Flags().StringVar(&sandboxMode, "sandbox", "", "Sandbox mode for the task: read-only, workspace-write, danger-full-access") - guiCmd.Flags().StringArrayVar(&capabilities, "capability", nil, "Task capability reference (repeatable): plugin:, plugin_skill::, skill:") - - // TUI command - tuiCmd := &cobra.Command{ - Use: "tui [task]", - Short: "Launch the terminal user interface", - Long: "Opens the Bubble Tea-based TUI for interactive workflow execution.", - Args: cobra.MaximumNArgs(1), - RunE: runTUI, - } - var tuiDemoMode bool - tuiCmd.Flags().BoolVar(&tuiDemoMode, "demo", false, "Run in demo mode with simulated workflow") - tuiCmd.Flags().StringVar(&taskFile, "task-file", "", "Load task input from a JSON or YAML file") - tuiCmd.Flags().StringVar(&workflowType, "workflow", "", "Select flow preset or flow YAML path") - tuiCmd.Flags().StringVar(&profileName, "profile", "", "Select execution profile or profile YAML path") - tuiCmd.Flags().StringVar(&policyName, "policy", "", "Select project policy or policy YAML path") - tuiCmd.Flags().StringVar(&sandboxMode, "sandbox", "", "Sandbox mode for the task: read-only, workspace-write, danger-full-access") - tuiCmd.Flags().StringArrayVar(&capabilities, "capability", nil, "Task capability reference (repeatable): plugin:, plugin_skill::, skill:") - - rootCmd.AddCommand(runCmd, statusCmd, historyCmd, guiCmd, tuiCmd) - - if err := rootCmd.Execute(); err != nil { + if err := newRootCmd().Execute(); err != nil { os.Exit(1) } } @@ -135,6 +57,29 @@ func runTask(cmd *cobra.Command, args []string) error { return err } + if dryRun { + repoRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolve repo root: %w", err) + } + preview, err := orchestrator.PreviewTaskSelection(repoRoot, req) + if err != nil { + return fmt.Errorf("dry-run preview: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "[DRY-RUN] Flow: %s\n", preview.Flow.ID) + fmt.Fprintf(cmd.OutOrStdout(), "Profile: %s\n", preview.Profile.ID) + fmt.Fprintf(cmd.OutOrStdout(), "Policy: %s\n", preview.Policy.ID) + fmt.Fprintf(cmd.OutOrStdout(), "Sandbox: %s\n", preview.EffectiveRequest.Sandbox) + fmt.Fprintf(cmd.OutOrStdout(), "Start state: %s (%s)\n", preview.StartState, preview.StartRole) + if len(preview.EffectiveRequest.Capabilities) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "Capabilities: %s\n", strings.Join(preview.EffectiveRequest.Capabilities, ", ")) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "Capabilities: none") + } + return nil + } + // Get max cycles from env or flag config := orchestrator.WorkflowConfig{ MaxReviewCycles: currentMaxCycles(), @@ -145,13 +90,6 @@ func runTask(cmd *cobra.Command, args []string) error { return fmt.Errorf("initialize orchestrator: %w", err) } - // Dry run mode - if dryRun { - role, confidence := orch.DryRun(req.Description) - fmt.Printf("[DRY-RUN] Task would be routed to: %s (confidence: %.0f%%)\n", role, confidence*100) - return nil - } - // Setup context with cancellation ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/cmd/coop/root.go b/cmd/coop/root.go new file mode 100644 index 0000000..87edf83 --- /dev/null +++ b/cmd/coop/root.go @@ -0,0 +1,89 @@ +package main + +import "github.com/spf13/cobra" + +func newRootCmd() *cobra.Command { + resetCLIState() + + rootCmd := &cobra.Command{ + Use: "coop", + Short: "Cooperations - local-first configurable flow runner", + Long: "Runs inspectable local workflows through declarative flows, execution profiles, and project policies.", + SilenceUsage: true, + SilenceErrors: true, + } + + runCmd := &cobra.Command{ + Use: "run [task]", + Short: "Execute a task through a selected flow", + Args: cobra.MaximumNArgs(1), + RunE: runTask, + } + addSharedTaskFlags(runCmd) + runCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Show detailed output") + runCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview the resolved flow/profile/policy without executing") + runCmd.Flags().IntVar(&maxCycles, "max-cycles", 0, "Override max review cycles") + runCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Write generated code to a file") + + statusCmd := &cobra.Command{ + Use: "status [task_id]", + Short: "Show task status", + Args: cobra.MaximumNArgs(1), + RunE: showStatus, + } + + historyCmd := &cobra.Command{ + Use: "history", + Short: "List past tasks", + RunE: showHistory, + } + var historyLimit int + historyCmd.Flags().IntVar(&historyLimit, "limit", 10, "Number of tasks to show") + + guiCmd := &cobra.Command{ + Use: "gui [task]", + Short: "Launch the graphical interface for a flow run", + Long: "Opens the exploratory Gio-based GUI for interactive flow execution and review.", + Args: cobra.MaximumNArgs(1), + RunE: runGUI, + } + var guiDemoMode bool + guiCmd.Flags().BoolVar(&guiDemoMode, "demo", false, "Run in demo mode with stub progress") + addSharedTaskFlags(guiCmd) + + tuiCmd := &cobra.Command{ + Use: "tui [task]", + Short: "Launch the terminal interface for a flow run", + Long: "Opens the Bubble Tea-based TUI for interactive flow execution.", + Args: cobra.MaximumNArgs(1), + RunE: runTUI, + } + var tuiDemoMode bool + tuiCmd.Flags().BoolVar(&tuiDemoMode, "demo", false, "Run in demo mode with simulated workflow") + addSharedTaskFlags(tuiCmd) + + rootCmd.AddCommand(runCmd, statusCmd, historyCmd, guiCmd, tuiCmd, newFlowsCmd()) + return rootCmd +} + +func addSharedTaskFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&workflowType, "workflow", "", "Select flow preset or flow YAML path") + cmd.Flags().StringVar(&profileName, "profile", "", "Select execution profile or profile YAML path") + cmd.Flags().StringVar(&policyName, "policy", "", "Select project policy or project-policy YAML path") + cmd.Flags().StringVar(&taskFile, "task-file", "", "Load task input from a JSON or YAML file") + cmd.Flags().StringVar(&sandboxMode, "sandbox", "", "Sandbox mode for the task: read-only, workspace-write, danger-full-access") + cmd.Flags().StringArrayVar(&capabilities, "capability", nil, "Task capability reference (repeatable): plugin:, plugin_skill::, skill:") +} + +func resetCLIState() { + verbose = false + dryRun = false + maxCycles = 0 + workflowType = "" + profileName = "" + policyName = "" + outputFile = "" + taskFile = "" + sandboxMode = "" + capabilities = nil +} diff --git a/docs/STATUS.md b/docs/STATUS.md index 9d8ada9..be820b7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -4,15 +4,31 @@ This document is the source of truth for what Cooperations currently implements ## Implemented Core -- CLI commands: `run`, `status`, `history` -- Dry-run routing without requiring model CLIs -- Heuristic role routing for `architect`, `implementer`, `reviewer`, and `navigator` +- Declarative configurable-flow engine with typed states including `agent`, `shell`, `mcp`, `approval`, `choice`, `wait`, `parallel`, `report`, `artifact`, `succeed`, and `fail` +- Three-layer config model: + - flow definition + - execution profile + - project policy +- Built-in presets: + - flows: `software_patch`, `recon_assessment`, `incident_triage` + - profiles: `default_workspace_write`, `recon_conservative`, `incident_conservative` + - project policy: `default_local` +- CLI commands: + - `run`, `status`, `history` + - `flows list`, `flows show`, `flows validate`, `flows generate-task` +- Dry-run preview of resolved flow/profile/policy without requiring model CLIs - Per-task sandbox contract for real runs - Optional task-scoped capability references for plugins and skills -- Local task, handoff, and artifact persistence with sandbox/capability metadata +- Local task, handoff, artifact, and workflow-state persistence with flow/profile/policy metadata - Codex CLI as the default runtime adapter on Linux/bash - Shared workflow event model used by the orchestrator and interfaces +## Supported But Partial + +- `approval` states are implemented and support seeded decisions plus unattended defaults +- `mcp` is a first-class typed flow state and validates truthfully, but the default CLI runtime still requires an injected MCP executor for real execution +- Local override discovery works from `cooperations/` and `.cooperations/`, but there is not yet a full interactive config authoring workflow + ## Secondary Interfaces - `tui`: supported secondary interface for interactive workflow execution, with plain fallback output when no controlling TTY is available @@ -27,11 +43,11 @@ This document is the source of truth for what Cooperations currently implements ## Not Part Of The Current Runtime Contract -- Learned or adaptive routing beyond simple heuristics -- Confidence-driven control flow in the default runtime +- Learned or adaptive routing beyond explicit flow definitions +- Hidden autonomous control flow outside the selected flow/profile/policy bundle - Wired RVR execution in the default runtime path - Benchmark automation or reproducible performance evaluation pipeline - OpenAI API integration or SDK-based provider runtime -- Implicit sandbox defaults for real tasks +- Visual flow editor or GUI-first config authoring - Production-readiness claims for the GUI or research layers - Default builds that assume desktop GUI system libraries are present diff --git a/docs/USAGE.md b/docs/USAGE.md index 3788471..0bff34d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1,6 +1,6 @@ # Cooperations Usage -See [STATUS.md](STATUS.md) for the current implementation matrix. This document focuses on the supported runtime paths. +See [STATUS.md](STATUS.md) for the implementation matrix. This document focuses on the supported runtime paths and the current user-facing contract. ## Prerequisites @@ -17,11 +17,102 @@ cp .env.example .env go build -o coop ./cmd/coop ``` -## Core Commands +## Configurable Flow Model + +Every run resolves through four inputs: + +- `flow`: workflow graph and step types +- `execution profile`: sandbox posture, capabilities, retry/parallel defaults, and executor posture +- `project policy`: role-to-provider/model choices plus privileged execution rules +- `task file`: task description, inputs, and optional seeded approvals + +Built-in presets: + +- flows: `software_patch`, `recon_assessment`, `incident_triage` +- profiles: `default_workspace_write`, `recon_conservative`, `incident_conservative` +- project policy: `default_local` + +Project-local overrides are discovered in either root: + +- `cooperations/` +- `.cooperations/` + +Supported override paths: + +- `cooperations/flows/.yaml` +- `cooperations/profiles/.yaml` +- `cooperations/project-policy.yaml` +- `.cooperations/flows/.yaml` +- `.cooperations/profiles/.yaml` +- `.cooperations/project-policy.yaml` + +## Flow Discovery Commands + +### `flows list` + +List built-in flows, execution profiles, and detected local overrides. + +```bash +coop flows list +coop flows list --json +``` + +Human-readable output is the default. `--json` prints the full catalog with source and path metadata. + +### `flows show` + +Show a resolved flow, execution profile, or project policy. + +```bash +coop flows show software_patch +coop flows show --profile default_workspace_write +coop flows show --policy default_local +coop flows show software_patch --json +``` + +`flows show` resolves the selected config the same way runtime execution does, so local overrides are reflected in the result. + +### `flows validate` + +Validate a flow/profile/policy/task bundle before execution. + +```bash +coop flows validate --task-file examples/configurable-flows/software_patch.task.yaml +coop flows validate --workflow incident_triage --sandbox read-only "summarize the incident" +coop flows validate --task-file examples/configurable-flows/recon_assessment.task.yaml --probe +coop flows validate --task-file examples/configurable-flows/software_patch.task.yaml --json +``` + +Default validation is safe local-first preflight. It checks: + +- config resolution +- schema and state-graph integrity +- flow/profile/policy compatibility +- sandbox and capability posture +- local executor availability on `PATH` +- whether the selected flow uses `approval` or `mcp` states and what runtime support currently exists + +`--probe` adds deeper local executor probes. Today that means local binary readiness checks, not synthetic backend auth or fake MCP success. + +Validation exits non-zero when the overall result is `FAIL`. `WARN` still exits zero and is intended for truthful but non-blocking signals. + +### `flows generate-task` + +Generate a starter YAML task file for a flow. + +```bash +coop flows generate-task --workflow software_patch +coop flows generate-task --workflow incident_triage --seed-approval approve +coop flows generate-task --workflow software_patch --output /tmp/software_patch.task.yaml +``` + +The generated shape matches the runtime task contract and is a convenient way to seed examples or local project automation. + +## Core Runtime Commands ### `run` -Execute a task through the orchestrated workflow. +Execute a task through the resolved workflow. ```bash coop run [task] [flags] @@ -29,13 +120,16 @@ coop run [task] [flags] Flags: -- `--output`, `-o`: write generated code to a file -- `--dry-run`: show routing decision without running model tooling -- `--verbose`, `-v`: show detailed output -- `--max-cycles`: override review cycle limit +- `--workflow`: select flow preset or flow YAML path +- `--profile`: select execution profile or profile YAML path +- `--policy`: select project policy or policy YAML path - `--task-file`: load a structured task request from JSON or YAML -- `--sandbox`: required for real workflow execution +- `--sandbox`: sandbox mode for the task - `--capability`: repeatable task-scoped capability reference +- `--dry-run`: preview the resolved flow/profile/policy without executing +- `--verbose`, `-v`: show detailed output +- `--max-cycles`: override max review cycles +- `--output`, `-o`: write generated code to a file Supported sandbox modes: @@ -52,19 +146,21 @@ Supported capability references: Examples: ```bash -coop run --dry-run "implement a health check endpoint" +coop run --dry-run --task-file examples/configurable-flows/software_patch.task.yaml coop run --sandbox workspace-write "implement a health check endpoint" -coop run --sandbox read-only "review this parser for bugs" -coop run --task-file examples/feature-request.json +coop run --task-file examples/configurable-flows/software_patch.task.yaml +coop run --task-file examples/configurable-flows/software_patch.seeded-approval.task.yaml coop run --task-file examples/feature-request.yaml --capability skill:playwright -coop run --sandbox workspace-write --capability plugin:authorial-player-models "update the arrangement docs" ``` +`run --dry-run` prints the resolved `flow`, `profile`, `policy`, `sandbox`, start state, start role, and effective capabilities. + Real runs fail closed if: -- no sandbox is provided +- no sandbox is provided after task/profile resolution - a capability reference is malformed -- a capability is requested but not available locally +- a requested capability is not available locally +- the selected flow requires runtime support that is not wired, such as the current default `mcp` executor path ### `status` @@ -75,7 +171,7 @@ coop status coop status ``` -`status` shows task description, sandbox, and any recorded capabilities. Handoff details include model, effort, sandbox, and token usage. +`status` shows task description, selected flow/profile/policy, sandbox, and recorded capabilities. Handoff details include model, effort, sandbox, token usage, and step metadata when available. ### `history` @@ -88,35 +184,51 @@ coop history --limit=20 `history` includes sandbox and capability references when present. -## Structured Task Files - -JSON and YAML task files are supported. A minimal task file looks like: - -```json -{ - "description": "Add a user authentication endpoint with JWT tokens", - "sandbox": "workspace-write", - "capabilities": [ - "skill:playwright" - ] -} -``` +## Task File Format -Equivalent YAML: +JSON and YAML task files are supported. The primary shape is: ```yaml -description: Add a user authentication endpoint with JWT tokens +description: Implement a health check endpoint and summarize the change. +flow: software_patch +profile: default_workspace_write +policy: default_local sandbox: workspace-write capabilities: - skill:playwright +inputs: + incident_id: INC-1042 +approvals: + approval: + decision: approve + note: Ready to ship. ``` +Relevant fields: + +- `description`: required task text +- `flow`: optional flow selector or YAML path +- `profile`: optional execution profile selector or YAML path +- `policy`: optional project policy selector or YAML path +- `sandbox`: required for real runs unless supplied by profile/task merge +- `capabilities`: optional task-scoped capability references +- `inputs`: free-form structured data available to flow states +- `approvals`: seeded approval outcomes keyed by approval state id + +Example seeded approvals: + +- `software_patch` uses the approval state id `approve` +- `incident_triage` uses the approval state id `approval` + Merge rules: - positional task description overrides the description from `--task-file` +- `--workflow`, `--profile`, and `--policy` override task-file values - `--sandbox` overrides the sandbox from `--task-file` - if one or more `--capability` flags are provided, they replace the capability list from `--task-file` +Runnable examples live in [../examples/configurable-flows](../examples/configurable-flows). + ## Secondary Interfaces ### `tui` @@ -125,11 +237,11 @@ Run the Bubble Tea interface against a live workflow or demo stream. ```bash coop tui --demo +coop tui --task-file examples/configurable-flows/software_patch.task.yaml coop tui --sandbox workspace-write "implement a health check endpoint" -coop tui --task-file examples/feature-request.json ``` -Live TUI runs use the same sandbox and capability contract as `run`. +Live TUI runs use the same flow/profile/policy contract as `run`. Behavior: @@ -146,7 +258,7 @@ Run the Gio-based exploratory GUI. ```bash go build -tags gio -o coop ./cmd/coop coop gui --demo "review the workflow" -coop gui --sandbox workspace-write "implement a health check endpoint" +coop gui --task-file examples/configurable-flows/software_patch.task.yaml ``` The GUI is not the primary supported runtime path. Treat it as a secondary interface and demo surface. Default builds use a stub and require `-tags gio` plus Gio system libraries for the real GUI. Live GUI runs use the same task contract as `run`. @@ -177,10 +289,11 @@ generated/ README.md ``` -Each task record includes description, status, sandbox, and capability references. +Each task record includes description, flow/profile/policy selection, sandbox, and capability references. ## Notes On Scope -- `status`, `history`, and `run --dry-run` work without Codex CLI initialization. +- `flows list`, `flows show`, `flows validate`, `flows generate-task`, `status`, `history`, and `run --dry-run` work without Codex CLI initialization. - Real workflow execution requires `codex` to be installed and authenticated. +- `recon_assessment` is supported as config and validation surface, but default CLI execution still requires a wired MCP backend. - Research documents in `docs/` are reference material, not part of the default runtime contract. diff --git a/examples/configurable-flows/README.md b/examples/configurable-flows/README.md new file mode 100644 index 0000000..3cf1083 --- /dev/null +++ b/examples/configurable-flows/README.md @@ -0,0 +1,26 @@ +# Configurable Flow Examples + +This directory contains runnable task-file examples for the built-in flow system. + +Files: + +- `software_patch.task.yaml`: minimal built-in software patch flow +- `software_patch.seeded-approval.task.yaml`: same flow with the `approve` gate pre-seeded +- `recon_assessment.task.yaml`: recon-oriented flow that demonstrates `mcp` validation +- `incident_triage.task.yaml`: incident flow with structured `inputs` +- `incident_triage.seeded-approval.task.yaml`: incident flow with the `approval` state pre-seeded +- `local-overrides/`: cookbook for overriding built-ins from `cooperations/` in another repo + +Useful commands: + +```bash +coop flows validate --task-file examples/configurable-flows/software_patch.task.yaml +coop flows validate --task-file examples/configurable-flows/recon_assessment.task.yaml +coop flows generate-task --workflow incident_triage --seed-approval approve +coop run --task-file examples/configurable-flows/software_patch.seeded-approval.task.yaml +``` + +Notes: + +- The generated examples are YAML-first because `flows generate-task` currently emits YAML. +- `recon_assessment` is expected to validate truthfully around MCP readiness; default CLI execution still needs a wired MCP executor. diff --git a/examples/configurable-flows/incident_triage.seeded-approval.task.yaml b/examples/configurable-flows/incident_triage.seeded-approval.task.yaml new file mode 100644 index 0000000..ca11f1c --- /dev/null +++ b/examples/configurable-flows/incident_triage.seeded-approval.task.yaml @@ -0,0 +1,14 @@ +description: Classify the incident and seed the approval gate with a return-to-step decision. +flow: incident_triage +profile: incident_conservative +policy: default_local +sandbox: read-only +inputs: + incident_id: INC-1042 + summary: Elevated error rate on the public API. + reporter: oncall@example.com +approvals: + approval: + decision: return-to-step + note: Gather one more round of context before dispatching actions. + return_to: classify diff --git a/examples/configurable-flows/incident_triage.task.yaml b/examples/configurable-flows/incident_triage.task.yaml new file mode 100644 index 0000000..9a50c8a --- /dev/null +++ b/examples/configurable-flows/incident_triage.task.yaml @@ -0,0 +1,9 @@ +description: Classify the incident, gather context, and prepare the first response summary. +flow: incident_triage +profile: incident_conservative +policy: default_local +sandbox: read-only +inputs: + incident_id: INC-1042 + summary: Elevated error rate on the public API. + reporter: oncall@example.com diff --git a/examples/configurable-flows/local-overrides/README.md b/examples/configurable-flows/local-overrides/README.md new file mode 100644 index 0000000..a41c628 --- /dev/null +++ b/examples/configurable-flows/local-overrides/README.md @@ -0,0 +1,24 @@ +# Local Override Cookbook + +This directory shows one minimal local override layout for `cooperations`. + +Copy the `cooperations/` folder into the root of a repository where you want +to test local overrides, then validate or run the bundled task file: + +```bash +cp -R examples/configurable-flows/local-overrides/cooperations /path/to/repo/ +cp examples/configurable-flows/local-overrides/task.yaml /path/to/repo/ +cd /path/to/repo +coop flows validate --task-file task.yaml +``` + +What this example demonstrates: + +- `cooperations/flows/software_patch.yaml` overrides the built-in `software_patch` + flow while keeping the same selector +- `cooperations/profiles/workspace_lite.yaml` adds a custom local execution + profile +- `cooperations/project-policy.yaml` overrides the default local project policy + +The task file intentionally selects the built-in flow id plus the custom local +profile so you can see both override styles in one run. diff --git a/examples/configurable-flows/local-overrides/cooperations/flows/software_patch.yaml b/examples/configurable-flows/local-overrides/cooperations/flows/software_patch.yaml new file mode 100644 index 0000000..0cfb6f4 --- /dev/null +++ b/examples/configurable-flows/local-overrides/cooperations/flows/software_patch.yaml @@ -0,0 +1,38 @@ +version: 1 +flow: + id: software_patch + name: Software Patch (Local Override) + start_at: plan + states: + plan: + type: agent + role: architect + next: implement + + implement: + type: agent + role: implementer + next: review + + review: + type: agent + role: reviewer + on: + approved: report + changes_requested: implement + completed: report + + report: + type: report + artifact: local_patch_report + sections: + - title: Task + ref: task.description + - title: Design + ref: state.plan.content + - title: Review + ref: state.review.content + next: done + + done: + type: succeed diff --git a/examples/configurable-flows/local-overrides/cooperations/profiles/workspace_lite.yaml b/examples/configurable-flows/local-overrides/cooperations/profiles/workspace_lite.yaml new file mode 100644 index 0000000..a63db72 --- /dev/null +++ b/examples/configurable-flows/local-overrides/cooperations/profiles/workspace_lite.yaml @@ -0,0 +1,14 @@ +version: 1 +profile: + id: workspace_lite + name: Workspace Lite + sandbox: workspace-write + capabilities: [] + defaults: + max_review_cycles: 1 + timeout: 10m + policy: + allow_parallel: false + allow_mcp_steps: false + allow_shell_steps: true + allow_privileged_shell: false diff --git a/examples/configurable-flows/local-overrides/cooperations/project-policy.yaml b/examples/configurable-flows/local-overrides/cooperations/project-policy.yaml new file mode 100644 index 0000000..299aa8c --- /dev/null +++ b/examples/configurable-flows/local-overrides/cooperations/project-policy.yaml @@ -0,0 +1,44 @@ +version: 1 +id: default_local +project: + root: . +roles: + architect: + default_assignment: + provider: codex + model: gpt-5.4 + reasoning_effort: high + allowed_assignments: + - provider: codex + model: gpt-5.4 + reasoning_effort: high + implementer: + default_assignment: + provider: codex + model: gpt-5.3-codex + reasoning_effort: medium + allowed_assignments: + - provider: codex + model: gpt-5.3-codex + reasoning_effort: medium + reviewer: + default_assignment: + provider: codex + model: gpt-5.4-mini + reasoning_effort: low + allowed_assignments: + - provider: codex + model: gpt-5.4-mini + reasoning_effort: low + navigator: + default_assignment: + provider: codex + model: gpt-5.4-mini + reasoning_effort: low + allowed_assignments: + - provider: codex + model: gpt-5.4-mini + reasoning_effort: low +policy: + allow_privileged_shell: false + require_explicit_choice_when_default_missing: true diff --git a/examples/configurable-flows/local-overrides/task.yaml b/examples/configurable-flows/local-overrides/task.yaml new file mode 100644 index 0000000..d40ea27 --- /dev/null +++ b/examples/configurable-flows/local-overrides/task.yaml @@ -0,0 +1,5 @@ +description: Run the locally overridden software patch flow with the custom local profile. +flow: software_patch +profile: workspace_lite +policy: default_local +sandbox: workspace-write diff --git a/examples/configurable-flows/recon_assessment.task.yaml b/examples/configurable-flows/recon_assessment.task.yaml new file mode 100644 index 0000000..5dfc858 --- /dev/null +++ b/examples/configurable-flows/recon_assessment.task.yaml @@ -0,0 +1,9 @@ +description: Assess the listed targets and summarize what the configured runtime can inspect. +flow: recon_assessment +profile: recon_conservative +policy: default_local +sandbox: read-only +inputs: + targets: + - example.com + - api.example.com diff --git a/examples/configurable-flows/software_patch.seeded-approval.task.yaml b/examples/configurable-flows/software_patch.seeded-approval.task.yaml new file mode 100644 index 0000000..294aeb2 --- /dev/null +++ b/examples/configurable-flows/software_patch.seeded-approval.task.yaml @@ -0,0 +1,9 @@ +description: Implement a health check endpoint and auto-seed the approval gate. +flow: software_patch +profile: default_workspace_write +policy: default_local +sandbox: workspace-write +approvals: + approve: + decision: approve + note: Ready to ship after review. diff --git a/examples/configurable-flows/software_patch.task.yaml b/examples/configurable-flows/software_patch.task.yaml new file mode 100644 index 0000000..39376ba --- /dev/null +++ b/examples/configurable-flows/software_patch.task.yaml @@ -0,0 +1,5 @@ +description: Implement a health check endpoint and summarize the change. +flow: software_patch +profile: default_workspace_write +policy: default_local +sandbox: workspace-write diff --git a/internal/orchestrator/flow_loader.go b/internal/orchestrator/flow_loader.go index 8108c6e..e3bcd57 100644 --- a/internal/orchestrator/flow_loader.go +++ b/internal/orchestrator/flow_loader.go @@ -1,19 +1,27 @@ package orchestrator import ( + "context" "fmt" "io/fs" "os" + "os/exec" "path/filepath" "reflect" "slices" "strings" + "time" "cooperations/internal/capabilities" "cooperations/internal/types" yaml "go.yaml.in/yaml/v3" ) +type resolvedConfigPath struct { + Path string + Source string +} + func resolveExecutionBundle(repoRoot string, req types.TaskRequest) (executionBundle, error) { flowID := strings.TrimSpace(req.Flow) if flowID == "" { @@ -21,7 +29,7 @@ func resolveExecutionBundle(repoRoot string, req types.TaskRequest) (executionBu } profileID := strings.TrimSpace(req.Profile) if profileID == "" { - profileID = defaultProfileID + profileID = defaultProfileForFlow(flowID) } policyID := strings.TrimSpace(req.Policy) if policyID == "" { @@ -140,16 +148,16 @@ func loadProjectPolicy(repoRoot string, id string) (ProjectPolicyDocument, error return doc, nil } -func resolveConfigPath(repoRoot string, selector string, localDir string, fallbackFile string, builtinDir string) (string, error) { +func resolveConfigLocation(repoRoot string, selector string, localDir string, fallbackFile string, builtinDir string) (resolvedConfigPath, error) { if explicit := strings.TrimSpace(selector); explicit != "" && (strings.ContainsRune(explicit, os.PathSeparator) || strings.HasSuffix(explicit, ".yaml") || strings.HasSuffix(explicit, ".yml")) { if _, err := os.Stat(explicit); err == nil { - return explicit, nil + return resolvedConfigPath{Path: explicit, Source: "local"}, nil } candidate := filepath.Join(repoRoot, explicit) if _, err := os.Stat(candidate); err == nil { - return candidate, nil + return resolvedConfigPath{Path: candidate, Source: "local"}, nil } - return "", fmt.Errorf("config file not found: %s", selector) + return resolvedConfigPath{}, fmt.Errorf("config file not found: %s", selector) } localCandidates := []string{ @@ -169,15 +177,23 @@ func resolveConfigPath(repoRoot string, selector string, localDir string, fallba continue } if _, err := os.Stat(candidate); err == nil { - return candidate, nil + return resolvedConfigPath{Path: candidate, Source: "local"}, nil } } builtinPath := pathJoinFS(builtinDir, fallbackFile) if _, err := fs.Stat(builtinFlowFS, builtinPath); err != nil { - return "", fmt.Errorf("built-in config not found: %s", selector) + return resolvedConfigPath{}, fmt.Errorf("built-in config not found: %s", selector) + } + return resolvedConfigPath{Path: builtinPath, Source: "builtin"}, nil +} + +func resolveConfigPath(repoRoot string, selector string, localDir string, fallbackFile string, builtinDir string) (string, error) { + location, err := resolveConfigLocation(repoRoot, selector, localDir, fallbackFile, builtinDir) + if err != nil { + return "", err } - return builtinPath, nil + return location.Path, nil } func loadYAMLDocument(path string, target any) error { @@ -559,3 +575,28 @@ func pathJoinFS(parts ...string) string { } return strings.Join(trimmed, "/") } + +func lookupBinary(binary string) (string, error) { + if strings.TrimSpace(binary) == "" { + return "", fmt.Errorf("binary path is empty") + } + path, err := exec.LookPath(binary) + if err != nil { + return "", err + } + return path, nil +} + +func probeBinary(path string, args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, path, args...) + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("probe timed out") + } + return err + } + return nil +} diff --git a/internal/orchestrator/flow_productize.go b/internal/orchestrator/flow_productize.go new file mode 100644 index 0000000..5ad9741 --- /dev/null +++ b/internal/orchestrator/flow_productize.go @@ -0,0 +1,765 @@ +package orchestrator + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "cooperations/internal/types" +) + +// ConfigCatalogEntry describes one discoverable flow/profile/policy definition. +type ConfigCatalogEntry struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Kind string `json:"kind"` + Source string `json:"source"` + Path string `json:"path"` + OverridesBuiltin bool `json:"overrides_builtin,omitempty"` + Error string `json:"error,omitempty"` +} + +// ConfigCatalog is the discoverable set of built-in and local configs. +type ConfigCatalog struct { + Flows []ConfigCatalogEntry `json:"flows"` + Profiles []ConfigCatalogEntry `json:"profiles"` + Policies []ConfigCatalogEntry `json:"policies"` +} + +// ResolvedFlowDefinition is a fully loaded flow plus its source metadata. +type ResolvedFlowDefinition struct { + ID string `json:"id"` + Source string `json:"source"` + Path string `json:"path"` + Flow FlowDefinition `json:"flow"` +} + +// ResolvedExecutionProfile is a fully loaded execution profile plus source metadata. +type ResolvedExecutionProfile struct { + ID string `json:"id"` + Source string `json:"source"` + Path string `json:"path"` + Profile ExecutionProfile `json:"profile"` +} + +// ResolvedProjectPolicy is a fully loaded project policy plus source metadata. +type ResolvedProjectPolicy struct { + ID string `json:"id"` + Source string `json:"source"` + Path string `json:"path"` + Policy ProjectPolicyDocument `json:"policy"` +} + +// TaskPreview describes the resolved flow/profile/policy combination for a task. +type TaskPreview struct { + EffectiveRequest types.TaskRequest `json:"effective_request"` + Flow ResolvedFlowDefinition `json:"flow"` + Profile ResolvedExecutionProfile `json:"profile"` + Policy ResolvedProjectPolicy `json:"policy"` + StartState string `json:"start_state"` + StartRole types.Role `json:"start_role"` + StateTypes []FlowStateType `json:"state_types"` +} + +// ValidationStatus is the status of one preflight check. +type ValidationStatus string + +const ( + ValidationPass ValidationStatus = "pass" + ValidationWarn ValidationStatus = "warn" + ValidationFail ValidationStatus = "fail" +) + +// ValidationCheck is one human- and machine-readable validation result. +type ValidationCheck struct { + Name string `json:"name"` + Status ValidationStatus `json:"status"` + Summary string `json:"summary"` + Detail string `json:"detail,omitempty"` +} + +// ValidationOptions controls optional preflight behavior. +type ValidationOptions struct { + Probe bool `json:"probe"` +} + +// ValidationReport captures the result of config and runtime preflight checks. +type ValidationReport struct { + Overall ValidationStatus `json:"overall"` + Preview TaskPreview `json:"preview"` + Checks []ValidationCheck `json:"checks"` +} + +// TaskTemplateOptions controls generated task-file shape. +type TaskTemplateOptions struct { + Flow string `json:"flow,omitempty"` + Profile string `json:"profile,omitempty"` + Policy string `json:"policy,omitempty"` + Description string `json:"description,omitempty"` + SeedApproval string `json:"seed_approval,omitempty"` + ApprovalNote string `json:"approval_note,omitempty"` +} + +// DiscoverConfigCatalog lists built-ins and detected local overrides. +func DiscoverConfigCatalog(repoRoot string) (ConfigCatalog, error) { + builtinFlows, err := discoverBuiltinEntries("builtins/flows", "flow") + if err != nil { + return ConfigCatalog{}, err + } + builtinProfiles, err := discoverBuiltinEntries("builtins/profiles", "profile") + if err != nil { + return ConfigCatalog{}, err + } + builtinPolicies, err := discoverBuiltinEntries("builtins/project-policies", "policy") + if err != nil { + return ConfigCatalog{}, err + } + + builtinFlowIDs := entryIDSet(builtinFlows) + builtinProfileIDs := entryIDSet(builtinProfiles) + builtinPolicyIDs := entryIDSet(builtinPolicies) + + localFlows := discoverLocalEntries(repoRoot, "flow", builtinFlowIDs) + localProfiles := discoverLocalEntries(repoRoot, "profile", builtinProfileIDs) + localPolicies := discoverLocalEntries(repoRoot, "policy", builtinPolicyIDs) + + return ConfigCatalog{ + Flows: append(builtinFlows, localFlows...), + Profiles: append(builtinProfiles, localProfiles...), + Policies: append(builtinPolicies, localPolicies...), + }, nil +} + +// DescribeFlow resolves one flow definition and its source metadata. +func DescribeFlow(repoRoot string, selector string) (ResolvedFlowDefinition, error) { + location, err := resolveConfigLocation(repoRoot, selector, "flows", selector+".yaml", "builtins/flows") + if err != nil { + return ResolvedFlowDefinition{}, err + } + var doc FlowDocument + if err := loadYAMLDocument(location.Path, &doc); err != nil { + return ResolvedFlowDefinition{}, err + } + id := strings.TrimSpace(doc.Flow.ID) + if id == "" { + id = strings.TrimSuffix(filepath.Base(location.Path), filepath.Ext(location.Path)) + doc.Flow.ID = id + } + return ResolvedFlowDefinition{ + ID: id, + Source: location.Source, + Path: location.Path, + Flow: doc.Flow, + }, nil +} + +// DescribeExecutionProfile resolves one execution profile and its source metadata. +func DescribeExecutionProfile(repoRoot string, selector string) (ResolvedExecutionProfile, error) { + location, err := resolveConfigLocation(repoRoot, selector, "profiles", selector+".yaml", "builtins/profiles") + if err != nil { + return ResolvedExecutionProfile{}, err + } + var doc ExecutionProfileDocument + if err := loadYAMLDocument(location.Path, &doc); err != nil { + return ResolvedExecutionProfile{}, err + } + id := strings.TrimSpace(doc.Profile.ID) + if id == "" { + id = strings.TrimSuffix(filepath.Base(location.Path), filepath.Ext(location.Path)) + doc.Profile.ID = id + } + if doc.Profile.Sandbox != "" { + sandbox, err := NormalizeSandboxMode(string(doc.Profile.Sandbox)) + if err != nil { + return ResolvedExecutionProfile{}, err + } + doc.Profile.Sandbox = sandbox + } + return ResolvedExecutionProfile{ + ID: id, + Source: location.Source, + Path: location.Path, + Profile: doc.Profile, + }, nil +} + +// DescribeProjectPolicy resolves one project policy and its source metadata. +func DescribeProjectPolicy(repoRoot string, selector string) (ResolvedProjectPolicy, error) { + filename := selector + ".yaml" + if strings.TrimSpace(selector) == "" { + selector = defaultPolicyID + filename = "default_local.yaml" + } + if selector == defaultPolicyID { + filename = "default_local.yaml" + } + location, err := resolveConfigLocation(repoRoot, selector, "project-policies", filename, "builtins/project-policies") + if err != nil { + return ResolvedProjectPolicy{}, err + } + var doc ProjectPolicyDocument + if err := loadYAMLDocument(location.Path, &doc); err != nil { + return ResolvedProjectPolicy{}, err + } + id := strings.TrimSpace(doc.ID) + if id == "" { + id = strings.TrimSuffix(filepath.Base(location.Path), filepath.Ext(location.Path)) + doc.ID = id + } + return ResolvedProjectPolicy{ + ID: id, + Source: location.Source, + Path: location.Path, + Policy: doc, + }, nil +} + +// PreviewTaskSelection resolves the flow/profile/policy combination without checking executor availability. +func PreviewTaskSelection(repoRoot string, req types.TaskRequest) (TaskPreview, error) { + normalized, err := NormalizeTaskRequest(req) + if err != nil { + return TaskPreview{}, err + } + + bundle, err := resolveExecutionBundle(repoRoot, normalized) + if err != nil { + return TaskPreview{}, err + } + normalized, err = applyExecutionProfile(normalized, bundle.Profile) + if err != nil { + return TaskPreview{}, err + } + if strings.TrimSpace(normalized.Flow) == "" { + normalized.Flow = bundle.Flow.ID + } + if strings.TrimSpace(normalized.Profile) == "" { + normalized.Profile = bundle.Profile.ID + } + if strings.TrimSpace(normalized.Policy) == "" { + normalized.Policy = bundle.Policy.ID + } + + flow, err := DescribeFlow(repoRoot, normalized.Flow) + if err != nil { + return TaskPreview{}, err + } + profile, err := DescribeExecutionProfile(repoRoot, normalized.Profile) + if err != nil { + return TaskPreview{}, err + } + policy, err := DescribeProjectPolicy(repoRoot, normalized.Policy) + if err != nil { + return TaskPreview{}, err + } + + stateTypes := make([]FlowStateType, 0, len(bundle.Flow.States)) + seenTypes := map[FlowStateType]struct{}{} + walkFlowStates(bundle.Flow, func(_ string, state FlowState) { + if _, ok := seenTypes[state.Type]; ok { + return + } + seenTypes[state.Type] = struct{}{} + stateTypes = append(stateTypes, state.Type) + }) + slices.Sort(stateTypes) + + return TaskPreview{ + EffectiveRequest: normalized, + Flow: flow, + Profile: profile, + Policy: policy, + StartState: bundle.Flow.StartAt, + StartRole: roleForState(bundle.Flow.States[bundle.Flow.StartAt]), + StateTypes: stateTypes, + }, nil +} + +// ValidateTaskConfiguration runs fail-closed config and runtime preflight checks. +func ValidateTaskConfiguration(repoRoot string, req types.TaskRequest, opts ValidationOptions) (ValidationReport, error) { + report := ValidationReport{} + preview, err := PreviewTaskSelection(repoRoot, req) + if err != nil { + return report, err + } + report.Preview = preview + + checks := []ValidationCheck{ + {Name: "flow", Status: ValidationPass, Summary: fmt.Sprintf("resolved flow %s", preview.Flow.ID), Detail: preview.Flow.Path}, + {Name: "profile", Status: ValidationPass, Summary: fmt.Sprintf("resolved execution profile %s", preview.Profile.ID), Detail: preview.Profile.Path}, + {Name: "policy", Status: ValidationPass, Summary: fmt.Sprintf("resolved project policy %s", preview.Policy.ID), Detail: preview.Policy.Path}, + {Name: "bundle", Status: ValidationPass, Summary: "flow/profile/policy bundle validated successfully"}, + } + + if strings.TrimSpace(preview.EffectiveRequest.Description) == "" { + checks = append(checks, ValidationCheck{ + Name: "task_description", + Status: ValidationWarn, + Summary: "task description is empty", + Detail: "validation can continue without a description, but `coop run` still requires one", + }) + } else { + checks = append(checks, ValidationCheck{ + Name: "task_description", + Status: ValidationPass, + Summary: "task description is present", + }) + } + + checks = append(checks, availabilityChecks(preview, opts.Probe)...) + checks = append(checks, approvalChecks(preview)...) + checks = append(checks, mcpChecks(preview, opts.Probe)...) + + report.Checks = checks + report.Overall = summarizeValidationChecks(checks) + return report, nil +} + +// GenerateTaskTemplate produces a starter YAML task contract for one flow. +func GenerateTaskTemplate(repoRoot string, opts TaskTemplateOptions) (types.TaskRequest, error) { + req := types.TaskRequest{ + Description: strings.TrimSpace(opts.Description), + Flow: strings.TrimSpace(opts.Flow), + Profile: strings.TrimSpace(opts.Profile), + Policy: strings.TrimSpace(opts.Policy), + } + if req.Description == "" { + req.Description = defaultTaskDescription(req.Flow) + } + + preview, err := PreviewTaskSelection(repoRoot, req) + if err != nil { + return types.TaskRequest{}, err + } + template := preview.EffectiveRequest + template.Description = req.Description + template.Inputs = defaultTaskInputs(preview.Flow.ID) + + seedDecision := strings.ToLower(strings.TrimSpace(opts.SeedApproval)) + if seedDecision != "" { + template.Approvals = make(map[string]types.ApprovalDecision) + for stateID, state := range preview.Flow.Flow.States { + if state.Type != FlowStateApproval { + continue + } + template.Approvals[stateID] = types.ApprovalDecision{ + Decision: seedDecision, + Note: strings.TrimSpace(opts.ApprovalNote), + ReturnTo: strings.TrimSpace(state.ReturnTo), + } + } + } + + return template, nil +} + +func discoverBuiltinEntries(dir string, kind string) ([]ConfigCatalogEntry, error) { + entries, err := fs.ReadDir(builtinFlowFS, dir) + if err != nil { + return nil, err + } + + out := make([]ConfigCatalogEntry, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + path := pathJoinFS(dir, entry.Name()) + out = append(out, catalogEntryFromPath(path, kind, "builtin", false)) + } + sortCatalogEntries(out) + return out, nil +} + +func discoverLocalEntries(repoRoot string, kind string, builtinIDs map[string]struct{}) []ConfigCatalogEntry { + paths := discoverLocalConfigPaths(repoRoot, kind) + out := make([]ConfigCatalogEntry, 0, len(paths)) + for _, path := range paths { + entry := catalogEntryFromPath(path, kind, "local", false) + if _, ok := builtinIDs[entry.ID]; ok && entry.ID != "" { + entry.OverridesBuiltin = true + } + out = append(out, entry) + } + sortCatalogEntries(out) + return out +} + +func discoverLocalConfigPaths(repoRoot string, kind string) []string { + paths := []string{} + globs := []string{} + switch kind { + case "flow": + globs = []string{ + filepath.Join(repoRoot, "cooperations", "flows", "*.yaml"), + filepath.Join(repoRoot, ".cooperations", "flows", "*.yaml"), + } + case "profile": + globs = []string{ + filepath.Join(repoRoot, "cooperations", "profiles", "*.yaml"), + filepath.Join(repoRoot, ".cooperations", "profiles", "*.yaml"), + } + case "policy": + globs = []string{ + filepath.Join(repoRoot, "cooperations", "project-policies", "*.yaml"), + filepath.Join(repoRoot, ".cooperations", "project-policies", "*.yaml"), + filepath.Join(repoRoot, "cooperations", "project-policy.yaml"), + filepath.Join(repoRoot, ".cooperations", "project-policy.yaml"), + } + } + + seen := map[string]struct{}{} + for _, pattern := range globs { + if strings.Contains(pattern, "*") { + matches, _ := filepath.Glob(pattern) + for _, match := range matches { + if _, ok := seen[match]; ok { + continue + } + seen[match] = struct{}{} + paths = append(paths, match) + } + continue + } + if _, err := os.Stat(pattern); err == nil { + if _, ok := seen[pattern]; ok { + continue + } + seen[pattern] = struct{}{} + paths = append(paths, pattern) + } + } + slices.Sort(paths) + return paths +} + +func catalogEntryFromPath(path string, kind string, source string, overridesBuiltin bool) ConfigCatalogEntry { + entry := ConfigCatalogEntry{ + Kind: kind, + Source: source, + Path: path, + OverridesBuiltin: overridesBuiltin, + } + + switch kind { + case "flow": + var doc FlowDocument + if err := loadYAMLDocument(path, &doc); err != nil { + entry.ID = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + entry.Error = err.Error() + return entry + } + entry.ID = doc.Flow.ID + entry.Name = doc.Flow.Name + case "profile": + var doc ExecutionProfileDocument + if err := loadYAMLDocument(path, &doc); err != nil { + entry.ID = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + entry.Error = err.Error() + return entry + } + entry.ID = doc.Profile.ID + entry.Name = doc.Profile.Name + case "policy": + var doc ProjectPolicyDocument + if err := loadYAMLDocument(path, &doc); err != nil { + entry.ID = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + entry.Error = err.Error() + return entry + } + entry.ID = doc.ID + } + + if strings.TrimSpace(entry.ID) == "" { + entry.ID = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + return entry +} + +func entryIDSet(entries []ConfigCatalogEntry) map[string]struct{} { + out := make(map[string]struct{}, len(entries)) + for _, entry := range entries { + if entry.ID == "" { + continue + } + out[entry.ID] = struct{}{} + } + return out +} + +func sortCatalogEntries(entries []ConfigCatalogEntry) { + slices.SortFunc(entries, func(a ConfigCatalogEntry, b ConfigCatalogEntry) int { + if a.ID == b.ID { + return strings.Compare(a.Path, b.Path) + } + return strings.Compare(a.ID, b.ID) + }) +} + +func availabilityChecks(preview TaskPreview, probe bool) []ValidationCheck { + checks := []ValidationCheck{} + + appCfg := DefaultAppConfig() + runtimeCfg, err := buildAgentRuntime(appCfg, preview.Policy.Policy) + if err != nil { + return []ValidationCheck{{ + Name: "agent_runtime", + Status: ValidationFail, + Summary: "failed to synthesize agent runtime from project policy", + Detail: err.Error(), + }} + } + + requiredProfiles := requiredAgentProfiles(preview.Flow.Flow, runtimeCfg) + for profileName, profile := range requiredProfiles { + checks = append(checks, validateModelProfileAvailability(profileName, profile, probe)) + } + + requiredShellBinaries := requiredShellCommands(preview.Flow.Flow) + for _, binary := range requiredShellBinaries { + checks = append(checks, validateBinaryAvailability("shell:"+binary, binary, probe, probeArgsForBinary(binary))) + } + + if len(requiredProfiles) == 0 && len(requiredShellBinaries) == 0 { + checks = append(checks, ValidationCheck{ + Name: "executors", + Status: ValidationPass, + Summary: "flow does not require local agent or shell executors", + }) + } + + return checks +} + +func approvalChecks(preview TaskPreview) []ValidationCheck { + checks := []ValidationCheck{} + walkFlowStates(preview.Flow.Flow, func(stateID string, state FlowState) { + if state.Type != FlowStateApproval { + return + } + if _, ok := preview.EffectiveRequest.Approvals[stateID]; ok { + checks = append(checks, ValidationCheck{ + Name: "approval:" + stateID, + Status: ValidationPass, + Summary: fmt.Sprintf("approval state %s has a seeded decision", stateID), + }) + return + } + if strings.TrimSpace(state.IfUnattended) != "" { + checks = append(checks, ValidationCheck{ + Name: "approval:" + stateID, + Status: ValidationPass, + Summary: fmt.Sprintf("approval state %s has unattended behavior %q", stateID, state.IfUnattended), + }) + return + } + checks = append(checks, ValidationCheck{ + Name: "approval:" + stateID, + Status: ValidationWarn, + Summary: fmt.Sprintf("approval state %s requires an interactive surface or seeded decision", stateID), + }) + }) + return checks +} + +func mcpChecks(preview TaskPreview, probe bool) []ValidationCheck { + checks := []ValidationCheck{} + walkFlowStates(preview.Flow.Flow, func(stateID string, state FlowState) { + if state.Type != FlowStateMCP { + return + } + summary := fmt.Sprintf("mcp state %s cannot run through the default CLI runtime yet", stateID) + detail := fmt.Sprintf("flow expects server=%s tool=%s, but `coop run` does not wire an MCP executor", state.Server, state.Tool) + if probe { + detail += "; no deeper probe is available until an MCP executor is wired into the CLI runtime" + } + checks = append(checks, ValidationCheck{ + Name: "mcp:" + stateID, + Status: ValidationFail, + Summary: summary, + Detail: detail, + }) + }) + return checks +} + +func summarizeValidationChecks(checks []ValidationCheck) ValidationStatus { + overall := ValidationPass + for _, check := range checks { + switch check.Status { + case ValidationFail: + return ValidationFail + case ValidationWarn: + overall = ValidationWarn + } + } + return overall +} + +func requiredAgentProfiles(flow FlowDefinition, runtimeCfg agentRuntimeConfig) map[string]ModelProfile { + required := map[string]ModelProfile{} + walkFlowStates(flow, func(_ string, state FlowState) { + if state.Type != FlowStateAgent { + return + } + role, err := parseRole(state.Role) + if err != nil { + return + } + profileName, ok := runtimeCfg.roleProfiles[role] + if !ok { + return + } + profile, ok := runtimeCfg.modelProfiles[profileName] + if !ok { + return + } + required[profileName] = profile + }) + return required +} + +func requiredShellCommands(flow FlowDefinition) []string { + seen := map[string]struct{}{} + walkFlowStates(flow, func(_ string, state FlowState) { + if state.Type != FlowStateShell || len(state.Command) == 0 { + return + } + binary := strings.TrimSpace(state.Command[0]) + if binary == "" { + return + } + seen[binary] = struct{}{} + }) + out := make([]string, 0, len(seen)) + for binary := range seen { + out = append(out, binary) + } + slices.Sort(out) + return out +} + +func validateModelProfileAvailability(profileName string, profile ModelProfile, probe bool) ValidationCheck { + provider := normalizeProvider(profile.Provider) + switch provider { + case string(types.ModelCodexCLI): + binary := strings.TrimSpace(profile.Codex.BinaryPath) + if binary == "" { + binary = os.Getenv("CODEX_CLI_PATH") + } + if binary == "" { + binary = "codex" + } + return validateBinaryAvailability("agent:"+profileName, binary, probe, []string{"--help"}) + case string(types.ModelClaudeCLI): + binary := strings.TrimSpace(profile.Claude.BinaryPath) + if binary == "" { + binary = os.Getenv("CLAUDE_CLI_PATH") + } + if binary == "" { + binary = "claude" + } + return validateBinaryAvailability("agent:"+profileName, binary, probe, []string{"--help"}) + default: + return ValidationCheck{ + Name: "agent:" + profileName, + Status: ValidationFail, + Summary: fmt.Sprintf("profile %s has unsupported provider %q", profileName, profile.Provider), + } + } +} + +func validateBinaryAvailability(name string, binary string, probe bool, probeArgs []string) ValidationCheck { + path, err := lookupBinary(binary) + if err != nil { + return ValidationCheck{ + Name: name, + Status: ValidationFail, + Summary: fmt.Sprintf("required binary %q is not available", binary), + Detail: err.Error(), + } + } + + check := ValidationCheck{ + Name: name, + Status: ValidationPass, + Summary: fmt.Sprintf("binary %q resolved", binary), + Detail: path, + } + if !probe { + return check + } + if err := probeBinary(path, probeArgs...); err != nil { + check.Status = ValidationFail + check.Summary = fmt.Sprintf("binary %q failed deep probe", binary) + check.Detail = err.Error() + return check + } + check.Detail = fmt.Sprintf("%s (deep probe passed)", path) + return check +} + +func probeArgsForBinary(binary string) []string { + switch filepath.Base(binary) { + case "bash": + return []string{"--version"} + default: + return []string{"--help"} + } +} + +func defaultTaskDescription(flowID string) string { + switch strings.TrimSpace(flowID) { + case "recon_assessment": + return "Assess the listed targets and summarize what the current runtime can inspect." + case "incident_triage": + return "Classify the incident, gather context, and prepare the next-action summary." + default: + return "Describe the work you want this flow to carry out." + } +} + +func defaultTaskInputs(flowID string) map[string]any { + switch strings.TrimSpace(flowID) { + case "recon_assessment": + return map[string]any{ + "targets": []string{"example.com"}, + } + case "incident_triage": + return map[string]any{ + "incident_id": "INC-0001", + "summary": "Summarize the incident here.", + "reporter": "operator@example.com", + } + default: + return nil + } +} + +func walkFlowStates(flow FlowDefinition, fn func(stateID string, state FlowState)) { + stateIDs := make([]string, 0, len(flow.States)) + for stateID := range flow.States { + stateIDs = append(stateIDs, stateID) + } + slices.Sort(stateIDs) + for _, stateID := range stateIDs { + walkFlowState(stateID, flow.States[stateID], fn) + } +} + +func walkFlowState(stateID string, state FlowState, fn func(stateID string, state FlowState)) { + fn(stateID, state) + if len(state.Branches) == 0 { + return + } + branchIDs := make([]string, 0, len(state.Branches)) + for branchID := range state.Branches { + branchIDs = append(branchIDs, branchID) + } + slices.Sort(branchIDs) + for _, branchID := range branchIDs { + walkFlowState(stateID+"."+branchID, state.Branches[branchID], fn) + } +} diff --git a/internal/orchestrator/flow_schema.go b/internal/orchestrator/flow_schema.go index e85af59..4b2ab54 100644 --- a/internal/orchestrator/flow_schema.go +++ b/internal/orchestrator/flow_schema.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "embed" + "strings" "cooperations/internal/types" ) @@ -16,6 +17,17 @@ const ( defaultPolicyID = "default_local" ) +func defaultProfileForFlow(flowID string) string { + switch strings.TrimSpace(flowID) { + case "recon_assessment": + return "recon_conservative" + case "incident_triage": + return "incident_conservative" + default: + return defaultProfileID + } +} + // FlowStateType identifies a supported executor or control-flow state. type FlowStateType string diff --git a/internal/types/types.go b/internal/types/types.go index 71785fd..8beeedc 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -1,7 +1,7 @@ // Package types defines shared types for the cooperations orchestrator. package types -// Role represents an agent role in the mob programming workflow. +// Role represents an agent role in the configurable flow runtime. type Role string const (