micro--go-micro
76961d503a
* feat(loop): go-micro now runs on `micro loop` (dogfood its own tool)
Replace go-micro's five hand-written loop workflows with ones generated by
`micro loop init --roles all`, making "go-micro builds itself with micro loop"
literally true rather than aspirational.
- Generate loop-planner/builder/triage/coherence/release.yml via the CLI with
go-micro's cadence and wiring (planner :59, builder :29, coherence 07:00,
release 23:00; CI gate "Harness (E2E)"; token CODEX_TRIGGER_TOKEN; base master;
tag prefix v). The old loop-architect.yml and loop-devrel.yml become
loop-planner.yml and loop-coherence.yml.
- Move the queue to .github/loop/PRIORITIES.md and add .github/loop/NORTH_STAR.md
(a concise steer pointing to internal/docs/THESIS.md), adopting the loop's
convention.
- Preserve go-micro's rich instructions as editable policy in
.github/loop/prompts/{planner,builder,triage,coherence}.md — the architect
founder-lens + adoption steer, the increment builder, harness-failure triage,
and the DevRel changelog/blog pass — faithfully ported from the old inline
prompts. Behavior is preserved; only the mechanism is now generated.
- CLI refinement the migration surfaced: prompts (and NORTH_STAR/PRIORITIES) are
now write-once — `micro loop init --force` refreshes workflow MECHANICS but
never clobbers customized POLICY. Added renderKeep + a test.
- Update internal/docs/CONTINUOUS_IMPROVEMENT.md (renamed workflows, moved queue,
the prompt-file model, and a note that these files are generated by micro loop).
Verified: build, go test ./cmd/micro/loop/..., golangci-lint (0 issues), gofmt;
`micro loop verify` passes; all generated workflows are valid YAML; re-running
init --force is idempotent and preserves policy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL
* loop: strip prompt editorial comments before posting to the agent
Verification of the migration surfaced that a dispatch workflow posted the
prompt file's leading <!-- editorial --> header to the agent, and __ISSUE__
inside it got substituted too (e.g. "Keep 4242 literal"). Harmless (invisible
in rendered markdown) but unclean and mildly confusing. The dispatch and triage
body construction now strips <!-- --> blocks with `sed '/<!--/,/-->/d'` before
substituting runtime tokens. Regenerated go-micro's workflows; added a test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL
---------
Co-authored-by: Claude <noreply@anthropic.com>
263 行
8.5 KiB
Go
263 行
8.5 KiB
Go
package loop
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
var testCfg = config{
|
|
DefaultBranch: "main",
|
|
AgentMention: "@codex",
|
|
TokenSecret: "LOOP_TOKEN",
|
|
CIWorkflow: "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,
|
|
}
|
|
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"} {
|
|
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"} {
|
|
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", "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-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"} {
|
|
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 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)
|
|
}
|
|
}
|