diff --git a/README.md b/README.md index d865857..12b68f2 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,9 @@ with `pattern config.json: no matching files found` — every compiling `make` t and posted as an issue comment naming the files and approach it would take. The issue gets the `agent-planned` label and the run ends there — no commit, no verify, no push, no PR. Any human reply that isn't exactly `implement` triggers another plan pass that revises it against that - feedback. + feedback. An issue that also carries `github.human_planned_label` skips this Claude run entirely: + the issue body itself is adopted as the plan verbatim (no clone, no worktree), posted and saved + the same way, so the only thing a human still has to do is reply `implement`. 5. **Run Claude** (once approved) — `claude -p --output-format stream-json --permission-mode bypassPermissions`, scoped to the worktree, with a system prompt that hands over the issue and the approved plan and forbids git/GitHub mutation. The lease is renewed periodically while the run is @@ -416,6 +418,7 @@ This repository's own `config.json` is also **compiled into the binary** at buil "done_label": "agent-done", "failed_label": "agent-failed", "plan_label": "agent-planned", + "human_planned_label": "human-planned", "owners": ["ableinc"], "exclude_repos": [], "search_limit": 50, @@ -487,6 +490,7 @@ This repository's own `config.json` is also **compiled into the binary** at buil | `github.label` | trigger label; **must not be empty**, or every open issue would match | | `github.working_label` / `done_label` / `failed_label` | status labels the daemon swaps `label` for | | `github.plan_label` | label added while a posted plan awaits an `implement` reply, removed once the change is delivered | +| `github.human_planned_label` | when present alongside `label`, the issue body is adopted as the plan verbatim instead of running Claude to draft one | | `github.owners` | users/orgs to search; **required, must list at least one non-blank entry** — the daemon refuses to start otherwise, so it can never fall back to scanning every repo the `gh` token can see | | `github.exclude_repos` | `owner/name` repos to never touch, even if labelled | | `github.search_limit` | max issues fetched per discovery pass | diff --git a/config.example.json b/config.example.json index c993a18..4a9d7af 100644 --- a/config.example.json +++ b/config.example.json @@ -5,6 +5,7 @@ "done_label": "agent-done", "failed_label": "agent-failed", "plan_label": "agent-planned", + "human_planned_label": "human-planned", "owners": [ "your-gh-username" ], diff --git a/internal/config/config.go b/internal/config/config.go index ab7ec5a..35e7378 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,10 @@ type GitHubConfig struct { FailedLabel string `json:"failed_label"` // PlanLabel marks an issue that has a plan comment awaiting human approval. PlanLabel string `json:"plan_label"` + // HumanPlannedLabel, when present alongside Label, tells the daemon the + // issue body already contains a human-authored plan: skip the Claude + // planning run and adopt the issue body as the plan verbatim. + HumanPlannedLabel string `json:"human_planned_label"` // Owners scopes discovery. Empty means "every repo the token can see", // which is broad — prefer naming the orgs/users you actually want. Owners []string `json:"owners"` @@ -208,14 +212,15 @@ func Default() Config { return Config{ ModelsPath: "models.json", GitHub: GitHubConfig{ - Label: "agent-ready", - WorkingLabel: "agent-working", - DoneLabel: "agent-done", - FailedLabel: "agent-failed", - PlanLabel: "agent-planned", - SearchLimit: 50, - PollInterval: Duration(5 * time.Minute), - Binary: "gh", + Label: "agent-ready", + WorkingLabel: "agent-working", + DoneLabel: "agent-done", + FailedLabel: "agent-failed", + PlanLabel: "agent-planned", + HumanPlannedLabel: "human-planned", + SearchLimit: 50, + PollInterval: Duration(5 * time.Minute), + Binary: "gh", PRComments: PRCommentsConfig{ Enabled: true, Mention: "@coding-agent", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6994902..58b5ce8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -279,3 +279,9 @@ func TestDefaultServerPasswordIsEmpty(t *testing.T) { t.Fatalf("Default().Server.Password = %q, want empty (auth disabled by default)", got) } } + +func TestDefaultHumanPlannedLabel(t *testing.T) { + if got := Default().GitHub.HumanPlannedLabel; got != "human-planned" { + t.Fatalf("Default().GitHub.HumanPlannedLabel = %q, want %q", got, "human-planned") + } +} diff --git a/internal/orchestrator/adopt_test.go b/internal/orchestrator/adopt_test.go index a30f535..2cb359f 100644 --- a/internal/orchestrator/adopt_test.go +++ b/internal/orchestrator/adopt_test.go @@ -435,6 +435,80 @@ func TestARealFailureStillReportsItself(t *testing.T) { } } +// An issue carrying both the trigger label and human-planned should have its +// body adopted as the plan verbatim, with no Claude run: the plan already +// exists, written by a person, so there is nothing to draft. +func TestHumanPlannedIssueAdoptsPlanFromTheIssueBody(t *testing.T) { + ctx := context.Background() + callLog := filepath.Join(t.TempDir(), "calls.txt") + const plan = "1. Do the thing. 2. Verify it works." + + ghBin := stubGH(t, callLog, map[string]string{ + "search issues": `[{"number":5,"title":"Change your commit name","url":"u", + "repository":{"name":"widgets","nameWithOwner":"acme/widgets"}, + "labels":[{"name":"agent-ready"},{"name":"human-planned"}],"isPullRequest":false,"state":"open"}]`, + "issue view": `{"number":5,"title":"Change your commit name","body":"` + plan + `","url":"u", + "state":"OPEN","labels":[{"name":"agent-ready"},{"name":"human-planned"}],"comments":[]}`, + "pr list": `[]`, + }) + + st := openTestStore(t) + if err := testOrchestrator(t, ghBin, st).RunOnce(ctx); err != nil { + t.Fatal(err) + } + + calls := ghCalls(t, callLog) + if strings.Contains(calls, "pr create") { + t.Fatalf("adopting a human plan must never open a pull request:\n%s", calls) + } + if !strings.Contains(calls, "issue comment") { + t.Fatalf("the human plan should be posted back as a plan comment:\n%s", calls) + } + if !strings.Contains(calls, "--add-label agent-planned") { + t.Fatalf("the issue should be labelled planned:\n%s", calls) + } + + stored, err := st.LatestPlan(ctx, "acme/widgets", 5) + if err != nil { + t.Fatal(err) + } + if stored != plan { + t.Fatalf("LatestPlan() = %q, want the issue body %q", stored, plan) + } +} + +// human-planned without a body is a misconfiguration, not a silent fallback +// to Claude-drafted planning: it must fail loudly so it goes through the +// normal failure path. +func TestHumanPlannedIssueWithEmptyBodyFails(t *testing.T) { + ctx := context.Background() + callLog := filepath.Join(t.TempDir(), "calls.txt") + + ghBin := stubGH(t, callLog, map[string]string{ + "search issues": `[{"number":5,"title":"Change your commit name","url":"u", + "repository":{"name":"widgets","nameWithOwner":"acme/widgets"}, + "labels":[{"name":"agent-ready"},{"name":"human-planned"}],"isPullRequest":false,"state":"open"}]`, + "issue view": `{"number":5,"title":"Change your commit name","body":" ","url":"u", + "state":"OPEN","labels":[{"name":"agent-ready"},{"name":"human-planned"}],"comments":[]}`, + "pr list": `[]`, + }) + + st := openTestStore(t) + if err := testOrchestrator(t, ghBin, st).RunOnce(ctx); err != nil { + t.Fatal(err) + } + + calls := ghCalls(t, callLog) + if !strings.Contains(calls, "agent-failed") { + t.Fatalf("an empty human-planned body should be reported as a failure:\n%s", calls) + } + if plan, err := st.LatestPlan(ctx, "acme/widgets", 5); err != nil { + t.Fatal(err) + } else if plan != "" { + t.Fatalf("no plan should be saved for an empty body, got %q", plan) + } +} + // A dry run that quietly spends subscription usage and rewrites a worktree is // not a dry run. This pins the property that makes the flag worth having. func TestDryRunSpendsNothingAndTouchesNothing(t *testing.T) { diff --git a/internal/orchestrator/loop.go b/internal/orchestrator/loop.go index 4acab00..0296992 100644 --- a/internal/orchestrator/loop.go +++ b/internal/orchestrator/loop.go @@ -631,6 +631,10 @@ func (o *Orchestrator) execute(ctx context.Context, log *slog.Logger, cand candi return errSkip{phaseReason} } + if phase == phasePlan && issue.HasLabel(cfg.GitHub.HumanPlannedLabel) { + return o.adoptHumanPlan(ctx, log, cand, runID, issue, ref) + } + meta, err := o.repoMetadata(ctx, cand.repo) if err != nil { return fmt.Errorf("repo metadata: %w", err) @@ -961,6 +965,37 @@ func (o *Orchestrator) adoptPR(ctx context.Context, log *slog.Logger, cand candi return nil } +// adoptHumanPlan handles an issue carrying both the trigger label and +// HumanPlannedLabel: the plan already exists in the issue body, written by a +// person, so the Claude planning run is skipped entirely and the body is +// posted and saved the same way a Claude-drafted plan would be. Notably this +// never clones the repo, creates a worktree, or invokes the runner. +func (o *Orchestrator) adoptHumanPlan(ctx context.Context, log *slog.Logger, cand candidate, runID string, issue gh.Issue, ref discord.RunRef) error { + cfg := o.opts.Config + plan := strings.TrimSpace(issue.Body) + if plan == "" { + return fmt.Errorf("issue carries the %q label but has an empty body, so there is no plan to adopt", + cfg.GitHub.HumanPlannedLabel) + } + + log.Info("adopting a human-authored plan from the issue body") + + if err := o.opts.GH.Comment(ctx, cand.repo, cand.number, humanPlanComment(plan, runID)); err != nil { + return fmt.Errorf("post plan comment: %w", err) + } + if err := o.opts.Store.SavePlan(ctx, cand.repo, cand.number, runID, plan); err != nil { + log.Warn("could not save plan", "error", err) + } + o.setLabels(ctx, log, ref, + []string{cfg.GitHub.PlanLabel}, []string{cfg.GitHub.WorkingLabel}) + if err := o.opts.Store.SetRunStatus(ctx, runID, store.StatusPlanned); err != nil { + log.Warn("status update failed", "error", err) + } + o.event(ctx, runID, "human_planned", "adopted plan from issue body") + o.opts.Discord.PlanPosted(ref, nil, 0) + return nil +} + // issueAnnouncesPR reports whether the issue already carries a harness comment // naming this pull request, so adoption does not re-announce it every poll. func issueAnnouncesPR(issue gh.Issue, prURL string) bool { diff --git a/internal/orchestrator/report.go b/internal/orchestrator/report.go index 8624ffb..56a3dcb 100644 --- a/internal/orchestrator/report.go +++ b/internal/orchestrator/report.go @@ -226,3 +226,19 @@ func planComment(plan, runID, modelID string, costUSD float64) string { fmt.Fprintf(&b, "coding-agent-loop run `%s`, model `%s`, cost $%.4f\n", runID, modelID, costUSD) return b.String() } + +// humanPlanComment mirrors planComment for a plan that was written by a human +// in the issue body rather than drafted by Claude: same marker and structure, +// so extractPlan/decidePhase treat it identically, but no model/cost line. +func humanPlanComment(plan, runID string) string { + var b strings.Builder + b.WriteString(markerPlan) + b.WriteString("\n\n## Plan\n\n") + b.WriteString(truncate(strings.TrimSpace(plan), maxPlanCommentChars)) + b.WriteString("\n\n---\n\n") + b.WriteString("This plan was written by a human in the issue body, not drafted by the agent. ") + b.WriteString("Reply with exactly `implement` to approve this plan and start the change. ") + b.WriteString("Reply with anything else and the plan will be revised to address it.\n\n") + fmt.Fprintf(&b, "coding-agent-loop run `%s`\n", runID) + return b.String() +} diff --git a/internal/orchestrator/report_test.go b/internal/orchestrator/report_test.go index 6ac1ebc..3f83eba 100644 --- a/internal/orchestrator/report_test.go +++ b/internal/orchestrator/report_test.go @@ -58,3 +58,15 @@ func TestPRCommentFailureCommentCarriesMarkerAndReason(t *testing.T) { t.Fatalf("failure reply should carry the run id:\n%s", body) } } + +func TestHumanPlanCommentRoundTripsThroughExtractPlan(t *testing.T) { + plan := "1. Do the thing.\n2. Verify it works." + body := humanPlanComment(plan, "run-3") + if !strings.Contains(body, markerPlan) { + t.Fatalf("human plan comment must carry the plan marker:\n%s", body) + } + got := extractPlan(body) + if got != plan { + t.Fatalf("extractPlan(humanPlanComment(...)) = %q, want %q", got, plan) + } +}