feat: update role-tier structure to include prefixes for models and enhance related configurations

This commit is contained in:
Lauren ten Hoor
2026-02-11 02:17:13 +08:00
parent 862813e6d3
commit b3c467a33f
9 changed files with 123 additions and 91 deletions

View File

@@ -21,18 +21,18 @@ export function registerCli(program: Command): void {
.option("--new-agent <name>", "Create a new agent with this name") .option("--new-agent <name>", "Create a new agent with this name")
.option("--agent <id>", "Use an existing agent by ID") .option("--agent <id>", "Use an existing agent by ID")
.option("--workspace <path>", "Direct workspace path") .option("--workspace <path>", "Direct workspace path")
.option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.junior})`) .option("--junior <model>", `Junior dev model (default: ${DEFAULT_MODELS.dev.junior})`)
.option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.medior})`) .option("--medior <model>", `Medior dev model (default: ${DEFAULT_MODELS.dev.medior})`)
.option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.senior})`) .option("--senior <model>", `Senior dev model (default: ${DEFAULT_MODELS.dev.senior})`)
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.reviewer})`) .option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`)
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.tester})`) .option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`)
.action(async (opts) => { .action(async (opts) => {
const models: Partial<Record<Tier, string>> = {}; const models: Partial<Record<Tier, string>> = {};
if (opts.junior) models.junior = opts.junior; if (opts.junior) models["dev.junior"] = opts.junior;
if (opts.medior) models.medior = opts.medior; if (opts.medior) models["dev.medior"] = opts.medior;
if (opts.senior) models.senior = opts.senior; if (opts.senior) models["dev.senior"] = opts.senior;
if (opts.reviewer) models.reviewer = opts.reviewer; if (opts.reviewer) models["qa.reviewer"] = opts.reviewer;
if (opts.tester) models.tester = opts.tester; if (opts.tester) models["qa.tester"] = opts.tester;
const result = await runSetup({ const result = await runSetup({
newAgentName: opts.newAgent, newAgentName: opts.newAgent,

View File

@@ -15,7 +15,7 @@ import {
getSessionForTier, getSessionForTier,
getWorker, getWorker,
} from "./projects.js"; } from "./projects.js";
import { TIER_EMOJI, isTier, resolveTierToModel } from "./tiers.js"; import { tierEmoji, resolveTierToModel } from "./tiers.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -129,7 +129,7 @@ export async function dispatchTask(
transitionLabel, pluginConfig, transitionLabel, pluginConfig,
} = opts; } = opts;
const model = resolveTierToModel(tier, pluginConfig, role); const model = resolveTierToModel(tier, pluginConfig);
const worker = getWorker(project, role); const worker = getWorker(project, role);
const existingSessionKey = getSessionForTier(worker, tier); const existingSessionKey = getSessionForTier(worker, tier);
const sessionAction = existingSessionKey ? "send" : "spawn"; const sessionAction = existingSessionKey ? "send" : "spawn";
@@ -276,7 +276,7 @@ function buildAnnouncement(
tier: string, role: string, sessionAction: "spawn" | "send", tier: string, role: string, sessionAction: "spawn" | "send",
issueId: number, issueTitle: string, issueUrl: string, issueId: number, issueTitle: string, issueUrl: string,
): string { ): string {
const emoji = isTier(tier) ? TIER_EMOJI[tier] : role === "qa" ? "🔍" : "🔧"; const emoji = tierEmoji(tier) ?? (role === "qa" ? "🔍" : "🔧");
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending"; const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`; return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Model selection for dev/qa tasks. * Model selection for dev/qa tasks.
* Keyword heuristic fallback — used when the orchestrator doesn't specify a tier. * Keyword heuristic fallback — used when the orchestrator doesn't specify a tier.
* Returns tier names (junior, medior, senior, qa) instead of model aliases. * Returns full tier names (dev.junior, dev.medior, dev.senior, qa.reviewer, qa.tester).
*/ */
export type TierRecommendation = { export type TierRecommendation = {
@@ -42,10 +42,11 @@ const COMPLEX_KEYWORDS = [
* Select appropriate developer tier based on task description. * Select appropriate developer tier based on task description.
* *
* Developer tiers: * Developer tiers:
* - junior: very simple (typos, single-file fixes, CSS tweaks) * - dev.junior: very simple (typos, single-file fixes, CSS tweaks)
* - medior: standard DEV (features, bug fixes, multi-file changes) * - dev.medior: standard DEV (features, bug fixes, multi-file changes)
* - senior: deep/architectural (system-wide refactoring, novel design) * - dev.senior: deep/architectural (system-wide refactoring, novel design)
* - qa: all QA tasks (code inspection, validation, test runs) * - qa.reviewer: QA code inspection and validation
* - qa.tester: QA manual testing
*/ */
export function selectTier( export function selectTier(
issueTitle: string, issueTitle: string,
@@ -54,7 +55,7 @@ export function selectTier(
): TierRecommendation { ): TierRecommendation {
if (role === "qa") { if (role === "qa") {
return { return {
tier: "qa", tier: "qa.reviewer",
reason: "Default QA tier for code inspection and validation", reason: "Default QA tier for code inspection and validation",
}; };
} }
@@ -66,7 +67,7 @@ export function selectTier(
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw)); const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
if (isSimple && wordCount < 100) { if (isSimple && wordCount < 100) {
return { return {
tier: "junior", tier: "dev.junior",
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`, reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
}; };
} }
@@ -75,14 +76,14 @@ export function selectTier(
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw)); const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
if (isComplex || wordCount > 500) { if (isComplex || wordCount > 500) {
return { return {
tier: "senior", tier: "dev.senior",
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`, reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
}; };
} }
// Default: medior for standard dev work // Default: medior for standard dev work
return { return {
tier: "medior", tier: "dev.medior",
reason: "Standard dev task — multi-file changes, features, bug fixes", reason: "Standard dev task — multi-file changes, features, bug fixes",
}; };
} }

View File

@@ -5,7 +5,7 @@
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js"; import { ALL_TIERS, defaultModel } from "./tiers.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Detection // Detection
@@ -42,7 +42,7 @@ function buildModelTable(pluginConfig?: Record<string, unknown>): string {
(pluginConfig as { models?: Record<string, string> })?.models ?? {}; (pluginConfig as { models?: Record<string, string> })?.models ?? {};
return ALL_TIERS.map( return ALL_TIERS.map(
(t) => (t) =>
` - **${t}**: ${models[t] || DEFAULT_MODELS[t as Tier]} (default: ${DEFAULT_MODELS[t as Tier]})`, ` - **${t}**: ${models[t] || defaultModel(t)} (default: ${defaultModel(t)})`,
).join("\n"); ).join("\n");
} }
@@ -96,10 +96,11 @@ Show the default tier-to-model mapping and ask if they want to customize:
| Tier | Default Model | Purpose | | Tier | Default Model | Purpose |
|------|---------------|---------| |------|---------------|---------|
| junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes | | dev.junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes |
| medior | anthropic/claude-sonnet-4-5 | Features, bug fixes | | dev.medior | anthropic/claude-sonnet-4-5 | Features, bug fixes |
| senior | anthropic/claude-opus-4-5 | Architecture, refactoring | | dev.senior | anthropic/claude-opus-4-5 | Architecture, refactoring |
| qa | anthropic/claude-sonnet-4-5 | Code review, testing | | qa.reviewer | anthropic/claude-sonnet-4-5 | Code review |
| qa.tester | anthropic/claude-haiku-4-5 | Testing |
If the defaults are fine, proceed. If customizing, ask which tiers to change. If the defaults are fine, proceed. If customizing, ask which tiers to change.

View File

@@ -5,7 +5,7 @@
*/ */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { DEV_TIERS, QA_TIERS, type Tier } from "../tiers.js"; import { DEV_TIERS, QA_TIERS, tierName, type Tier } from "../tiers.js";
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js"; import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
function openclawConfigPath(): string { function openclawConfigPath(): string {
@@ -20,10 +20,10 @@ function buildRoleTierModels(models: Record<Tier, string>): { dev: Record<string
const qa: Record<string, string> = {}; const qa: Record<string, string> = {};
for (const tier of DEV_TIERS) { for (const tier of DEV_TIERS) {
dev[tier] = models[tier]; dev[tierName(tier)] = models[tier];
} }
for (const tier of QA_TIERS) { for (const tier of QA_TIERS) {
qa[tier] = models[tier]; qa[tierName(tier)] = models[tier];
} }
return { dev, qa }; return { dev, qa };

View File

@@ -4,7 +4,7 @@
* Coordinates: agent creation → model config → workspace scaffolding. * Coordinates: agent creation → model config → workspace scaffolding.
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command. * Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
*/ */
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js"; import { ALL_TIERS, allDefaultModels, type Tier } from "../tiers.js";
import { migrateChannelBinding } from "../binding-manager.js"; import { migrateChannelBinding } from "../binding-manager.js";
import { createAgent, resolveWorkspacePath } from "./agent.js"; import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js"; import { writePluginConfig } from "./config.js";
@@ -108,7 +108,7 @@ async function tryMigrateBinding(
} }
function buildModelConfig(overrides?: Partial<Record<Tier, string>>): Record<Tier, string> { function buildModelConfig(overrides?: Partial<Record<Tier, string>>): Record<Tier, string> {
const models = { ...DEFAULT_MODELS }; const models = allDefaultModels();
if (overrides) { if (overrides) {
for (const [tier, model] of Object.entries(overrides)) { for (const [tier, model] of Object.entries(overrides)) {
if (model && (ALL_TIERS as readonly string[]).includes(tier)) { if (model && (ALL_TIERS as readonly string[]).includes(tier)) {

View File

@@ -1,28 +1,20 @@
/** /**
* Developer tier definitions and model resolution. * Developer tier definitions and model resolution.
* *
* Tasks are assigned to developer tiers (junior, medior, senior, qa) * Tier names always include the role prefix: "dev.junior", "qa.reviewer", etc.
* instead of raw model names. Each tier maps to a configurable LLM model. * This makes tier names globally unique and self-documenting.
*/ */
export const DEV_TIERS = ["junior", "medior", "senior"] as const; export const DEV_TIERS = ["dev.junior", "dev.medior", "dev.senior"] as const;
export const QA_TIERS = ["reviewer", "tester"] as const; export const QA_TIERS = ["qa.reviewer", "qa.tester"] as const;
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const; export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
export type DevTier = (typeof DEV_TIERS)[number]; export type DevTier = (typeof DEV_TIERS)[number];
export type QaTier = (typeof QA_TIERS)[number]; export type QaTier = (typeof QA_TIERS)[number];
export type Tier = (typeof ALL_TIERS)[number]; export type Tier = (typeof ALL_TIERS)[number];
export const DEFAULT_MODELS: Record<Tier, string> = { /** Default models, nested by role. */
junior: "anthropic/claude-haiku-4-5", export const DEFAULT_MODELS = {
medior: "anthropic/claude-sonnet-4-5",
senior: "anthropic/claude-opus-4-5",
reviewer: "anthropic/claude-sonnet-4-5",
tester: "anthropic/claude-haiku-4-5",
};
/** Default models by role-tier structure. */
export const DEFAULT_MODELS_BY_ROLE = {
dev: { dev: {
junior: "anthropic/claude-haiku-4-5", junior: "anthropic/claude-haiku-4-5",
medior: "anthropic/claude-sonnet-4-5", medior: "anthropic/claude-sonnet-4-5",
@@ -34,13 +26,17 @@ export const DEFAULT_MODELS_BY_ROLE = {
}, },
}; };
/** Emoji used in announcements per tier. */ /** Emoji used in announcements, nested by role. */
export const TIER_EMOJI: Record<Tier, string> = { export const TIER_EMOJI = {
dev: {
junior: "⚡", junior: "⚡",
medior: "🔧", medior: "🔧",
senior: "🧠", senior: "🧠",
},
qa: {
reviewer: "🔍", reviewer: "🔍",
tester: "👀", tester: "👀",
},
}; };
/** Check if a string is a valid tier name. */ /** Check if a string is a valid tier name. */
@@ -48,38 +44,72 @@ export function isTier(value: string): value is Tier {
return (ALL_TIERS as readonly string[]).includes(value); return (ALL_TIERS as readonly string[]).includes(value);
} }
/** Check if a string is a valid dev tier name. */ /** Check if a tier belongs to the dev role. */
export function isDevTier(value: string): value is DevTier { export function isDevTier(value: string): value is DevTier {
return (DEV_TIERS as readonly string[]).includes(value); return (DEV_TIERS as readonly string[]).includes(value);
} }
/** Extract the role from a tier name (e.g. "dev.junior" → "dev"). */
export function tierRole(tier: string): "dev" | "qa" | undefined {
if (tier.startsWith("dev.")) return "dev";
if (tier.startsWith("qa.")) return "qa";
return undefined;
}
/** Extract the short name from a tier (e.g. "dev.junior" → "junior"). */
export function tierName(tier: string): string {
const dot = tier.indexOf(".");
return dot >= 0 ? tier.slice(dot + 1) : tier;
}
/** Look up a value from a nested role structure using a full tier name. */
function lookupNested<T>(map: Record<string, Record<string, T>>, tier: string): T | undefined {
const role = tierRole(tier);
if (!role) return undefined;
return map[role]?.[tierName(tier)];
}
/** Get the default model for a tier. */
export function defaultModel(tier: string): string | undefined {
return lookupNested(DEFAULT_MODELS, tier);
}
/** Get the emoji for a tier. */
export function tierEmoji(tier: string): string | undefined {
return lookupNested(TIER_EMOJI, tier);
}
/** Build a flat Record<Tier, string> of all default models. */
export function allDefaultModels(): Record<Tier, string> {
const result = {} as Record<Tier, string>;
for (const tier of ALL_TIERS) {
result[tier] = defaultModel(tier)!;
}
return result;
}
/** /**
* Resolve a tier name to a full model ID. * Resolve a tier name to a full model ID.
* *
* Resolution order: * Resolution order:
* 1. Plugin config `models.<role>.<tier>` nested structure (user overrides) * 1. Parse "role.name" → look up config `models.<role>.<name>`
* 2. Plugin config `models.<tier>` flat structure (backward compatibility) * 2. DEFAULT_MODELS[role][name]
* 3. DEFAULT_MODELS (hardcoded defaults) * 3. Passthrough (treat as raw model ID)
* 4. Treat input as raw model ID (passthrough for non-tier values)
*/ */
export function resolveTierToModel( export function resolveTierToModel(
tier: string, tier: string,
pluginConfig?: Record<string, unknown>, pluginConfig?: Record<string, unknown>,
role?: "dev" | "qa",
): string { ): string {
const models = (pluginConfig as { models?: Record<string, unknown> })?.models; const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
// Try nested role-tier structure first
if (role && models && typeof models === "object") {
const roleModels = models[role] as Record<string, string> | undefined;
if (roleModels?.[tier]) return roleModels[tier];
}
// Fall back to flat structure for backward compatibility
if (models && typeof models === "object") { if (models && typeof models === "object") {
const flatModel = (models as Record<string, string>)[tier]; const role = tierRole(tier);
if (flatModel) return flatModel; const name = tierName(tier);
if (role) {
const roleModels = models[role] as Record<string, string> | undefined;
if (roleModels?.[name]) return roleModels[name];
}
} }
return DEFAULT_MODELS[tier as Tier] ?? tier; return defaultModel(tier) ?? tier;
} }

View File

@@ -43,15 +43,15 @@ export function createSetupTool(api: OpenClawPluginApi) {
properties: { properties: {
junior: { junior: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.junior}`, description: `Default: ${DEFAULT_MODELS.dev.junior}`,
}, },
medior: { medior: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.medior}`, description: `Default: ${DEFAULT_MODELS.dev.medior}`,
}, },
senior: { senior: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.senior}`, description: `Default: ${DEFAULT_MODELS.dev.senior}`,
}, },
}, },
}, },
@@ -61,11 +61,11 @@ export function createSetupTool(api: OpenClawPluginApi) {
properties: { properties: {
reviewer: { reviewer: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.reviewer}`, description: `Default: ${DEFAULT_MODELS.qa.reviewer}`,
}, },
tester: { tester: {
type: "string", type: "string",
description: `Default: ${DEFAULT_MODELS.tester}`, description: `Default: ${DEFAULT_MODELS.qa.tester}`,
}, },
}, },
}, },

View File

@@ -26,13 +26,13 @@ const INACTIVE_WORKER: WorkerState = {
}; };
const ACTIVE_DEV: WorkerState = { const ACTIVE_DEV: WorkerState = {
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "medior", active: true, issueId: "42", startTime: new Date().toISOString(), tier: "dev.medior",
sessions: { medior: "session-dev-42" }, sessions: { "dev.medior": "session-dev-42" },
}; };
const ACTIVE_QA: WorkerState = { const ACTIVE_QA: WorkerState = {
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "qa", active: true, issueId: "42", startTime: new Date().toISOString(), tier: "qa.reviewer",
sessions: { qa: "session-qa-42" }, sessions: { "qa.reviewer": "session-qa-42" },
}; };
function makeProject(overrides: Partial<Project> = {}): Project { function makeProject(overrides: Partial<Project> = {}): Project {
@@ -67,10 +67,10 @@ let tmpDir: string;
async function setupWorkspace(projects: Record<string, Project>): Promise<string> { async function setupWorkspace(projects: Record<string, Project>): Promise<string> {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-"));
const memoryDir = path.join(tmpDir, "memory"); const projectsDir = path.join(tmpDir, "projects");
await fs.mkdir(memoryDir, { recursive: true }); await fs.mkdir(projectsDir, { recursive: true });
await fs.writeFile( await fs.writeFile(
path.join(memoryDir, "projects.json"), path.join(projectsDir, "projects.json"),
JSON.stringify({ projects }, null, 2) + "\n", JSON.stringify({ projects }, null, 2) + "\n",
"utf-8", "utf-8",
); );
@@ -284,13 +284,13 @@ describe("work_heartbeat: tier assignment", () => {
afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); }); afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); });
it("uses label-based tier when present", async () => { it("uses label-based tier when present", async () => {
// Given: issue with "senior" label → tier should be "senior" // Given: issue with "dev.senior" label → tier should be "dev.senior"
const workspaceDir = await setupWorkspace({ const workspaceDir = await setupWorkspace({
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
}); });
const provider = mockProvider({ const provider = mockProvider({
"To Do": [{ iid: 10, title: "Refactor auth", description: "", labels: ["To Do", "senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], "To Do": [{ iid: 10, title: "Refactor auth", description: "", labels: ["To Do", "dev.senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }],
}); });
const result = await projectTick({ const result = await projectTick({
@@ -299,18 +299,18 @@ describe("work_heartbeat: tier assignment", () => {
const pickup = result.pickups.find((p) => p.role === "dev"); const pickup = result.pickups.find((p) => p.role === "dev");
assert.ok(pickup); assert.ok(pickup);
assert.strictEqual(pickup.tier, "senior", "Should use label-based tier"); assert.strictEqual(pickup.tier, "dev.senior", "Should use label-based tier");
}); });
it("overrides to qa tier for qa role regardless of label", async () => { it("overrides to reviewer tier for qa role regardless of label", async () => {
// Given: issue with "senior" label but picked up by QA // Given: issue with "dev.senior" label but picked up by QA
// Expected: tier = "qa" (QA always uses qa tier) // Expected: tier = "qa.reviewer" (QA always uses reviewer tier)
const workspaceDir = await setupWorkspace({ const workspaceDir = await setupWorkspace({
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }), "-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
}); });
const provider = mockProvider({ const provider = mockProvider({
"To Test": [{ iid: 10, title: "Review auth", description: "", labels: ["To Test", "senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }], "To Test": [{ iid: 10, title: "Review auth", description: "", labels: ["To Test", "dev.senior"], web_url: "https://github.com/test/alpha/issues/10", state: "opened" }],
}); });
const result = await projectTick({ const result = await projectTick({
@@ -319,7 +319,7 @@ describe("work_heartbeat: tier assignment", () => {
const qaPickup = result.pickups.find((p) => p.role === "qa"); const qaPickup = result.pickups.find((p) => p.role === "qa");
assert.ok(qaPickup); assert.ok(qaPickup);
assert.strictEqual(qaPickup.tier, "qa", "QA always uses qa tier regardless of issue label"); assert.strictEqual(qaPickup.tier, "qa.reviewer", "QA always uses reviewer tier regardless of issue label");
}); });
it("falls back to heuristic when no tier label", async () => { it("falls back to heuristic when no tier label", async () => {
@@ -339,7 +339,7 @@ describe("work_heartbeat: tier assignment", () => {
const pickup = result.pickups.find((p) => p.role === "dev"); const pickup = result.pickups.find((p) => p.role === "dev");
assert.ok(pickup); assert.ok(pickup);
// Heuristic should select junior for a typo fix // Heuristic should select junior for a typo fix
assert.strictEqual(pickup.tier, "junior", "Heuristic should assign junior for simple typo fix"); assert.strictEqual(pickup.tier, "dev.junior", "Heuristic should assign junior for simple typo fix");
}); });
}); });