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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -260,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
Expand All @@ -285,11 +287,11 @@ Requirements:
- `/agent fix pr <prNumber>` checks out the PR branch, gives OpenCode failing pipeline context, commits a fix, and pushes it.
- `/agent watch pr <prNumber>` watches pipeline checks, attempts one repair, and runs review when checks pass.
- `/agent codex review pr <prNumber>` 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 <capability request>` 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 <implement|review> <model>` persists a Slack-controlled OpenCode model override.
- `/agent model set <implement|review|grooming> <model>` 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 <issueNumber>` explains whether an issue triggers the high-risk approval gate.
- `/agent queue` summarizes queued, active, failed, and PR-backed jobs.
Expand Down
111 changes: 107 additions & 4 deletions src/agents/grooming/GroomingAgent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,110 @@
import { StubAgent } from "../StubAgent.js";
import { repoForName } from "../../config.js";
import { readinessReasons } from "../../control.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";

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<AgentResult> {
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);
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" });
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);
}
}

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" });
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");
}
2 changes: 1 addition & 1 deletion src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
14 changes: 14 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 }
Expand Down
12 changes: 10 additions & 2 deletions src/models.ts
Original file line number Diff line number Diff line change
@@ -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<string, ModelControlledAgent> = {
code: "implementation-agent",
Expand All @@ -14,7 +16,10 @@ const AGENT_ALIASES: Record<string, ModelControlledAgent> = {
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 = {
Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ModelControlledAgent, string>>;
Expand Down
10 changes: 10 additions & 0 deletions test/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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",
Expand Down
Loading