chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var testCfg = config{
|
||||
DefaultBranch: "main",
|
||||
AgentMention: "@codex",
|
||||
TokenSecret: "LOOP_TOKEN",
|
||||
CIWorkflow: "CI",
|
||||
CIWorkflowsYAML: `["CI"]`,
|
||||
TagPrefix: "v",
|
||||
ReleaseCron: "0 23 * * *",
|
||||
}
|
||||
|
||||
var testCrons = map[string]string{"planner": "0 * * * *", "builder": "30 * * * *", "coherence": "0 7 * * *"}
|
||||
|
||||
// renderable is every template a full scaffold touches, with the per-role config
|
||||
// applied the same way scaffold does.
|
||||
func renderCases() map[string]config {
|
||||
cases := map[string]config{
|
||||
"templates/loop-triage.yml.tmpl": testCfg,
|
||||
"templates/loop-release.yml.tmpl": testCfg,
|
||||
"templates/prompts/triage.md.tmpl": testCfg,
|
||||
"templates/prompts/planner.md.tmpl": testCfg,
|
||||
"templates/prompts/builder.md.tmpl": testCfg,
|
||||
"templates/prompts/coherence.md.tmpl": testCfg,
|
||||
"templates/prompts/security.md.tmpl": testCfg,
|
||||
}
|
||||
for role, d := range dispatchRoles {
|
||||
rc := testCfg
|
||||
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = role, d.workflowName, d.issueTitle, d.group, d.defaultCron
|
||||
cases["dispatch:"+role] = rc
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
func TestRenderIsPlaceholderFreeAndKeepsGHAExpressions(t *testing.T) {
|
||||
for name, cfg := range renderCases() {
|
||||
tmplName := name
|
||||
if strings.HasPrefix(name, "dispatch:") {
|
||||
tmplName = "templates/dispatch.yml.tmpl"
|
||||
}
|
||||
rendered, err := render(tmplName, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("render %s: %v", name, err)
|
||||
}
|
||||
s := string(rendered)
|
||||
|
||||
// No unresolved substitution delimiters remain in any template.
|
||||
if strings.Contains(s, "<<") || strings.Contains(s, ">>") {
|
||||
t.Errorf("%s still contains << >> placeholders", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseBranchSubstitutedIntoPrompts(t *testing.T) {
|
||||
// The base branch appears in the PR-opening instructions of these prompts.
|
||||
for _, p := range []string{"planner", "builder", "coherence", "security"} {
|
||||
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
|
||||
if !strings.Contains(s, "--base main") {
|
||||
t.Errorf("%s prompt missing substituted base branch", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowTemplatesPreserveGHAAndAreStructural(t *testing.T) {
|
||||
// Only the workflow YAML templates (not the markdown prompts).
|
||||
wf := map[string]config{
|
||||
"templates/loop-triage.yml.tmpl": testCfg,
|
||||
"templates/loop-release.yml.tmpl": testCfg,
|
||||
}
|
||||
for role, d := range dispatchRoles {
|
||||
rc := testCfg
|
||||
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = role, d.workflowName, d.issueTitle, d.group, d.defaultCron
|
||||
wf["dispatch:"+role] = rc
|
||||
}
|
||||
for name, cfg := range wf {
|
||||
tmplName := name
|
||||
if strings.HasPrefix(name, "dispatch:") {
|
||||
tmplName = "templates/dispatch.yml.tmpl"
|
||||
}
|
||||
s := mustRender(t, tmplName, cfg)
|
||||
if !strings.Contains(s, "${{ secrets.LOOP_TOKEN") {
|
||||
t.Errorf("%s lost its ${{ secrets.LOOP_TOKEN }} expression", name)
|
||||
}
|
||||
for _, key := range []string{"name:", "on:", "jobs:"} {
|
||||
if !strings.Contains(s, key) {
|
||||
t.Errorf("%s missing top-level %q", name, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchWorkflowsStripPromptComments(t *testing.T) {
|
||||
// The posted body must not include the prompt's editorial <!-- --> header;
|
||||
// the workflow strips it. Guard the sed directive in both dispatch paths.
|
||||
rc := testCfg
|
||||
d := dispatchRoles["planner"]
|
||||
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = "planner", d.workflowName, d.issueTitle, d.group, d.defaultCron
|
||||
for _, tc := range []struct {
|
||||
name, tmpl string
|
||||
cfg config
|
||||
}{
|
||||
{"dispatch", "templates/dispatch.yml.tmpl", rc},
|
||||
{"triage", "templates/loop-triage.yml.tmpl", testCfg},
|
||||
} {
|
||||
s := mustRender(t, tc.tmpl, tc.cfg)
|
||||
if !strings.Contains(s, `/<!--/,/-->/d`) {
|
||||
t.Errorf("%s workflow does not strip prompt HTML comments before posting", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptsLeaveRuntimeTokensLiteral(t *testing.T) {
|
||||
// __ISSUE__ must survive render (the workflow substitutes it at runtime).
|
||||
for _, p := range []string{"planner", "builder", "coherence", "triage", "security"} {
|
||||
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
|
||||
if !strings.Contains(s, "__ISSUE__") {
|
||||
t.Errorf("%s prompt lost its __ISSUE__ runtime token", p)
|
||||
}
|
||||
}
|
||||
// triage additionally uses __RUNURL__.
|
||||
if s := mustRender(t, "templates/prompts/triage.md.tmpl", testCfg); !strings.Contains(s, "__RUNURL__") {
|
||||
t.Error("triage prompt lost its __RUNURL__ runtime token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldAllRolesWritesEverything(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, wfDir, "ci.yml"), "name: CI\n")
|
||||
|
||||
roles := []string{"planner", "builder", "triage", "coherence", "security", "release"}
|
||||
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
|
||||
wantWorkflows := []string{"loop-planner.yml", "loop-builder.yml", "loop-triage.yml", "loop-coherence.yml", "loop-security.yml", "loop-release.yml"}
|
||||
for _, w := range wantWorkflows {
|
||||
if !fileExists(filepath.Join(dir, wfDir, w)) {
|
||||
t.Errorf("expected %s", w)
|
||||
}
|
||||
}
|
||||
// Dispatch + triage roles have prompts; release does not.
|
||||
for _, p := range []string{"planner.md", "builder.md", "triage.md", "coherence.md", "security.md"} {
|
||||
if !fileExists(filepath.Join(dir, promptDir, p)) {
|
||||
t.Errorf("expected prompt %s", p)
|
||||
}
|
||||
}
|
||||
if fileExists(filepath.Join(dir, promptDir, "release.md")) {
|
||||
t.Error("release should not have a prompt")
|
||||
}
|
||||
if _, missing := verifyState(dir); len(missing) != 0 {
|
||||
t.Errorf("verify reported missing after full scaffold: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaffoldDefaultRolesOmitsOptional(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := scaffold(dir, testCfg, []string{"planner", "builder", "triage"}, testCrons, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
if fileExists(filepath.Join(dir, wfDir, "loop-coherence.yml")) {
|
||||
t.Error("coherence should not be written by default")
|
||||
}
|
||||
if fileExists(filepath.Join(dir, wfDir, "loop-release.yml")) {
|
||||
t.Error("release should not be written by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReinitForceKeepsPromptsRefreshesWorkflows(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
roles := []string{"planner", "builder", "triage"}
|
||||
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
|
||||
// Customize a prompt and edit direction/queue, as a real user would.
|
||||
customPrompt := filepath.Join(dir, promptDir, "builder.md")
|
||||
mustWrite(t, customPrompt, "MY CUSTOM BUILDER POLICY")
|
||||
northStar := filepath.Join(dir, loopDir, "NORTH_STAR.md")
|
||||
mustWrite(t, northStar, "MY MISSION")
|
||||
|
||||
// Re-run with --force to refresh workflow mechanics.
|
||||
if err := scaffold(dir, testCfg, roles, testCrons, true); err != nil {
|
||||
t.Fatalf("re-scaffold --force: %v", err)
|
||||
}
|
||||
|
||||
// Policy (prompt, North Star) must survive --force untouched.
|
||||
if b, _ := os.ReadFile(customPrompt); string(b) != "MY CUSTOM BUILDER POLICY" {
|
||||
t.Errorf("--force clobbered a customized prompt: %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(northStar); string(b) != "MY MISSION" {
|
||||
t.Errorf("--force clobbered the North Star: %q", b)
|
||||
}
|
||||
// Mechanism (workflow) must be regenerated (present and non-empty).
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, wfDir, "loop-builder.yml")); !strings.Contains(string(b), "Loop: Builder") {
|
||||
t.Error("--force did not refresh the workflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIWorkflowListRendersAsYAMLArray(t *testing.T) {
|
||||
if got := yamlStringArray([]string{"Harness (E2E)", "Lint", "Run Tests"}); got != `["Harness (E2E)", "Lint", "Run Tests"]` {
|
||||
t.Errorf("yamlStringArray = %q", got)
|
||||
}
|
||||
if got := splitCSV("Harness (E2E), Lint ,Run Tests"); strings.Join(got, "|") != "Harness (E2E)|Lint|Run Tests" {
|
||||
t.Errorf("splitCSV = %v", got)
|
||||
}
|
||||
if got := splitCSV(" "); strings.Join(got, "|") != "CI" {
|
||||
t.Errorf("splitCSV empty should default to CI, got %v", got)
|
||||
}
|
||||
// The triage workflow must embed the array so workflow_run watches all of them.
|
||||
cfg := testCfg
|
||||
cfg.CIWorkflowsYAML = `["Harness (E2E)", "Lint", "Run Tests"]`
|
||||
s := mustRender(t, "templates/loop-triage.yml.tmpl", cfg)
|
||||
if !strings.Contains(s, `workflows: ["Harness (E2E)", "Lint", "Run Tests"]`) {
|
||||
t.Errorf("triage workflow does not watch the CI workflow list:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoles(t *testing.T) {
|
||||
if got, err := parseRoles("all"); err != nil || len(got) != len(allRoles) {
|
||||
t.Errorf("all => %v, %v", got, err)
|
||||
}
|
||||
// Canonical order preserved regardless of input order.
|
||||
got, err := parseRoles("release,planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got, ",") != "planner,release" {
|
||||
t.Errorf("expected canonical order planner,release; got %v", got)
|
||||
}
|
||||
if _, err := parseRoles("bogus"); err == nil {
|
||||
t.Error("expected error for unknown role")
|
||||
}
|
||||
if _, err := parseRoles(""); err == nil {
|
||||
t.Error("expected error for empty roles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMissingPromptFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := scaffold(dir, testCfg, []string{"planner", "builder", "triage"}, testCrons, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
// Delete a prompt → verify must flag it.
|
||||
if err := os.Remove(filepath.Join(dir, promptDir, "builder.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, missing := verifyState(dir)
|
||||
found := false
|
||||
for _, m := range missing {
|
||||
if strings.Contains(m, "builder.md") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected verify to flag the missing builder prompt; got %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRender(t *testing.T, tmplName string, cfg config) string {
|
||||
t.Helper()
|
||||
b, err := render(tmplName, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("render %s: %v", tmplName, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# North Star
|
||||
|
||||
> **Edit this file.** It is the single source of direction the loop aligns every
|
||||
> increment to. The planner ranks work against it; the builder builds toward it.
|
||||
> Be concrete — vague direction produces vague increments.
|
||||
|
||||
## Mission
|
||||
|
||||
<One or two sentences: the problem this repository solves and who it's for.>
|
||||
|
||||
## Right now
|
||||
|
||||
<The current priority — what "better" means this month. The planner weights the
|
||||
queue toward this.>
|
||||
|
||||
## Guardrails
|
||||
|
||||
- One concern per PR; small and reversible.
|
||||
- The gate is green CI, not a human review — keep the test/lint suite strong,
|
||||
because the loop is only as good as its evaluator.
|
||||
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
|
||||
public API changes, brand/positioning/marketing copy, new dependencies,
|
||||
architectural rewrites, product-default changes with broad behavioral impact.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Priorities
|
||||
|
||||
A single ranked queue, highest-value first. Each item links a scoped issue the
|
||||
loop can build and CI can verify. The **planner** keeps this current; the
|
||||
**builder** takes the top item whose issue is still open.
|
||||
|
||||
<!--
|
||||
Seed this with a few real items to give the loop a running start, e.g.:
|
||||
|
||||
1. Add retry with backoff to the HTTP client — #123
|
||||
2. Document the config file format — #124
|
||||
3. Fix flaky timeout in the cache tests — #125
|
||||
|
||||
The planner will re-rank, drop completed items, and file issues for new gaps.
|
||||
Reorder or edit this file at any time to redirect the loop.
|
||||
-->
|
||||
@@ -0,0 +1,60 @@
|
||||
name: "<< .WorkflowName >>"
|
||||
|
||||
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
|
||||
# cadence it opens a fresh tracking issue and posts the instruction in
|
||||
# .github/loop/prompts/<< .Role >>.md to the agent (<< .AgentMention >>).
|
||||
#
|
||||
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
|
||||
# change what this role does by editing the prompt, not this YAML. A FRESH
|
||||
# issue per run is deliberate: agents derive the PR branch name from the
|
||||
# triggering issue, so reusing one tracker collapses every run onto one branch.
|
||||
#
|
||||
# Gated on << .TokenSecret >>: the agent ignores @mentions from the
|
||||
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "<< .Cron >>"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: << .Group >>
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # needed to read the prompt file
|
||||
- name: Dispatch << .Role >>
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
|
||||
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
if [ "$HAS_TOKEN" != "true" ]; then
|
||||
echo "<< .TokenSecret >> is not set — skipping (the agent ignores bot @mentions)."
|
||||
exit 0
|
||||
fi
|
||||
PROMPT=".github/loop/prompts/<< .Role >>.md"
|
||||
if [ ! -f "$PROMPT" ]; then
|
||||
echo "missing $PROMPT — run 'micro loop init'." >&2
|
||||
exit 1
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "<< .IssueTitle >> #$RUN_NUMBER" \
|
||||
--body "Autonomous << .Role >> pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching << .Role >>."
|
||||
# The prompt file is the policy; strip its editorial <!-- --> header and
|
||||
# substitute the tracking issue number (__ISSUE__) at runtime.
|
||||
{
|
||||
echo "<< .AgentMention >>"
|
||||
echo
|
||||
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
|
||||
} > "$RUNNER_TEMP/loop-body.md"
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
|
||||
@@ -0,0 +1,97 @@
|
||||
name: "Loop: Release"
|
||||
|
||||
# Generated by `micro loop init`. Cuts the next tag when the default branch has
|
||||
# new commits since the latest one, and pushes it with a PAT (<< .TokenSecret >>)
|
||||
# so any tag-triggered release workflow fires. The bump reflects what shipped,
|
||||
# read from the CHANGELOG [Unreleased] section: new features (Added/Changed) cut
|
||||
# a MINOR; fixes/docs only cut a PATCH; breaking changes are skipped so a MAJOR
|
||||
# stays a human decision.
|
||||
#
|
||||
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
|
||||
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "<< .ReleaseCron >>"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: loop-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # need full history + all tags
|
||||
# Do NOT persist the default GITHUB_TOKEN as a git credential: it would
|
||||
# be sent on the PAT push below and override it, so the tag push would
|
||||
# authenticate as github-actions[bot] and 403. Letting the PAT in the
|
||||
# push URL be the only credential is the whole point.
|
||||
persist-credentials: false
|
||||
- name: Cut the next patch tag if there are new commits
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.<< .TokenSecret >> }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_TOKEN" ]; then
|
||||
echo "<< .TokenSecret >> is not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
git fetch --tags --force
|
||||
|
||||
LATEST=$(git tag --list '<< .TagPrefix >>*.*.*' --sort=-v:refname | head -1)
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo "no << .TagPrefix >>MAJOR.MINOR.PATCH tag found — aborting so nothing weird gets tagged."
|
||||
exit 1
|
||||
fi
|
||||
echo "latest tag: $LATEST"
|
||||
|
||||
COUNT=$(git rev-list --count "$LATEST"..HEAD)
|
||||
echo "commits since $LATEST: $COUNT"
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "no new commits since $LATEST — no release."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ver="${LATEST#<< .TagPrefix >>}"
|
||||
major="${ver%%.*}"
|
||||
rest="${ver#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest#*.}"
|
||||
case "$major.$minor.$patch" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Choose the bump from what actually shipped, read from the CHANGELOG
|
||||
# [Unreleased] section (kept current by the coherence role):
|
||||
# new features (### Added / ### Changed) -> MINOR
|
||||
# fixes/docs only -> PATCH
|
||||
# breaking (### Removed / "(breaking)") -> skip; a major is a human call
|
||||
UNRELEASED=""
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
UNRELEASED=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md)
|
||||
fi
|
||||
if printf '%s\n' "$UNRELEASED" | grep -qiE '^### Removed|^### Changed \(breaking\)|BREAKING'; then
|
||||
echo "CHANGELOG [Unreleased] contains breaking changes — a major release is a human decision. Skipping."
|
||||
exit 0
|
||||
elif printf '%s\n' "$UNRELEASED" | grep -qE '^### (Added|Changed)'; then
|
||||
NEXT="<< .TagPrefix >>${major}.$((minor + 1)).0"
|
||||
KIND="minor (new features)"
|
||||
else
|
||||
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
|
||||
KIND="patch (fixes/docs only)"
|
||||
fi
|
||||
echo "cutting: $NEXT — $KIND ($COUNT commits since $LATEST)"
|
||||
|
||||
git config user.name "loop release bot"
|
||||
git config user.email "noreply@users.noreply.github.com"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
|
||||
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
|
||||
echo "Pushed $NEXT."
|
||||
@@ -0,0 +1,57 @@
|
||||
name: "Loop: Triage"
|
||||
|
||||
# Generated by `micro loop init`. The feedback path of the evaluator: when a CI
|
||||
# workflow (<< .CIWorkflow >>) fails on a non-PR run, dispatch the agent
|
||||
# (<< .AgentMention >>) with the instruction in .github/loop/prompts/triage.md
|
||||
# to root-cause the failure and file scoped fix issues back into the queue — so
|
||||
# failures become fixes with no human in the middle. Gated on << .TokenSecret >>.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: << .CIWorkflowsYAML >>
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: loop-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
# Only real failures on branch pushes/schedules — not PR-run failures, which
|
||||
# the PR author already sees.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # needed to read the prompt file
|
||||
- name: Dispatch triage
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
|
||||
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_URL: ${{ github.event.workflow_run.html_url }}
|
||||
WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
|
||||
run: |
|
||||
if [ "$HAS_TOKEN" != "true" ]; then
|
||||
echo "<< .TokenSecret >> is not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
PROMPT=".github/loop/prompts/triage.md"
|
||||
if [ ! -f "$PROMPT" ]; then
|
||||
echo "missing $PROMPT — run 'micro loop init'." >&2
|
||||
exit 1
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Loop: triage failed run $RUN_ID ($WORKFLOW_NAME)" \
|
||||
--body "The '$WORKFLOW_NAME' workflow failed on a non-PR run: $RUN_URL")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching triage."
|
||||
{
|
||||
echo "<< .AgentMention >>"
|
||||
echo
|
||||
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" -e "s#__RUNURL__#$RUN_URL#g" "$PROMPT"
|
||||
} > "$RUNNER_TEMP/loop-body.md"
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
The BUILDER prompt — the editable policy for the builder role. The workflow
|
||||
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
|
||||
__ISSUE__ literal.
|
||||
-->
|
||||
Build one increment for this repository, aligned to `.github/loop/NORTH_STAR.md`.
|
||||
|
||||
PICK THE WORK: take the highest-ranked item in `.github/loop/PRIORITIES.md` whose linked issue is still OPEN — that is your task, and its issue is the one you close. If the queue is empty or every item's issue is closed, pick the single highest-value improvement yourself.
|
||||
|
||||
Implement it, then VERIFY the project builds, tests, and lints (use the commands documented in the README or the CI workflow).
|
||||
|
||||
Open the PR YOURSELF from the shell — do NOT use a make_pr tool (it may be a no-op stub): `git switch -c loop/increment-__ISSUE__`, `git push -u origin loop/increment-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<body; include 'Closes #<the item's issue>' so it leaves the queue, and 'Closes #__ISSUE__' for this run's tracker>"`, then `gh pr merge --squash --auto --delete-branch` so it lands once CI is green.
|
||||
|
||||
One concern per PR. Stay out of breaking public API changes and brand/positioning copy — surface those as notes for a human instead.
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
The COHERENCE (DevRel) prompt — the editable policy for the coherence role. The
|
||||
workflow prepends the agent @mention and substitutes __ISSUE__ before posting.
|
||||
Keep __ISSUE__ literal.
|
||||
-->
|
||||
Act as DevRel for this repository — keep the public story coherent and honest.
|
||||
|
||||
Audit the public surface — `README`, docs, and any website/blog — for coherence with `.github/loop/NORTH_STAR.md`: places that contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recently merged PRs). If the repo keeps a `CHANGELOG.md`, reconcile its `[Unreleased]` section against what actually merged.
|
||||
|
||||
SAFE factual-alignment and crispness fixes (and the CHANGELOG upkeep): open ONE PR and auto-merge it — `git switch -c loop/coherence-__ISSUE__`, `git push -u origin loop/coherence-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`.
|
||||
|
||||
Brand / positioning / marketing copy and any opinion blog posts are NOT auto-merge material — the public voice stays with a human. Describe them in a comment on this issue, or open a PR WITHOUT enabling auto-merge, and leave it for review.
|
||||
|
||||
Post a short findings report as a comment on this issue (#__ISSUE__): what's aligned, what drifted, what you fixed. Open PRs yourself from the shell with `gh`; do not use a make_pr tool.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
The PLANNER prompt. This file is the editable policy for the planner role —
|
||||
change what the planner does by editing this text. The workflow prepends the
|
||||
agent @mention and substitutes __ISSUE__ (this run's tracking issue) before
|
||||
posting it. Keep __ISSUE__ literal.
|
||||
-->
|
||||
Act as the planner for this repository.
|
||||
|
||||
(1) Read `.github/loop/NORTH_STAR.md` for direction, then scan recently merged PRs and open issues so the queue reflects reality — drop done items, don't re-queue work already in flight.
|
||||
|
||||
(2) Maintain a SINGLE ranked queue in `.github/loop/PRIORITIES.md`, highest-value first, each item linking a scoped, CI-verifiable issue (#N). For any prioritized gap that has no issue, file one: `gh issue create --title "<scoped task>" --body "<goal, scope, acceptance criteria>"`.
|
||||
|
||||
(3) If the ranking actually changed, open ONE PR for `PRIORITIES.md`: `git switch -c loop/planner-__ISSUE__`, `git push -u origin loop/planner-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. If the queue is already accurate, just close this issue (`gh issue close __ISSUE__`).
|
||||
|
||||
Do NOT make breaking or architectural changes yourself — surface those as notes for a human. Open the PR yourself from the shell with `gh`; do not use a make_pr tool (it may be a no-op stub).
|
||||
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
The SECURITY prompt — the editable policy for the security role. The workflow
|
||||
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
|
||||
__ISSUE__ literal.
|
||||
|
||||
Security is deliberately more conservative than the other roles: it does NOT
|
||||
auto-merge fixes, and it does NOT publish exploit details in public issues.
|
||||
-->
|
||||
Act as the security reviewer for this repository. Audit for real, exploitable vulnerabilities — do not pad the report with theoretical or low-value lint-style noise.
|
||||
|
||||
WHAT TO LOOK FOR: injection (SQL/command/template), authentication and authorization bypass, credential/secret/token exposure (in code, logs, or error messages), SSRF and unsafe outbound requests (especially user- or config-controlled URLs), path traversal, unsafe deserialization, missing or incorrect input validation on trust boundaries (HTTP handlers, RPC endpoints, message consumers), insecure defaults (TLS, auth, permissions), unsafe use of `crypto`/randomness, and known-vulnerable dependencies (run `govulncheck ./...` if available, or inspect `go.mod`).
|
||||
|
||||
DEDUPE against open issues before filing anything.
|
||||
|
||||
HOW TO REPORT — this matters:
|
||||
- **Known/public dependency CVEs** (already disclosed): file an issue labeled `security` referencing the CVE and the affected module, and you MAY open a PR that bumps the dependency to the patched version. Do **NOT** enable auto-merge — leave it for human review.
|
||||
- **Novel, exploitable vulnerabilities in this codebase** (not yet public): do **NOT** post a working exploit, proof-of-concept, or step-by-step reproduction in a public issue — that is irresponsible disclosure. File a CONCISE issue labeled `security` and `needs-human` that names the vulnerability *class*, the *location* (file/function), and the *impact*, with only enough detail for a maintainer to find it — and note it should be handled via the repository's private vulnerability reporting if the repo is public. Do NOT open a public fix PR that reveals the vulnerability; leave the fix to a human.
|
||||
- **Low-risk hardening** (defense-in-depth, missing validation with no proven exploit): a normal `security` issue is fine.
|
||||
|
||||
NEVER auto-merge a security change. Never weaken a control to make a test pass. Anything requiring an architectural or breaking change: label it `needs-human` and describe the tradeoff.
|
||||
|
||||
Post a summary as a comment on this issue (#__ISSUE__) — how many findings by severity, what you filed, and what needs a human — then close it (`gh issue close __ISSUE__`). If you open a dependency-bump PR, do it yourself from the shell: `git switch -c loop/security-__ISSUE__`, `git push -u origin loop/security-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<summary, Closes #__ISSUE__>"` — then STOP; do NOT run `gh pr merge --auto`. Do not use a make_pr tool.
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
The TRIAGE prompt — the editable policy for the triage role. The workflow
|
||||
prepends the agent @mention and substitutes __ISSUE__ (this tracking issue) and
|
||||
__RUNURL__ (the failed CI run) before posting. Keep both literal.
|
||||
-->
|
||||
Triage the failed CI run at __RUNURL__.
|
||||
|
||||
Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment "recurred" there instead of filing a duplicate.
|
||||
|
||||
For each genuine, self-contained defect, file a scoped issue (`gh issue create --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the planner/builder can pick it up and the next CI run verifies it.
|
||||
|
||||
IGNORE transient flakes — network blips, provider outages, timeouts with no code cause. Anything needing a breaking or architectural change: label it `needs-human` and describe it, rather than auto-filing it as a routine fix.
|
||||
|
||||
Close this issue (`gh issue close __ISSUE__`) when triage is done.
|
||||
Reference in New Issue
Block a user