feat(triage): U12 — triage trait for issues and pull requests

Registers a built-in 'triage' onEnter trait (via TraitRegistry +
registerTraitHookImpl) that classifies and decomposes incoming signals/issues
into todo tasks, and routes inbound PRs (dependency-bump vs feature) without
minting issues, with a Fusion-opened-PR self-loop guard. Reuses subtask-breakdown
via a new decomposeForTriage seam.

Follow-up: auto-firing the built-in async onEnter from store.moveTaskInternal
(currently fires plugin: hooks only) — invoked via the registry seam meanwhile.
This commit is contained in:
gsxdsm
2026-06-15 20:08:38 -07:00
parent 29acf14884
commit f45dde22e5
3 changed files with 971 additions and 0 deletions

View File

@@ -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<TaskStore["createTask"]>[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<TaskStore["updateTask"]>[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<string, unknown> })
.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<Task> & { id: string; description: string }): Task {
return {
column: "triage",
...partial,
} as unknown as Task;
}
const decomposeTo =
(items: Array<Partial<SubtaskItem>>) =>
async (_d: string): Promise<SubtaskItem[]> =>
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<string, unknown>).triageParentTaskId).toBe("FN-10");
}
// signal stamped as triaged
expect((signal.source?.sourceMetadata as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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");
});
});

View File

@@ -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<SubtaskItem[]> {
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,

View File

@@ -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<string, unknown>;
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<SubtaskItem[]>;
}
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<string, unknown>;
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<string, unknown> = {},
): Promise<void> {
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<TriageOutcome> {
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<TriageOutcome> {
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;
}