diff --git a/ai/prompts/comments.md.tmpl b/ai/prompts/comments.md.tmpl
index 7add249..b2d0059 100644
--- a/ai/prompts/comments.md.tmpl
+++ b/ai/prompts/comments.md.tmpl
@@ -1,6 +1,10 @@
-{{define "pickup"}}π€ Picked up ({{.Kind}} flow). Branch: `{{.Branch}}`{{end}}
+{{define "pickup"}}π€ Picked up ({{.Kind}} flow). Branch: `{{.Branch}}`
-{{define "already-done"}}π€ Already implemented β closing. {{.Reason}}{{end}}
+{{.BotMarker}}{{end}}
+
+{{define "already-done"}}π€ Already implemented β closing. {{.Reason}}
+
+{{.BotMarker}}{{end}}
{{define "needs-info"}}π€ Not confident enough to implement (confidence {{.Score}}/100). Answer the numbered questions below in a comment, then remove the `{{.Label}}` label to re-queue:
@@ -18,9 +22,13 @@ Remove the `{{.Label}}` label to queue a fresh attempt β any worktree, branch
{{.Error}}
````
-{{end}}{{end}}
+{{end}}
+
+{{.BotMarker}}{{end}}
+
+{{define "pr-comment"}}π€ PR: {{.URL}}
-{{define "pr-comment"}}π€ PR: {{.URL}}{{end}}
+{{.BotMarker}}{{end}}
{{define "pr-title"}}{{.Title}} (#{{.Number}}){{end}}
diff --git a/github.go b/github.go
index 58645e7..c127854 100644
--- a/github.go
+++ b/github.go
@@ -154,10 +154,22 @@ func (g *GitHub) FetchIssueContent(ctx context.Context, num int) (string, error)
}
var b strings.Builder
fmt.Fprintf(&b, "# %s (#%d)\n\n%s\n", detail.Title, num, detail.Body)
- if len(detail.Comments) > 0 {
+ // The daemon comments on its own issues (pickup, park + error dump, PR link,
+ // ...), so without this filter every re-run feeds the model a growing
+ // transcript of the previous runs' status chatter as if it were part of the
+ // report. Only human-written comments β and the bot's needs-info questions,
+ // which the human's answer refers back to β are context.
+ var comments []string
+ for _, c := range detail.Comments {
+ if isBotStatusComment(c.Body) {
+ continue
+ }
+ comments = append(comments, fmt.Sprintf("\n@%s: %s\n", c.Author.Login, c.Body))
+ }
+ if len(comments) > 0 {
b.WriteString("\n## Comments\n")
- for _, c := range detail.Comments {
- fmt.Fprintf(&b, "\n@%s: %s\n", c.Author.Login, c.Body)
+ for _, c := range comments {
+ b.WriteString(c)
}
}
return b.String(), nil
diff --git a/github_test.go b/github_test.go
index fde1149..a291ed5 100644
--- a/github_test.go
+++ b/github_test.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "encoding/json"
"errors"
"strings"
"testing"
@@ -201,6 +202,67 @@ func TestFetchIssueContent(t *testing.T) {
}
}
+// The daemon's own status chatter (pickup, park + error dump, PR link, ...) is
+// noise in the prompt the issue content becomes β and it piles up on every
+// re-run. It must be stripped, both when carried by the marker and, for
+// comments posted before the marker existed, by its leading text.
+func TestFetchIssueContentDropsLoopeStatusComments(t *testing.T) {
+ comments := []string{
+ pickupComment("bug", "ai/issue-7"),
+ parkComment("ai-rework", "", "dial tcp: i/o timeout"),
+ prComment("https://example.test/pr/1"),
+ alreadyDoneComment("already there."),
+ stoppedComment(),
+ // Legacy: posted before the marker was introduced.
+ "π€ Picked up (feature flow). Branch: `ai/issue-7`",
+ "βΈ Stopped by user. Worktree, logs and session are preserved.",
+ }
+ var arr []string
+ for _, c := range comments {
+ b, _ := json.Marshal(struct {
+ Author struct {
+ Login string `json:"login"`
+ } `json:"author"`
+ Body string `json:"body"`
+ }{Body: c})
+ arr = append(arr, string(b))
+ }
+ arr = append(arr,
+ `{"author": {"login": "alice"}, "body": "repro attached"}`,
+ `{"author": {"login": "loope"}, "body": `+mustJSON(needsInfoComment(42, "ai-needs-info", "Which database?"))+`}`,
+ `{"author": {"login": "alice"}, "body": "1a, 2c"}`,
+ )
+ f := &fakeRunner{queue: []rresp{{stdout: `{
+ "title": "Crash on save",
+ "body": "It crashes.",
+ "comments": [` + strings.Join(arr, ",") + `]
+ }`}}}
+ g := testGitHub(f)
+ got, err := g.FetchIssueContent(context.Background(), 7)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"Picked up", "Parked as", "PR: https", "Already implemented", "Stopped by user", "i/o timeout", botMarker} {
+ if strings.Contains(got, bad) {
+ t.Errorf("content still carries loope status text %q:\n%s", bad, got)
+ }
+ }
+ // A human comment, and the questions its answer refers to, must survive.
+ for _, want := range []string{"repro attached", "Which database?", "1a, 2c"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("content missing %q:\n%s", want, got)
+ }
+ }
+}
+
+func mustJSON(s string) string {
+ b, err := json.Marshal(s)
+ if err != nil {
+ panic(err)
+ }
+ return string(b)
+}
+
func TestCreatePRReturnsURL(t *testing.T) {
f := &fakeRunner{queue: []rresp{{stdout: "https://github.com/org/repo/pull/9\n"}}}
g := testGitHub(f)
diff --git a/loop.go b/loop.go
index ce0b020..8bd4dfd 100644
--- a/loop.go
+++ b/loop.go
@@ -396,7 +396,7 @@ func (o *Orchestrator) pause(ctx context.Context, n int) {
// stoppedComment is the fixed notice posted when a run is stopped by the user.
func stoppedComment() string {
- return "βΈ Stopped by user. Worktree, logs and session are preserved. Press Continue to re-queue it; the run continues in the same worktree."
+ return "βΈ Stopped by user. Worktree, logs and session are preserved. Press Continue to re-queue it; the run continues in the same worktree.\n\n" + botMarker
}
// Continue re-queues a stopped issue: it only rewrites labels/state on disk β
@@ -518,6 +518,44 @@ func (o *Orchestrator) abort(ctx context.Context, n int, cause error) error {
return o.park(ctx, n, cause)
}
+// botMarker tags the daemon's own status chatter β pickup, park, PR link,
+// already-done, stopped. Like uatMarker it is an HTML comment, so it is
+// invisible on GitHub while staying greppable in the raw body, and it lets
+// FetchIssueContent strip that chatter back out instead of feeding the model a
+// transcript of its own past runs (see isBotStatusComment).
+//
+// The needs-info comment is deliberately NOT tagged: it carries the numbered
+// questions a human answers in the next comment, so removing it would orphan
+// the answer.
+const botMarker = ""
+
+// legacyBotStatusPrefixes recognise status comments posted before botMarker
+// existed. They are the exact opening text of each tagged template, so nothing
+// a human writes is mistaken for chatter, and needs-info ("π€ Not confident
+// enoughβ¦") is left alone here too.
+var legacyBotStatusPrefixes = []string{
+ "π€ Picked up (",
+ "π€ Parked as `",
+ "π€ PR: ",
+ "π€ Already implemented β closing.",
+ "βΈ Stopped by user.",
+}
+
+// isBotStatusComment reports whether a comment is the daemon's own status
+// chatter and so should be kept out of the issue content handed to Claude.
+func isBotStatusComment(body string) bool {
+ if strings.Contains(body, botMarker) {
+ return true
+ }
+ trimmed := strings.TrimSpace(body)
+ for _, p := range legacyBotStatusPrefixes {
+ if strings.HasPrefix(trimmed, p) {
+ return true
+ }
+ }
+ return false
+}
+
func pickupComment(kind, branch string) string {
d := promptData()
d["Kind"] = kind
diff --git a/prompts.go b/prompts.go
index 1541f7d..b996978 100644
--- a/prompts.go
+++ b/prompts.go
@@ -61,5 +61,6 @@ func promptData() map[string]any {
"UATBeginSentinel": uatBeginSentinel,
"UATEndSentinel": uatEndSentinel,
"UATMarker": uatMarker,
+ "BotMarker": botMarker,
}
}
diff --git a/prompts_golden_test.go b/prompts_golden_test.go
index 441e92b..962a5ee 100644
--- a/prompts_golden_test.go
+++ b/prompts_golden_test.go
@@ -182,12 +182,12 @@ Respond with ONLY a JSON object, no other text:
func TestGoldenPickupComment(t *testing.T) {
check(t, "pickupComment", pickupComment("feature", "ai/issue-12"),
- "π€ Picked up (feature flow). Branch: `ai/issue-12`")
+ "π€ Picked up (feature flow). Branch: `ai/issue-12`\n\n"+botMarker)
}
func TestGoldenAlreadyDoneComment(t *testing.T) {
check(t, "alreadyDoneComment", alreadyDoneComment("The flag already exists."),
- "π€ Already implemented β closing. The flag already exists.")
+ "π€ Already implemented β closing. The flag already exists.\n\n"+botMarker)
}
func TestGoldenNeedsInfoComment(t *testing.T) {
@@ -198,28 +198,32 @@ func TestGoldenNeedsInfoComment(t *testing.T) {
const parkHead = "\U0001f916 Parked as `ai-rework` β this issue will not be retried automatically.\n\n" +
"Remove the `ai-rework` label to queue a fresh attempt β any worktree, branch and logs this run produced are preserved and reused, so no work is lost."
+// parkTail is the hidden marker every status comment ends with, so
+// FetchIssueContent can strip it back out of the next run's issue content.
+const parkTail = "\n\n" + botMarker
+
func TestGoldenParkCommentFull(t *testing.T) {
check(t, "parkComment(guidance+error)", parkComment("ai-rework", "Cause: network outage. Re-queue once connectivity is back.", "dial tcp: i/o timeout"),
parkHead+"\n\nCause: network outage. Re-queue once connectivity is back."+
- "\n\nError detail
\n\n````\ndial tcp: i/o timeout\n````\n\n ")
+ "\n\nError detail
\n\n````\ndial tcp: i/o timeout\n````\n\n "+parkTail)
}
func TestGoldenParkCommentNoGuidance(t *testing.T) {
check(t, "parkComment(error only)", parkComment("ai-rework", "", "boom"),
- parkHead+"\n\nError detail
\n\n````\nboom\n````\n\n ")
+ parkHead+"\n\nError detail
\n\n````\nboom\n````\n\n "+parkTail)
}
func TestGoldenParkCommentNoError(t *testing.T) {
check(t, "parkComment(guidance only)", parkComment("ai-rework", "Cause: x.", ""),
- parkHead+"\n\nCause: x.")
+ parkHead+"\n\nCause: x."+parkTail)
}
func TestGoldenParkCommentBare(t *testing.T) {
- check(t, "parkComment(bare)", parkComment("ai-rework", "", ""), parkHead)
+ check(t, "parkComment(bare)", parkComment("ai-rework", "", ""), parkHead+parkTail)
}
func TestGoldenPRComment(t *testing.T) {
- check(t, "prComment", prComment("https://example.test/pr/1"), "π€ PR: https://example.test/pr/1")
+ check(t, "prComment", prComment("https://example.test/pr/1"), "π€ PR: https://example.test/pr/1\n\n"+botMarker)
}
func TestGoldenPRTitle(t *testing.T) {