ajhahn.de
← eeco
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
}