e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
498 lines
17 KiB
Go
498 lines
17 KiB
Go
// Package loop implements the 'micro loop' command, which scaffolds and
|
|
// verifies an autonomous improvement loop for a repository.
|
|
//
|
|
// The loop is a set of GitHub Actions workflows that dispatch a coding agent by
|
|
// @mention on a fresh tracking issue each run. It has up to five roles:
|
|
//
|
|
// planner keeps a ranked queue in .github/loop/PRIORITIES.md
|
|
// builder builds the top open item as a single-concern PR (auto-merged on green CI)
|
|
// triage turns CI failures into scoped fix issues back into the queue
|
|
// coherence keeps README/docs/CHANGELOG aligned with the North Star (opt-in)
|
|
// release cuts the next patch tag when the branch has new commits (opt-in)
|
|
//
|
|
// The workflows are the MECHANISM; each dispatch role's instruction lives in an
|
|
// editable .github/loop/prompts/<role>.md file — the POLICY. That split is what
|
|
// lets any repo (including go-micro itself) customize behavior by editing prompt
|
|
// files rather than forking the CLI. `micro loop init` writes it all; `micro
|
|
// loop verify` checks the wiring.
|
|
package loop
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/urfave/cli/v2"
|
|
"go-micro.dev/v6/cmd"
|
|
)
|
|
|
|
//go:embed templates/*
|
|
var templatesFS embed.FS
|
|
|
|
// config is the substitution surface for the templates — the whole config-vs-core
|
|
// boundary. The workflows and prompts are the reusable core; these are what a
|
|
// given repo tunes.
|
|
type config struct {
|
|
// Shared.
|
|
DefaultBranch string // base branch for the loop's PRs (e.g. main)
|
|
AgentMention string // how the workflows summon the agent (e.g. @codex)
|
|
TokenSecret string // repo secret holding the user PAT that drives dispatch
|
|
CIWorkflow string // human-readable CI workflow name(s) triage watches
|
|
CIWorkflowsYAML string // the same as a YAML array literal, e.g. ["Lint", "Run Tests"]
|
|
|
|
// Per-dispatch-role (set while rendering each one).
|
|
Role string
|
|
WorkflowName string
|
|
IssueTitle string
|
|
Group string
|
|
Cron string
|
|
|
|
// Release role.
|
|
TagPrefix string // tag prefix to match/bump, e.g. "v"
|
|
ReleaseCron string
|
|
}
|
|
|
|
// dispatchRole is a cron-driven role rendered from templates/dispatch.yml.tmpl.
|
|
type dispatchRole struct {
|
|
workflowName string
|
|
issueTitle string
|
|
group string
|
|
cronFlag string
|
|
defaultCron string
|
|
}
|
|
|
|
var dispatchRoles = map[string]dispatchRole{
|
|
"planner": {"Loop: Planner", "Loop: planning review", "loop-planner", "planner-cron", "0 * * * *"},
|
|
"builder": {"Loop: Builder", "Loop: build increment", "loop-builder", "builder-cron", "30 * * * *"},
|
|
"coherence": {"Loop: Coherence", "Loop: coherence review", "loop-coherence", "coherence-cron", "0 7 * * *"},
|
|
"security": {"Loop: Security", "Loop: security review", "loop-security", "security-cron", "0 6 * * 1"},
|
|
}
|
|
|
|
// allRoles is the full set, in a stable order, for --roles=all and help text.
|
|
var allRoles = []string{"planner", "builder", "triage", "coherence", "security", "release"}
|
|
|
|
const (
|
|
promptDir = ".github/loop/prompts"
|
|
loopDir = ".github/loop"
|
|
wfDir = ".github/workflows"
|
|
)
|
|
|
|
func init() {
|
|
cmd.Register(&cli.Command{
|
|
Name: "loop",
|
|
Usage: "Scaffold an autonomous improvement loop for a repository",
|
|
Description: `Set up a self-improving loop for a repo: GitHub Actions workflows that
|
|
dispatch a coding agent to plan, build, triage, and (optionally) keep docs
|
|
coherent and cut releases — gated by CI.
|
|
|
|
Roles (choose with --roles, default: planner,builder,triage):
|
|
planner keeps a ranked queue in .github/loop/PRIORITIES.md
|
|
builder builds the top open item as a single-concern PR (auto-merged on green CI)
|
|
triage turns CI failures into scoped fix issues back into the queue
|
|
coherence keeps README/docs/CHANGELOG aligned with the North Star
|
|
security audits for vulnerabilities and files them (fixes stay human-reviewed)
|
|
release cuts the next patch tag when the branch has new commits
|
|
|
|
Each dispatch role's instruction is an editable file in .github/loop/prompts/ —
|
|
edit those to steer behavior. Direction lives in .github/loop/NORTH_STAR.md.
|
|
|
|
Examples:
|
|
# Scaffold the default loop (planner, builder, triage)
|
|
micro loop init
|
|
|
|
# The full loop, all five roles
|
|
micro loop init --roles all
|
|
|
|
# Customize the agent, token secret, base branch, and CI workflow name
|
|
micro loop init --agent @codex --token-secret LOOP_TOKEN \
|
|
--branch main --ci-workflow CI
|
|
|
|
# Check that a repo is wired correctly
|
|
micro loop verify`,
|
|
Subcommands: []*cli.Command{
|
|
{
|
|
Name: "init",
|
|
Usage: "Scaffold the loop workflows, prompts, and queue into a repo",
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
|
|
&cli.StringFlag{Name: "roles", Usage: "Comma-separated roles, or 'all'", Value: "planner,builder,triage"},
|
|
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
|
|
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent — any @mention-driven coding agent (e.g. @codex, @claude)", Value: "@codex"},
|
|
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
|
|
&cli.StringFlag{Name: "ci-workflow", Usage: "CI workflow name(s) triage watches for failures (comma-separated)", Value: "CI"},
|
|
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
|
|
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
|
|
&cli.StringFlag{Name: "coherence-cron", Usage: "Cron schedule for the coherence role", Value: "0 7 * * *"},
|
|
&cli.StringFlag{Name: "security-cron", Usage: "Cron schedule for the security role", Value: "0 6 * * 1"},
|
|
&cli.StringFlag{Name: "release-cron", Usage: "Cron schedule for the release role", Value: "0 23 * * *"},
|
|
&cli.StringFlag{Name: "tag-prefix", Usage: "Tag prefix the release role matches and bumps", Value: "v"},
|
|
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
|
|
},
|
|
Action: runInit,
|
|
},
|
|
{
|
|
Name: "verify",
|
|
Usage: "Verify a repo is wired for the loop",
|
|
Flags: []cli.Flag{&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."}},
|
|
Action: runVerify,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func runInit(c *cli.Context) error {
|
|
dir := c.String("dir")
|
|
roles, err := parseRoles(c.String("roles"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ciNames := splitCSV(c.String("ci-workflow"))
|
|
cfg := config{
|
|
DefaultBranch: c.String("branch"),
|
|
AgentMention: strings.TrimSpace(c.String("agent")),
|
|
TokenSecret: strings.TrimSpace(c.String("token-secret")),
|
|
CIWorkflow: strings.Join(ciNames, ", "),
|
|
CIWorkflowsYAML: yamlStringArray(ciNames),
|
|
TagPrefix: c.String("tag-prefix"),
|
|
ReleaseCron: c.String("release-cron"),
|
|
}
|
|
if cfg.DefaultBranch == "" {
|
|
cfg.DefaultBranch = detectDefaultBranch(dir)
|
|
}
|
|
if !strings.HasPrefix(cfg.AgentMention, "@") {
|
|
cfg.AgentMention = "@" + cfg.AgentMention
|
|
}
|
|
|
|
crons := map[string]string{
|
|
"planner": c.String("planner-cron"),
|
|
"builder": c.String("builder-cron"),
|
|
"coherence": c.String("coherence-cron"),
|
|
"security": c.String("security-cron"),
|
|
}
|
|
|
|
if err := scaffold(dir, cfg, roles, crons, c.Bool("force")); err != nil {
|
|
return err
|
|
}
|
|
printNextSteps(cfg, roles)
|
|
return nil
|
|
}
|
|
|
|
// parseRoles resolves the --roles flag into a validated, stable-ordered set.
|
|
func parseRoles(spec string) ([]string, error) {
|
|
if strings.TrimSpace(spec) == "all" {
|
|
return append([]string(nil), allRoles...), nil
|
|
}
|
|
want := map[string]bool{}
|
|
for _, r := range strings.Split(spec, ",") {
|
|
r = strings.TrimSpace(r)
|
|
if r == "" {
|
|
continue
|
|
}
|
|
if !isRole(r) {
|
|
return nil, fmt.Errorf("unknown role %q (valid: %s, or 'all')", r, strings.Join(allRoles, ", "))
|
|
}
|
|
want[r] = true
|
|
}
|
|
if len(want) == 0 {
|
|
return nil, fmt.Errorf("no roles selected")
|
|
}
|
|
var out []string
|
|
for _, r := range allRoles { // preserve canonical order
|
|
if want[r] {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// splitCSV splits a comma-separated flag into trimmed, non-empty values.
|
|
func splitCSV(s string) []string {
|
|
var out []string
|
|
for _, v := range strings.Split(s, ",") {
|
|
if v = strings.TrimSpace(v); v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
out = []string{"CI"}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// yamlStringArray renders names as a YAML/JSON flow array, e.g. ["Lint", "Run Tests"].
|
|
// Names are known workflow display names (no embedded quotes), so a simple quote is safe.
|
|
func yamlStringArray(names []string) string {
|
|
quoted := make([]string, len(names))
|
|
for i, n := range names {
|
|
quoted[i] = fmt.Sprintf("%q", n)
|
|
}
|
|
return "[" + strings.Join(quoted, ", ") + "]"
|
|
}
|
|
|
|
func isRole(r string) bool {
|
|
for _, x := range allRoles {
|
|
if x == r {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// scaffold renders the selected roles into dir. The split is deliberate:
|
|
// - Workflows are the MECHANISM — regenerated, and overwritten with --force.
|
|
// - Prompts, NORTH_STAR, and PRIORITIES are the POLICY — written once and
|
|
// never clobbered, even with --force, so re-running init to refresh the
|
|
// workflow mechanics can't wipe curated instructions, direction, or queue.
|
|
func scaffold(dir string, cfg config, roles []string, crons map[string]string, force bool) error {
|
|
for _, role := range roles {
|
|
switch role {
|
|
case "triage":
|
|
if err := renderTo(dir, "templates/loop-triage.yml.tmpl", filepath.Join(wfDir, "loop-triage.yml"), cfg, force); err != nil {
|
|
return err
|
|
}
|
|
if err := renderKeep(dir, "templates/prompts/triage.md.tmpl", filepath.Join(promptDir, "triage.md"), cfg); err != nil {
|
|
return err
|
|
}
|
|
case "release":
|
|
if err := renderTo(dir, "templates/loop-release.yml.tmpl", filepath.Join(wfDir, "loop-release.yml"), cfg, force); err != nil {
|
|
return err
|
|
}
|
|
default: // dispatch roles
|
|
d := dispatchRoles[role]
|
|
rc := cfg
|
|
rc.Role = role
|
|
rc.WorkflowName = d.workflowName
|
|
rc.IssueTitle = d.issueTitle
|
|
rc.Group = d.group
|
|
rc.Cron = crons[role]
|
|
if rc.Cron == "" {
|
|
rc.Cron = d.defaultCron
|
|
}
|
|
if err := renderTo(dir, "templates/dispatch.yml.tmpl", filepath.Join(wfDir, "loop-"+role+".yml"), rc, force); err != nil {
|
|
return err
|
|
}
|
|
if err := renderKeep(dir, "templates/prompts/"+role+".md.tmpl", filepath.Join(promptDir, role+".md"), cfg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Direction + queue: policy, written once, never clobbered.
|
|
if err := renderKeep(dir, "templates/NORTH_STAR.md", filepath.Join(loopDir, "NORTH_STAR.md"), cfg); err != nil {
|
|
return err
|
|
}
|
|
return renderKeep(dir, "templates/PRIORITIES.md", filepath.Join(loopDir, "PRIORITIES.md"), cfg)
|
|
}
|
|
|
|
// renderTo renders a template with cfg and writes it to dir/dest (honoring force).
|
|
func renderTo(dir, tmplName, dest string, cfg config, force bool) error {
|
|
rendered, err := render(tmplName, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeFile(filepath.Join(dir, dest), rendered, force); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf(" wrote %s\n", dest)
|
|
return nil
|
|
}
|
|
|
|
// renderKeep writes dir/dest only if it does not already exist — used for
|
|
// policy files (prompts, North Star, queue) so re-running init never clobbers
|
|
// customizations, regardless of --force.
|
|
func renderKeep(dir, tmplName, dest string, cfg config) error {
|
|
full := filepath.Join(dir, dest)
|
|
if fileExists(full) {
|
|
fmt.Printf(" kept %s (already exists)\n", dest)
|
|
return nil
|
|
}
|
|
rendered, err := render(tmplName, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writeFile(full, rendered, true); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf(" wrote %s\n", dest)
|
|
return nil
|
|
}
|
|
|
|
// verifyState reports what's wrong with dir's loop setup: warnings are
|
|
// non-fatal, missing are required files that aren't present.
|
|
func verifyState(dir string) (warnings, missing []string) {
|
|
// A loop needs direction, a queue, and at least one role workflow.
|
|
for _, dest := range []string{filepath.Join(loopDir, "NORTH_STAR.md"), filepath.Join(loopDir, "PRIORITIES.md")} {
|
|
if !fileExists(filepath.Join(dir, dest)) {
|
|
missing = append(missing, dest)
|
|
}
|
|
}
|
|
|
|
present := presentLoopWorkflows(dir)
|
|
if len(present) == 0 {
|
|
missing = append(missing, wfDir+"/loop-*.yml (no role workflows found)")
|
|
}
|
|
|
|
// Every dispatch/triage role workflow needs its prompt file. (release has none.)
|
|
for _, role := range present {
|
|
if role == "release" {
|
|
continue
|
|
}
|
|
prompt := filepath.Join(promptDir, role+".md")
|
|
if !fileExists(filepath.Join(dir, prompt)) {
|
|
missing = append(missing, prompt+" (prompt for the loop-"+role+" workflow)")
|
|
}
|
|
}
|
|
|
|
// The loop is only as good as its gate.
|
|
if !hasCIWorkflow(dir) {
|
|
warnings = append(warnings, "no non-loop workflow found in "+wfDir+" — the loop needs a CI gate (build/test/lint) to merge safely")
|
|
}
|
|
return warnings, missing
|
|
}
|
|
|
|
// presentLoopWorkflows returns the role names for which a loop-<role>.yml exists.
|
|
func presentLoopWorkflows(dir string) []string {
|
|
entries, err := os.ReadDir(filepath.Join(dir, wfDir))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, "loop-") {
|
|
continue
|
|
}
|
|
role := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(name, "loop-"), ".yml"), ".yaml")
|
|
out = append(out, role)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func runVerify(c *cli.Context) error {
|
|
dir := c.String("dir")
|
|
warnings, missing := verifyState(dir)
|
|
|
|
for _, m := range missing {
|
|
fmt.Printf(" MISSING %s\n", m)
|
|
}
|
|
for _, w := range warnings {
|
|
fmt.Printf(" WARN %s\n", w)
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("loop is not fully scaffolded (%d item(s) missing) — run `micro loop init`", len(missing))
|
|
}
|
|
|
|
fmt.Printf(" OK loop is wired: %s\n", strings.Join(presentLoopWorkflows(dir), ", "))
|
|
fmt.Println()
|
|
fmt.Println("Reminders the CLI can't check:")
|
|
fmt.Println(" • The token secret must be set in the repo (Settings → Secrets).")
|
|
fmt.Println(" • Branch protection must require the CI checks with 0 approvals,")
|
|
fmt.Println(" so the builder's auto-merge can land PRs on green CI.")
|
|
if len(warnings) > 0 {
|
|
return fmt.Errorf("%d warning(s) — see above", len(warnings))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func render(tmplName string, cfg config) ([]byte, error) {
|
|
b, err := templatesFS.ReadFile(tmplName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Custom delimiters so GitHub Actions' own ${{ }} expressions pass through
|
|
// untouched — only << >> placeholders are substituted.
|
|
t, err := template.New(filepath.Base(tmplName)).Delims("<<", ">>").Option("missingkey=error").Parse(string(b))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", tmplName, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, cfg); err != nil {
|
|
return nil, fmt.Errorf("render %s: %w", tmplName, err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func writeFile(path string, content []byte, force bool) error {
|
|
if fileExists(path) && !force {
|
|
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, content, 0o644)
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// hasCIWorkflow reports whether .github/workflows holds any workflow that is
|
|
// not one of the loop's own (i.e. a plausible CI gate).
|
|
func hasCIWorkflow(dir string) bool {
|
|
entries, err := os.ReadDir(filepath.Join(dir, wfDir))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if strings.HasPrefix(name, "loop-") {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// detectDefaultBranch best-effort resolves the repo's default branch, falling
|
|
// back to "main".
|
|
func detectDefaultBranch(dir string) string {
|
|
out, err := exec.Command("git", "-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Output()
|
|
if err == nil {
|
|
ref := strings.TrimSpace(string(out))
|
|
if i := strings.LastIndex(ref, "/"); i >= 0 {
|
|
ref = ref[i+1:]
|
|
}
|
|
if ref != "" {
|
|
return ref
|
|
}
|
|
}
|
|
return "main"
|
|
}
|
|
|
|
func printNextSteps(cfg config, roles []string) {
|
|
fmt.Printf(`
|
|
Loop scaffolded (%s). Next steps (the CLI can't do these for you):
|
|
|
|
1. Edit .github/loop/NORTH_STAR.md — the direction the loop aligns to.
|
|
Seed .github/loop/PRIORITIES.md with a few real items.
|
|
Tune the per-role instructions in .github/loop/prompts/ if you like.
|
|
|
|
2. Add a repo secret named %s: a fine-grained user PAT (contents + pull
|
|
requests + issues write) for an account the agent (%s) responds to.
|
|
The workflows no-op until this secret exists.
|
|
|
|
3. Ensure a CI workflow named %q exists and that branch protection on %q
|
|
requires its checks with 0 approving reviews — that green-CI gate is
|
|
what lets the builder auto-merge safely.
|
|
|
|
4. Commit these files, then trigger a run from the Actions tab.
|
|
|
|
Verify anytime with: micro loop verify
|
|
`, strings.Join(roles, ", "), cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
|
|
}
|