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)
+ }
+}