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\n
Error detail\n\n````\ndial tcp: i/o timeout\n````\n\n
") + "\n\n
Error 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\n
Error detail\n\n````\nboom\n````\n\n
") + parkHead+"\n\n
Error 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) {