From 8daf424dee57b084b6ddb8d5ab2aeeaae8d894b6 Mon Sep 17 00:00:00 2001 From: Miso Date: Sat, 16 May 2026 11:22:04 -0600 Subject: [PATCH 1/5] feat: add GPT-lane outcome tracking and audit parent decomposition (#66) - Add GptLaneOutcome enum to Prisma schema (PR_OPENED, PR_UPDATED, FOLLOW_UP_CREATED, DESIGN_COMMENT_POSTED, DECOMPOSED_SKIPPED, STUCK) - Add decomposed/decomposedAt/decomposedBy/followUpUrls fields to Issue - Add isValidGptOutcome validation and GPT_OUTCOME_LABELS mapping - Update AgentRun type/interface to include optional outcome field - Update POST /api/agent-runs to accept and validate outcome field - Add POST /api/issues/actions/decompose endpoint for marking audit parents as decomposed/reactivated with follow-up URL linking - Update GET /api/issues to support ?decomposed=true|false filter - Update agent queue endpoint to support exclude_decomposed query param - Add buildAgentQueue excludeDecomposed option and decomposed field to RankedIssue result shape - Add 15 tests: GPT outcome validation, decomposed parent filtering, combined lane+decomposed filtering, no hardcoded names/repos - Document the GPT-lane outcome contract in docs/gpt-lane-outcome-contract.md --- docs/gpt-lane-outcome-contract.md | 218 ++++++++++++++++++ prisma/schema.prisma | 52 +++++ src/app/api/agent-runs/route.ts | 15 +- src/app/api/agents/[agentName]/queue/route.ts | 10 +- src/app/api/issues/actions/decompose/route.ts | 93 ++++++++ src/app/api/issues/route.ts | 7 + src/lib/agent-queue.ts | 38 ++- src/lib/gpt-outcomes.test.ts | 186 +++++++++++++++ src/types/index.ts | 81 ++++++- 9 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 docs/gpt-lane-outcome-contract.md create mode 100644 src/app/api/issues/actions/decompose/route.ts create mode 100644 src/lib/gpt-outcomes.test.ts diff --git a/docs/gpt-lane-outcome-contract.md b/docs/gpt-lane-outcome-contract.md new file mode 100644 index 0000000..3204125 --- /dev/null +++ b/docs/gpt-lane-outcome-contract.md @@ -0,0 +1,218 @@ +# GPT-Lane Outcome Contract + +> **Issue:** [misospace/mission-control#66](https://github.com/misospace/mission-control/issues/66) +> **Date:** 2026-05-16 + +This document defines the operational contract for GPT-lane outcomes and audit parent decomposition in Mission Control. It enables agents to report non-code outcomes (design comments, follow-up issues, decompositions) beyond simple PR creation. + +## Overview + +The GPT lane handles work requiring higher-judgment model support — architecture decisions, security reviews, API boundary design, RFC evaluation, and broad audit parent decomposition. Unlike the NORMAL lane where agents primarily open or update PRs, GPT-lane work may produce several different outcome types. + +Mission Control must understand these outcomes so agents can rely on it as the assignment layer. + +--- + +## Supported Outcomes + +| Outcome Constant | Human Label | Description | +|------------------|-------------|-------------| +| `PR_OPENED` | PR opened | Agent opened a new PR in response to the issue | +| `PR_UPDATED` | PR updated | Agent pushed changes to an existing PR | +| `FOLLOW_UP_CREATED` | Follow-up issues created | Agent created concrete sub-issues from a broad parent | +| `DESIGN_COMMENT_POSTED` | Design/RFC comment posted | Agent posted a design document, RFC, or architectural review as a GitHub comment | +| `DECOMPOSED_SKIPPED` | Decomposed/skipped | The issue is a broad audit/umbrella parent that has been decomposed into concrete follow-up issues; the parent itself has no remaining direct work | +| `STUCK` | Stuck | Agent encountered a clear, unresolvable blocker and reported it with context | + +--- + +## Reporting Outcomes via Agent Run + +Agents report outcomes by including an `outcome` field in the `POST /api/agent-runs` request body: + +```http +POST /api/agent-runs +Authorization: Bearer +Content-Type: application/json + +{ + "agentName": "saffron", + "runType": "heartbeat", + "status": "completed", + "startedAt": "2026-05-16T10:00:00.000Z", + "finishedAt": "2026-05-16T10:05:00.000Z", + "summary": "Posted design review for auth boundary issue", + "outcome": "DESIGN_COMMENT_POSTED", + "touchedIssueUrls": [ + "https://github.com/misospace/miso-chat/issues/473" + ] +} +``` + +### Validation + +- If `outcome` is provided, it **must** be one of the six valid constants. +- Invalid values return HTTP 400 with a list of valid options. +- `outcome` is optional — NORMAL-lane agents may omit it. +- No hardcoded agent names or repo names in validation logic. + +--- + +## Audit Parent Decomposition + +### Problem + +Broad audit/umbrella issues (e.g., "Security audit decomposition", "Cross-service design review") are not directly actionable. They need to be decomposed into concrete follow-up issues before agents can work on them. Once decomposed, the parent issue should be excluded from the assignment queue without being closed — child work continues independently. + +### Decomposition Endpoint + +```http +POST /api/issues/actions/decompose +Authorization: Bearer +Content-Type: application/json + +{ + "repo": "misospace/mission-control", + "issueNumber": 66, + "decomposed": true, + "followUpUrls": [ + "https://github.com/misospace/mission-control/issues/100", + "https://github.com/misospace/mission-control/issues/101" + ], + "note": "Decomposed into two concrete auth boundary review tasks" +} +``` + +### Parameters + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `repo` | string | Yes | Owner/repo format (e.g., `"misospace/mission-control"`) | +| `issueNumber` | number | Yes | GitHub issue number | +| `decomposed` | boolean | Yes | `true` to mark as decomposed, `false` to reactivate | +| `followUpUrls` | string[] | No | URLs of concrete follow-up issues created from this parent | +| `note` | string | No | Human-readable note about the decomposition | + +### Response + +```json +{ + "success": true, + "issueId": "clx123...", + "decomposed": true, + "decomposedAt": "2026-05-16T10:05:00.000Z", + "followUpUrls": [ + "https://github.com/misospace/mission-control/issues/100", + "https://github.com/misospace/mission-control/issues/101" + ] +} +``` + +### Audit Trail + +Every decomposition action is logged in the `AuditLog` model: +- `action`: `"issue_decomposed"` or `"issue_reactivated"` +- `notes`: Includes the decomposition note and follow-up URLs +- Actor is always `"agent"` for automated decompositions + +--- + +## Queue Behavior with Decomposed Parents + +### Default Behavior + +By default, decomposed issues **are included** in the queue. This allows operators to see all issues, including those that have been decomposed. + +### Excluding Decomposed Parents + +The queue endpoint supports an `exclude_decomposed=true` query parameter: + +``` +GET /api/agents/saffron/queue?lane=gpt&exclude_decomposed=true +``` + +This filters out issues where `decomposed` is `true`, so agents only see actionable work. + +### Filtering Issues by Decomposed Status + +The general issues endpoint supports a `decomposed` query parameter: + +``` +GET /api/issues?decomposed=true # Show only decomposed issues +GET /api/issues?decomposed=false # Show only non-decomposed issues +``` + +Default behavior (no parameter): returns all issues regardless of decomposed status. + +--- + +## Linking Follow-Up Issues to Parents + +When an agent creates concrete follow-up issues from a broad parent: + +1. Report the outcome as `FOLLOW_UP_CREATED` via `POST /api/agent-runs`. +2. Call `POST /api/issues/actions/decompose` with `followUpUrls` containing the URLs of the new issues. +3. The parent issue stores these URLs in its `followUpUrls` array for traceability. + +This creates a bidirectional link: +- The queue can exclude decomposed parents (no duplicate assignments). +- Operators can see all follow-up work from a single parent issue. +- The `AuditLog` records the full chain of actions. + +--- + +## Stuck Reporting + +When an agent encounters a clear, unresolvable blocker: + +```json +{ + "agentName": "saffron", + "runType": "heartbeat", + "status": "completed", + "startedAt": "2026-05-16T10:00:00.000Z", + "finishedAt": "2026-05-16T10:05:00.000Z", + "outcome": "STUCK", + "summary": "Cannot resolve cross-service auth boundary without access to service X's config schema", + "errorMessage": "Blocked: missing access to service configuration", + "notes": "Need operator to grant read access to miso-gateway config repo" +} +``` + +The `STUCK` outcome signals that the agent needs human intervention. The `summary` and `notes` fields provide context for operators to resolve the blocker. + +--- + +## Source of Truth Rules + +| Rule | Detail | +|------|--------| +| GitHub is authoritative | Issues and PRs on GitHub are the single source of truth. Mission Control's Postgres is a cache. | +| Decomposed state is local | The `decomposed` flag lives only in Mission Control's database. It does not create or modify GitHub labels. Operators may optionally add a `status/decomposed` label on GitHub for visibility, but it is not required. | +| No auto-close | Decomposed issues are **not** closed on GitHub. They remain open with their child work continuing independently. | + +--- + +## Security Constraints + +| Constraint | Detail | +|------------|--------| +| Auth required | Both `POST /api/agent-runs` and `POST /api/issues/actions/decompose` require a valid `MISSION_CONTROL_AGENT_TOKEN`. | +| No hardcoded names | Outcome validation, lane filtering, and decomposed exclusion apply uniformly across all agents and repositories. | + +--- + +## API Reference Summary + +| Endpoint | Method | Auth | Purpose | +|----------|--------|------|---------| +| `/api/agent-runs` | POST | Bearer token | Submit agent run with optional GPT-lane outcome | +| `/api/issues/actions/decompose` | POST | Bearer token | Mark audit parent as decomposed/reactivated | +| `/api/agents//queue?exclude_decomposed=true` | GET | None | Get queue excluding decomposed audit parents | +| `/api/issues?decomposed=true` | GET | None | Filter issues by decomposed status | + +--- + +## History + +- **2026-05-16** — Created to support GPT-lane non-code outcomes and audit decomposition (Issue #66). diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ff42b04..732bf8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,10 +36,45 @@ model Issue { lastSyncedAt DateTime @default(now()) agentRuns AgentRun[] auditLogs AuditLog[] + lane IssueLane @default(NORMAL) + laneConfidence Decimal? @db.Decimal(3,2) + laneReason String? @db.Text + laneModel String? + laneJudgedAt DateTime? + laneHistory IssueLaneEntry[] + + // GPT-lane outcome tracking + decomposed Boolean @default(false) + decomposedAt DateTime? + decomposedBy String? @db.Text + decomposedNote String? @db.Text + followUpUrls String[] @default([]) @@unique([repositoryId, number]) @@index([state]) @@index([labels]) + @@index([lane]) +} + +model IssueLaneEntry { + id String @id @default(cuid()) + issueId String + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + lane IssueLane + confidence Decimal? @db.Decimal(3,2) + reason String? @db.Text + model String? + judgedAt DateTime @default(now()) + createdAt DateTime @default(now()) + + @@index([issueId]) + @@index([judgedAt]) +} + +enum IssueLane { + NORMAL + GPT + BACKLOG } model AgentRun { @@ -56,6 +91,23 @@ model AgentRun { createdAt DateTime @default(now()) issueId String? issue Issue? @relation(fields: [issueId], references: [id]) + + // GPT-lane outcome tracking + outcome GptLaneOutcome? + + @@index([agentName]) + @@index([runType]) + @@index([status]) + @@index([createdAt]) +} + +enum GptLaneOutcome { + PR_OPENED + PR_UPDATED + FOLLOW_UP_CREATED + DESIGN_COMMENT_POSTED + DECOMPOSED_SKIPPED + STUCK } model AuditLog { diff --git a/src/app/api/agent-runs/route.ts b/src/app/api/agent-runs/route.ts index 44fca44..86c578e 100644 --- a/src/app/api/agent-runs/route.ts +++ b/src/app/api/agent-runs/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { isValidGptOutcome, VALID_GPT_OUTCOMES } from "@/types"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -35,12 +36,23 @@ export async function POST(request: Request) { errorMessage, touchedIssueUrls, issueId, + outcome, } = body; if (!agentName || !runType || !status || !startedAt) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } + // Validate GPT-lane outcome if provided + if (outcome !== undefined && outcome !== null) { + if (!isValidGptOutcome(outcome)) { + return NextResponse.json( + { error: `Invalid outcome: "${outcome}". Valid values: ${VALID_GPT_OUTCOMES.join(", ")}` }, + { status: 400 }, + ); + } + } + const run = await prisma.agentRun.create({ data: { agentName, @@ -52,6 +64,7 @@ export async function POST(request: Request) { errorMessage, touchedIssueUrls: touchedIssueUrls || [], issueId, + outcome: outcome ?? undefined, }, }); @@ -60,4 +73,4 @@ export async function POST(request: Request) { console.error("Failed to create agent run:", error); return NextResponse.json({ error: "Failed to create agent run" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/agents/[agentName]/queue/route.ts b/src/app/api/agents/[agentName]/queue/route.ts index 5aae76d..bc71a52 100644 --- a/src/app/api/agents/[agentName]/queue/route.ts +++ b/src/app/api/agents/[agentName]/queue/route.ts @@ -4,6 +4,9 @@ import { buildAgentQueue } from "@/lib/agent-queue"; export async function GET(request: Request, { params }: { params: Promise<{ agentName: string }> }) { const { agentName } = await params; + const { searchParams } = new URL(request.url); + const lane = searchParams.get("lane"); + const excludeDecomposed = searchParams.get("exclude_decomposed"); try { // Fetch all open issues from enabled repos, using GitHub Issues as source of truth @@ -17,10 +20,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen title: true, url: true, labels: true, + lane: true, + decomposed: true, }, }); - const queue = buildAgentQueue(issues, agentName); + const queue = buildAgentQueue(issues, agentName, { + lane: lane?.toUpperCase() as "NORMAL" | "GPT" | "BACKLOG" | undefined, + excludeDecomposed: excludeDecomposed === "true", + }); return NextResponse.json(queue); } catch (error) { diff --git a/src/app/api/issues/actions/decompose/route.ts b/src/app/api/issues/actions/decompose/route.ts new file mode 100644 index 0000000..a4f19ea --- /dev/null +++ b/src/app/api/issues/actions/decompose/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +/** + * Mark an issue as decomposed (GPT-lane audit parent tracking). + * + * This allows broad audit/umbrella issues to be marked as decomposed or + * no longer actionable without closing child work. Follow-up issue URLs + * can be linked to the parent issue so the queue endpoint can exclude them. + * + * No hardcoded agent names or repo names — applies uniformly. + */ +export async function POST(request: Request) { + const token = request.headers.get("authorization")?.replace("Bearer ", ""); + if (token !== process.env.MISSION_CONTROL_AGENT_TOKEN) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await request.json(); + const { repo, issueNumber, decomposed, followUpUrls, note } = body; + + if (!repo || !issueNumber) { + return NextResponse.json({ error: "Missing required fields: repo, issueNumber" }, { status: 400 }); + } + + if (typeof decomposed !== "boolean") { + return NextResponse.json({ error: "Field 'decomposed' must be a boolean" }, { status: 400 }); + } + + // Parse repo as owner/repo format + const parts = repo.split("/"); + if (parts.length !== 2) { + return NextResponse.json({ error: "Invalid repo format. Expected 'owner/repo'" }, { status: 400 }); + } + const [owner, name] = parts; + + // Find the issue in the database + const issue = await prisma.issue.findFirst({ + where: { + number: issueNumber, + repository: { + owner, + name, + }, + }, + }); + + if (!issue) { + return NextResponse.json({ error: `Issue #${issueNumber} not found in ${repo}` }, { status: 404 }); + } + + // Update decomposed state + const updated = await prisma.issue.update({ + where: { id: issue.id }, + data: { + decomposed, + decomposedAt: decomposed ? new Date() : null, + decomposedBy: decomposed ? "agent" : null, + decomposedNote: note ?? null, + followUpUrls: followUpUrls ?? [], + }, + }); + + // Log the action in audit trail + await prisma.auditLog.create({ + data: { + actor: "agent", + action: decomposed ? "issue_decomposed" : "issue_reactivated", + repoFullName: `${owner}/${name}`, + issueNumber, + issueId: issue.id, + beforeLabels: [...issue.labels], + afterLabels: [...issue.labels], + success: true, + notes: decomposed + ? `Issue marked as decomposed. Note: ${note ?? "none"}. Follow-up URLs: ${(followUpUrls ?? []).join(", ")}` + : `Issue reactivated (decomposed set to false)`, + }, + }); + + return NextResponse.json({ + success: true, + issueId: updated.id, + decomposed: updated.decomposed, + decomposedAt: updated.decomposedAt, + followUpUrls: updated.followUpUrls, + }, { status: 200 }); + } catch (error) { + console.error("Failed to update decomposed state:", error); + return NextResponse.json({ error: "Failed to update decomposed state" }, { status: 500 }); + } +} diff --git a/src/app/api/issues/route.ts b/src/app/api/issues/route.ts index 445e894..c5d5ce4 100644 --- a/src/app/api/issues/route.ts +++ b/src/app/api/issues/route.ts @@ -9,6 +9,7 @@ export async function GET(request: Request) { const owner = searchParams.get("owner"); const project = searchParams.get("project"); const priority = searchParams.get("priority"); + const decomposed = searchParams.get("decomposed"); try { const where: Record = { repository: { enabled: true } }; @@ -17,6 +18,12 @@ export async function GET(request: Request) { where.repository = { ...(where.repository as object), fullName: repo }; } + // Filter by decomposed status if requested (default: exclude decomposed) + if (decomposed !== null) { + const parsed = decomposed === "true"; + where.decomposed = parsed; + } + const labels = buildLabelWhere([agent, owner, toProjectLabel(project), priority]); if (labels) where.labels = labels; diff --git a/src/lib/agent-queue.ts b/src/lib/agent-queue.ts index 8b0770f..1a368c9 100644 --- a/src/lib/agent-queue.ts +++ b/src/lib/agent-queue.ts @@ -20,6 +20,8 @@ export interface RankedIssue { status: string | null; agentMatch: boolean; rankingReason: string; + lane?: string; + decomposed?: boolean; } /** @@ -84,8 +86,6 @@ function isActionable(issueLabels: string[]): boolean { if (status === DONE_STATUS) return false; // Include: no status, backlog, in-progress - // Exclude: anything else that might be a status we don't recognize - // We allow in-progress and backlog (and no-status) as actionable if (status === null || status === BACKLOG_STATUS || status === IN_PROGRESS_STATUS) { return true; } @@ -95,13 +95,39 @@ function isActionable(issueLabels: string[]): boolean { /** * Build the agent queue: filter, rank, and return issues for a given agent. + * Optionally filters by execution lane (normal | gpt | backlog). + * Optionally excludes decomposed audit parents. */ -export function buildAgentQueue(issues: Array<{ labels: string[]; number: number; title: string; url: string }>, agentName: string): RankedIssue[] { +export function buildAgentQueue( + issues: Array<{ + labels: string[]; + number: number; + title: string; + url: string; + lane?: string; + decomposed?: boolean; + }>, + agentName: string, + options?: { + lane?: "NORMAL" | "GPT" | "BACKLOG"; + excludeDecomposed?: boolean; + }, +): RankedIssue[] { // Filter actionable issues (open, not done) - const actionable = issues.filter((issue) => isActionable(issue.labels)); + let actionable = issues.filter((issue) => isActionable(issue.labels)); + + // Exclude decomposed audit parents if requested + if (options?.excludeDecomposed) { + actionable = actionable.filter((issue) => !issue.decomposed); + } + + // Lane filter: exclude BACKLOG lane items from normal agent queue + const filtered = options?.lane + ? actionable.filter((issue) => issue.lane === options.lane) + : actionable.filter((issue) => issue.lane !== "BACKLOG"); // Rank and filter out excluded items - const ranked = actionable + const ranked = filtered .map((issue) => { const { score, reason } = rankIssue(issue.labels, agentName); return { ...issue, score, reason }; @@ -128,6 +154,8 @@ export function buildAgentQueue(issues: Array<{ labels: string[]; number: number status, agentMatch, rankingReason: item.reason, + lane: item.lane, + decomposed: item.decomposed ?? false, }; }); } diff --git a/src/lib/gpt-outcomes.test.ts b/src/lib/gpt-outcomes.test.ts new file mode 100644 index 0000000..319a8da --- /dev/null +++ b/src/lib/gpt-outcomes.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentQueue } from "./agent-queue"; +import { + VALID_GPT_OUTCOMES, + isValidGptOutcome, + GPT_OUTCOME_LABELS, +} from "@/types"; + +const makeIssue = ( + overrides: Partial<{ + number: number; + title: string; + url: string; + labels: string[]; + lane?: string; + decomposed?: boolean; + }> = {}, +) => ({ + number: overrides.number ?? 1, + title: overrides.title ?? "Test issue", + url: overrides.url ?? "https://github.com/test/repo/issues/1", + labels: overrides.labels ?? [], + lane: overrides.lane, + decomposed: overrides.decomposed ?? false, +}); + +// ─── GPT Outcome Validation Tests ──────────────────────────────────────────── + +describe("GPT outcome validation", () => { + it("accepts all valid outcomes", () => { + for (const outcome of VALID_GPT_OUTCOMES) { + expect(isValidGptOutcome(outcome)).toBe(true); + } + }); + + it("rejects invalid outcomes", () => { + expect(isValidGptOutcome("PR_MERGED")).toBe(false); + expect(isValidGptOutcome("completed")).toBe(false); + expect(isValidGptOutcome("")).toBe(false); + expect(isValidGptOutcome("random")).toBe(false); + }); + + it("provides human-readable labels for all outcomes", () => { + for (const outcome of VALID_GPT_OUTCOMES) { + expect(GPT_OUTCOME_LABELS[outcome]).toBeDefined(); + expect(typeof GPT_OUTCOME_LABELS[outcome]).toBe("string"); + expect(GPT_OUTCOME_LABELS[outcome].length).toBeGreaterThan(0); + } + }); + + it("labels match expected human-readable descriptions", () => { + expect(GPT_OUTCOME_LABELS.PR_OPENED).toBe("PR opened"); + expect(GPT_OUTCOME_LABELS.PR_UPDATED).toBe("PR updated"); + expect(GPT_OUTCOME_LABELS.FOLLOW_UP_CREATED).toBe("Follow-up issues created"); + expect(GPT_OUTCOME_LABELS.DESIGN_COMMENT_POSTED).toBe("Design/RFC comment posted"); + expect(GPT_OUTCOME_LABELS.DECOMPOSED_SKIPPED).toBe("Decomposed/skipped"); + expect(GPT_OUTCOME_LABELS.STUCK).toBe("Stuck"); + }); +}); + +// ─── Decomposed Audit Parent Tests ─────────────────────────────────────────── + +describe("buildAgentQueue with decomposed audit parents", () => { + it("includes decomposed issues when excludeDecomposed is false (default)", () => { + const issues = [makeIssue({ number: 1, labels: ["priority/p1"], decomposed: true })]; + const result = buildAgentQueue(issues, "saffron"); + expect(result).toHaveLength(1); + expect(result[0].decomposed).toBe(true); + }); + + it("excludes decomposed issues when excludeDecomposed is true", () => { + const issues = [ + makeIssue({ number: 1, labels: ["priority/p1"], decomposed: true }), + makeIssue({ number: 2, labels: ["priority/p1"], decomposed: false }), + ]; + const result = buildAgentQueue(issues, "saffron", { excludeDecomposed: true }); + expect(result).toHaveLength(1); + expect(result[0].number).toBe(2); + }); + + it("excludes only decomposed issues, keeps non-decomposed ones", () => { + const issues = [ + makeIssue({ number: 1, labels: ["priority/p1"], lane: "GPT", decomposed: true }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), + makeIssue({ number: 3, labels: ["priority/p0"], lane: "GPT", decomposed: true }), + makeIssue({ number: 4, labels: ["priority/p0"], lane: "GPT", decomposed: false }), + ]; + const result = buildAgentQueue(issues, "saffron", { lane: "GPT", excludeDecomposed: true }); + expect(result).toHaveLength(2); + expect(result.map((i) => i.number)).toEqual([4, 2]); // p0 first, then p1 + }); + + it("returns decomposed flag in result for each issue", () => { + const issues = [ + makeIssue({ number: 1, labels: ["priority/p1"], decomposed: true }), + makeIssue({ number: 2, labels: ["priority/p1"], decomposed: false }), + ]; + const result = buildAgentQueue(issues, "saffron"); + expect(result[0].decomposed).toBe(true); + expect(result[1].decomposed).toBe(false); + }); + + it("defaults decomposed to false when not provided", () => { + const issues = [makeIssue({ number: 1, labels: ["priority/p1"] })]; + const result = buildAgentQueue(issues, "saffron"); + expect(result[0].decomposed).toBe(false); + }); + + it("works with GPT lane + excludeDecomposed for audit parent workflow", () => { + // Simulates: broad audit parent (decomposed) vs concrete follow-up issues (not decomposed) + const issues = [ + makeIssue({ number: 10, title: "Security audit decomposition", labels: ["priority/p1"], lane: "GPT", decomposed: true }), + makeIssue({ number: 11, title: "Review auth module", labels: ["priority/p1"], lane: "GPT", decomposed: false }), + makeIssue({ number: 12, title: "Update CI config", labels: ["priority/p2"], lane: "GPT", decomposed: false }), + ]; + const result = buildAgentQueue(issues, "saffron", { lane: "GPT", excludeDecomposed: true }); + expect(result).toHaveLength(2); + expect(result[0].number).toBe(11); // p1 first + expect(result[1].number).toBe(12); // p2 second + }); + + it("does not hardcode agent names in decomposed filtering", () => { + const issues = [makeIssue({ number: 1, labels: ["priority/p1"], lane: "GPT", decomposed: true })]; + const resultSaffron = buildAgentQueue(issues, "saffron", { excludeDecomposed: true }); + const resultBeta = buildAgentQueue(issues, "beta", { excludeDecomposed: true }); + + expect(resultSaffron).toHaveLength(0); + expect(resultBeta).toHaveLength(0); + }); + + it("does not hardcode repo names in decomposed filtering", () => { + const issues = [ + makeIssue({ + number: 1, + url: "https://github.com/misospace/mission-control/issues/1", + labels: ["priority/p1"], + decomposed: true, + }), + makeIssue({ + number: 2, + url: "https://github.com/misospace/miso-chat/issues/42", + labels: ["priority/p1"], + decomposed: false, + }), + ]; + const result = buildAgentQueue(issues, "saffron", { excludeDecomposed: true }); + expect(result).toHaveLength(1); + expect(result[0].number).toBe(2); + }); +}); + +// ─── Combined Lane + Decomposed Filtering ──────────────────────────────────── + +describe("Combined lane and decomposed filtering", () => { + it("applies both lane filter and decomposed exclusion together", () => { + const issues = [ + makeIssue({ number: 1, labels: ["priority/p1"], lane: "GPT", decomposed: true }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), + makeIssue({ number: 3, labels: ["priority/p1"], lane: "NORMAL", decomposed: true }), + makeIssue({ number: 4, labels: ["priority/p0"], lane: "GPT", decomposed: false }), + ]; + const result = buildAgentQueue(issues, "saffron", { lane: "GPT", excludeDecomposed: true }); + expect(result).toHaveLength(2); + expect(result.map((i) => i.number)).toEqual([4, 2]); + }); + + it("excludes BACKLOG lane items even when excludeDecomposed is false", () => { + const issues = [ + makeIssue({ number: 1, labels: ["priority/p1"], lane: "BACKLOG", decomposed: true }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), + ]; + const result = buildAgentQueue(issues, "saffron"); + expect(result).toHaveLength(1); + expect(result[0].number).toBe(2); + }); + + it("excludes BACKLOG lane items even when excludeDecomposed is true", () => { + const issues = [ + makeIssue({ number: 1, labels: ["priority/p1"], lane: "BACKLOG", decomposed: false }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), + ]; + const result = buildAgentQueue(issues, "saffron", { excludeDecomposed: true }); + expect(result).toHaveLength(1); + expect(result[0].number).toBe(2); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 5d1049a..b38e070 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,6 +37,25 @@ export interface Issue { repository: { fullName: string; }; + lane?: string; + laneConfidence?: number | null; + laneReason?: string | null; + laneModel?: string | null; + laneJudgedAt?: Date | null; + + // GPT-lane outcome tracking + decomposed?: boolean; + decomposedAt?: Date | null; + decomposedBy?: string | null; + decomposedNote?: string | null; + followUpUrls?: string[]; +} + +export interface IssueLaneClassification { + lane: "NORMAL" | "GPT" | "BACKLOG"; + confidence: number | null; + reason: string; + model: string; } export interface AgentRun { @@ -49,6 +68,7 @@ export interface AgentRun { summary: string | null; errorMessage: string | null; touchedIssueUrls: string[]; + outcome?: GptLaneOutcome | null; } export interface AuditLog { @@ -70,6 +90,7 @@ export type OwnerLabel = `owner/${string}`; export type PriorityLabel = "priority/p0" | "priority/p1" | "priority/p2" | "priority/p3"; export type TypeLabel = "type/bug" | "type/feature" | "type/chore" | "type/research" | "type/security"; export type ProjectLabel = `project/${string}`; +export type IssueLane = "NORMAL" | "GPT" | "BACKLOG"; export const STATUS_LABELS: StatusLabel[] = ["status/backlog", "status/in-progress", "status/in-review", "status/done"]; export const PRIORITY_LABELS: PriorityLabel[] = ["priority/p0", "priority/p1", "priority/p2", "priority/p3"]; @@ -115,4 +136,62 @@ export const LABEL_COLORS: Record = { "priority/p1": "f97316", "priority/p2": "eab308", "priority/p3": "22c55e", -}; \ No newline at end of file +}; + +// Lane classification constants and helpers +export const VALID_LANES: IssueLane[] = ["NORMAL", "GPT", "BACKLOG"]; + +export function isValidLane(lane: string): lane is IssueLane { + return VALID_LANES.includes(lane as IssueLane); +} + +export const LANE_LABELS: Record = { + NORMAL: "normal", + GPT: "gpt", + BACKLOG: "backlog", +}; + +export const LANE_COLORS: Record = { + NORMAL: "22c55e", + GPT: "a855f7", + BACKLOG: "6b7280", +}; + +// ─── GPT-Lane Outcome Constants ────────────────────────────────────────────── + +export type GptLaneOutcome = + | "PR_OPENED" + | "PR_UPDATED" + | "FOLLOW_UP_CREATED" + | "DESIGN_COMMENT_POSTED" + | "DECOMPOSED_SKIPPED" + | "STUCK"; + +export const VALID_GPT_OUTCOMES: GptLaneOutcome[] = [ + "PR_OPENED", + "PR_UPDATED", + "FOLLOW_UP_CREATED", + "DESIGN_COMMENT_POSTED", + "DECOMPOSED_SKIPPED", + "STUCK", +]; + +/** + * Returns true if the given outcome is a valid GPT-lane outcome. + * No hardcoded agent or repo names — this applies to all agents and repos uniformly. + */ +export function isValidGptOutcome(outcome: string): outcome is GptLaneOutcome { + return VALID_GPT_OUTCOMES.includes(outcome as GptLaneOutcome); +} + +/** + * Human-readable label for a GPT-lane outcome. + */ +export const GPT_OUTCOME_LABELS: Record = { + PR_OPENED: "PR opened", + PR_UPDATED: "PR updated", + FOLLOW_UP_CREATED: "Follow-up issues created", + DESIGN_COMMENT_POSTED: "Design/RFC comment posted", + DECOMPOSED_SKIPPED: "Decomposed/skipped", + STUCK: "Stuck", +}; From de092cfa08e282e02b514cc6b82f649304e1e5e3 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Sat, 16 May 2026 12:56:15 -0600 Subject: [PATCH 2/5] fix: resolve Decimal type incompatibility causing typecheck failure --- src/components/issue-card.tsx | 2 +- src/types/index.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/issue-card.tsx b/src/components/issue-card.tsx index 8d68f46..1123e2a 100644 --- a/src/components/issue-card.tsx +++ b/src/components/issue-card.tsx @@ -259,7 +259,7 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) )} - {/* Assign agent */} + {/* Assign agent */}

Assign agent diff --git a/src/types/index.ts b/src/types/index.ts index b38e070..c2c86b1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ +import { Prisma } from "@prisma/client"; + export interface GitHubIssue { number: number; title: string; @@ -38,7 +40,7 @@ export interface Issue { fullName: string; }; lane?: string; - laneConfidence?: number | null; + laneConfidence?: number | Prisma.Decimal | null; laneReason?: string | null; laneModel?: string | null; laneJudgedAt?: Date | null; From d4ad3381e9782e3129f8dbe4d193a60ca04e615f Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Sat, 16 May 2026 13:51:37 -0600 Subject: [PATCH 3/5] refactor: rename GPT lane to Escalated lane for provider-neutral terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename Prisma enum IssueLane.GPT → IssueLane.ESCALATED - Rename Prisma enum GptLaneOutcome → EscalatedOutcome - Rename TypeScript types/constants: GptLaneOutcome, VALID_GPT_OUTCOMES, GPT_OUTCOME_LABELS, isValidGptOutcome → EscalatedOutcome equivalents - Update lane type in agent-queue.ts and API routes - Add transitional lane=gpt → lane=escalated alias in queue route - Rename test file gpt-outcomes.test.ts → escalated-outcomes.test.ts - Rename doc gpt-lane-outcome-contract.md → escalated-lane-outcome-contract.md - Update worker-execution-contract.md and AGENTS.md lane references - All 221 tests pass, lint and typecheck clean --- AGENTS.md | 12 +-- ....md => escalated-lane-outcome-contract.md} | 12 +-- docs/worker-execution-contract.md | 10 +-- prisma/schema.prisma | 10 +-- src/app/api/agent-runs/route.ts | 8 +- src/app/api/agents/[agentName]/queue/route.ts | 2 +- src/app/api/issues/actions/decompose/route.ts | 2 +- src/lib/agent-queue.ts | 4 +- ...mes.test.ts => escalated-outcomes.test.ts} | 76 +++++++++---------- src/types/index.ts | 30 ++++---- 10 files changed, 83 insertions(+), 83 deletions(-) rename docs/{gpt-lane-outcome-contract.md => escalated-lane-outcome-contract.md} (88%) rename src/lib/{gpt-outcomes.test.ts => escalated-outcomes.test.ts} (66%) diff --git a/AGENTS.md b/AGENTS.md index e66d19d..56d6a76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,14 +56,14 @@ Labels follow a `category/value` pattern: Mission Control classifies issues into three execution lanes: - **NORMAL**: Concrete, scoped, testable implementation work suitable for a standard worker. Examples: bounded frontend/backend fixes, documentation, tests, CI/lint, release/version drift, dependency updates, concrete follow-up issues with clear acceptance criteria. -- **GPT**: Requires higher-judgment model support. Examples: architecture/security/API/auth boundary design, database/schema migration strategy, distributed/cross-service design, ambiguous product behavior, broad refactor planning, RFC/design/alternatives decisions, audit parent decomposition. +- **ESCALATED**: Requires higher-judgment model support (may be GPT-5.5, Claude Opus, GLM-5.1, or another provider). Examples: architecture/security/API/auth boundary design, database/schema migration strategy, distributed/cross-service design, ambiguous product behavior, broad refactor planning, RFC/design/alternatives decisions, audit parent decomposition. - **BACKLOG**: Not actionable yet — placeholder, missing enough detail, or a parent/umbrella item that hasn't been decomposed into concrete work. Each classification stores: `lane`, `confidence` (0.0–1.0), `reason`, and `model/source`. A full history of classifications is maintained in the `IssueLaneEntry` table. **Routing rules:** -- Do NOT route to GPT only because labels include `needs-gpt`, `escalated`, or `priority/p1`. -- DO route broad audit parent/umbrella issues to GPT for decomposition/design unless already decomposed. +- Do NOT route to ESCALATED only because labels include `needs-gpt`, `escalated`, or `priority/p1`. +- DO route broad audit parent/umbrella issues to ESCALATED for decomposition/design unless already decomposed. - If the issue has clear acceptance criteria, prefer NORMAL. - If confidence is low and the issue is not actionable, choose BACKLOG. @@ -182,9 +182,9 @@ At the **end** of each heartbeat: 3. **Read issues from `GET /api/issues`.** Do not query the Postgres cache directly — the API is the contract. 4. **Prefer issues assigned via `agent/` label** if present. If no `agent/*` label exists, fall back to general backlog. -5. **Filter by execution lane** using the `lane` query param on `GET /api/agents/[agentName]/queue` (values: `NORMAL`, `GPT`, `BACKLOG`). By default, BACKLOG issues are excluded from the normal agent queue. -5. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states. -7. **Respect execution lane classification** when present: NORMAL issues are the primary queue for agents; GPT issues may require higher-judgment support; BACKLOG issues are not actionable until decomposed. +5. **Filter by execution lane** using the `lane` query param on `GET /api/agents/[agentName]/queue` (values: `NORMAL`, `ESCALATED`, `BACKLOG`). By default, BACKLOG issues are excluded from the normal agent queue. +6. **Treat "no status label" or `status/backlog` as backlog work.** Both are valid entry states. +7. **Respect execution lane classification** when present: NORMAL issues are the primary queue for agents; ESCALATED issues may require higher-judgment support; BACKLOG issues are not actionable until decomposed. ### Source of truth diff --git a/docs/gpt-lane-outcome-contract.md b/docs/escalated-lane-outcome-contract.md similarity index 88% rename from docs/gpt-lane-outcome-contract.md rename to docs/escalated-lane-outcome-contract.md index 3204125..0eedf46 100644 --- a/docs/gpt-lane-outcome-contract.md +++ b/docs/escalated-lane-outcome-contract.md @@ -1,13 +1,13 @@ -# GPT-Lane Outcome Contract +# Escalated-Lane Outcome Contract > **Issue:** [misospace/mission-control#66](https://github.com/misospace/mission-control/issues/66) > **Date:** 2026-05-16 -This document defines the operational contract for GPT-lane outcomes and audit parent decomposition in Mission Control. It enables agents to report non-code outcomes (design comments, follow-up issues, decompositions) beyond simple PR creation. +This document defines the operational contract for escalated-lane outcomes and audit parent decomposition in Mission Control. It enables agents to report non-code outcomes (design comments, follow-up issues, decompositions) beyond simple PR creation. ## Overview -The GPT lane handles work requiring higher-judgment model support — architecture decisions, security reviews, API boundary design, RFC evaluation, and broad audit parent decomposition. Unlike the NORMAL lane where agents primarily open or update PRs, GPT-lane work may produce several different outcome types. +The Escalated lane handles work requiring higher-judgment model support — architecture decisions, security reviews, API boundary design, RFC evaluation, and broad audit parent decomposition. Unlike the NORMAL lane where agents primarily open or update PRs, escalated-lane work may produce several different outcome types. The specific model used for escalated work may vary (e.g., GPT-5.5, Claude Opus, GLM-5.1) depending on configuration; the lane concept is provider-neutral. Mission Control must understand these outcomes so agents can rely on it as the assignment layer. @@ -128,7 +128,7 @@ By default, decomposed issues **are included** in the queue. This allows operato The queue endpoint supports an `exclude_decomposed=true` query parameter: ``` -GET /api/agents/saffron/queue?lane=gpt&exclude_decomposed=true +GET /api/agents/saffron/queue?lane=escalated&exclude_decomposed=true ``` This filters out issues where `decomposed` is `true`, so agents only see actionable work. @@ -206,7 +206,7 @@ The `STUCK` outcome signals that the agent needs human intervention. The `summar | Endpoint | Method | Auth | Purpose | |----------|--------|------|---------| -| `/api/agent-runs` | POST | Bearer token | Submit agent run with optional GPT-lane outcome | +| `/api/agent-runs` | POST | Bearer token | Submit agent run with optional escalated-lane outcome | | `/api/issues/actions/decompose` | POST | Bearer token | Mark audit parent as decomposed/reactivated | | `/api/agents//queue?exclude_decomposed=true` | GET | None | Get queue excluding decomposed audit parents | | `/api/issues?decomposed=true` | GET | None | Filter issues by decomposed status | @@ -215,4 +215,4 @@ The `STUCK` outcome signals that the agent needs human intervention. The `summar ## History -- **2026-05-16** — Created to support GPT-lane non-code outcomes and audit decomposition (Issue #66). +- **2026-05-16** — Created to support Escalated lane non-code outcomes and audit decomposition (Issue #66). diff --git a/docs/worker-execution-contract.md b/docs/worker-execution-contract.md index f93dbcb..10e7e7f 100644 --- a/docs/worker-execution-contract.md +++ b/docs/worker-execution-contract.md @@ -23,7 +23,7 @@ This document defines the generic execution contract for any agent worker consum A worker must handle **exactly one** queue item per execution: - Either one PR fix from the PR review-fix queue, or -- One issue from the normal (or GPT) assignment queue. +- One issue from the normal (or Escalated) assignment queue. Workers must not batch multiple issues or PR fixes into a single run. @@ -149,9 +149,9 @@ The normal lane contains concrete, scoped, testable work items: Workers consume one item per run from the normal lane's queue endpoint. -### GPT Lane +### Escalated Lane -The GPT lane contains work requiring higher-judgment model support: +The Escalated lane contains work requiring higher-judgment model support: - Architecture, security, or API boundary design - Database/schema migration strategy - Distributed/cross-service design decisions @@ -160,13 +160,13 @@ The GPT lane contains work requiring higher-judgment model support: - RFC/design/alternatives evaluation - Audit parent decomposition -Workers consume one item per run from the GPT lane's queue endpoint. +Workers consume one item per run from the Escalated lane's queue endpoint. ### Lane Selection Workers receive their lane via the queue endpoint's `lane` query parameter: - `GET /api/agents//queue?lane=normal` -- `GET /api/agents//queue?lane=gpt` +- `GET /api/agents//queue?lane=escalated` (also accepts `lane=gpt` as a deprecated alias) BACKLOG issues are excluded from the normal agent queue by default. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 732bf8c..eee2daf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,7 +43,7 @@ model Issue { laneJudgedAt DateTime? laneHistory IssueLaneEntry[] - // GPT-lane outcome tracking + // Escalated lane outcome tracking decomposed Boolean @default(false) decomposedAt DateTime? decomposedBy String? @db.Text @@ -73,7 +73,7 @@ model IssueLaneEntry { enum IssueLane { NORMAL - GPT + ESCALATED BACKLOG } @@ -92,8 +92,8 @@ model AgentRun { issueId String? issue Issue? @relation(fields: [issueId], references: [id]) - // GPT-lane outcome tracking - outcome GptLaneOutcome? + // Escalated lane outcome tracking + outcome EscalatedOutcome? @@index([agentName]) @@index([runType]) @@ -101,7 +101,7 @@ model AgentRun { @@index([createdAt]) } -enum GptLaneOutcome { +enum EscalatedOutcome { PR_OPENED PR_UPDATED FOLLOW_UP_CREATED diff --git a/src/app/api/agent-runs/route.ts b/src/app/api/agent-runs/route.ts index 86c578e..368cb57 100644 --- a/src/app/api/agent-runs/route.ts +++ b/src/app/api/agent-runs/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { isValidGptOutcome, VALID_GPT_OUTCOMES } from "@/types"; +import { isValidEscalatedOutcome, VALID_ESCALATED_OUTCOMES } from "@/types"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -43,11 +43,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); } - // Validate GPT-lane outcome if provided + // Validate escalated-lane outcome if provided if (outcome !== undefined && outcome !== null) { - if (!isValidGptOutcome(outcome)) { + if (!isValidEscalatedOutcome(outcome)) { return NextResponse.json( - { error: `Invalid outcome: "${outcome}". Valid values: ${VALID_GPT_OUTCOMES.join(", ")}` }, + { error: `Invalid outcome: "${outcome}". Valid values: ${VALID_ESCALATED_OUTCOMES.join(", ")}` }, { status: 400 }, ); } diff --git a/src/app/api/agents/[agentName]/queue/route.ts b/src/app/api/agents/[agentName]/queue/route.ts index bc71a52..f3495a3 100644 --- a/src/app/api/agents/[agentName]/queue/route.ts +++ b/src/app/api/agents/[agentName]/queue/route.ts @@ -26,7 +26,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen }); const queue = buildAgentQueue(issues, agentName, { - lane: lane?.toUpperCase() as "NORMAL" | "GPT" | "BACKLOG" | undefined, + lane: (lane === "gpt" ? "escalated" : lane?.toUpperCase()) as "NORMAL" | "ESCALATED" | "BACKLOG" | undefined, excludeDecomposed: excludeDecomposed === "true", }); diff --git a/src/app/api/issues/actions/decompose/route.ts b/src/app/api/issues/actions/decompose/route.ts index a4f19ea..6d0cf05 100644 --- a/src/app/api/issues/actions/decompose/route.ts +++ b/src/app/api/issues/actions/decompose/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; /** - * Mark an issue as decomposed (GPT-lane audit parent tracking). + * Mark an issue as decomposed (escalated-lane audit parent tracking). * * This allows broad audit/umbrella issues to be marked as decomposed or * no longer actionable without closing child work. Follow-up issue URLs diff --git a/src/lib/agent-queue.ts b/src/lib/agent-queue.ts index 1a368c9..390d160 100644 --- a/src/lib/agent-queue.ts +++ b/src/lib/agent-queue.ts @@ -95,7 +95,7 @@ function isActionable(issueLabels: string[]): boolean { /** * Build the agent queue: filter, rank, and return issues for a given agent. - * Optionally filters by execution lane (normal | gpt | backlog). + * Optionally filters by execution lane (normal | escalated | backlog). * Optionally excludes decomposed audit parents. */ export function buildAgentQueue( @@ -109,7 +109,7 @@ export function buildAgentQueue( }>, agentName: string, options?: { - lane?: "NORMAL" | "GPT" | "BACKLOG"; + lane?: "NORMAL" | "ESCALATED" | "BACKLOG"; excludeDecomposed?: boolean; }, ): RankedIssue[] { diff --git a/src/lib/gpt-outcomes.test.ts b/src/lib/escalated-outcomes.test.ts similarity index 66% rename from src/lib/gpt-outcomes.test.ts rename to src/lib/escalated-outcomes.test.ts index 319a8da..245383d 100644 --- a/src/lib/gpt-outcomes.test.ts +++ b/src/lib/escalated-outcomes.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { buildAgentQueue } from "./agent-queue"; import { - VALID_GPT_OUTCOMES, - isValidGptOutcome, - GPT_OUTCOME_LABELS, + VALID_ESCALATED_OUTCOMES, + isValidEscalatedOutcome, + ESCALATED_OUTCOME_LABELS, } from "@/types"; const makeIssue = ( @@ -24,37 +24,37 @@ const makeIssue = ( decomposed: overrides.decomposed ?? false, }); -// ─── GPT Outcome Validation Tests ──────────────────────────────────────────── +// ─── Escalated Outcome Validation Tests ────────────────────────────────────── -describe("GPT outcome validation", () => { +describe("Escalated outcome validation", () => { it("accepts all valid outcomes", () => { - for (const outcome of VALID_GPT_OUTCOMES) { - expect(isValidGptOutcome(outcome)).toBe(true); + for (const outcome of VALID_ESCALATED_OUTCOMES) { + expect(isValidEscalatedOutcome(outcome)).toBe(true); } }); it("rejects invalid outcomes", () => { - expect(isValidGptOutcome("PR_MERGED")).toBe(false); - expect(isValidGptOutcome("completed")).toBe(false); - expect(isValidGptOutcome("")).toBe(false); - expect(isValidGptOutcome("random")).toBe(false); + expect(isValidEscalatedOutcome("PR_MERGED")).toBe(false); + expect(isValidEscalatedOutcome("completed")).toBe(false); + expect(isValidEscalatedOutcome("")).toBe(false); + expect(isValidEscalatedOutcome("random")).toBe(false); }); it("provides human-readable labels for all outcomes", () => { - for (const outcome of VALID_GPT_OUTCOMES) { - expect(GPT_OUTCOME_LABELS[outcome]).toBeDefined(); - expect(typeof GPT_OUTCOME_LABELS[outcome]).toBe("string"); - expect(GPT_OUTCOME_LABELS[outcome].length).toBeGreaterThan(0); + for (const outcome of VALID_ESCALATED_OUTCOMES) { + expect(ESCALATED_OUTCOME_LABELS[outcome]).toBeDefined(); + expect(typeof ESCALATED_OUTCOME_LABELS[outcome]).toBe("string"); + expect(ESCALATED_OUTCOME_LABELS[outcome].length).toBeGreaterThan(0); } }); it("labels match expected human-readable descriptions", () => { - expect(GPT_OUTCOME_LABELS.PR_OPENED).toBe("PR opened"); - expect(GPT_OUTCOME_LABELS.PR_UPDATED).toBe("PR updated"); - expect(GPT_OUTCOME_LABELS.FOLLOW_UP_CREATED).toBe("Follow-up issues created"); - expect(GPT_OUTCOME_LABELS.DESIGN_COMMENT_POSTED).toBe("Design/RFC comment posted"); - expect(GPT_OUTCOME_LABELS.DECOMPOSED_SKIPPED).toBe("Decomposed/skipped"); - expect(GPT_OUTCOME_LABELS.STUCK).toBe("Stuck"); + expect(ESCALATED_OUTCOME_LABELS.PR_OPENED).toBe("PR opened"); + expect(ESCALATED_OUTCOME_LABELS.PR_UPDATED).toBe("PR updated"); + expect(ESCALATED_OUTCOME_LABELS.FOLLOW_UP_CREATED).toBe("Follow-up issues created"); + expect(ESCALATED_OUTCOME_LABELS.DESIGN_COMMENT_POSTED).toBe("Design/RFC comment posted"); + expect(ESCALATED_OUTCOME_LABELS.DECOMPOSED_SKIPPED).toBe("Decomposed/skipped"); + expect(ESCALATED_OUTCOME_LABELS.STUCK).toBe("Stuck"); }); }); @@ -80,12 +80,12 @@ describe("buildAgentQueue with decomposed audit parents", () => { it("excludes only decomposed issues, keeps non-decomposed ones", () => { const issues = [ - makeIssue({ number: 1, labels: ["priority/p1"], lane: "GPT", decomposed: true }), - makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), - makeIssue({ number: 3, labels: ["priority/p0"], lane: "GPT", decomposed: true }), - makeIssue({ number: 4, labels: ["priority/p0"], lane: "GPT", decomposed: false }), + makeIssue({ number: 1, labels: ["priority/p1"], lane: "ESCALATED", decomposed: true }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "ESCALATED", decomposed: false }), + makeIssue({ number: 3, labels: ["priority/p0"], lane: "ESCALATED", decomposed: true }), + makeIssue({ number: 4, labels: ["priority/p0"], lane: "ESCALATED", decomposed: false }), ]; - const result = buildAgentQueue(issues, "saffron", { lane: "GPT", excludeDecomposed: true }); + const result = buildAgentQueue(issues, "saffron", { lane: "ESCALATED", excludeDecomposed: true }); expect(result).toHaveLength(2); expect(result.map((i) => i.number)).toEqual([4, 2]); // p0 first, then p1 }); @@ -106,21 +106,21 @@ describe("buildAgentQueue with decomposed audit parents", () => { expect(result[0].decomposed).toBe(false); }); - it("works with GPT lane + excludeDecomposed for audit parent workflow", () => { + it("works with Escalated lane + excludeDecomposed for audit parent workflow", () => { // Simulates: broad audit parent (decomposed) vs concrete follow-up issues (not decomposed) const issues = [ - makeIssue({ number: 10, title: "Security audit decomposition", labels: ["priority/p1"], lane: "GPT", decomposed: true }), - makeIssue({ number: 11, title: "Review auth module", labels: ["priority/p1"], lane: "GPT", decomposed: false }), - makeIssue({ number: 12, title: "Update CI config", labels: ["priority/p2"], lane: "GPT", decomposed: false }), + makeIssue({ number: 10, title: "Security audit decomposition", labels: ["priority/p1"], lane: "ESCALATED", decomposed: true }), + makeIssue({ number: 11, title: "Review auth module", labels: ["priority/p1"], lane: "ESCALATED", decomposed: false }), + makeIssue({ number: 12, title: "Update CI config", labels: ["priority/p2"], lane: "ESCALATED", decomposed: false }), ]; - const result = buildAgentQueue(issues, "saffron", { lane: "GPT", excludeDecomposed: true }); + const result = buildAgentQueue(issues, "saffron", { lane: "ESCALATED", excludeDecomposed: true }); expect(result).toHaveLength(2); expect(result[0].number).toBe(11); // p1 first expect(result[1].number).toBe(12); // p2 second }); it("does not hardcode agent names in decomposed filtering", () => { - const issues = [makeIssue({ number: 1, labels: ["priority/p1"], lane: "GPT", decomposed: true })]; + const issues = [makeIssue({ number: 1, labels: ["priority/p1"], lane: "ESCALATED", decomposed: true })]; const resultSaffron = buildAgentQueue(issues, "saffron", { excludeDecomposed: true }); const resultBeta = buildAgentQueue(issues, "beta", { excludeDecomposed: true }); @@ -154,12 +154,12 @@ describe("buildAgentQueue with decomposed audit parents", () => { describe("Combined lane and decomposed filtering", () => { it("applies both lane filter and decomposed exclusion together", () => { const issues = [ - makeIssue({ number: 1, labels: ["priority/p1"], lane: "GPT", decomposed: true }), - makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), + makeIssue({ number: 1, labels: ["priority/p1"], lane: "ESCALATED", decomposed: true }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "ESCALATED", decomposed: false }), makeIssue({ number: 3, labels: ["priority/p1"], lane: "NORMAL", decomposed: true }), - makeIssue({ number: 4, labels: ["priority/p0"], lane: "GPT", decomposed: false }), + makeIssue({ number: 4, labels: ["priority/p0"], lane: "ESCALATED", decomposed: false }), ]; - const result = buildAgentQueue(issues, "saffron", { lane: "GPT", excludeDecomposed: true }); + const result = buildAgentQueue(issues, "saffron", { lane: "ESCALATED", excludeDecomposed: true }); expect(result).toHaveLength(2); expect(result.map((i) => i.number)).toEqual([4, 2]); }); @@ -167,7 +167,7 @@ describe("Combined lane and decomposed filtering", () => { it("excludes BACKLOG lane items even when excludeDecomposed is false", () => { const issues = [ makeIssue({ number: 1, labels: ["priority/p1"], lane: "BACKLOG", decomposed: true }), - makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "ESCALATED", decomposed: false }), ]; const result = buildAgentQueue(issues, "saffron"); expect(result).toHaveLength(1); @@ -177,7 +177,7 @@ describe("Combined lane and decomposed filtering", () => { it("excludes BACKLOG lane items even when excludeDecomposed is true", () => { const issues = [ makeIssue({ number: 1, labels: ["priority/p1"], lane: "BACKLOG", decomposed: false }), - makeIssue({ number: 2, labels: ["priority/p1"], lane: "GPT", decomposed: false }), + makeIssue({ number: 2, labels: ["priority/p1"], lane: "ESCALATED", decomposed: false }), ]; const result = buildAgentQueue(issues, "saffron", { excludeDecomposed: true }); expect(result).toHaveLength(1); diff --git a/src/types/index.ts b/src/types/index.ts index c2c86b1..2508203 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,7 +45,7 @@ export interface Issue { laneModel?: string | null; laneJudgedAt?: Date | null; - // GPT-lane outcome tracking + // Escalated lane outcome tracking decomposed?: boolean; decomposedAt?: Date | null; decomposedBy?: string | null; @@ -54,7 +54,7 @@ export interface Issue { } export interface IssueLaneClassification { - lane: "NORMAL" | "GPT" | "BACKLOG"; + lane: "NORMAL" | "ESCALATED" | "BACKLOG"; confidence: number | null; reason: string; model: string; @@ -70,7 +70,7 @@ export interface AgentRun { summary: string | null; errorMessage: string | null; touchedIssueUrls: string[]; - outcome?: GptLaneOutcome | null; + outcome?: EscalatedOutcome | null; } export interface AuditLog { @@ -92,7 +92,7 @@ export type OwnerLabel = `owner/${string}`; export type PriorityLabel = "priority/p0" | "priority/p1" | "priority/p2" | "priority/p3"; export type TypeLabel = "type/bug" | "type/feature" | "type/chore" | "type/research" | "type/security"; export type ProjectLabel = `project/${string}`; -export type IssueLane = "NORMAL" | "GPT" | "BACKLOG"; +export type IssueLane = "NORMAL" | "ESCALATED" | "BACKLOG"; export const STATUS_LABELS: StatusLabel[] = ["status/backlog", "status/in-progress", "status/in-review", "status/done"]; export const PRIORITY_LABELS: PriorityLabel[] = ["priority/p0", "priority/p1", "priority/p2", "priority/p3"]; @@ -141,7 +141,7 @@ export const LABEL_COLORS: Record = { }; // Lane classification constants and helpers -export const VALID_LANES: IssueLane[] = ["NORMAL", "GPT", "BACKLOG"]; +export const VALID_LANES: IssueLane[] = ["NORMAL", "ESCALATED", "BACKLOG"]; export function isValidLane(lane: string): lane is IssueLane { return VALID_LANES.includes(lane as IssueLane); @@ -149,19 +149,19 @@ export function isValidLane(lane: string): lane is IssueLane { export const LANE_LABELS: Record = { NORMAL: "normal", - GPT: "gpt", + ESCALATED: "escalated", BACKLOG: "backlog", }; export const LANE_COLORS: Record = { NORMAL: "22c55e", - GPT: "a855f7", + ESCALATED: "a855f7", BACKLOG: "6b7280", }; -// ─── GPT-Lane Outcome Constants ────────────────────────────────────────────── +// ─── Escalated-Lane Outcome Constants ──────────────────────────────────────── -export type GptLaneOutcome = +export type EscalatedOutcome = | "PR_OPENED" | "PR_UPDATED" | "FOLLOW_UP_CREATED" @@ -169,7 +169,7 @@ export type GptLaneOutcome = | "DECOMPOSED_SKIPPED" | "STUCK"; -export const VALID_GPT_OUTCOMES: GptLaneOutcome[] = [ +export const VALID_ESCALATED_OUTCOMES: EscalatedOutcome[] = [ "PR_OPENED", "PR_UPDATED", "FOLLOW_UP_CREATED", @@ -179,17 +179,17 @@ export const VALID_GPT_OUTCOMES: GptLaneOutcome[] = [ ]; /** - * Returns true if the given outcome is a valid GPT-lane outcome. + * Returns true if the given outcome is a valid escalated-lane outcome. * No hardcoded agent or repo names — this applies to all agents and repos uniformly. */ -export function isValidGptOutcome(outcome: string): outcome is GptLaneOutcome { - return VALID_GPT_OUTCOMES.includes(outcome as GptLaneOutcome); +export function isValidEscalatedOutcome(outcome: string): outcome is EscalatedOutcome { + return VALID_ESCALATED_OUTCOMES.includes(outcome as EscalatedOutcome); } /** - * Human-readable label for a GPT-lane outcome. + * Human-readable label for an escalated-lane outcome. */ -export const GPT_OUTCOME_LABELS: Record = { +export const ESCALATED_OUTCOME_LABELS: Record = { PR_OPENED: "PR opened", PR_UPDATED: "PR updated", FOLLOW_UP_CREATED: "Follow-up issues created", From db056adf713e34603e660e1b817f1fea50110a01 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Sat, 16 May 2026 14:45:53 -0600 Subject: [PATCH 4/5] fix: tighten escalated decomposition attribution --- AGENTS.md | 2 +- docs/escalated-lane-outcome-contract.md | 6 +- .../issues/actions/decompose/route.test.ts | 246 ++++++++++++++++++ src/app/api/issues/actions/decompose/route.ts | 41 ++- 4 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 src/app/api/issues/actions/decompose/route.test.ts diff --git a/AGENTS.md b/AGENTS.md index 56d6a76..9b53160 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ Mission Control classifies issues into three execution lanes: Each classification stores: `lane`, `confidence` (0.0–1.0), `reason`, and `model/source`. A full history of classifications is maintained in the `IssueLaneEntry` table. **Routing rules:** -- Do NOT route to ESCALATED only because labels include `needs-gpt`, `escalated`, or `priority/p1`. +- Do NOT route to ESCALATED only because labels include `needs-escalation`, legacy `needs-gpt`, `escalated`, or `priority/p1`. - DO route broad audit parent/umbrella issues to ESCALATED for decomposition/design unless already decomposed. - If the issue has clear acceptance criteria, prefer NORMAL. - If confidence is low and the issue is not actionable, choose BACKLOG. diff --git a/docs/escalated-lane-outcome-contract.md b/docs/escalated-lane-outcome-contract.md index 0eedf46..4348907 100644 --- a/docs/escalated-lane-outcome-contract.md +++ b/docs/escalated-lane-outcome-contract.md @@ -36,7 +36,7 @@ Authorization: Bearer Content-Type: application/json { - "agentName": "saffron", + "agentName": "example-agent", "runType": "heartbeat", "status": "completed", "startedAt": "2026-05-16T10:00:00.000Z", @@ -128,7 +128,7 @@ By default, decomposed issues **are included** in the queue. This allows operato The queue endpoint supports an `exclude_decomposed=true` query parameter: ``` -GET /api/agents/saffron/queue?lane=escalated&exclude_decomposed=true +GET /api/agents/example-agent/queue?lane=escalated&exclude_decomposed=true ``` This filters out issues where `decomposed` is `true`, so agents only see actionable work. @@ -167,7 +167,7 @@ When an agent encounters a clear, unresolvable blocker: ```json { - "agentName": "saffron", + "agentName": "example-agent", "runType": "heartbeat", "status": "completed", "startedAt": "2026-05-16T10:00:00.000Z", diff --git a/src/app/api/issues/actions/decompose/route.test.ts b/src/app/api/issues/actions/decompose/route.test.ts new file mode 100644 index 0000000..e10d081 --- /dev/null +++ b/src/app/api/issues/actions/decompose/route.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + findFirstIssue: vi.fn().mockResolvedValue(null), + updateIssue: vi.fn().mockResolvedValue(undefined), + createAuditLog: vi.fn().mockResolvedValue({ id: "log-1" }), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { + findFirst: mocks.findFirstIssue, + update: mocks.updateIssue, + }, + auditLog: { + create: mocks.createAuditLog, + }, + }, +})); + +// Store the original env so we can restore it +const originalAgentToken = process.env.MISSION_CONTROL_AGENT_TOKEN; + +import { POST } from "./route"; + +function decomposeRequest(payload: Record) { + return POST( + new Request("http://localhost/api/issues/actions/decompose", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-token", + }, + body: JSON.stringify(payload), + }) + ); +} + +describe("POST /api/issues/actions/decompose — actor attribution", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.MISSION_CONTROL_AGENT_TOKEN = "test-token"; + mocks.findFirstIssue.mockResolvedValue({ + id: "issue-1", + number: 66, + labels: ["priority/p1"], + }); + mocks.updateIssue.mockResolvedValue({ + id: "issue-1", + decomposed: true, + decomposedAt: new Date(), + decomposedBy: "example-agent", + decomposedNote: null, + followUpUrls: [], + }); + mocks.createAuditLog.mockResolvedValue({ id: "log-1" }); + }); + + afterEach(() => { + process.env.MISSION_CONTROL_AGENT_TOKEN = originalAgentToken; + }); + + it("defaults actor to 'agent' when no actor or agentName supplied", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + }); + expect(res.status).toBe(200); + + expect(mocks.createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ actor: "agent" }) }) + ); + expect(mocks.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ decomposedBy: "agent" }), + }) + ); + }); + + it("uses actor when provided", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + actor: "example-agent", + }); + expect(res.status).toBe(200); + + expect(mocks.createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ actor: "example-agent" }) }) + ); + expect(mocks.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ decomposedBy: "example-agent" }), + }) + ); + }); + + it("uses agentName as fallback when actor is not provided", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + agentName: "fallback-agent", + }); + expect(res.status).toBe(200); + + expect(mocks.createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ actor: "fallback-agent" }) }) + ); + expect(mocks.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ decomposedBy: "fallback-agent" }), + }) + ); + }); + + it("prefers actor over agentName when both are provided", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + actor: "primary-agent", + agentName: "secondary-agent", + }); + expect(res.status).toBe(200); + + expect(mocks.createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ actor: "primary-agent" }) }) + ); + expect(mocks.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ decomposedBy: "primary-agent" }), + }) + ); + }); + + it("returns 400 when actor is not a string", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + actor: 123 as unknown as string, + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("'actor'/'agentName' must be a string"); + }); + + it("returns 400 when actor is empty string", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + actor: "", + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("'actor'/'agentName' must not be empty after trimming"); + }); + + it("returns 400 when actor is whitespace only", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + actor: " ", + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("'actor'/'agentName' must not be empty after trimming"); + }); + + it("returns 400 when actor exceeds 100 characters", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + actor: "a".repeat(101), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("'actor'/'agentName' must be at most 100 characters"); + }); + + it("trims actor value before storing", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: true, + actor: " trimmed-agent ", + }); + expect(res.status).toBe(200); + + expect(mocks.createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ actor: "trimmed-agent" }) }) + ); + }); +}); + +describe("POST /api/issues/actions/decompose — reactivity", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.MISSION_CONTROL_AGENT_TOKEN = "test-token"; + mocks.findFirstIssue.mockResolvedValue({ + id: "issue-1", + number: 66, + labels: ["priority/p1"], + }); + mocks.updateIssue.mockResolvedValue({ + id: "issue-1", + decomposed: false, + decomposedAt: null, + decomposedBy: null, + decomposedNote: null, + followUpUrls: [], + }); + mocks.createAuditLog.mockResolvedValue({ id: "log-1" }); + }); + + afterEach(() => { + process.env.MISSION_CONTROL_AGENT_TOKEN = originalAgentToken; + }); + + it("stores null decomposedBy when reactivating (decomposed=false)", async () => { + const res = await decomposeRequest({ + repo: "misospace/mission-control", + issueNumber: 66, + decomposed: false, + actor: "example-agent", + }); + expect(res.status).toBe(200); + + expect(mocks.updateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ decomposedBy: null }), + }) + ); + expect(mocks.createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ action: "issue_reactivated" }) }) + ); + }); +}); diff --git a/src/app/api/issues/actions/decompose/route.ts b/src/app/api/issues/actions/decompose/route.ts index 6d0cf05..e58d6e5 100644 --- a/src/app/api/issues/actions/decompose/route.ts +++ b/src/app/api/issues/actions/decompose/route.ts @@ -1,6 +1,37 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +/** + * Resolve the actor name for decomposition attribution. + * + * Resolution order: actor > agentName > "agent" (default). + * Validates that the resolved value is a non-empty trimmed string <= 100 chars. + */ +function resolveActor(body: unknown): { actor: string; error?: string } { + const raw = body && typeof body === "object" ? (body as Record) : null; + if (!raw) return { actor: "agent" }; + + // Prefer `actor`, fall back to `agentName`, then default to "agent" + let value: unknown; + if ("actor" in raw) value = raw.actor; + else if ("agentName" in raw) value = raw.agentName; + else return { actor: "agent" }; + + if (typeof value !== "string") { + return { actor: "", error: "'actor'/'agentName' must be a string" }; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return { actor: "", error: "'actor'/'agentName' must not be empty after trimming" }; + } + if (trimmed.length > 100) { + return { actor: "", error: "'actor'/'agentName' must be at most 100 characters" }; + } + + return { actor: trimmed }; +} + /** * Mark an issue as decomposed (escalated-lane audit parent tracking). * @@ -28,6 +59,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Field 'decomposed' must be a boolean" }, { status: 400 }); } + // Resolve attribution actor + const { actor, error: actorError } = resolveActor(body); + if (actorError) { + return NextResponse.json({ error: actorError }, { status: 400 }); + } + // Parse repo as owner/repo format const parts = repo.split("/"); if (parts.length !== 2) { @@ -56,7 +93,7 @@ export async function POST(request: Request) { data: { decomposed, decomposedAt: decomposed ? new Date() : null, - decomposedBy: decomposed ? "agent" : null, + decomposedBy: decomposed ? actor : null, decomposedNote: note ?? null, followUpUrls: followUpUrls ?? [], }, @@ -65,7 +102,7 @@ export async function POST(request: Request) { // Log the action in audit trail await prisma.auditLog.create({ data: { - actor: "agent", + actor, action: decomposed ? "issue_decomposed" : "issue_reactivated", repoFullName: `${owner}/${name}`, issueNumber, From 36b4d655bea6c3d9a637ccc15b73deafd14757da Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Sat, 16 May 2026 14:51:35 -0600 Subject: [PATCH 5/5] fix: restore whitespace on issue-card comment indentation --- src/components/issue-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/issue-card.tsx b/src/components/issue-card.tsx index 1123e2a..8d68f46 100644 --- a/src/components/issue-card.tsx +++ b/src/components/issue-card.tsx @@ -259,7 +259,7 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps)

)} - {/* Assign agent */} + {/* Assign agent */}

Assign agent