Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
21 changes: 13 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
74 changes: 74 additions & 0 deletions internal/orchestrator/adopt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
35 changes: 35 additions & 0 deletions internal/orchestrator/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions internal/orchestrator/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,19 @@ func planComment(plan, runID, modelID string, costUSD float64) string {
fmt.Fprintf(&b, "<sub>coding-agent-loop run `%s`, model `%s`, cost $%.4f</sub>\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, "<sub>coding-agent-loop run `%s`</sub>\n", runID)
return b.String()
}
12 changes: 12 additions & 0 deletions internal/orchestrator/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading