package workflow import ( "context" "fmt" "path/filepath" "strings" "time" "github.com/ajhahnde/eeco/internal/ai" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/gitx" "github.com/ajhahnde/eeco/internal/queue" ) // evolveCandidatePrefix is the line prefix the AI pass uses to name a // proposed workflow, parsed back out for the scaffold/auto levels. const evolveCandidatePrefix = "WORKFLOW:" // maxEvolveLogLines caps how much commit history feeds the digest: a // repetition signal does not need the entire history, and the digest // must stay terse and deterministic. const maxEvolveLogLines = 40 // evolve detects repeated manual activity and turns it into proposed // workflows. Its aggressiveness scales with the configured automation // level, never past the floor invariants (PLAN.md §Automation level): // // - manual: does nothing (new workflows only via `eeco new`); // - propose: a gated AI pass proposes; the proposal is queued; // - scaffold/auto: as propose, and each proposed workflow is written // inactive into the workspace and queued "ready to activate". // // It never activates, runs, or commits a workflow, and every AI pass is // consent-gated, budget-capped, and parked on skip by the shared Gate — // exactly the bug-sweep / handover-refresh discipline. type evolve struct{} func (evolve) Name() string { return "evolve" } func (evolve) Summary() string { return "propose workflows from repeated activity; scaffold per the automation level" } func (evolve) Run(env Env) (Result, error) { cfg := env.Config if cfg.Automation == config.AutomationManual { return Result{ Code: CodeClean, Summary: "evolve disabled at automation=manual (scaffold workflows with `eeco new`)", }, nil } stamp := time.Now().UTC() stateDir := filepath.Join(cfg.Workspace, "state") project := filepath.Base(cfg.RepoRoot) // 0. Load + reconcile the repetition ledger. Reconciliation is a // one-way flip (unresolved → resolved when the queue row is now // ticked); a corrupt ledger degrades to empty so a broken file // never wedges evolve. Save when any resolution flipped. history, herr := LoadHistory(stateDir) if herr != nil { return Result{}, fmt.Errorf("evolve: load history: %w", herr) } if reconciled, changed := ReconcileHistory(stateDir, history, stamp); changed { history = reconciled if serr := SaveHistory(stateDir, history); serr != nil { return Result{}, fmt.Errorf("evolve: save reconciled history: %w", serr) } } // 1. Deterministic signal extraction — no AI spend, no Gate touch. // Each surfaced candidate becomes its own queue item the operator // can resolve independently; the AI pass below is optional // enrichment, not the only path to output. A candidate whose // (SignalKind, SignalKey) is already in the repetition ledger is // suppressed — once proposed, never re-proposed; the // re-propose-on-signal-recurrence knob is a follow-on slice. detCandidates := computeDeterministicCandidates(cfg) survivors := make([]Candidate, 0, len(detCandidates)) for _, c := range detCandidates { if len(c.Signals) > 0 && history.HasProposed(c.Signals[0].Kind, c.Signals[0].Key) { continue } survivors = append(survivors, c) } detCandidates = survivors findings := make([]Finding, 0, len(detCandidates)+2) ledgerDirty := false for _, c := range detCandidates { title := "Workflow candidate: " + c.Title if _, qerr := queue.AppendUnique(stateDir, queue.Item{ Kind: "evolve", Title: title, Project: project, Detail: c.Reason, Date: stamp, }); qerr != nil { return Result{}, fmt.Errorf("evolve: queue deterministic candidate: %w", qerr) } findings = append(findings, Finding{Path: c.Title, Msg: c.Reason}) if len(c.Signals) > 0 { history.Records = append(history.Records, HistoryRecord{ SignalKind: c.Signals[0].Kind, SignalKey: c.Signals[0].Key, CountAtProposal: c.Signals[0].Count, QueueKind: "evolve", QueueTitle: title, ProposedAt: stamp.Format(time.RFC3339), }) ledgerDirty = true } } if ledgerDirty { if serr := SaveHistory(stateDir, history); serr != nil { return Result{}, fmt.Errorf("evolve: save history: %w", serr) } } // 2. AI gate pass. A nil Gate or a Skipped outcome is the // no-consent / over-budget / provider-error path: the Gate has // already parked the prompt and queued an item. With zero // deterministic candidates the workflow defers (the exit-3 contract // is preserved — there is genuinely nothing to report); with at // least one deterministic candidate the workflow exits clean // surfacing the deterministic list, the AI enrichment simply did // not run. if env.Gate == nil { if len(detCandidates) == 0 { return Result{ Code: CodeAIDeferred, Summary: "evolve deferred: no AI gate available and no deterministic candidates", }, nil } return Result{ Code: CodeClean, Summary: fmt.Sprintf("evolve surfaced %d deterministic candidate(s) (no AI gate)", len(detCandidates)), Findings: findings, }, nil } out, gerr := env.Gate.Run(context.Background(), ai.Request{ Label: "evolve", System: ai.ProjectDigest(cfg), User: evolveUserPrompt(cfg), Cache: true, }) if gerr != nil { return Result{}, fmt.Errorf("evolve: ai gate: %w", gerr) } if !out.Ran { if len(detCandidates) == 0 { return Result{ Code: CodeAIDeferred, Summary: "evolve deferred: AI pass not run (prompt parked, queued)", }, nil } return Result{ Code: CodeClean, Summary: fmt.Sprintf("evolve surfaced %d deterministic candidate(s); AI pass not run", len(detCandidates)), Findings: findings, }, nil } // 3. AI ran — queue the proposal summary and scaffold AI candidates // per the automation level. Deterministic candidates above are // advisory only; scaffolding stays on the AI path so the existing // consent + budget contract still gates any workspace write here. if _, err := queue.AppendUnique(stateDir, queue.Item{ Kind: "evolve", Title: "Workflow proposal ready for review", Project: project, Detail: "automation=" + string(cfg.Automation) + "\n" + condense(firstLine(out.Text)), Date: stamp, }); err != nil { return Result{}, fmt.Errorf("evolve: queue proposal: %w", err) } findings = append(findings, Finding{Path: "evolve", Msg: "proposal queued for review"}) if cfg.Automation.ScaffoldsWorkflows() { for _, name := range parseCandidates(out.Text) { dir, serr := Scaffold(cfg, name) if serr != nil { // A name collision or invalid name is not fatal: record // it for the maintainer and keep going. findings = append(findings, Finding{ Path: name, Msg: "not scaffolded: " + serr.Error(), }) _, _ = queue.AppendUnique(stateDir, queue.Item{ Kind: "evolve", Title: "Proposed workflow could not be scaffolded: " + name, Project: project, Detail: serr.Error(), Date: stamp, }) continue } rel := dir if r, rerr := filepath.Rel(cfg.RepoRoot, dir); rerr == nil { rel = filepath.ToSlash(r) } findings = append(findings, Finding{ Path: name, Msg: "scaffolded inactive at " + rel, }) _, _ = queue.AppendUnique(stateDir, queue.Item{ Kind: "evolve", Title: "Scaffolded workflow ready to activate: " + name, Project: project, Detail: "wrote " + rel + " (inactive) — review, then `eeco run " + name + "` to use it", Date: stamp, }) } } return Result{ Code: CodeClean, Summary: fmt.Sprintf("evolve proposed and queued (automation=%s)", cfg.Automation), Findings: findings, }, nil } // computeDeterministicCandidates reads the same git log evolvePrompt // already feeds to the AI pass, extracts repeated commit-type signals, // and turns them into proposed workflow candidates. Returns nil when // git is unavailable or the log is empty — the caller treats that the // same as zero candidates. func computeDeterministicCandidates(cfg *config.Config) []Candidate { if !gitx.Available() { return nil } log, _, err := gitx.ChangesSince(cfg.RepoRoot, "") if err != nil || log == "" { return nil } lines := splitLines(log) if len(lines) > maxEvolveLogLines { lines = lines[:maxEvolveLogLines] } return ProposeCandidates(ComputeSignals(lines)) } // evolveUserPrompt builds the volatile User turn the gated pass reasons // over: the instruction, recent commit subjects (a manual-repetition // signal), and the workspace decision backlog. The deterministic project // shape is the cacheable System block (ai.ProjectDigest), threaded // separately at the call site. Reading these is not an AI spend; only // the gated Gate.Run is. func evolveUserPrompt(cfg *config.Config) string { var b strings.Builder b.WriteString("Identify gaps in the project's cockpit playbooks and propose small " + "playbook/cockpit improvements that absorb maintenance the maintainer keeps " + "doing by hand. Be terse and concrete.\n") b.WriteString("After any prose, list at most three proposals, one per line, as:\n") b.WriteString(evolveCandidatePrefix + " \n") if gitx.Available() { if log, _, err := gitx.ChangesSince(cfg.RepoRoot, ""); err == nil && log != "" { b.WriteString("\nRecent commits:\n") lines := splitLines(log) if len(lines) > maxEvolveLogLines { lines = lines[:maxEvolveLogLines] } for _, ln := range lines { b.WriteString(ln + "\n") } } } if n, err := queue.Count(filepath.Join(cfg.Workspace, "state")); err == nil { fmt.Fprintf(&b, "\nOpen queue items: %d\n", n) } return b.String() } // parseCandidates extracts the proposed workflow names from the AI // text. Only well-formed lower-kebab names are kept (Scaffold enforces // this too); duplicates collapse so a name is scaffolded at most once. func parseCandidates(text string) []string { seen := map[string]struct{}{} var names []string for _, raw := range splitLines(text) { line := strings.TrimSpace(raw) rest, ok := strings.CutPrefix(line, evolveCandidatePrefix) if !ok { continue } rest = strings.TrimSpace(rest) // Take the token up to the first space / em-dash / hyphen-spacer. name := rest for _, sep := range []string{" ", "—", "\t"} { if i := strings.Index(name, sep); i >= 0 { name = name[:i] } } name = strings.TrimSpace(name) if !workflowNameRE.MatchString(name) { continue } if _, dup := seen[name]; dup { continue } seen[name] = struct{}{} names = append(names, name) } return names } func firstLine(s string) string { first, _, _ := strings.Cut(strings.TrimSpace(s), "\n") return first }