Go 314 lines
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 + " <lower-kebab-name> — <one-line purpose>\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
}