From 0af445184c6328989788f5559359501eef0fe039 Mon Sep 17 00:00:00 2001 From: Alex Mackay Date: Fri, 22 May 2026 16:33:02 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(pipeline):=20Add=20software=20lifecycl?= =?UTF-8?q?e=20harness=20=E2=80=94=20requirements=20=E2=86=92=20design=20?= =?UTF-8?q?=E2=86=92=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a pipeline orchestration system that drives a software project from natural language requirements through structured PRD, technical design, and implementation task list, with human approval gates between each stage. New packages: - agents/requirements: RequirementsAnalyzer agent that transforms informal requirements into an IEEE 830-style PRD (user stories, acceptance criteria, NFRs, success metrics, open questions). - agents/architect: Architect agent that produces a C4-inspired technical design document (components, API contracts, data models, tech choices) and a TDD-framed implementation task list. Runs as two distinct stages. - pipeline: Sequential stage orchestrator with: - YAML-driven pipeline spec (.pipeline.yaml) generated by 'pipeline init' - File-persisted state (.pipeline-state/.json) enabling resume after failure or interruption via --from-stage - Interactive human approval gates ([y]es/[e]dit/[n]o/[q]uit) with artifact preview and $EDITOR support; skippable with --yes-all New CLI commands under 'agent pipeline': init Generate a pre-populated .pipeline.yaml spec from flags run Execute the pipeline; supports --from-stage and --yes-all status Tabular view of stage completion with timestamps and output paths State constants for the two new agents are added to state/state.go. go.yaml.in/yaml/v3 is promoted from indirect to direct dependency. Architecture is forward-compatible: adding V2 (Claude Code subprocess for implementation) or V3 (git commit + PR for delivery) requires only extending the newAgentForKind() switch in pipeline/pipeline.go — no orchestration changes needed. Co-Authored-By: Claude Sonnet 4.6 --- agents/architect/architect.go | 60 +++++++ agents/architect/config.go | 21 +++ agents/architect/config_test.go | 51 ++++++ agents/architect/prompt.go | 117 ++++++++++++++ agents/requirements/config.go | 16 ++ agents/requirements/config_test.go | 36 +++++ agents/requirements/prompt.go | 84 ++++++++++ agents/requirements/requirements.go | 51 ++++++ cmd/cmd.go | 1 + cmd/pipeline.go | 43 +++++ cmd/pipeline_init.go | 62 +++++++ cmd/pipeline_run.go | 76 +++++++++ cmd/pipeline_status.go | 69 ++++++++ go.mod | 2 +- pipeline/config.go | 102 ++++++++++++ pipeline/config_test.go | 156 ++++++++++++++++++ pipeline/gate.go | 83 ++++++++++ pipeline/pipeline.go | 242 ++++++++++++++++++++++++++++ pipeline/state.go | 79 +++++++++ pipeline/state_test.go | 94 +++++++++++ state/state.go | 10 ++ 21 files changed, 1454 insertions(+), 1 deletion(-) create mode 100644 agents/architect/architect.go create mode 100644 agents/architect/config.go create mode 100644 agents/architect/config_test.go create mode 100644 agents/architect/prompt.go create mode 100644 agents/requirements/config.go create mode 100644 agents/requirements/config_test.go create mode 100644 agents/requirements/prompt.go create mode 100644 agents/requirements/requirements.go create mode 100644 cmd/pipeline.go create mode 100644 cmd/pipeline_init.go create mode 100644 cmd/pipeline_run.go create mode 100644 cmd/pipeline_status.go create mode 100644 pipeline/config.go create mode 100644 pipeline/config_test.go create mode 100644 pipeline/gate.go create mode 100644 pipeline/pipeline.go create mode 100644 pipeline/state.go create mode 100644 pipeline/state_test.go diff --git a/agents/architect/architect.go b/agents/architect/architect.go new file mode 100644 index 0000000..e96a651 --- /dev/null +++ b/agents/architect/architect.go @@ -0,0 +1,60 @@ +package architect + +import ( + "context" + + "github.com/ATMackay/agent/state" + "github.com/ATMackay/agent/tools" + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + adkmodel "google.golang.org/adk/model" +) + +const AgentName = "architect" + +// Architect produces technical design documents and implementation task lists from PRDs. +type Architect struct { + agent.Agent +} + +// NewArchitect returns an Architect agent. +func NewArchitect(ctx context.Context, cfg *Config, mod adkmodel.LLM) (*Architect, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + deps := tools.Deps{} + + kinds := []tools.Kind{ + tools.ReadLocalFile, + tools.WriteFile, + tools.SearchFiles, + } + if cfg.WorkDir != "" { + kinds = append(kinds, tools.ListDir) + } + + functionTools, err := tools.GetTools(kinds, &deps) + if err != nil { + return nil, err + } + + outputKey := state.StateDesign + if cfg.Task == "tasks" { + outputKey = state.StateTaskList + } + + ag, err := llmagent.New(llmagent.Config{ + Name: AgentName, + Model: mod, + Description: "Produces technical design documents and implementation task lists from product requirements.", + Instruction: buildInstruction(), + Tools: functionTools, + OutputKey: outputKey, + }) + if err != nil { + return nil, err + } + + return &Architect{Agent: ag}, nil +} diff --git a/agents/architect/config.go b/agents/architect/config.go new file mode 100644 index 0000000..ec02622 --- /dev/null +++ b/agents/architect/config.go @@ -0,0 +1,21 @@ +package architect + +import "errors" + +// Config is the base config for the architect agent. +type Config struct { + WorkDir string // optional: existing project root for context + PRDPath string // path to the input document (PRD or design doc) + OutputPath string + Task string // "design" or "tasks" +} + +func (c Config) Validate() error { + if c.OutputPath == "" { + return errors.New("empty output path supplied") + } + if c.Task != "design" && c.Task != "tasks" { + return errors.New("task must be 'design' or 'tasks'") + } + return nil +} diff --git a/agents/architect/config_test.go b/agents/architect/config_test.go new file mode 100644 index 0000000..c1e327b --- /dev/null +++ b/agents/architect/config_test.go @@ -0,0 +1,51 @@ +package architect + +import "testing" + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "missing output path", + cfg: Config{Task: "design"}, + wantErr: true, + }, + { + name: "missing task", + cfg: Config{OutputPath: "docs/DESIGN.md"}, + wantErr: true, + }, + { + name: "invalid task value", + cfg: Config{OutputPath: "docs/DESIGN.md", Task: "review"}, + wantErr: true, + }, + { + name: "valid design task", + cfg: Config{OutputPath: "docs/DESIGN.md", Task: "design"}, + wantErr: false, + }, + { + name: "valid tasks task", + cfg: Config{OutputPath: "docs/TASKS.md", Task: "tasks"}, + wantErr: false, + }, + { + name: "valid with all fields", + cfg: Config{WorkDir: "/src/app", PRDPath: "docs/PRD.md", OutputPath: "docs/DESIGN.md", Task: "design"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/agents/architect/prompt.go b/agents/architect/prompt.go new file mode 100644 index 0000000..477162a --- /dev/null +++ b/agents/architect/prompt.go @@ -0,0 +1,117 @@ +package architect + +import "google.golang.org/genai" + +func buildInstruction() string { + return ` +You are a software architect. You produce precise, implementable technical specifications +from product requirements documents. + +Output path: {output_path} +Input document: {prd_path} +Existing project directory (optional): {work_dir?} +Task: {agent_task} + +Workflow: +1. If {prd_path} is set, read it with read_local_file to understand the requirements. +2. If {work_dir} is set, use list_dir and search_files to understand the existing codebase — + read key files (main entry points, core packages, config structures, existing interfaces). + Incorporate existing conventions and avoid proposing changes that conflict with them. +3. Produce the output document for {agent_task} as described below. +4. Write the result with write_output_file. + +━━━ When agent_task = "design" ━━━ + +Produce a Technical Design Document: + +# Technical Design — + +## 1. Architecture Overview +Describe the high-level architecture. Include a text diagram (ASCII or Mermaid) of the +main components and their relationships. + +## 2. Components & Responsibilities +For each component or package, describe: +- Purpose +- Key responsibilities +- Public interface (types, functions, or API endpoints it exposes) +- Dependencies on other components + +## 3. Data Models +Define all significant data structures, schemas, or domain types. +Use pseudocode or the target language syntax. + +## 4. API Contracts +For each public API or inter-component interface, specify: +- Method/endpoint signature +- Request/response types +- Error conditions +- Example usage + +## 5. Technology Choices +List the key libraries, frameworks, and infrastructure components chosen. +For each, give a one-line rationale. + +## 6. Security Considerations +Identify the top 3–5 security concerns and how each is addressed. + +## 7. Observability +Describe logging, metrics, and tracing strategy. + +## 8. Open Questions +List any design decisions still unresolved. + +━━━ When agent_task = "tasks" ━━━ + +Produce an Implementation Task List from the design document: + +# Implementation Task List — + +For each task, use this format: + +## Task N: +**Files:** list files to create or modify +**Description:** what to implement, precisely +**Acceptance Criteria:** +- [ ] criterion 1 +- [ ] criterion 2 +**Test Requirements:** describe the unit/integration tests that must pass + +Rules for the task list: +- Order tasks so dependencies come first (foundational types before business logic, + storage layer before HTTP layer, etc.). +- Keep each task small enough to complete in one focused session (~1–3 hours). +- Every task must have at least one test requirement. +- Use TDD framing: describe what tests prove the task is done. +- Do not include tasks for things already present in the existing codebase. + +Efficiency rules (apply to both tasks): +- search_files before reading any file in full. +- Prefer snippet reads (start_line/end_line) over full-file reads. +- Do not read files you have already read unless their content has changed. +- Stop reading when you have enough information to write the output. +` +} + +// UserMessage returns the initial message to kick off an architect session. +func UserMessage(inputDocPath, workDir, task string) *genai.Content { + var text string + switch task { + case "tasks": + text = "Read the design document and produce a detailed, ordered implementation task list. " + + "Order tasks so that foundational work comes first. Each task must include test requirements." + default: + text = "Read the PRD and produce a technical design document. " + + "If a work_dir is set, explore it first to understand existing patterns and constraints." + } + if inputDocPath != "" { + text += "\n\nInput document: " + inputDocPath + } + if workDir != "" { + text += "\nExisting project: " + workDir + } + return &genai.Content{ + Role: "user", + Parts: []*genai.Part{{Text: text}}, + } +} diff --git a/agents/requirements/config.go b/agents/requirements/config.go new file mode 100644 index 0000000..1a6047c --- /dev/null +++ b/agents/requirements/config.go @@ -0,0 +1,16 @@ +package requirements + +import "errors" + +// Config is the base config for the requirements analyzer agent. +type Config struct { + WorkDir string // optional: existing project root for context + OutputPath string +} + +func (c Config) Validate() error { + if c.OutputPath == "" { + return errors.New("empty output path supplied") + } + return nil +} diff --git a/agents/requirements/config_test.go b/agents/requirements/config_test.go new file mode 100644 index 0000000..fa48f42 --- /dev/null +++ b/agents/requirements/config_test.go @@ -0,0 +1,36 @@ +package requirements + +import "testing" + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "missing output path", + cfg: Config{}, + wantErr: true, + }, + { + name: "valid without work dir", + cfg: Config{OutputPath: "docs/PRD.md"}, + wantErr: false, + }, + { + name: "valid with work dir", + cfg: Config{WorkDir: "/src/myapp", OutputPath: "docs/PRD.md"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/agents/requirements/prompt.go b/agents/requirements/prompt.go new file mode 100644 index 0000000..4af9cb5 --- /dev/null +++ b/agents/requirements/prompt.go @@ -0,0 +1,84 @@ +package requirements + +import "google.golang.org/genai" + +func buildInstruction() string { + return ` +You are a software requirements analyst. Your job is to transform raw, informal requirements +into a structured, unambiguous Product Requirements Document (PRD). + +Output path: {output_path} +Requirements input: {requirements_prompt} +Existing project directory (optional): {work_dir?} + +If a working directory is provided: +1. Call list_dir on {work_dir} to understand the project's current state. +2. Call search_files or read_local_file to read key files such as README, existing docs, + configuration files, or entry points — enough to understand existing tech stack, + conventions, and constraints. +3. Incorporate relevant constraints and existing decisions into the PRD. +If no working directory is provided, skip the exploration steps. + +PRD Structure (write all sections): + +# Product Requirements Document — + +## 1. Problem Statement +What problem does this solve? Who has this problem? What is the current pain? + +## 2. Goals +What must this solution achieve? 3–5 concrete, measurable goals. + +## 3. Non-Goals +What is explicitly out of scope? List at least 3 things this project will NOT do. + +## 4. Target Users +Who will use this? Describe 1–3 user personas with name, role, and key needs. + +## 5. Functional Requirements +List every required capability as a user story: + As a , I want to so that . +For each user story, add numbered acceptance criteria (Given/When/Then). + +## 6. Non-Functional Requirements +Address each category if relevant: +- Performance: response time, throughput, concurrency targets +- Security: authentication, authorisation, data protection +- Reliability: uptime, error handling, recovery +- Scalability: growth expectations +- Observability: logging, metrics, tracing +- Developer experience: build time, test coverage targets + +## 7. Out of Scope +Explicitly list features, integrations, or concerns this version does not address. + +## 8. Success Metrics +How will we know this is a success? List 3–5 measurable criteria. + +## 9. Open Questions +List any ambiguities or decisions that still need to be resolved before implementation. + +Quality rules: +- Be specific and concrete. Avoid vague terms like "fast", "easy", "better". +- Every functional requirement must have at least one acceptance criterion. +- Do not invent requirements not implied by the input. +- If the input is ambiguous, document the ambiguity in Open Questions rather than guessing. +- Write for a technical audience who will implement directly from this document. + +After writing the PRD, call write_output_file with the full document and the output_path. +` +} + +// UserMessage returns the initial message to kick off requirements analysis. +func UserMessage(prompt string) *genai.Content { + return &genai.Content{ + Role: "user", + Parts: []*genai.Part{ + { + Text: "Analyse the following requirements and produce a structured PRD. " + + "If a work_dir is set, explore it first for context.\n\n" + + "Requirements:\n" + prompt, + }, + }, + } +} diff --git a/agents/requirements/requirements.go b/agents/requirements/requirements.go new file mode 100644 index 0000000..43ed329 --- /dev/null +++ b/agents/requirements/requirements.go @@ -0,0 +1,51 @@ +package requirements + +import ( + "context" + + "github.com/ATMackay/agent/state" + "github.com/ATMackay/agent/tools" + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + adkmodel "google.golang.org/adk/model" +) + +const AgentName = "requirements-analyzer" + +// RequirementsAnalyzer transforms natural language requirements into a structured PRD. +type RequirementsAnalyzer struct { + agent.Agent +} + +// NewRequirementsAnalyzer returns a RequirementsAnalyzer agent. +func NewRequirementsAnalyzer(ctx context.Context, cfg *Config, mod adkmodel.LLM) (*RequirementsAnalyzer, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + kinds := []tools.Kind{ + tools.WriteFile, + } + if cfg.WorkDir != "" { + kinds = append(kinds, tools.ListDir, tools.ReadLocalFile, tools.SearchFiles) + } + + functionTools, err := tools.GetTools(kinds, &tools.Deps{}) + if err != nil { + return nil, err + } + + ag, err := llmagent.New(llmagent.Config{ + Name: AgentName, + Model: mod, + Description: "Transforms natural language requirements into a structured Product Requirements Document.", + Instruction: buildInstruction(), + Tools: functionTools, + OutputKey: state.StatePRD, + }) + if err != nil { + return nil, err + } + + return &RequirementsAnalyzer{Agent: ag}, nil +} diff --git a/cmd/cmd.go b/cmd/cmd.go index ddff08a..49a488a 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -30,6 +30,7 @@ Version: } cmd.AddCommand(NewRunCmd()) + cmd.AddCommand(NewPipelineCmd()) cmd.AddCommand(VersionCmd()) return cmd } diff --git a/cmd/pipeline.go b/cmd/pipeline.go new file mode 100644 index 0000000..bab2ea8 --- /dev/null +++ b/cmd/pipeline.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/ATMackay/agent/constants" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewPipelineCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "pipeline", + Short: fmt.Sprintf("Manage %s lifecycle pipelines", constants.ServiceName), + Long: `Pipeline — software lifecycle orchestration + +Drive a project from natural language requirements through design and +implementation planning. Each stage is executed by a specialized agent +with optional human approval gates between stages. + +Use 'pipeline init' to generate a pipeline spec, then 'pipeline run' to execute it.`, + RunE: runHelp, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + logLevel := viper.GetString("log-level") + logFormat := viper.GetString("log-format") + if err := initLogging(logLevel, logFormat); err != nil { + return fmt.Errorf("failed to initialize logger: %w", err) + } + return nil + }, + } + + cmd.AddCommand(NewPipelineInitCmd()) + cmd.AddCommand(NewPipelineRunCmd()) + cmd.AddCommand(NewPipelineStatusCmd()) + + cmd.PersistentFlags().String("log-level", "info", "Log level (debug, info, warn, error)") + cmd.PersistentFlags().String("log-format", "text", "Log format (text, json)") + must(viper.BindPFlag("log-level", cmd.PersistentFlags().Lookup("log-level"))) + must(viper.BindPFlag("log-format", cmd.PersistentFlags().Lookup("log-format"))) + + return cmd +} diff --git a/cmd/pipeline_init.go b/cmd/pipeline_init.go new file mode 100644 index 0000000..4439191 --- /dev/null +++ b/cmd/pipeline_init.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/ATMackay/agent/pipeline" + "github.com/spf13/cobra" +) + +const defaultSpecFile = ".pipeline.yaml" + +func NewPipelineInitCmd() *cobra.Command { + var name, requirements, workDir, outputDir, specFile string + + cmd := &cobra.Command{ + Use: "init", + Short: "Generate a pipeline spec file", + Long: `Generate a .pipeline.yaml spec file pre-populated with the standard +three-stage lifecycle pipeline (requirements → design → tasks). + +Edit the generated file to customise requirements, stages, or model overrides, +then run 'pipeline run' to execute it.`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + name = cwd + } + + cfg := pipeline.DefaultConfig(name, requirements, workDir, outputDir) + + data, err := cfg.Marshal() + if err != nil { + return fmt.Errorf("marshal pipeline config: %w", err) + } + + if _, err := os.Stat(specFile); err == nil { + return fmt.Errorf("%s already exists; delete it or specify a different --spec path", specFile) + } + + if err := os.WriteFile(specFile, data, 0o644); err != nil { + return fmt.Errorf("write pipeline spec: %w", err) + } + + fmt.Printf("Pipeline spec written to %s\n", specFile) + fmt.Printf("Edit the requirements field, then run:\n\n") + fmt.Printf(" agent pipeline run --spec %s\n\n", specFile) + return nil + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Pipeline name (defaults to current directory name)") + cmd.Flags().StringVar(&requirements, "requirements", "", "Natural language requirements (inline text)") + cmd.Flags().StringVar(&workDir, "work-dir", "", "Existing project directory for agent context") + cmd.Flags().StringVar(&outputDir, "output-dir", "docs", "Directory for generated output files") + cmd.Flags().StringVar(&specFile, "spec", defaultSpecFile, "Output path for the generated spec file") + + return cmd +} diff --git a/cmd/pipeline_run.go b/cmd/pipeline_run.go new file mode 100644 index 0000000..e82a169 --- /dev/null +++ b/cmd/pipeline_run.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "fmt" + "log/slog" + + "github.com/ATMackay/agent/model" + "github.com/ATMackay/agent/pipeline" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewPipelineRunCmd() *cobra.Command { + var specFile, fromStage, modelName, modelProvider string + var yesAll bool + + cmd := &cobra.Command{ + Use: "run", + Short: "Execute a pipeline spec", + Long: `Execute the stages defined in a pipeline spec file sequentially. + +Each stage runs a specialized agent and writes its output to disk. +Human approval gates pause execution between stages (skip with --yes-all). + +To resume a failed or interrupted pipeline, use --from-stage . +Completed stages are skipped unless explicitly re-run with --from-stage.`, + RunE: func(cmd *cobra.Command, args []string) error { + apiKey := viper.GetString("api-key") + if apiKey == "" { + return fmt.Errorf("api key is required; set --api-key or export API_KEY") + } + + cfg, err := pipeline.LoadConfig(specFile) + if err != nil { + return fmt.Errorf("load pipeline spec: %w", err) + } + + ctx := cmd.Context() + + slog.Info("starting pipeline", + "name", cfg.Name, + "stages", len(cfg.Stages), + "model", modelName, + "provider", modelProvider, + ) + + modelCfg := &model.Config{ + Provider: model.Provider(modelProvider), + Model: modelName, + } + mod, err := model.New(ctx, modelCfg.WithAPIKey(apiKey)) + if err != nil { + return fmt.Errorf("create model: %w", err) + } + + p, err := pipeline.New(cfg) + if err != nil { + return fmt.Errorf("create pipeline: %w", err) + } + + return p.Run(ctx, mod, fromStage, yesAll) + }, + } + + cmd.Flags().StringVar(&specFile, "spec", defaultSpecFile, "Path to the pipeline spec file") + cmd.Flags().StringVar(&fromStage, "from-stage", "", "Resume execution from this stage ID") + cmd.Flags().BoolVar(&yesAll, "yes-all", false, "Skip all human approval gates (non-interactive mode)") + cmd.Flags().StringVar(&modelName, "model", "claude-opus-4-5-20251101", "Language model to use") + cmd.Flags().StringVar(&modelProvider, "provider", "claude", "LLM provider (claude or gemini)") + + must(viper.BindPFlag("model", cmd.Flags().Lookup("model"))) + must(viper.BindPFlag("provider", cmd.Flags().Lookup("provider"))) + must(viper.BindEnv("api-key", "API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY", "CLAUDE_API_KEY")) + + return cmd +} diff --git a/cmd/pipeline_status.go b/cmd/pipeline_status.go new file mode 100644 index 0000000..d2b60f7 --- /dev/null +++ b/cmd/pipeline_status.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/ATMackay/agent/pipeline" + "github.com/spf13/cobra" +) + +func NewPipelineStatusCmd() *cobra.Command { + var specFile string + + cmd := &cobra.Command{ + Use: "status", + Short: "Show pipeline stage completion status", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := pipeline.LoadConfig(specFile) + if err != nil { + return fmt.Errorf("load pipeline spec: %w", err) + } + + p, err := pipeline.New(cfg) + if err != nil { + return fmt.Errorf("load pipeline state: %w", err) + } + + ps := p.State() + + fmt.Printf("Pipeline: %s\n", cfg.Name) + fmt.Printf("Started: %s\n\n", ps.CreatedAt.Format("2006-01-02 15:04:05")) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "STAGE\tAGENT\tSTATUS\tCOMPLETED\tOUTPUT") + fmt.Fprintln(w, "─────\t─────\t──────\t─────────\t──────") + + for _, stage := range cfg.Stages { + st, ok := ps.Stages[stage.ID] + if !ok { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", stage.ID, stage.Agent, "pending", "—", "—") + continue + } + + completedAt := "—" + if st.CompletedAt != nil { + completedAt = st.CompletedAt.Format("15:04:05") + } + + outputPath := st.OutputPath + if outputPath == "" { + outputPath = "—" + } + + statusDisplay := st.Status + if st.Error != "" { + statusDisplay = fmt.Sprintf("failed: %s", st.Error) + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + stage.ID, stage.Agent, statusDisplay, completedAt, outputPath) + } + return w.Flush() + }, + } + + cmd.Flags().StringVar(&specFile, "spec", defaultSpecFile, "Path to the pipeline spec file") + return cmd +} diff --git a/go.mod b/go.mod index 65b2350..81cf887 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/louislef299/claude-go-adk v0.0.0-20260217224925-68eb91ba1ac6 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + go.yaml.in/yaml/v3 v3.0.4 google.golang.org/adk v0.6.0 google.golang.org/genai v1.50.0 ) @@ -47,7 +48,6 @@ require ( go.opentelemetry.io/otel/log v0.16.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/pipeline/config.go b/pipeline/config.go new file mode 100644 index 0000000..4305a1f --- /dev/null +++ b/pipeline/config.go @@ -0,0 +1,102 @@ +package pipeline + +import ( + "fmt" + "os" + + "go.yaml.in/yaml/v3" +) + +// PipelineConfig defines a named pipeline with ordered stages. +type PipelineConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Requirements string `yaml:"requirements"` // inline text or @file: + WorkDir string `yaml:"work_dir"` // optional: existing project root + OutputDir string `yaml:"output_dir"` // default: "docs" + Stages []StageConfig `yaml:"stages"` +} + +// StageConfig defines a single pipeline stage. +type StageConfig struct { + ID string `yaml:"id"` + Agent string `yaml:"agent"` // "requirements" or "architect" + Output string `yaml:"output"` // output file path (relative) + Gate *GateConfig `yaml:"gate,omitempty"` // optional human approval gate + Model string `yaml:"model,omitempty"` // optional per-stage model override +} + +// GateConfig defines a human approval gate after a stage. +type GateConfig struct { + Enabled bool `yaml:"enabled"` + Message string `yaml:"message"` +} + +// DefaultConfig returns the standard three-stage lifecycle pipeline template. +func DefaultConfig(name, requirements, workDir, outputDir string) *PipelineConfig { + if outputDir == "" { + outputDir = "docs" + } + return &PipelineConfig{ + Name: name, + Requirements: requirements, + WorkDir: workDir, + OutputDir: outputDir, + Stages: []StageConfig{ + { + ID: "requirements-analysis", + Agent: "requirements", + Output: outputDir + "/PRD.md", + Gate: &GateConfig{ + Enabled: true, + Message: "Review the PRD — does this capture your requirements?", + }, + }, + { + ID: "technical-design", + Agent: "architect", + Output: outputDir + "/DESIGN.md", + Gate: &GateConfig{ + Enabled: true, + Message: "Review the technical design before generating the task list", + }, + }, + { + ID: "task-breakdown", + Agent: "architect", + Output: outputDir + "/TASKS.md", + Gate: &GateConfig{ + Enabled: true, + Message: "Review the task list — ready to hand off to implementation?", + }, + }, + }, + } +} + +// LoadConfig reads and parses a pipeline YAML file. +func LoadConfig(path string) (*PipelineConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read pipeline config %s: %w", path, err) + } + var cfg PipelineConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse pipeline config: %w", err) + } + if cfg.Name == "" { + return nil, fmt.Errorf("pipeline config: name is required") + } + if len(cfg.Stages) == 0 { + return nil, fmt.Errorf("pipeline config: at least one stage is required") + } + if cfg.OutputDir == "" { + cfg.OutputDir = "docs" + } + return &cfg, nil +} + +// Marshal serialises a PipelineConfig to YAML bytes. +func (c *PipelineConfig) Marshal() ([]byte, error) { + return yaml.Marshal(c) +} diff --git a/pipeline/config_test.go b/pipeline/config_test.go new file mode 100644 index 0000000..8351a1f --- /dev/null +++ b/pipeline/config_test.go @@ -0,0 +1,156 @@ +package pipeline + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + tests := []struct { + name string + pipelineName string + requirements string + workDir string + outputDir string + wantStages int + wantOutputDir string + }{ + { + name: "default output dir", + pipelineName: "my-project", + requirements: "Build a REST API", + wantStages: 3, + wantOutputDir: "docs", + }, + { + name: "custom output dir", + pipelineName: "my-project", + requirements: "Build a CLI tool", + outputDir: "specs", + wantStages: 3, + wantOutputDir: "specs", + }, + { + name: "with work dir", + pipelineName: "existing-project", + requirements: "Add JWT auth", + workDir: "/src/myapp", + wantStages: 3, + wantOutputDir: "docs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig(tt.pipelineName, tt.requirements, tt.workDir, tt.outputDir) + + if cfg.Name != tt.pipelineName { + t.Errorf("Name = %q, want %q", cfg.Name, tt.pipelineName) + } + if cfg.Requirements != tt.requirements { + t.Errorf("Requirements = %q, want %q", cfg.Requirements, tt.requirements) + } + if cfg.WorkDir != tt.workDir { + t.Errorf("WorkDir = %q, want %q", cfg.WorkDir, tt.workDir) + } + if cfg.OutputDir != tt.wantOutputDir { + t.Errorf("OutputDir = %q, want %q", cfg.OutputDir, tt.wantOutputDir) + } + if len(cfg.Stages) != tt.wantStages { + t.Errorf("len(Stages) = %d, want %d", len(cfg.Stages), tt.wantStages) + } + for _, s := range cfg.Stages { + if s.Gate == nil || !s.Gate.Enabled { + t.Errorf("stage %q: gate should be enabled by default", s.ID) + } + } + }) + } +} + +func TestLoadConfig_RoundTrip(t *testing.T) { + cfg := DefaultConfig("test-pipeline", "Build something great", "", "docs") + + data, err := cfg.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + tmp := t.TempDir() + specPath := filepath.Join(tmp, "test.pipeline.yaml") + if err := os.WriteFile(specPath, data, 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + loaded, err := LoadConfig(specPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + if loaded.Name != cfg.Name { + t.Errorf("Name: got %q, want %q", loaded.Name, cfg.Name) + } + if loaded.Requirements != cfg.Requirements { + t.Errorf("Requirements: got %q, want %q", loaded.Requirements, cfg.Requirements) + } + if len(loaded.Stages) != len(cfg.Stages) { + t.Errorf("Stages: got %d, want %d", len(loaded.Stages), len(cfg.Stages)) + } + for i, s := range loaded.Stages { + if s.ID != cfg.Stages[i].ID { + t.Errorf("Stages[%d].ID: got %q, want %q", i, s.ID, cfg.Stages[i].ID) + } + if s.Agent != cfg.Stages[i].Agent { + t.Errorf("Stages[%d].Agent: got %q, want %q", i, s.Agent, cfg.Stages[i].Agent) + } + } +} + +func TestLoadConfig_Validation(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr bool + }{ + { + name: "missing name", + yaml: ` +stages: + - id: s1 + agent: requirements +`, + wantErr: true, + }, + { + name: "missing stages", + yaml: `name: my-project +`, + wantErr: true, + }, + { + name: "valid minimal", + yaml: `name: minimal +stages: + - id: s1 + agent: requirements + output: docs/PRD.md +`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "spec.yaml") + if err := os.WriteFile(path, []byte(tt.yaml), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + _, err := LoadConfig(path) + if (err != nil) != tt.wantErr { + t.Errorf("LoadConfig() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pipeline/gate.go b/pipeline/gate.go new file mode 100644 index 0000000..ac05a9d --- /dev/null +++ b/pipeline/gate.go @@ -0,0 +1,83 @@ +package pipeline + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" +) + +// GateResult is the outcome of a human approval gate. +type GateResult int + +const ( + GateResultProceed GateResult = iota // user approved: proceed to next stage + GateResultRerun // user rejected: re-run the current stage + GateResultQuit // user quit: stop the pipeline +) + +// gate is an interactive human approval checkpoint displayed after a stage completes. +type gate struct { + message string + artifactPath string +} + +// prompt displays the artifact preview and waits for user input. +func (g *gate) prompt() (GateResult, error) { + fmt.Printf("\n┌─ Gate ───────────────────────────────────────────────────\n") + fmt.Printf("│ %s\n", g.message) + fmt.Printf("│ Artifact: %s\n", g.artifactPath) + fmt.Printf("└──────────────────────────────────────────────────────────\n\n") + + if data, err := os.ReadFile(g.artifactPath); err == nil { + lines := strings.Split(string(data), "\n") + limit := min(40, len(lines)) + for i, line := range lines[:limit] { + fmt.Printf("%3d │ %s\n", i+1, line) + } + if len(lines) > 40 { + fmt.Printf(" │ ... (%d more lines, open %s to read in full)\n", + len(lines)-40, g.artifactPath) + } + } + + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("\n[y]es / [e]dit / [n]o (re-run stage) / [q]uit: ") + raw, err := reader.ReadString('\n') + if err != nil { + return GateResultQuit, fmt.Errorf("read input: %w", err) + } + choice := strings.TrimSpace(strings.ToLower(raw)) + switch choice { + case "y", "yes": + return GateResultProceed, nil + case "e", "edit": + if err := openInEditor(g.artifactPath); err != nil { + fmt.Printf("editor error: %v\n", err) + continue + } + // Re-display after edit and re-prompt. + return g.prompt() + case "n", "no": + return GateResultRerun, nil + case "q", "quit": + return GateResultQuit, nil + default: + fmt.Println(" invalid choice — enter y, e, n, or q") + } + } +} + +func openInEditor(path string) error { + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "nano" + } + cmd := exec.Command(editor, path) //nolint:gosec + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/pipeline/pipeline.go b/pipeline/pipeline.go new file mode 100644 index 0000000..1ab1dc6 --- /dev/null +++ b/pipeline/pipeline.go @@ -0,0 +1,242 @@ +package pipeline + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/ATMackay/agent/agents/architect" + "github.com/ATMackay/agent/agents/requirements" + "github.com/ATMackay/agent/state" + "github.com/ATMackay/agent/workflow" + adkmodel "google.golang.org/adk/model" + "google.golang.org/adk/session" +) + +const defaultStateDir = ".pipeline-state" + +// Pipeline orchestrates a sequence of agent stages driven by a PipelineConfig. +type Pipeline struct { + cfg *PipelineConfig + ps *PipelineState + stateDir string +} + +// New creates a Pipeline, loading any existing run state from disk. +func New(cfg *PipelineConfig) (*Pipeline, error) { + ps, err := LoadState(defaultStateDir, cfg.Name) + if err != nil { + return nil, fmt.Errorf("load pipeline state: %w", err) + } + return &Pipeline{ + cfg: cfg, + ps: ps, + stateDir: defaultStateDir, + }, nil +} + +// Run executes the pipeline sequentially. +// - fromStage: if non-empty, resume execution from this stage ID (re-runs it). +// - yesAll: skip all human gates (non-interactive mode). +func (p *Pipeline) Run(ctx context.Context, mod adkmodel.LLM, fromStage string, yesAll bool) error { + startIdx, err := p.resolveStartIndex(fromStage) + if err != nil { + return err + } + + for i := startIdx; i < len(p.cfg.Stages); i++ { + stageCfg := &p.cfg.Stages[i] + + // Skip already-completed stages unless explicitly resuming from this one. + if existing, ok := p.ps.Stages[stageCfg.ID]; ok && + existing.Status == StatusCompleted && + stageCfg.ID != fromStage { + slog.Info("skipping completed stage", "stage", stageCfg.ID, "output", existing.OutputPath) + continue + } + + outputPath := p.resolveOutputPath(stageCfg) + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return fmt.Errorf("create output dir for stage %q: %w", stageCfg.ID, err) + } + + slog.Info("starting stage", "stage", stageCfg.ID, "agent", stageCfg.Agent, "output", outputPath) + p.ps.Stages[stageCfg.ID] = &StageState{Status: StatusRunning} + if err := p.ps.Save(p.stateDir); err != nil { + return fmt.Errorf("save state: %w", err) + } + + if err := p.runStage(ctx, stageCfg, mod, outputPath); err != nil { + p.ps.Stages[stageCfg.ID] = &StageState{ + Status: StatusFailed, + Error: err.Error(), + } + _ = p.ps.Save(p.stateDir) + return fmt.Errorf("stage %q: %w", stageCfg.ID, err) + } + + now := time.Now() + p.ps.Stages[stageCfg.ID] = &StageState{ + Status: StatusCompleted, + CompletedAt: &now, + OutputPath: outputPath, + } + if err := p.ps.Save(p.stateDir); err != nil { + return fmt.Errorf("save state: %w", err) + } + slog.Info("stage complete", "stage", stageCfg.ID, "output", outputPath) + + if !yesAll && stageCfg.Gate != nil && stageCfg.Gate.Enabled { + g := &gate{message: stageCfg.Gate.Message, artifactPath: outputPath} + result, err := g.prompt() + if err != nil { + return fmt.Errorf("gate for stage %q: %w", stageCfg.ID, err) + } + switch result { + case GateResultQuit: + slog.Info("pipeline stopped at gate", "stage", stageCfg.ID) + return nil + case GateResultRerun: + slog.Info("re-running stage", "stage", stageCfg.ID) + p.ps.Stages[stageCfg.ID].Status = StatusPending + i-- // repeat this loop iteration + } + } + } + + slog.Info("pipeline complete", "name", p.cfg.Name) + return nil +} + +// State returns the current pipeline state (for status display). +func (p *Pipeline) State() *PipelineState { + return p.ps +} + +func (p *Pipeline) resolveStartIndex(fromStage string) (int, error) { + if fromStage == "" { + return 0, nil + } + for i, s := range p.cfg.Stages { + if s.ID == fromStage { + // Reset the target stage so it always re-runs. + if st, ok := p.ps.Stages[fromStage]; ok { + st.Status = StatusPending + } + return i, nil + } + } + return 0, fmt.Errorf("stage %q not found in pipeline", fromStage) +} + +func (p *Pipeline) resolveOutputPath(stageCfg *StageConfig) string { + if stageCfg.Output != "" { + return stageCfg.Output + } + return filepath.Join(p.cfg.OutputDir, stageCfg.ID+".md") +} + +func (p *Pipeline) runStage(ctx context.Context, stageCfg *StageConfig, mod adkmodel.LLM, outputPath string) error { + switch stageCfg.Agent { + case "requirements": + return p.runRequirementsStage(ctx, mod, outputPath) + case "architect": + return p.runArchitectStage(ctx, stageCfg, mod, outputPath) + default: + return fmt.Errorf("unknown agent %q", stageCfg.Agent) + } +} + +func (p *Pipeline) runRequirementsStage(ctx context.Context, mod adkmodel.LLM, outputPath string) error { + cfg := &requirements.Config{ + WorkDir: p.cfg.WorkDir, + OutputPath: outputPath, + } + if err := cfg.Validate(); err != nil { + return err + } + + ag, err := requirements.NewRequirementsAnalyzer(ctx, cfg, mod) + if err != nil { + return fmt.Errorf("create requirements agent: %w", err) + } + + initState := map[string]any{ + state.StateOutputPath: outputPath, + state.StateRequirementsPrompt: p.cfg.Requirements, + state.StateWorkDir: p.cfg.WorkDir, + } + + w, err := workflow.New(ctx, requirements.AgentName, session.InMemoryService(), ag, initState) + if err != nil { + return err + } + return w.Start(ctx, "pipeline", requirements.UserMessage(p.cfg.Requirements)) +} + +func (p *Pipeline) runArchitectStage(ctx context.Context, stageCfg *StageConfig, mod adkmodel.LLM, outputPath string) error { + task := "design" + if stageCfg.ID == "task-breakdown" { + task = "tasks" + } + + // Find the most relevant input document from preceding completed stages. + inputDoc := p.findLatestOutput("requirements") + if task == "tasks" { + if designDoc := p.findLatestOutput("architect"); designDoc != "" { + inputDoc = designDoc + } + } + + cfg := &architect.Config{ + WorkDir: p.cfg.WorkDir, + PRDPath: inputDoc, + OutputPath: outputPath, + Task: task, + } + if err := cfg.Validate(); err != nil { + return err + } + + ag, err := architect.NewArchitect(ctx, cfg, mod) + if err != nil { + return fmt.Errorf("create architect agent: %w", err) + } + + initState := map[string]any{ + state.StateOutputPath: outputPath, + state.StatePRDPath: inputDoc, + state.StateWorkDir: p.cfg.WorkDir, + state.StateAgentTask: task, + } + + w, err := workflow.New(ctx, architect.AgentName, session.InMemoryService(), ag, initState) + if err != nil { + return err + } + return w.Start(ctx, "pipeline", architect.UserMessage(inputDoc, p.cfg.WorkDir, task)) +} + +// findLatestOutput finds the output path from the most recently completed stage +// that used the given agent type. +func (p *Pipeline) findLatestOutput(agentType string) string { + var latestTime time.Time + var latestPath string + for _, stageCfg := range p.cfg.Stages { + if stageCfg.Agent != agentType { + continue + } + st, ok := p.ps.Stages[stageCfg.ID] + if !ok || st.Status != StatusCompleted || st.OutputPath == "" { + continue + } + if st.CompletedAt != nil && st.CompletedAt.After(latestTime) { + latestTime = *st.CompletedAt + latestPath = st.OutputPath + } + } + return latestPath +} diff --git a/pipeline/state.go b/pipeline/state.go new file mode 100644 index 0000000..46fcd1b --- /dev/null +++ b/pipeline/state.go @@ -0,0 +1,79 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Stage status values. +const ( + StatusPending = "pending" + StatusRunning = "running" + StatusCompleted = "completed" + StatusFailed = "failed" + StatusSkipped = "skipped" +) + +// PipelineState records the execution status of each stage, persisted between runs. +type PipelineState struct { + PipelineID string `json:"pipeline_id"` + CreatedAt time.Time `json:"created_at"` + Stages map[string]*StageState `json:"stages"` +} + +// StageState records the outcome of a single stage execution. +type StageState struct { + Status string `json:"status"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + OutputPath string `json:"output_path,omitempty"` + Error string `json:"error,omitempty"` +} + +func newPipelineState(pipelineID string) *PipelineState { + return &PipelineState{ + PipelineID: pipelineID, + CreatedAt: time.Now(), + Stages: make(map[string]*StageState), + } +} + +func stateFilePath(dir, pipelineID string) string { + return filepath.Join(dir, pipelineID+".json") +} + +// LoadState reads the state file for the given pipeline. Returns a fresh state if the +// file does not exist (first run). +func LoadState(dir, pipelineID string) (*PipelineState, error) { + path := stateFilePath(dir, pipelineID) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return newPipelineState(pipelineID), nil + } + if err != nil { + return nil, fmt.Errorf("read state file: %w", err) + } + var s PipelineState + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parse state file: %w", err) + } + return &s, nil +} + +// Save writes the state to disk, creating the state directory if needed. +func (s *PipelineState) Save(dir string) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create state dir: %w", err) + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("marshal state: %w", err) + } + path := stateFilePath(dir, s.PipelineID) + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write state file: %w", err) + } + return nil +} diff --git a/pipeline/state_test.go b/pipeline/state_test.go new file mode 100644 index 0000000..44d96ea --- /dev/null +++ b/pipeline/state_test.go @@ -0,0 +1,94 @@ +package pipeline + +import ( + "testing" + "time" +) + +func TestLoadState_NewPipeline(t *testing.T) { + dir := t.TempDir() + ps, err := LoadState(dir, "my-project") + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if ps.PipelineID != "my-project" { + t.Errorf("PipelineID = %q, want %q", ps.PipelineID, "my-project") + } + if ps.Stages == nil { + t.Error("Stages should be initialised (not nil)") + } + if len(ps.Stages) != 0 { + t.Errorf("Stages len = %d, want 0", len(ps.Stages)) + } +} + +func TestPipelineState_SaveLoad(t *testing.T) { + dir := t.TempDir() + now := time.Now().Truncate(time.Second).UTC() + + ps := &PipelineState{ + PipelineID: "test-pipeline", + CreatedAt: now, + Stages: map[string]*StageState{ + "requirements-analysis": { + Status: StatusCompleted, + CompletedAt: &now, + OutputPath: "docs/PRD.md", + }, + "technical-design": { + Status: StatusPending, + }, + }, + } + + if err := ps.Save(dir); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := LoadState(dir, "test-pipeline") + if err != nil { + t.Fatalf("LoadState: %v", err) + } + + if loaded.PipelineID != ps.PipelineID { + t.Errorf("PipelineID = %q, want %q", loaded.PipelineID, ps.PipelineID) + } + if len(loaded.Stages) != len(ps.Stages) { + t.Fatalf("Stages len = %d, want %d", len(loaded.Stages), len(ps.Stages)) + } + + req := loaded.Stages["requirements-analysis"] + if req == nil { + t.Fatal("requirements-analysis stage not found") + } + if req.Status != StatusCompleted { + t.Errorf("Status = %q, want %q", req.Status, StatusCompleted) + } + if req.OutputPath != "docs/PRD.md" { + t.Errorf("OutputPath = %q, want %q", req.OutputPath, "docs/PRD.md") + } + + design := loaded.Stages["technical-design"] + if design == nil { + t.Fatal("technical-design stage not found") + } + if design.Status != StatusPending { + t.Errorf("Status = %q, want %q", design.Status, StatusPending) + } +} + +func TestPipelineState_SaveCreatesDir(t *testing.T) { + dir := t.TempDir() + "/nested/state" + ps := newPipelineState("create-dir-test") + if err := ps.Save(dir); err != nil { + t.Fatalf("Save should create the directory: %v", err) + } + + loaded, err := LoadState(dir, "create-dir-test") + if err != nil { + t.Fatalf("LoadState after save: %v", err) + } + if loaded.PipelineID != "create-dir-test" { + t.Errorf("PipelineID = %q, want %q", loaded.PipelineID, "create-dir-test") + } +} diff --git a/state/state.go b/state/state.go index b6c3e9d..fb3d8b6 100644 --- a/state/state.go +++ b/state/state.go @@ -19,4 +19,14 @@ const ( // Analyzer agent state keys. StateWorkDir = "work_dir" + + // Requirements agent state keys. + StateRequirementsPrompt = "requirements_prompt" + StatePRD = "prd_markdown" + + // Architect agent state keys. + StatePRDPath = "prd_path" + StateDesign = "design_markdown" + StateTaskList = "task_list_markdown" + StateAgentTask = "agent_task" ) From 17abc80d311fa711fa0ff9632d20d94a7f73c203 Mon Sep 17 00:00:00 2001 From: Alex Mackay Date: Sun, 24 May 2026 13:21:13 +0100 Subject: [PATCH 2/2] err --- cmd/pipeline_status.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/pipeline_status.go b/cmd/pipeline_status.go index d2b60f7..7f9a319 100644 --- a/cmd/pipeline_status.go +++ b/cmd/pipeline_status.go @@ -32,13 +32,13 @@ func NewPipelineStatusCmd() *cobra.Command { fmt.Printf("Started: %s\n\n", ps.CreatedAt.Format("2006-01-02 15:04:05")) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "STAGE\tAGENT\tSTATUS\tCOMPLETED\tOUTPUT") - fmt.Fprintln(w, "─────\t─────\t──────\t─────────\t──────") + _, _ = fmt.Fprintln(w, "STAGE\tAGENT\tSTATUS\tCOMPLETED\tOUTPUT") + _, _ = fmt.Fprintln(w, "─────\t─────\t──────\t─────────\t──────") for _, stage := range cfg.Stages { st, ok := ps.Stages[stage.ID] if !ok { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", stage.ID, stage.Agent, "pending", "—", "—") + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", stage.ID, stage.Agent, "pending", "—", "—") continue } @@ -57,7 +57,7 @@ func NewPipelineStatusCmd() *cobra.Command { statusDisplay = fmt.Sprintf("failed: %s", st.Error) } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", stage.ID, stage.Agent, statusDisplay, completedAt, outputPath) } return w.Flush()