From df6c157caa0e3d6c0126794f41012a6509bc3457 Mon Sep 17 00:00:00 2001 From: ReidSS Date: Sun, 7 Jun 2026 20:49:24 -0500 Subject: [PATCH 1/2] Implement backlog grooming agent --- README.md | 7 +- src/agents/grooming/GroomingAgent.ts | 96 ++++++++++++++++++++++++++-- src/control.ts | 2 +- test/grooming-agent.test.ts | 72 +++++++++++++++++++++ 4 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 test/grooming-agent.test.ts diff --git a/README.md b/README.md index b528786..467c04d 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ Create a small digital engineering organization capable of: - Implements `repair-agent` with OpenCode for failed PR pipeline repair. - Implements `watch-agent` to monitor PR checks, attempt one repair, and request review once green. - Implements `self-improvement-agent` with OpenCode for adding agent-control capabilities from Slack. +- Implements `grooming-agent` as a backlog analyzer that labels needs-grooming issues and posts a summary. - Supports Slack-controlled model routing, validation commands, queue views, budget telemetry, readiness checks, risk checks, Codex handoff packets, and lightweight failure memory. -- Stubs triage, docs, grooming, and support agents with clear not-implemented responses. +- Stubs triage, docs, and support agents with clear not-implemented responses. - Uses a JSON-backed `JobStore` for local job state. - Captures safe, redacted logs. - Creates branches, runs OpenCode, pushes branches, and opens PRs for implementation jobs. @@ -70,7 +71,7 @@ Current plugins: - `watch-agent`: implemented as a bounded PR pipeline watch and repair loop. - `self-improvement-agent`: implemented as a PR-only workflow for changing this control plane. - `docs-agent`: stub. -- `grooming-agent`: stub. +- `grooming-agent`: implemented as a backlog analyzer that checks issue quality, labels needs-grooming issues, and posts a summary. - `support-agent`: stub. ## Agent Responsibilities @@ -285,7 +286,7 @@ Requirements: - `/agent fix pr ` checks out the PR branch, gives OpenCode failing pipeline context, commits a fix, and pushes it. - `/agent watch pr ` watches pipeline checks, attempts one repair, and runs review when checks pass. - `/agent codex review pr ` creates a compact Codex review packet with PR metadata, checks, and a truncated diff. -- `/agent groom backlog` creates a grooming stub job. +- `/agent groom backlog` analyzes open issues, labels needs-grooming issues, and posts a summary. - `/agent improve ` changes this control plane, validates locally, pushes a branch, and opens a PR. - `/agent models` lists current model settings and available OpenRouter models. - `/agent model get` shows the active implementation and review model settings. diff --git a/src/agents/grooming/GroomingAgent.ts b/src/agents/grooming/GroomingAgent.ts index b4f2815..1b48e70 100644 --- a/src/agents/grooming/GroomingAgent.ts +++ b/src/agents/grooming/GroomingAgent.ts @@ -1,7 +1,95 @@ -import { StubAgent } from "../StubAgent.js"; +import { repoForName } from "../../config.js"; +import { readinessReasons } from "../../control.js"; +import { addIssueLabels, fetchOpenIssues } from "../../github.js"; +import { makeLogWriter, writeLog } from "../../safe-log.js"; +import type { Agent, AgentContext, AgentJob, AgentResult } from "../../types.js"; -export class GroomingAgent extends StubAgent { - constructor() { - super("grooming-agent"); +const GROOMING_LABEL = "needs-grooming"; + +export class GroomingAgent implements Agent { + readonly name = "grooming-agent" as const; + + canHandle(job: AgentJob): boolean { + return job.type === this.name; } + + async run(job: AgentJob, context: AgentContext): Promise { + const repo = repoForName(context.config, job.repo); + + let current = await context.updateJob(job.id, { status: "running", phase: "fetching-issues" }); + await context.postUpdate(current, "Fetching open issues for backlog grooming."); + + const logStream = await makeLogWriter(current.logPath); + try { + writeLog(logStream, `Starting ${this.name} job ${job.id} for ${repo.name}\n`); + + const issues = await fetchOpenIssues(repo, current.dryRun, 50); + writeLog(logStream, `Fetched ${issues.length} open issues.\n`); + + current = await context.updateJob(job.id, { phase: "analyzing-backlog" }); + await context.postUpdate(current, `Analyzing ${issues.length} open issues.`); + + const groomingResults = issues.map((issue) => ({ + issue, + reasons: readinessReasons(issue) + })); + + const needsGrooming = groomingResults.filter((r) => r.reasons.length > 0); + + if (needsGrooming.length > 0 && !current.dryRun) { + current = await context.updateJob(job.id, { phase: "labeling-issues" }); + await context.postUpdate(current, `Labeling ${needsGrooming.length} issue(s) as "${GROOMING_LABEL}".`); + + for (const { issue } of needsGrooming) { + writeLog(logStream, `Adding label "${GROOMING_LABEL}" to issue #${issue.number}\n`); + await addIssueLabels(repo, issue.number, [GROOMING_LABEL], current.dryRun); + } + } + + const summary = formatGroomingSummary(repo.name, issues.length, groomingResults); + + current = await context.updateJob(job.id, { status: "completed", phase: "grooming-complete" }); + await context.postUpdate(current, summary); + writeLog(logStream, `Grooming complete.\n${summary}\n`); + + return { status: "completed", message: summary }; + } finally { + context.clearCancel(job.id); + logStream.end(); + } + } +} + +export function formatGroomingSummary( + repoName: string, + totalIssues: number, + results: Array<{ issue: { number: number; title: string; url: string }; reasons: string[] }> +): string { + const needsGrooming = results.filter((r) => r.reasons.length > 0); + const groomed = results.filter((r) => r.reasons.length === 0); + + const lines: string[] = [ + `Backlog grooming results for ${repoName}:`, + `- ${totalIssues} open issue(s) analyzed`, + `- ${needsGrooming.length} issue(s) need grooming`, + `- ${groomed.length} issue(s) look good`, + "" + ]; + + if (needsGrooming.length > 0) { + lines.push("Issues needing attention:"); + for (const { issue, reasons } of needsGrooming) { + lines.push(` - #${issue.number}: ${issue.title} (${reasons.join(", ")})`); + } + lines.push(""); + } + + if (groomed.length > 0) { + lines.push("Groomed issues (no action needed):"); + for (const { issue } of groomed) { + lines.push(` - #${issue.number}: ${issue.title}`); + } + } + + return lines.join("\n"); } diff --git a/src/control.ts b/src/control.ts index f1a5e44..0f17a39 100644 --- a/src/control.ts +++ b/src/control.ts @@ -255,7 +255,7 @@ export function implementationModel(settings: RuntimeSettings, config: AppConfig return effectiveModelForAgent(settings, "implementation-agent", config.opencodeCommand); } -function readinessReasons(issue: Issue): string[] { +export function readinessReasons(issue: Issue): string[] { const text = `${issue.title}\n${issue.body}`.toLowerCase(); const reasons: string[] = []; if (!issue.body.trim()) { diff --git a/test/grooming-agent.test.ts b/test/grooming-agent.test.ts new file mode 100644 index 0000000..2646ee8 --- /dev/null +++ b/test/grooming-agent.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { formatGroomingSummary } from "../src/agents/grooming/GroomingAgent.js"; + +describe("formatGroomingSummary", () => { + it("reports when all issues look good", () => { + const result = formatGroomingSummary("owner/repo", 2, [ + { issue: { number: 1, title: "Add logging", url: "https://github.com/owner/repo/issues/1" }, reasons: [] }, + { issue: { number: 2, title: "Fix bug", url: "https://github.com/owner/repo/issues/2" }, reasons: [] } + ]); + + expect(result).toContain("Backlog grooming results for owner/repo"); + expect(result).toContain("2 open issue(s) analyzed"); + expect(result).toContain("0 issue(s) need grooming"); + expect(result).toContain("2 issue(s) look good"); + expect(result).toContain("Groomed issues (no action needed):"); + expect(result).toContain("#1: Add logging"); + expect(result).toContain("#2: Fix bug"); + }); + + it("flags issues needing grooming with their reasons", () => { + const result = formatGroomingSummary("owner/repo", 3, [ + { + issue: { number: 10, title: "Missing body", url: "https://github.com/owner/repo/issues/10" }, + reasons: ["missing body"] + }, + { + issue: { number: 11, title: "No criteria", url: "https://github.com/owner/repo/issues/11" }, + reasons: ["missing acceptance criteria"] + }, + { + issue: { number: 12, title: "No AC", url: "https://github.com/owner/repo/issues/12" }, + reasons: ["missing body", "missing acceptance criteria"] + } + ]); + + expect(result).toContain("3 open issue(s) analyzed"); + expect(result).toContain("3 issue(s) need grooming"); + expect(result).toContain("0 issue(s) look good"); + expect(result).toContain("Issues needing attention:"); + expect(result).toContain("#10: Missing body (missing body)"); + expect(result).toContain("#11: No criteria (missing acceptance criteria)"); + expect(result).toContain("#12: No AC (missing body, missing acceptance criteria)"); + }); + + it("includes both groomed and needs-grooming sections when mixed", () => { + const result = formatGroomingSummary("owner/repo", 2, [ + { + issue: { number: 5, title: "Needs work", url: "https://github.com/owner/repo/issues/5" }, + reasons: ["missing body"] + }, + { + issue: { number: 6, title: "Ready issue", url: "https://github.com/owner/repo/issues/6" }, + reasons: [] + } + ]); + + expect(result).toContain("1 issue(s) need grooming"); + expect(result).toContain("1 issue(s) look good"); + expect(result).toContain("Issues needing attention:"); + expect(result).toContain("#5: Needs work"); + expect(result).toContain("Groomed issues (no action needed):"); + expect(result).toContain("#6: Ready issue"); + }); + + it("handles empty issue list", () => { + const result = formatGroomingSummary("owner/repo", 0, []); + + expect(result).toContain("0 open issue(s) analyzed"); + expect(result).toContain("0 issue(s) need grooming"); + expect(result).toContain("0 issue(s) look good"); + }); +}); From 689a941eac83da4ebc163f3597c642dc02ef178e Mon Sep 17 00:00:00 2001 From: ReidSS Date: Sun, 7 Jun 2026 20:54:50 -0500 Subject: [PATCH 2/2] Harden backlog grooming labels and model --- README.md | 3 +- src/agents/grooming/GroomingAgent.ts | 17 +++- src/github.ts | 14 +++ src/models.ts | 12 ++- src/types.ts | 2 +- test/command.test.ts | 10 +++ test/grooming-agent.test.ts | 123 ++++++++++++++++++++++++++- test/opencode.test.ts | 8 +- 8 files changed, 181 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 467c04d..176541f 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ Requirements: /agent model get /agent model set implement openrouter/deepseek/deepseek-v4-flash /agent model set review openrouter/anthropic/claude-sonnet-4 +/agent model set grooming openrouter/google/gemini-2.5-flash /agent ready /agent risk issue 123 /agent queue @@ -290,7 +291,7 @@ Requirements: - `/agent improve ` changes this control plane, validates locally, pushes a branch, and opens a PR. - `/agent models` lists current model settings and available OpenRouter models. - `/agent model get` shows the active implementation and review model settings. -- `/agent model set ` persists a Slack-controlled OpenCode model override. +- `/agent model set ` persists a Slack-controlled model override. Grooming defaults to `openrouter/google/gemini-2.5-flash`. - `/agent ready` lists open issues and flags missing acceptance criteria or high-risk work. - `/agent risk issue ` explains whether an issue triggers the high-risk approval gate. - `/agent queue` summarizes queued, active, failed, and PR-backed jobs. diff --git a/src/agents/grooming/GroomingAgent.ts b/src/agents/grooming/GroomingAgent.ts index 1b48e70..78ef92b 100644 --- a/src/agents/grooming/GroomingAgent.ts +++ b/src/agents/grooming/GroomingAgent.ts @@ -1,8 +1,9 @@ import { repoForName } from "../../config.js"; import { readinessReasons } from "../../control.js"; -import { addIssueLabels, fetchOpenIssues } from "../../github.js"; +import { addIssueLabels, fetchOpenIssues, removeIssueLabels } from "../../github.js"; import { makeLogWriter, writeLog } from "../../safe-log.js"; import type { Agent, AgentContext, AgentJob, AgentResult } from "../../types.js"; +import { effectiveModelForAgent } from "../../models.js"; const GROOMING_LABEL = "needs-grooming"; @@ -35,6 +36,10 @@ export class GroomingAgent implements Agent { })); const needsGrooming = groomingResults.filter((r) => r.reasons.length > 0); + const readyWithStaleLabel = groomingResults.filter((r) => r.reasons.length === 0 && r.issue.labels.includes(GROOMING_LABEL)); + current = await context.updateJob(job.id, { + model: effectiveModelForAgent(await context.store.getSettings(), this.name, context.config.opencodeCommand) + }); if (needsGrooming.length > 0 && !current.dryRun) { current = await context.updateJob(job.id, { phase: "labeling-issues" }); @@ -46,6 +51,16 @@ export class GroomingAgent implements Agent { } } + if (readyWithStaleLabel.length > 0 && !current.dryRun) { + current = await context.updateJob(job.id, { phase: "clearing-stale-labels" }); + await context.postUpdate(current, `Clearing stale "${GROOMING_LABEL}" label from ${readyWithStaleLabel.length} issue(s).`); + + for (const { issue } of readyWithStaleLabel) { + writeLog(logStream, `Removing label "${GROOMING_LABEL}" from issue #${issue.number}\n`); + await removeIssueLabels(repo, issue.number, [GROOMING_LABEL], current.dryRun); + } + } + const summary = formatGroomingSummary(repo.name, issues.length, groomingResults); current = await context.updateJob(job.id, { status: "completed", phase: "grooming-complete" }); diff --git a/src/github.ts b/src/github.ts index e7cb75c..09696b4 100644 --- a/src/github.ts +++ b/src/github.ts @@ -135,6 +135,20 @@ export async function addIssueLabels(repo: RepoConfig, issueNumber: number, labe } } +export async function removeIssueLabels(repo: RepoConfig, issueNumber: number, labels: string[], dryRun: boolean): Promise { + if (dryRun) { + return; + } + const result = await runCommand( + "gh", + ["issue", "edit", String(issueNumber), "--repo", repo.name, "--remove-label", labels.join(",")], + { cwd: repo.path } + ); + if (result.exitCode !== 0) { + throw new Error(`Failed to remove labels from issue #${issueNumber}: ${result.stderr.trim()}`); + } +} + export async function openPullRequest( repo: RepoConfig, input: { branch: string; issue: Issue; body: string; dryRun: boolean } diff --git a/src/models.ts b/src/models.ts index a597585..d5ee876 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,7 +1,9 @@ import { parseConfiguredCommand } from "./process.js"; import type { ModelControlledAgent, RuntimeSettings } from "./types.js"; -export const MODEL_CONTROLLED_AGENTS: ModelControlledAgent[] = ["implementation-agent", "review-agent"]; +export const DEFAULT_GROOMING_MODEL = "openrouter/google/gemini-2.5-flash"; + +export const MODEL_CONTROLLED_AGENTS: ModelControlledAgent[] = ["implementation-agent", "review-agent", "grooming-agent"]; const AGENT_ALIASES: Record = { code: "implementation-agent", @@ -14,7 +16,10 @@ const AGENT_ALIASES: Record = { judgment: "review-agent", review: "review-agent", reviewer: "review-agent", - "review-agent": "review-agent" + "review-agent": "review-agent", + groom: "grooming-agent", + grooming: "grooming-agent", + "grooming-agent": "grooming-agent" }; export type OpenRouterModel = { @@ -41,6 +46,9 @@ export function effectiveModelForAgent( agent: ModelControlledAgent, configuredCommand: string ): string | undefined { + if (agent === "grooming-agent") { + return settings.modelByAgent?.[agent] || DEFAULT_GROOMING_MODEL; + } return settings.modelByAgent?.[agent] || defaultModelFromCommand(configuredCommand); } diff --git a/src/types.ts b/src/types.ts index 73e03b7..6f94a9f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,7 +24,7 @@ export type AgentJobType = | "grooming-agent" | "support-agent"; -export type ModelControlledAgent = "implementation-agent" | "review-agent"; +export type ModelControlledAgent = "implementation-agent" | "review-agent" | "grooming-agent"; export type RuntimeSettings = { modelByAgent?: Partial>; diff --git a/test/command.test.ts b/test/command.test.ts index 4ef2fef..8023eb2 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -87,6 +87,11 @@ describe("parseSlashCommand", () => { agent: "review-agent", model: "openrouter/anthropic/claude-sonnet-4" }); + expect(parseSlashCommand("model set grooming openrouter/google/gemini-2.5-flash")).toEqual({ + action: "model-set", + agent: "grooming-agent", + model: "openrouter/google/gemini-2.5-flash" + }); expect(parseSlashCommand("logs job-1")).toEqual({ action: "logs", jobId: "job-1" }); expect(parseSlashCommand("cancel job-1")).toEqual({ action: "cancel", jobId: "job-1" }); expect(parseSlashCommand("approve job-1")).toEqual({ action: "approve", jobId: "job-1" }); @@ -124,6 +129,11 @@ describe("parseSlashCommand", () => { agent: "review-agent", model: "openai/gpt-5.5" }); + expect(parseSlashCommand("change the grooming agent to openrouter/google/gemini-2.5-flash")).toEqual({ + action: "model-set", + agent: "grooming-agent", + model: "openrouter/google/gemini-2.5-flash" + }); expect(parseSlashCommand("implement backlog grooming")).toEqual({ action: "submit", jobType: "self-improvement-agent", diff --git a/test/grooming-agent.test.ts b/test/grooming-agent.test.ts index 2646ee8..103b730 100644 --- a/test/grooming-agent.test.ts +++ b/test/grooming-agent.test.ts @@ -1,5 +1,28 @@ -import { describe, expect, it } from "vitest"; -import { formatGroomingSummary } from "../src/agents/grooming/GroomingAgent.js"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GroomingAgent, formatGroomingSummary } from "../src/agents/grooming/GroomingAgent.js"; +import type { AppConfig } from "../src/config.js"; +import type { AgentJob, JobState } from "../src/types.js"; + +vi.mock("../src/github.js", () => ({ + fetchOpenIssues: vi.fn(), + addIssueLabels: vi.fn(), + removeIssueLabels: vi.fn() +})); + +const github = await import("../src/github.js"); + +let tmpDir: string | undefined; + +afterEach(async () => { + vi.clearAllMocks(); + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + } +}); describe("formatGroomingSummary", () => { it("reports when all issues look good", () => { @@ -69,4 +92,100 @@ describe("formatGroomingSummary", () => { expect(result).toContain("0 issue(s) need grooming"); expect(result).toContain("0 issue(s) look good"); }); + + it("labels issues that need grooming and clears stale labels from ready issues", async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), "agent-control-grooming-")); + vi.mocked(github.fetchOpenIssues).mockResolvedValue([ + { + number: 1, + title: "Missing body", + body: "", + url: "https://github.com/owner/repo/issues/1", + labels: [] + }, + { + number: 2, + title: "Ready issue", + body: "Acceptance criteria:\n- Done when tested.", + url: "https://github.com/owner/repo/issues/2", + labels: ["needs-grooming"] + } + ]); + const state: JobState = { jobs: {} }; + const job = makeJob(tmpDir); + state.jobs[job.id] = job; + const updates: Partial[] = []; + + const result = await new GroomingAgent().run(job, { + config: makeConfig(tmpDir), + store: { + load: async () => state, + save: async () => {}, + create: async (newJob) => newJob, + update: async (jobId, patch) => { + updates.push(patch); + state.jobs[jobId] = { ...state.jobs[jobId], ...patch, updatedAt: new Date().toISOString() }; + return state.jobs[jobId]; + }, + get: async (jobId) => state.jobs[jobId], + list: async () => Object.values(state.jobs), + activeJobs: async () => [], + activeJob: async () => undefined, + lastJob: async () => state.jobs[job.id], + getSettings: async () => ({}), + updateSettings: async (settings) => settings + }, + updateJob: async (jobId, patch) => { + updates.push(patch); + state.jobs[jobId] = { ...state.jobs[jobId], ...patch, updatedAt: new Date().toISOString() }; + return state.jobs[jobId]; + }, + postUpdate: async () => {}, + registerCancel: () => {}, + clearCancel: () => {} + }); + + expect(result.status).toBe("completed"); + expect(github.addIssueLabels).toHaveBeenCalledWith(expect.objectContaining({ name: "owner/repo" }), 1, ["needs-grooming"], false); + expect(github.removeIssueLabels).toHaveBeenCalledWith(expect.objectContaining({ name: "owner/repo" }), 2, ["needs-grooming"], false); + expect(updates).toEqual(expect.arrayContaining([ + expect.objectContaining({ model: "openrouter/google/gemini-2.5-flash" }), + expect.objectContaining({ phase: "clearing-stale-labels" }) + ])); + }); }); + +function makeConfig(repoPath: string): AppConfig { + return { + slackBotToken: "xoxb-test", + slackSigningSecret: "secret", + allowedUserIds: new Set(["U123"]), + repos: [{ name: "owner/repo", path: repoPath, baseBranch: "main" }], + selfRepo: { name: "owner/agent-control", path: repoPath, baseBranch: "main" }, + baseBranch: "main", + opencodeCommand: "opencode run --model openrouter/default", + pollIntervalMs: 60000, + dryRun: false, + port: 3000, + statePath: path.join(repoPath, "state.json"), + logsDir: path.join(repoPath, "logs") + }; +} + +function makeJob(repoPath: string): AgentJob { + const now = new Date().toISOString(); + return { + id: "job-1", + type: "grooming-agent", + source: "slack", + repo: "owner/repo", + repoPath, + input: { action: "submit", jobType: "grooming-agent", target: "backlog" }, + status: "running", + createdAt: now, + updatedAt: now, + slackUserId: "U123", + logPath: path.join(repoPath, "logs", "job.log"), + dryRun: false + }; +} diff --git a/test/opencode.test.ts b/test/opencode.test.ts index c6a9890..ddac52b 100644 --- a/test/opencode.test.ts +++ b/test/opencode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { withOpenCodeRunDir } from "../src/opencode.js"; -import { defaultModelFromCommand, withOpenCodeModel } from "../src/models.js"; +import { defaultModelFromCommand, effectiveModelForAgent, withOpenCodeModel } from "../src/models.js"; describe("withOpenCodeRunDir", () => { it("adds the target repo directory for headless opencode runs", () => { @@ -31,4 +31,10 @@ describe("withOpenCodeModel", () => { expect(defaultModelFromCommand("opencode run --model openrouter/default")).toBe("openrouter/default"); expect(defaultModelFromCommand("opencode run --model=openrouter/default")).toBe("openrouter/default"); }); + + it("uses a default model for backlog grooming", () => { + expect(effectiveModelForAgent({}, "grooming-agent", "opencode run --model openrouter/default")).toBe( + "openrouter/google/gemini-2.5-flash" + ); + }); });