diff --git a/packages/dashboard/src/__tests__/triage-trait.test.ts b/packages/dashboard/src/__tests__/triage-trait.test.ts new file mode 100644 index 0000000000..7e760cf6a8 --- /dev/null +++ b/packages/dashboard/src/__tests__/triage-trait.test.ts @@ -0,0 +1,414 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { PrEntity, Task, TaskStore } from "@fusion/core"; +import { + getTraitRegistry, + __resetTraitRegistryForTests, +} from "@fusion/core"; +import { + TRIAGE_TRAIT_ID, + TRIAGE_DEFAULT_ROUTE_COLUMN, + TRIAGE_REVIEW_COLUMN, + classifyTriageItem, + resolveTriageSubject, + registerTriageTrait, + runTriageOnEnter, + __resetTriageTraitForTests, +} from "../triage-trait.js"; +import type { SubtaskItem } from "../subtask-breakdown.js"; + +// ── Fake store ────────────────────────────────────────────────────────────── + +interface FakeStore extends TaskStore { + _tasks: Task[]; + _prEntities: PrEntity[]; +} + +function makeStore(prEntities: PrEntity[] = []): FakeStore { + const tasks: Task[] = []; + let counter = 0; + const store = { + async createTask(input: Parameters[0]) { + const task = { + id: `FN-${++counter}`, + title: input.title, + description: input.description, + column: input.column, + priority: input.priority, + source: input.source, + } as unknown as Task; + tasks.push(task); + return task; + }, + async updateTask( + id: string, + updates: Parameters[1], + ) { + const task = tasks.find((t) => t.id === id); + if (!task) throw new Error(`task ${id} not found`); + if (updates.priority !== undefined && updates.priority !== null) { + task.priority = updates.priority; + } + const patch = (updates as { sourceMetadataPatch?: Record }) + .sourceMetadataPatch; + if (patch) { + task.source = { + sourceType: task.source?.sourceType ?? "api", + ...task.source, + sourceMetadata: { ...(task.source?.sourceMetadata ?? {}), ...patch }, + }; + } + return task; + }, + async moveTask(id: string, toColumn: string) { + const task = tasks.find((t) => t.id === id); + if (!task) throw new Error(`task ${id} not found`); + (task as { column: string }).column = toColumn; + return task; + }, + getPrEntity(id: string) { + return prEntities.find((p) => p.id === id) ?? null; + }, + getActivePrEntityBySource(sourceType: string, sourceId: string) { + return ( + prEntities.find( + (p) => + p.sourceType === sourceType && + p.sourceId === sourceId && + p.state !== "merged" && + p.state !== "closed", + ) ?? null + ); + }, + _tasks: tasks, + _prEntities: prEntities, + }; + return store as unknown as FakeStore; +} + +function makeTask(partial: Partial & { id: string; description: string }): Task { + return { + column: "triage", + ...partial, + } as unknown as Task; +} + +const decomposeTo = + (items: Array>) => + async (_d: string): Promise => + items.map((it, i) => ({ + id: it.id ?? `subtask-${i + 1}`, + title: it.title ?? `Sub ${i + 1}`, + description: it.description ?? "", + suggestedSize: it.suggestedSize ?? "M", + priority: it.priority, + dependsOn: it.dependsOn ?? [], + })); + +afterEach(() => { + __resetTriageTraitForTests(); + __resetTraitRegistryForTests(); +}); + +// ── classification ────────────────────────────────────────────────────────── + +describe("classifyTriageItem", () => { + it("classifies a dependency bump PR", () => { + const c = classifyTriageItem({ + kind: "pull_request", + title: "Bump lodash from 4.17.20 to 4.17.21", + prAuthor: "dependabot[bot]", + }); + expect(c.dependencyBump).toBe(true); + expect(c.area).toBe("dependency"); + expect(c.labels).toContain("automated"); + }); + + it("classifies a feature PR as not a dependency bump", () => { + const c = classifyTriageItem({ + kind: "pull_request", + title: "Add dark mode support", + prAuthor: "contributor", + }); + expect(c.dependencyBump).toBe(false); + expect(c.area).toBe("feature"); + }); + + it("maps critical severity to urgent priority", () => { + const c = classifyTriageItem({ kind: "signal", title: "DB down", severity: "critical" }); + expect(c.priority).toBe("urgent"); + expect(c.labels).toContain("signal"); + }); +}); + +// ── PR-vs-issue + self-loop guard ──────────────────────────────────────────── + +describe("resolveTriageSubject", () => { + it("treats a signal-sourced task as a triageable signal", () => { + const task = makeTask({ + id: "FN-1", + description: "err", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry" } }, + }); + const subj = resolveTriageSubject(task); + expect(subj.kind).toBe("signal"); + expect(subj.triageable).toBe(true); + }); + + it("treats an inbound PR with no Fusion entity as triageable", () => { + const task = makeTask({ + id: "FN-2", + description: "pr", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr", prInbound: true } }, + }); + const subj = resolveTriageSubject(task, makeStore()); + expect(subj.kind).toBe("pull_request"); + expect(subj.triageable).toBe(true); + }); + + it("does NOT triage a PR Fusion itself opened (owned by a task PrEntity)", () => { + const store = makeStore([ + { + id: "PR-1", + sourceType: "task", + sourceId: "FN-3", + repo: "o/r", + headBranch: "feat", + state: "open", + } as unknown as PrEntity, + ]); + const task = makeTask({ + id: "FN-3", + description: "pr", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr", prInbound: true } }, + }); + const subj = resolveTriageSubject(task, store); + expect(subj.kind).toBe("pull_request"); + expect(subj.triageable).toBe(false); + expect(subj.skipReason).toContain("self-loop"); + }); + + it("does NOT triage a PR not marked inbound", () => { + const task = makeTask({ + id: "FN-4", + description: "pr", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr" } }, + }); + const subj = resolveTriageSubject(task, makeStore()); + expect(subj.triageable).toBe(false); + }); +}); + +// ── runTriageOnEnter scenarios ─────────────────────────────────────────────── + +describe("runTriageOnEnter", () => { + it("decomposes a signal task into N todo tasks linked to the signal", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-10", + title: "Outage report", + description: "Investigate the production outage and fix the root cause", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry", signalSeverity: "error" } }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { + store, + decompose: decomposeTo([{ title: "Diagnose" }, { title: "Fix" }, { title: "Verify" }]), + }); + + expect(outcome.kind).toBe("decomposed"); + if (outcome.kind !== "decomposed") throw new Error("unreachable"); + expect(outcome.childTaskIds).toHaveLength(3); + expect(outcome.routedColumn).toBe(TRIAGE_DEFAULT_ROUTE_COLUMN); + // children created in todo, linked back to the signal + const children = store._tasks.filter((t) => outcome.childTaskIds.includes(t.id)); + for (const c of children) { + expect(c.column).toBe("todo"); + expect(c.source?.sourceParentTaskId).toBe("FN-10"); + expect((c.source?.sourceMetadata as Record).triageParentTaskId).toBe("FN-10"); + } + // signal stamped as triaged + expect((signal.source?.sourceMetadata as Record).triageProcessedAt).toBeTruthy(); + }); + + it("passes a too-small signal through as a SINGLE task (not zero)", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-11", + title: "Tiny", + description: "trivial one-liner", + source: { sourceType: "api", sourceMetadata: { signalSource: "webhook" } }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { + store, + decompose: decomposeTo([{ title: "Only one" }]), + }); + + expect(outcome.kind).toBe("passthrough"); + if (outcome.kind !== "passthrough") throw new Error("unreachable"); + expect(outcome.taskId).toBe("FN-11"); + expect(outcome.routedColumn).toBe("todo"); + expect(signal.column).toBe("todo"); + // no children minted + expect(store._tasks).toHaveLength(1); + }); + + it("routes a dependency-bump PR to review (no issue minted)", async () => { + const store = makeStore(); + const pr = makeTask({ + id: "FN-12", + title: "Bump express from 4.18.0 to 4.18.2", + description: "dependabot bump", + source: { + sourceType: "api", + sourceMetadata: { resourceType: "pr", prInbound: true, prAuthor: "dependabot[bot]" }, + }, + }); + store._tasks.push(pr); + + const outcome = await runTriageOnEnter(pr, { store }); + + expect(outcome.kind).toBe("pr-review"); + expect(pr.column).toBe(TRIAGE_REVIEW_COLUMN); + // exactly one task (the PR itself); no follow-up, no issue + expect(store._tasks).toHaveLength(1); + }); + + it("opens a follow-up task for a feature PR linked to its PR entity", async () => { + const store = makeStore(); + const pr = makeTask({ + id: "FN-13", + title: "Add new export format", + description: "feature PR from external contributor", + source: { + sourceType: "api", + sourceMetadata: { resourceType: "pr", prInbound: true, prEntityId: "PR-99" }, + }, + }); + store._tasks.push(pr); + + const outcome = await runTriageOnEnter(pr, { store }); + + expect(outcome.kind).toBe("pr-follow-up"); + if (outcome.kind !== "pr-follow-up") throw new Error("unreachable"); + expect(pr.column).toBe(TRIAGE_REVIEW_COLUMN); + const followUp = store._tasks.find((t) => t.id === outcome.followUpTaskId); + expect(followUp).toBeDefined(); + expect(followUp!.source?.sourceParentTaskId).toBe("FN-13"); + expect((followUp!.source?.sourceMetadata as Record).prEntityId).toBe("PR-99"); + }); + + it("does NOT re-triage a PR Fusion itself opened (no self-loop)", async () => { + const store = makeStore([ + { + id: "PR-1", + sourceType: "task", + sourceId: "FN-14", + repo: "o/r", + headBranch: "feat", + state: "open", + } as unknown as PrEntity, + ]); + const pr = makeTask({ + id: "FN-14", + title: "feat: my own change", + description: "PR Fusion opened", + column: "triage", + source: { sourceType: "api", sourceMetadata: { resourceType: "pr", prInbound: true } }, + }); + store._tasks.push(pr); + + const outcome = await runTriageOnEnter(pr, { store }); + + expect(outcome.kind).toBe("skipped"); + if (outcome.kind !== "skipped") throw new Error("unreachable"); + expect(outcome.reason).toContain("self-loop"); + // no follow-up created, PR not moved out of triage + expect(store._tasks).toHaveLength(1); + expect(pr.column).toBe("triage"); + }); + + it("PARKS the item in triage on classifier/decompose failure (does not drop it)", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-15", + title: "Boom", + description: "will fail to decompose", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry" } }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { + store, + decompose: async () => { + throw new Error("classifier exploded"); + }, + }); + + expect(outcome.kind).toBe("parked"); + if (outcome.kind !== "parked") throw new Error("unreachable"); + expect(outcome.reason).toContain("classifier exploded"); + // still in triage, marker recorded, not dropped + expect(signal.column).toBe("triage"); + expect(store._tasks).toHaveLength(1); + expect((signal.source?.sourceMetadata as Record).triageError).toContain( + "classifier exploded", + ); + }); + + it("is idempotent: an already-triaged task is a no-op skip", async () => { + const store = makeStore(); + const signal = makeTask({ + id: "FN-16", + title: "done already", + description: "x", + source: { + sourceType: "api", + sourceMetadata: { signalSource: "sentry", triageProcessedAt: "2026-01-01T00:00:00Z" }, + }, + }); + store._tasks.push(signal); + + const outcome = await runTriageOnEnter(signal, { store, decompose: decomposeTo([{}, {}]) }); + expect(outcome.kind).toBe("skipped"); + expect(store._tasks).toHaveLength(1); + }); +}); + +// ── registry wiring ────────────────────────────────────────────────────────── + +describe("registerTriageTrait", () => { + it("registers a triage trait with an onEnter hook resolvable through the registry", async () => { + __resetTraitRegistryForTests(); + __resetTriageTraitForTests(); + registerTriageTrait(); + + const registry = getTraitRegistry(); + const def = registry.getTrait(TRIAGE_TRAIT_ID); + expect(def).toBeDefined(); + expect(def!.builtin).toBe(true); + expect(def!.hooks?.onEnter).toBe(true); + + const resolved = registry.resolveTraitHook(TRIAGE_TRAIT_ID, "onEnter"); + expect(resolved.warning).toBeUndefined(); + expect(typeof resolved.impl).toBe("function"); + + // The resolved impl runs the triage pass against the ctx-supplied deps. + const store = makeStore(); + const signal = makeTask({ + id: "FN-20", + title: "via hook", + description: "decompose me", + source: { sourceType: "api", sourceMetadata: { signalSource: "sentry" } }, + }); + store._tasks.push(signal); + + const result = await resolved.impl!({ + task: signal, + deps: { store, decompose: decomposeTo([{}, {}, {}]) }, + }); + expect((result as { kind: string }).kind).toBe("decomposed"); + }); +}); diff --git a/packages/dashboard/src/subtask-breakdown.ts b/packages/dashboard/src/subtask-breakdown.ts index d42c1c00c0..6a90fa420c 100644 --- a/packages/dashboard/src/subtask-breakdown.ts +++ b/packages/dashboard/src/subtask-breakdown.ts @@ -381,6 +381,58 @@ export class SubtaskStreamManager extends EventEmitter { export const subtaskStreamManager = new SubtaskStreamManager(); +/** + * U12 reuse seam: a one-shot decomposition for the triage trait. Reuses the same + * agent (`createFnAgent`), system prompt, JSON parsing, and deterministic + * fallback as the streaming subtask-breakdown flow, but returns the parsed + * {@link SubtaskItem}[] directly (no session/stream machinery). When the engine + * agent is unavailable, falls back to the deterministic 3-item breakdown so + * triage always yields ≥1 item (a too-small item is then routed as a single + * passthrough by the caller). + * + * Throws on a genuine generation/parse failure so the triage caller can PARK the + * item in triage with a diagnostic rather than silently dropping it. + */ +export async function decomposeForTriage( + description: string, + rootDir?: string, + promptOverrides?: PromptOverrideMap, +): Promise { + await ensureEngineReady(); + const cwd = rootDir ?? process.cwd(); + const systemPrompt = resolvePrompt("subtask-breakdown-system", promptOverrides) || SUBTASK_BREAKDOWN_PROMPT; + + if (!createFnAgent) { + return generateFallbackSubtasks(description); + } + + const agent: SubtaskAgent = await createFnAgent({ cwd, systemPrompt, tools: "readonly" }); + try { + await agent.session.prompt(description); + const messages = agent.session.state.messages as Array<{ + role: string; + content?: string | Array<{ type: string; text: string }>; + }>; + const lastAssistant = messages.filter((m) => m.role === "assistant").pop(); + let responseText = ""; + if (typeof lastAssistant?.content === "string") { + responseText = lastAssistant.content; + } else if (Array.isArray(lastAssistant?.content)) { + responseText = lastAssistant.content + .filter((item): item is { type: "text"; text: string } => item.type === "text") + .map((item) => item.text) + .join(""); + } + return parseSubtasks(responseText); + } finally { + try { + agent.session.dispose?.(); + } catch { + // ignore cleanup errors + } + } +} + export async function createSubtaskSession( initialDescription: string, _store?: TaskStore, diff --git a/packages/dashboard/src/triage-trait.ts b/packages/dashboard/src/triage-trait.ts new file mode 100644 index 0000000000..c8d1bf8735 --- /dev/null +++ b/packages/dashboard/src/triage-trait.ts @@ -0,0 +1,505 @@ +import type { + Task, + TaskCreateInput, + TaskPriority, + TaskStore, + TraitDefinition, +} from "@fusion/core"; +import { + getTraitRegistry, + registerTraitHookImpl, + type PromptOverrideMap, +} from "@fusion/core"; +import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; +import { decomposeForTriage, type SubtaskItem } from "./subtask-breakdown.js"; + +/** + * U12 — Triage stage (auto-classify + decompose, for issues AND pull requests). + * + * Triage is expressed as a Trait with an `onEnter` hook (KTD7 / R8 / R14), NOT a + * hardcoded executor branch. A column carrying the `triage` trait runs a + * classify + decompose pass when a card enters it: + * + * - Signals / issues: classified (priority / area / labels), then decomposed + * into N `todo` child tasks linked back to the originating signal task. A + * signal too small to decompose passes through as a single task (routed to + * `todo`), never zero. + * + * - Inbound pull requests (external contributors, dependabot, …): classified + * (dependency-bump vs feature) and either routed for review (labeled, moved + * to the review/`in-review` column) or used to open a follow-up `todo` task + * linked to the PR entity. PR triage reuses the existing `pull_requests` / + * PR-entity model — it never mints issues for PRs. + * + * - Self-loop guard: a PR Fusion itself opened (an inbound==false PR, or one + * already owned by a non-terminal Fusion `PrEntity` for a task source) is + * NOT re-triaged. + * + * - Classifier failure parks the item in triage with a diagnostic — it is + * never dropped. + * + * The trait DEFINITION lives in core's vocabulary-free registry slot (registered + * here as a `builtin: true` def so plugins cannot override it). The IMPLEMENTATION + * lives in dashboard because it reuses `subtask-breakdown` (engine agents) — wired + * through the core→engine DI seam (`registerTraitHookImpl`), exactly like the + * default-workflow hooks. Core stays engine-free. + */ + +const diagnostics = createSessionDiagnostics("triage-trait"); + +/** Registry id of the triage trait. */ +export const TRIAGE_TRAIT_ID = "triage"; + +/** Column a triaged item is routed TO once decomposed/classified. */ +export const TRIAGE_DEFAULT_ROUTE_COLUMN = "todo"; +/** Column an inbound PR routed for review lands in. */ +export const TRIAGE_REVIEW_COLUMN = "in-review"; + +/** Metadata key marking a task as a triage product (a decomposed child). */ +const TRIAGE_PARENT_META_KEY = "triageParentTaskId"; +/** Metadata key recording that a task has been triaged (idempotency). */ +const TRIAGE_DONE_META_KEY = "triageProcessedAt"; +/** Metadata key on a PR-origin task carrying its PR entity id. */ +const TRIAGE_PR_ENTITY_META_KEY = "prEntityId"; + +/** + * The triage trait definition. Registered as a built-in (so a plugin cannot + * override the id) but intentionally NOT part of the 14-entry vocabulary table — + * it is a behavior trait shipped by U12, resolved through the same registry. + */ +export const TRIAGE_TRAIT_DEFINITION: TraitDefinition = { + id: TRIAGE_TRAIT_ID, + name: "Triage", + description: + "Auto-classify and decompose incoming signals/issues and inbound pull requests, then route to the board.", + builtin: true, + flags: { intake: true }, + hooks: { onEnter: true }, + configSchema: { + fields: [ + { key: "routeColumn", type: "string", description: "Column to route triaged items to (default 'todo')" }, + { key: "reviewColumn", type: "string", description: "Column inbound PRs routed for review land in" }, + { key: "maxSubtasks", type: "number", description: "Cap on decomposed child tasks" }, + ], + }, +}; + +// ── Classification (pure, deterministic) ──────────────────────────────────── + +export type TriageItemKind = "signal" | "issue" | "pull_request"; + +export interface TriageClassification { + priority: TaskPriority; + /** Coarse area bucket inferred from the title/body. */ + area: "bug" | "feature" | "dependency" | "docs" | "infra" | "chore" | "unknown"; + /** Suggested labels (deduped). */ + labels: string[]; + /** PR-only: a dependency bump (dependabot / renovate / `bump`). */ + dependencyBump: boolean; +} + +const DEP_BUMP_RE = /\b(bump|dependabot|renovate|update .* from .* to|upgrade dependenc)/i; +const BUG_RE = /\b(bug|error|crash|exception|fix|regress|fail|incident|outage|broken)\b/i; +const DOCS_RE = /\b(docs?|documentation|readme|typo)\b/i; +const INFRA_RE = /\b(ci|pipeline|deploy|infra|docker|k8s|kubernetes|build)\b/i; +const FEATURE_RE = /\b(feature|add|implement|support|enhanc)\b/i; + +function inferPriority(severity: unknown, text: string): TaskPriority { + const sev = typeof severity === "string" ? severity.toLowerCase() : ""; + if (sev === "critical") return "urgent"; + if (sev === "error") return "high"; + if (/\b(urgent|critical|sev-?1|p0)\b/i.test(text)) return "urgent"; + if (/\b(high|important|sev-?2|p1)\b/i.test(text)) return "high"; + if (sev === "warning") return "normal"; + return "normal"; +} + +/** Classify a triage item purely from its title/body + provenance. */ +export function classifyTriageItem(params: { + kind: TriageItemKind; + title: string; + body?: string; + severity?: unknown; + /** PR author login, when known (dependabot[bot], renovate[bot], …). */ + prAuthor?: string; +}): TriageClassification { + const { kind, title, body, severity, prAuthor } = params; + const text = `${title}\n${body ?? ""}`; + const labels: string[] = []; + + const author = (prAuthor ?? "").toLowerCase(); + const dependencyBump = + kind === "pull_request" && + (DEP_BUMP_RE.test(text) || author.includes("dependabot") || author.includes("renovate")); + + let area: TriageClassification["area"] = "unknown"; + if (dependencyBump) area = "dependency"; + else if (BUG_RE.test(text)) area = "bug"; + else if (DOCS_RE.test(text)) area = "docs"; + else if (INFRA_RE.test(text)) area = "infra"; + else if (FEATURE_RE.test(text)) area = "feature"; + else if (kind === "signal") area = "bug"; + + if (area !== "unknown") labels.push(area); + if (dependencyBump) labels.push("automated"); + if (kind === "signal") labels.push("signal"); + + return { + priority: inferPriority(severity, text), + area, + labels: [...new Set(labels)], + dependencyBump, + }; +} + +// ── PR-vs-issue + self-loop guard ─────────────────────────────────────────── + +/** + * Decide what kind of triage item a task represents, and (for PRs) whether it is + * an INBOUND PR (external contribution that warrants triage) or a Fusion-opened + * PR (which must NOT be re-triaged — no self-loop). + */ +export interface TriageSubject { + kind: TriageItemKind; + /** True only for PRs that should be triaged (inbound external PRs). */ + triageable: boolean; + /** Reason a PR was skipped (for diagnostics). */ + skipReason?: string; + severity?: unknown; + prAuthor?: string; + prEntityId?: string; +} + +/** + * Classify the task's subject from its source provenance. A PR-origin task is + * marked inbound only when its metadata says so AND no non-terminal Fusion + * PrEntity already owns it (a Fusion-opened PR is owned by a `task`-sourced + * entity → self-loop, skip). + */ +export function resolveTriageSubject(task: Task, store?: TaskStore): TriageSubject { + const meta = (task.source?.sourceMetadata ?? {}) as Record; + const signalSource = meta.signalSource; + const isPr = + meta.triageItemKind === "pull_request" || + meta.resourceType === "pr" || + typeof meta.prNumber === "number" || + typeof meta[TRIAGE_PR_ENTITY_META_KEY] === "string"; + + if (isPr) { + const inboundFlag = meta.prInbound === true || meta.inbound === true; + // Self-loop guard: a PR Fusion itself opened is owned by a non-terminal + // PrEntity whose sourceType is "task" (the task that produced the branch). + let fusionOwned = false; + const prEntityId = typeof meta[TRIAGE_PR_ENTITY_META_KEY] === "string" + ? (meta[TRIAGE_PR_ENTITY_META_KEY] as string) + : undefined; + if (store) { + try { + const entity = prEntityId + ? store.getPrEntity(prEntityId) + : store.getActivePrEntityBySource("task", task.id); + if (entity && entity.sourceType === "task" && entity.state !== "closed" && entity.state !== "merged") { + fusionOwned = true; + } + } catch { + // Read failure → fall back to the inbound flag only. + } + } + const triageable = inboundFlag && !fusionOwned; + return { + kind: "pull_request", + triageable, + skipReason: triageable + ? undefined + : fusionOwned + ? "fusion-opened-pr (no self-loop)" + : "not-marked-inbound", + severity: meta.signalSeverity, + prAuthor: typeof meta.prAuthor === "string" ? meta.prAuthor : undefined, + prEntityId, + }; + } + + return { + kind: signalSource ? "signal" : "issue", + triageable: true, + severity: meta.signalSeverity, + }; +} + +// ── Triage execution ──────────────────────────────────────────────────────── + +export interface TriageDeps { + store: TaskStore; + /** Working directory for the decomposition agent. */ + rootDir?: string; + promptOverrides?: PromptOverrideMap; + /** Override the decomposer (tests). Resolves to subtask items or throws. */ + decompose?: (description: string) => Promise; +} + +export type TriageOutcome = + | { kind: "decomposed"; childTaskIds: string[]; routedColumn: string } + | { kind: "passthrough"; taskId: string; routedColumn: string } + | { kind: "pr-review"; taskId: string; routedColumn: string } + | { kind: "pr-follow-up"; followUpTaskId: string; routedColumn: string } + | { kind: "skipped"; reason: string } + | { kind: "parked"; reason: string }; + +function alreadyTriaged(task: Task): boolean { + const meta = (task.source?.sourceMetadata ?? {}) as Record; + return typeof meta[TRIAGE_DONE_META_KEY] === "string"; +} + +/** Mark the originating task as triaged + apply classification labels/priority. + * Metadata is merged via `sourceMetadataPatch` (the store's merge seam), not by + * rebuilding `source`. */ +async function stampTriaged( + store: TaskStore, + task: Task, + classification: TriageClassification, + extra: Record = {}, +): Promise { + await store.updateTask(task.id, { + priority: classification.priority, + sourceMetadataPatch: { + [TRIAGE_DONE_META_KEY]: new Date().toISOString(), + triageArea: classification.area, + triageLabels: classification.labels, + ...extra, + }, + }); +} + +/** + * Run the triage onEnter pass for a task. Idempotent: an already-triaged task is + * a no-op skip. Routing/decomposition/PR handling per the plan's scenarios. + */ +export async function runTriageOnEnter(task: Task, deps: TriageDeps): Promise { + const { store } = deps; + if (alreadyTriaged(task)) { + return { kind: "skipped", reason: "already-triaged" }; + } + + const subject = resolveTriageSubject(task, store); + + // ── PR path ─────────────────────────────────────────────────────────────── + if (subject.kind === "pull_request") { + if (!subject.triageable) { + // Self-loop / non-inbound PR: do not re-triage. Mark processed so a later + // re-entry is a clean no-op, but take no decomposition action. + try { + await stampTriaged( + store, + task, + classifyTriageItem({ kind: "pull_request", title: task.title ?? task.description, body: task.description }), + { triageSkipped: subject.skipReason }, + ); + } catch (err) { + diagnostics.errorFromException("Failed to stamp skipped PR", err, { taskId: task.id }); + } + return { kind: "skipped", reason: subject.skipReason ?? "not-triageable" }; + } + + let classification: TriageClassification; + try { + classification = classifyTriageItem({ + kind: "pull_request", + title: task.title ?? task.description, + body: task.description, + severity: subject.severity, + prAuthor: subject.prAuthor, + }); + } catch (err) { + return parkInTriage(store, task, err, "pr-classify"); + } + + try { + if (classification.dependencyBump) { + // Dependency bumps are mechanical → route straight to review. + await stampTriaged(store, task, classification, { triagePrRoute: "review" }); + await store.moveTask(task.id, TRIAGE_REVIEW_COLUMN); + return { kind: "pr-review", taskId: task.id, routedColumn: TRIAGE_REVIEW_COLUMN }; + } + // Feature/other inbound PR → open a follow-up review task linked to the PR + // entity (we route the PR card to review and create the follow-up todo). + await stampTriaged(store, task, classification, { triagePrRoute: "follow-up" }); + const followUp = await store.createTask( + buildFollowUpTaskInput(task, classification, subject.prEntityId), + ); + await store.moveTask(task.id, TRIAGE_REVIEW_COLUMN); + return { kind: "pr-follow-up", followUpTaskId: followUp.id, routedColumn: TRIAGE_REVIEW_COLUMN }; + } catch (err) { + return parkInTriage(store, task, err, "pr-route"); + } + } + + // ── Signal / issue path ───────────────────────────────────────────────────── + let classification: TriageClassification; + try { + classification = classifyTriageItem({ + kind: subject.kind, + title: task.title ?? task.description, + body: task.description, + severity: subject.severity, + }); + } catch (err) { + return parkInTriage(store, task, err, "classify"); + } + + let subtasks: SubtaskItem[]; + try { + const decompose = deps.decompose ?? ((d: string) => decomposeForTriage(d, deps.rootDir, deps.promptOverrides)); + subtasks = await decompose(task.description); + } catch (err) { + return parkInTriage(store, task, err, "decompose"); + } + + const routeColumn = TRIAGE_DEFAULT_ROUTE_COLUMN; + + // Too small to decompose → pass through as a single task (NOT zero). + if (subtasks.length <= 1) { + try { + await stampTriaged(store, task, classification, { triageDecomposed: false }); + await store.moveTask(task.id, routeColumn); + } catch (err) { + return parkInTriage(store, task, err, "passthrough"); + } + return { kind: "passthrough", taskId: task.id, routedColumn: routeColumn }; + } + + // Decompose into N child todo tasks linked back to the signal. + try { + const childIds: string[] = []; + for (const sub of subtasks) { + const child = await store.createTask( + buildChildTaskInput(task, sub, classification, routeColumn), + ); + childIds.push(child.id); + } + await stampTriaged(store, task, classification, { + triageDecomposed: true, + triageChildTaskIds: childIds, + }); + return { kind: "decomposed", childTaskIds: childIds, routedColumn: routeColumn }; + } catch (err) { + return parkInTriage(store, task, err, "create-children"); + } +} + +function buildChildTaskInput( + parent: Task, + sub: SubtaskItem, + classification: TriageClassification, + routeColumn: string, +): TaskCreateInput { + const title = sub.title?.trim() || "Triaged subtask"; + const description = sub.description?.trim() + ? `${title}\n\n${sub.description.trim()}` + : `${title}\n\nDerived from triage of: ${parent.title ?? parent.id}`; + return { + title, + description, + column: routeColumn as TaskCreateInput["column"], + priority: sub.priority ?? classification.priority, + source: { + sourceType: "automation", + sourceParentTaskId: parent.id, + sourceMetadata: { + [TRIAGE_PARENT_META_KEY]: parent.id, + triageArea: classification.area, + triageLabels: classification.labels, + }, + }, + }; +} + +function buildFollowUpTaskInput( + prTask: Task, + classification: TriageClassification, + prEntityId?: string, +): TaskCreateInput { + const title = `Review inbound PR: ${prTask.title ?? prTask.id}`; + return { + title, + description: `${title}\n\nClassified as ${classification.area}. Follow-up to triaged inbound pull request.`, + column: TRIAGE_DEFAULT_ROUTE_COLUMN as TaskCreateInput["column"], + priority: classification.priority, + source: { + sourceType: "automation", + sourceParentTaskId: prTask.id, + sourceMetadata: { + [TRIAGE_PARENT_META_KEY]: prTask.id, + triageArea: classification.area, + triageLabels: classification.labels, + ...(prEntityId ? { [TRIAGE_PR_ENTITY_META_KEY]: prEntityId } : {}), + }, + }, + }; +} + +/** + * Classifier/decompose failure → PARK the item in triage with a diagnostic. The + * task stays in `triage` (not dropped, not routed); a marker records the failure + * so the surface can show it and a retry can re-run. + */ +async function parkInTriage( + store: TaskStore, + task: Task, + err: unknown, + phase: string, +): Promise { + const message = err instanceof Error ? err.message : String(err); + diagnostics.errorFromException(`Triage ${phase} failed; parking in triage`, err, { + taskId: task.id, + }); + try { + await store.updateTask(task.id, { + sourceMetadataPatch: { + triageError: message, + triageErrorPhase: phase, + triageErrorAt: new Date().toISOString(), + }, + }); + } catch (writeErr) { + diagnostics.errorFromException("Failed to record triage park marker", writeErr, { + taskId: task.id, + }); + } + return { kind: "parked", reason: message }; +} + +// ── Registration (DI seam) ────────────────────────────────────────────────── + +let registered = false; + +/** + * Register the triage trait definition + onEnter hook implementation into the + * shared trait registry. Idempotent. The hook impl resolves `runTriageOnEnter` + * against the provided store/deps factory — the engine-adjacent caller supplies + * the live deps in the hook context (mirroring the default-workflow hook DI). + */ +export function registerTriageTrait(): void { + if (registered) return; + const registry = getTraitRegistry(); + if (!registry.has(TRIAGE_TRAIT_ID)) { + registry.register(TRIAGE_TRAIT_DEFINITION); + } + registerTraitHookImpl( + TRIAGE_TRAIT_ID, + "onEnter", + (...args: unknown[]) => { + const ctx = args[0] as + | { task?: Task; deps?: TriageDeps } + | undefined; + if (!ctx?.task || !ctx.deps) return undefined; + return runTriageOnEnter(ctx.task, ctx.deps); + }, + ); + registered = true; +} + +/** Test-only: reset the registration latch. */ +export function __resetTriageTraitForTests(): void { + registered = false; +}