refactor: rename 'tier' to 'level' across the codebase
- Updated WorkerState type to use 'level' instead of 'tier'. - Modified functions related to worker state management, including parseWorkerState, emptyWorkerState, getSessionForLevel, activateWorker, and deactivateWorker to reflect the new terminology. - Adjusted health check logic to utilize 'level' instead of 'tier'. - Refactored tick and setup tools to accommodate the change from 'tier' to 'level', including model configuration and workspace scaffolding. - Updated tests to ensure consistency with the new 'level' terminology. - Revised documentation and comments to reflect the changes in terminology from 'tier' to 'level'.
This commit is contained in:
27
lib/cli.ts
27
lib/cli.ts
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { Command } from "commander";
|
||||
import { runSetup } from "./setup/index.js";
|
||||
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "./tiers.js";
|
||||
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "./tiers.js";
|
||||
|
||||
/**
|
||||
* Register the `devclaw` CLI command group on a Commander program.
|
||||
@@ -27,18 +27,24 @@ export function registerCli(program: Command): void {
|
||||
.option("--reviewer <model>", `Reviewer model (default: ${DEFAULT_MODELS.qa.reviewer})`)
|
||||
.option("--tester <model>", `Tester model (default: ${DEFAULT_MODELS.qa.tester})`)
|
||||
.action(async (opts) => {
|
||||
const models: Partial<Record<Tier, string>> = {};
|
||||
if (opts.junior) models["dev.junior"] = opts.junior;
|
||||
if (opts.medior) models["dev.medior"] = opts.medior;
|
||||
if (opts.senior) models["dev.senior"] = opts.senior;
|
||||
if (opts.reviewer) models["qa.reviewer"] = opts.reviewer;
|
||||
if (opts.tester) models["qa.tester"] = opts.tester;
|
||||
const dev: Record<string, string> = {};
|
||||
const qa: Record<string, string> = {};
|
||||
if (opts.junior) dev.junior = opts.junior;
|
||||
if (opts.medior) dev.medior = opts.medior;
|
||||
if (opts.senior) dev.senior = opts.senior;
|
||||
if (opts.reviewer) qa.reviewer = opts.reviewer;
|
||||
if (opts.tester) qa.tester = opts.tester;
|
||||
|
||||
const hasOverrides = Object.keys(dev).length > 0 || Object.keys(qa).length > 0;
|
||||
const models = hasOverrides
|
||||
? { ...(Object.keys(dev).length > 0 && { dev }), ...(Object.keys(qa).length > 0 && { qa }) }
|
||||
: undefined;
|
||||
|
||||
const result = await runSetup({
|
||||
newAgentName: opts.newAgent,
|
||||
agentId: opts.agent,
|
||||
workspacePath: opts.workspace,
|
||||
models: Object.keys(models).length > 0 ? models : undefined,
|
||||
models,
|
||||
});
|
||||
|
||||
if (result.agentCreated) {
|
||||
@@ -46,9 +52,8 @@ export function registerCli(program: Command): void {
|
||||
}
|
||||
|
||||
console.log("Models configured:");
|
||||
for (const tier of ALL_TIERS) {
|
||||
console.log(` ${tier}: ${result.models[tier]}`);
|
||||
}
|
||||
for (const t of DEV_LEVELS) console.log(` dev.${t}: ${result.models.dev[t]}`);
|
||||
for (const t of QA_LEVELS) console.log(` qa.${t}: ${result.models.qa[t]}`);
|
||||
|
||||
console.log("Files written:");
|
||||
for (const file of result.filesWritten) {
|
||||
|
||||
@@ -12,10 +12,10 @@ import { log as auditLog } from "./audit.js";
|
||||
import {
|
||||
type Project,
|
||||
activateWorker,
|
||||
getSessionForTier,
|
||||
getSessionForLevel,
|
||||
getWorker,
|
||||
} from "./projects.js";
|
||||
import { tierEmoji, resolveTierToModel } from "./tiers.js";
|
||||
import { resolveModel, levelEmoji } from "./tiers.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -29,8 +29,8 @@ export type DispatchOpts = {
|
||||
issueDescription: string;
|
||||
issueUrl: string;
|
||||
role: "dev" | "qa";
|
||||
/** Developer tier (junior, medior, senior, qa) or raw model ID */
|
||||
tier: string;
|
||||
/** Developer level (junior, medior, senior, reviewer) or raw model ID */
|
||||
level: string;
|
||||
/** Label to transition FROM (e.g. "To Do", "To Test", "To Improve") */
|
||||
fromLabel: string;
|
||||
/** Label to transition TO (e.g. "Doing", "Testing") */
|
||||
@@ -46,14 +46,14 @@ export type DispatchOpts = {
|
||||
export type DispatchResult = {
|
||||
sessionAction: "spawn" | "send";
|
||||
sessionKey: string;
|
||||
tier: string;
|
||||
level: string;
|
||||
model: string;
|
||||
announcement: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the task message sent to a worker session.
|
||||
* Reads role-specific instructions from workspace/projects/prompts/<project>/<role>.md.
|
||||
* Reads role-specific instructions from workspace/projects/roles/<project>/<role>.md (falls back to projects/roles/default/).
|
||||
*/
|
||||
export async function buildTaskMessage(opts: {
|
||||
workspaceDir: string;
|
||||
@@ -125,13 +125,13 @@ export async function dispatchTask(
|
||||
): Promise<DispatchResult> {
|
||||
const {
|
||||
workspaceDir, agentId, groupId, project, issueId, issueTitle,
|
||||
issueDescription, issueUrl, role, tier, fromLabel, toLabel,
|
||||
issueDescription, issueUrl, role, level, fromLabel, toLabel,
|
||||
transitionLabel, pluginConfig,
|
||||
} = opts;
|
||||
|
||||
const model = resolveTierToModel(tier, pluginConfig);
|
||||
const model = resolveModel(role, level, pluginConfig);
|
||||
const worker = getWorker(project, role);
|
||||
const existingSessionKey = getSessionForTier(worker, tier);
|
||||
const existingSessionKey = getSessionForLevel(worker, level);
|
||||
const sessionAction = existingSessionKey ? "send" : "spawn";
|
||||
|
||||
const taskMessage = await buildTaskMessage({
|
||||
@@ -147,7 +147,7 @@ export async function dispatchTask(
|
||||
|
||||
try {
|
||||
sessionKey = await ensureSession(sessionAction, sessionKey, {
|
||||
agentId, projectName: project.name, role, tier, model,
|
||||
agentId, projectName: project.name, role, level, model,
|
||||
});
|
||||
|
||||
await sendToAgent(sessionKey!, taskMessage, {
|
||||
@@ -158,7 +158,7 @@ export async function dispatchTask(
|
||||
dispatched = true;
|
||||
|
||||
await recordWorkerState(workspaceDir, groupId, role, {
|
||||
issueId, tier, sessionKey: sessionKey!, sessionAction,
|
||||
issueId, level, sessionKey: sessionKey!, sessionAction,
|
||||
});
|
||||
} catch (err) {
|
||||
if (dispatched) {
|
||||
@@ -179,13 +179,13 @@ export async function dispatchTask(
|
||||
|
||||
await auditDispatch(workspaceDir, {
|
||||
project: project.name, groupId, issueId, issueTitle,
|
||||
role, tier, model, sessionAction, sessionKey: sessionKey!,
|
||||
role, level, model, sessionAction, sessionKey: sessionKey!,
|
||||
fromLabel, toLabel,
|
||||
});
|
||||
|
||||
const announcement = buildAnnouncement(tier, role, sessionAction, issueId, issueTitle, issueUrl);
|
||||
const announcement = buildAnnouncement(level, role, sessionAction, issueId, issueTitle, issueUrl);
|
||||
|
||||
return { sessionAction, sessionKey: sessionKey!, tier, model, announcement };
|
||||
return { sessionAction, sessionKey: sessionKey!, level, model, announcement };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -195,19 +195,21 @@ export async function dispatchTask(
|
||||
async function loadRoleInstructions(
|
||||
workspaceDir: string, projectName: string, role: "dev" | "qa",
|
||||
): Promise<string> {
|
||||
const projectFile = path.join(workspaceDir, "projects", "prompts", projectName, `${role}.md`);
|
||||
const projectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
|
||||
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* none */ }
|
||||
const defaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
|
||||
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* none */ }
|
||||
return "";
|
||||
}
|
||||
|
||||
async function ensureSession(
|
||||
action: "spawn" | "send",
|
||||
existingKey: string | null,
|
||||
opts: { agentId?: string; projectName: string; role: string; tier: string; model: string },
|
||||
opts: { agentId?: string; projectName: string; role: string; level: string; model: string },
|
||||
): Promise<string> {
|
||||
if (action === "send") return existingKey!;
|
||||
|
||||
const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.tier}`;
|
||||
const sessionKey = `agent:${opts.agentId ?? "unknown"}:subagent:${opts.projectName}-${opts.role}-${opts.level}`;
|
||||
await execFileAsync(
|
||||
"openclaw",
|
||||
["gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model: opts.model })],
|
||||
@@ -239,12 +241,12 @@ function sendToAgent(
|
||||
|
||||
async function recordWorkerState(
|
||||
workspaceDir: string, groupId: string, role: "dev" | "qa",
|
||||
opts: { issueId: number; tier: string; sessionKey: string; sessionAction: "spawn" | "send" },
|
||||
opts: { issueId: number; level: string; sessionKey: string; sessionAction: "spawn" | "send" },
|
||||
): Promise<void> {
|
||||
const params: { issueId: string; tier: string; sessionKey?: string; startTime: string } = {
|
||||
const params: { issueId: string; level: string; sessionKey?: string; startTime: string } = {
|
||||
issueId: String(opts.issueId),
|
||||
tier: opts.tier,
|
||||
startTime: new Date().toISOString(), // Always reset startTime for new task assignment
|
||||
level: opts.level,
|
||||
startTime: new Date().toISOString(),
|
||||
};
|
||||
if (opts.sessionAction === "spawn") {
|
||||
params.sessionKey = opts.sessionKey;
|
||||
@@ -256,27 +258,27 @@ async function auditDispatch(
|
||||
workspaceDir: string,
|
||||
opts: {
|
||||
project: string; groupId: string; issueId: number; issueTitle: string;
|
||||
role: string; tier: string; model: string; sessionAction: string;
|
||||
role: string; level: string; model: string; sessionAction: string;
|
||||
sessionKey: string; fromLabel: string; toLabel: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await auditLog(workspaceDir, "work_start", {
|
||||
project: opts.project, groupId: opts.groupId,
|
||||
issue: opts.issueId, issueTitle: opts.issueTitle,
|
||||
role: opts.role, tier: opts.tier,
|
||||
role: opts.role, level: opts.level,
|
||||
sessionAction: opts.sessionAction, sessionKey: opts.sessionKey,
|
||||
labelTransition: `${opts.fromLabel} → ${opts.toLabel}`,
|
||||
});
|
||||
await auditLog(workspaceDir, "model_selection", {
|
||||
issue: opts.issueId, role: opts.role, tier: opts.tier, model: opts.model,
|
||||
issue: opts.issueId, role: opts.role, level: opts.level, model: opts.model,
|
||||
});
|
||||
}
|
||||
|
||||
function buildAnnouncement(
|
||||
tier: string, role: string, sessionAction: "spawn" | "send",
|
||||
level: string, role: string, sessionAction: "spawn" | "send",
|
||||
issueId: number, issueTitle: string, issueUrl: string,
|
||||
): string {
|
||||
const emoji = tierEmoji(tier) ?? (role === "qa" ? "🔍" : "🔧");
|
||||
const emoji = levelEmoji(role as "dev" | "qa", level) ?? (role === "qa" ? "🔍" : "🔧");
|
||||
const actionVerb = sessionAction === "spawn" ? "Spawning" : "Sending";
|
||||
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${tier}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
||||
return `${emoji} ${actionVerb} ${role.toUpperCase()} (${level}) for #${issueId}: ${issueTitle}\n🔗 ${issueUrl}`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Model selection for dev/qa tasks.
|
||||
* Keyword heuristic fallback — used when the orchestrator doesn't specify a tier.
|
||||
* Returns full tier names (dev.junior, dev.medior, dev.senior, qa.reviewer, qa.tester).
|
||||
* Keyword heuristic fallback — used when the orchestrator doesn't specify a level.
|
||||
* Returns plain level names (junior, medior, senior, reviewer, tester).
|
||||
*/
|
||||
|
||||
export type TierRecommendation = {
|
||||
tier: string;
|
||||
export type LevelSelection = {
|
||||
level: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
@@ -39,24 +39,24 @@ const COMPLEX_KEYWORDS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Select appropriate developer tier based on task description.
|
||||
* Select appropriate developer level based on task description.
|
||||
*
|
||||
* Developer tiers:
|
||||
* - dev.junior: very simple (typos, single-file fixes, CSS tweaks)
|
||||
* - dev.medior: standard DEV (features, bug fixes, multi-file changes)
|
||||
* - dev.senior: deep/architectural (system-wide refactoring, novel design)
|
||||
* - qa.reviewer: QA code inspection and validation
|
||||
* - qa.tester: QA manual testing
|
||||
* Developer levels:
|
||||
* - junior: very simple (typos, single-file fixes, CSS tweaks)
|
||||
* - medior: standard DEV (features, bug fixes, multi-file changes)
|
||||
* - senior: deep/architectural (system-wide refactoring, novel design)
|
||||
* - reviewer: QA code inspection and validation
|
||||
* - tester: QA manual testing
|
||||
*/
|
||||
export function selectTier(
|
||||
export function selectLevel(
|
||||
issueTitle: string,
|
||||
issueDescription: string,
|
||||
role: "dev" | "qa",
|
||||
): TierRecommendation {
|
||||
): LevelSelection {
|
||||
if (role === "qa") {
|
||||
return {
|
||||
tier: "qa.reviewer",
|
||||
reason: "Default QA tier for code inspection and validation",
|
||||
level: "reviewer",
|
||||
reason: "Default QA level for code inspection and validation",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function selectTier(
|
||||
const isSimple = SIMPLE_KEYWORDS.some((kw) => text.includes(kw));
|
||||
if (isSimple && wordCount < 100) {
|
||||
return {
|
||||
tier: "dev.junior",
|
||||
level: "junior",
|
||||
reason: `Simple task detected (keywords: ${SIMPLE_KEYWORDS.filter((kw) => text.includes(kw)).join(", ")})`,
|
||||
};
|
||||
}
|
||||
@@ -76,14 +76,14 @@ export function selectTier(
|
||||
const isComplex = COMPLEX_KEYWORDS.some((kw) => text.includes(kw));
|
||||
if (isComplex || wordCount > 500) {
|
||||
return {
|
||||
tier: "dev.senior",
|
||||
level: "senior",
|
||||
reason: `Complex task detected (${isComplex ? "keywords: " + COMPLEX_KEYWORDS.filter((kw) => text.includes(kw)).join(", ") : "long description"})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: medior for standard dev work
|
||||
return {
|
||||
tier: "dev.medior",
|
||||
level: "medior",
|
||||
reason: "Standard dev task — multi-file changes, features, bug fixes",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export type NotifyEvent =
|
||||
issueTitle: string;
|
||||
issueUrl: string;
|
||||
role: "dev" | "qa";
|
||||
tier: string;
|
||||
level: string;
|
||||
sessionAction: "spawn" | "send";
|
||||
}
|
||||
| {
|
||||
@@ -69,7 +69,7 @@ function buildMessage(event: NotifyEvent): string {
|
||||
switch (event.type) {
|
||||
case "workerStart": {
|
||||
const action = event.sessionAction === "spawn" ? "🚀 Started" : "▶️ Resumed";
|
||||
return `${action} ${event.role.toUpperCase()} (${event.tier}) on #${event.issueId}: ${event.issueTitle}\n🔗 ${event.issueUrl}`;
|
||||
return `${action} ${event.role.toUpperCase()} (${event.level}) on #${event.issueId}: ${event.issueTitle}\n🔗 ${event.issueUrl}`;
|
||||
}
|
||||
|
||||
case "workerComplete": {
|
||||
@@ -253,7 +253,7 @@ export async function notifyTickPickups(
|
||||
issueTitle: pickup.issueTitle,
|
||||
issueUrl: pickup.issueUrl,
|
||||
role: pickup.role,
|
||||
tier: pickup.tier,
|
||||
level: pickup.level,
|
||||
sessionAction: pickup.sessionAction,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { ALL_TIERS, defaultModel } from "./tiers.js";
|
||||
import { DEFAULT_MODELS } from "./tiers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detection
|
||||
@@ -38,12 +38,15 @@ export async function hasWorkspaceFiles(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildModelTable(pluginConfig?: Record<string, unknown>): string {
|
||||
const models =
|
||||
(pluginConfig as { models?: Record<string, string> })?.models ?? {};
|
||||
return ALL_TIERS.map(
|
||||
(t) =>
|
||||
` - **${t}**: ${models[t] || defaultModel(t)} (default: ${defaultModel(t)})`,
|
||||
).join("\n");
|
||||
const cfg = (pluginConfig as { models?: { dev?: Record<string, string>; qa?: Record<string, string> } })?.models;
|
||||
const lines: string[] = [];
|
||||
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
|
||||
for (const [level, defaultModel] of Object.entries(levels)) {
|
||||
const model = cfg?.[role as "dev" | "qa"]?.[level] || defaultModel;
|
||||
lines.push(` - **${role} ${level}**: ${model} (default: ${defaultModel})`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildReconfigContext(
|
||||
@@ -57,7 +60,7 @@ The user wants to reconfigure DevClaw. Current model configuration:
|
||||
${modelTable}
|
||||
|
||||
## What can be changed
|
||||
1. **Model tiers** — call \`setup\` with a \`models\` object containing only the tiers to change
|
||||
1. **Model levels** — call \`setup\` with a \`models\` object containing only the levels to change
|
||||
2. **Workspace files** — \`setup\` re-writes AGENTS.md, HEARTBEAT.md (backs up existing files)
|
||||
3. **Register new projects** — use \`project_register\`
|
||||
|
||||
@@ -67,12 +70,28 @@ Ask what they want to change, then call the appropriate tool.
|
||||
}
|
||||
|
||||
export function buildOnboardToolContext(): string {
|
||||
// Build the model table dynamically from DEFAULT_MODELS
|
||||
const rows: string[] = [];
|
||||
const purposes: Record<string, string> = {
|
||||
junior: "Typos, single-file fixes",
|
||||
medior: "Features, bug fixes",
|
||||
senior: "Architecture, refactoring",
|
||||
reviewer: "Code review",
|
||||
tester: "Testing",
|
||||
};
|
||||
for (const [role, levels] of Object.entries(DEFAULT_MODELS)) {
|
||||
for (const [level, model] of Object.entries(levels)) {
|
||||
rows.push(`| ${role} | ${level} | ${model} | ${purposes[level] ?? ""} |`);
|
||||
}
|
||||
}
|
||||
const modelTable = rows.join("\n");
|
||||
|
||||
return `# DevClaw Onboarding
|
||||
|
||||
## What is DevClaw?
|
||||
DevClaw turns each Telegram group into an autonomous development team:
|
||||
- An **orchestrator** that manages backlogs and delegates work
|
||||
- **DEV workers** (junior/medior/senior tiers) that write code in isolated sessions
|
||||
- **DEV workers** (junior/medior/senior levels) that write code in isolated sessions
|
||||
- **QA workers** that review code and run tests
|
||||
- Atomic tools for label transitions, session dispatch, state management, and audit logging
|
||||
|
||||
@@ -92,21 +111,17 @@ Ask: "Do you want to configure DevClaw for the current agent, or create a new de
|
||||
- If none selected, user can add bindings manually later via openclaw.json
|
||||
|
||||
**Step 2: Model Configuration**
|
||||
Show the default tier-to-model mapping and ask if they want to customize:
|
||||
Show the default level-to-model mapping and ask if they want to customize:
|
||||
|
||||
| Tier | Default Model | Purpose |
|
||||
|------|---------------|---------|
|
||||
| dev.junior | anthropic/claude-haiku-4-5 | Typos, single-file fixes |
|
||||
| dev.medior | anthropic/claude-sonnet-4-5 | Features, bug fixes |
|
||||
| dev.senior | anthropic/claude-opus-4-5 | Architecture, refactoring |
|
||||
| qa.reviewer | anthropic/claude-sonnet-4-5 | Code review |
|
||||
| qa.tester | anthropic/claude-haiku-4-5 | Testing |
|
||||
| Role | Level | Default Model | Purpose |
|
||||
|------|-------|---------------|---------|
|
||||
${modelTable}
|
||||
|
||||
If the defaults are fine, proceed. If customizing, ask which tiers to change.
|
||||
If the defaults are fine, proceed. If customizing, ask which levels to change.
|
||||
|
||||
**Step 3: Run Setup**
|
||||
Call \`setup\` with the collected answers:
|
||||
- Current agent: \`setup({})\` or \`setup({ models: { ... } })\`
|
||||
- Current agent: \`setup({})\` or \`setup({ models: { dev: { ... }, qa: { ... } } })\`
|
||||
- New agent: \`setup({ newAgentName: "<name>", channelBinding: "telegram"|"whatsapp"|null, migrateFrom: "<agentId>"|null, models: { ... } })\`
|
||||
- \`migrateFrom\`: Include if user wants to migrate an existing channel-wide binding
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export type WorkerState = {
|
||||
active: boolean;
|
||||
issueId: string | null;
|
||||
startTime: string | null;
|
||||
tier: string | null;
|
||||
level: string | null;
|
||||
sessions: Record<string, string | null>;
|
||||
};
|
||||
|
||||
@@ -39,36 +39,36 @@ function parseWorkerState(worker: Record<string, unknown>): WorkerState {
|
||||
active: worker.active as boolean,
|
||||
issueId: worker.issueId as string | null,
|
||||
startTime: worker.startTime as string | null,
|
||||
tier: worker.tier as string | null,
|
||||
level: (worker.level ?? worker.tier ?? null) as string | null,
|
||||
sessions: (worker.sessions as Record<string, string | null>) ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a blank WorkerState with null sessions for given tier names.
|
||||
* Create a blank WorkerState with null sessions for given level names.
|
||||
*/
|
||||
export function emptyWorkerState(tiers: string[]): WorkerState {
|
||||
export function emptyWorkerState(levels: string[]): WorkerState {
|
||||
const sessions: Record<string, string | null> = {};
|
||||
for (const t of tiers) {
|
||||
sessions[t] = null;
|
||||
for (const l of levels) {
|
||||
sessions[l] = null;
|
||||
}
|
||||
return {
|
||||
active: false,
|
||||
issueId: null,
|
||||
startTime: null,
|
||||
tier: null,
|
||||
level: null,
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session key for a specific tier from a worker's sessions map.
|
||||
* Get session key for a specific level from a worker's sessions map.
|
||||
*/
|
||||
export function getSessionForTier(
|
||||
export function getSessionForLevel(
|
||||
worker: WorkerState,
|
||||
tier: string,
|
||||
level: string,
|
||||
): string | null {
|
||||
return worker.sessions[tier] ?? null;
|
||||
return worker.sessions[level] ?? null;
|
||||
}
|
||||
|
||||
function projectsPath(workspaceDir: string): string {
|
||||
@@ -148,7 +148,7 @@ export async function updateWorker(
|
||||
|
||||
/**
|
||||
* Mark a worker as active with a new task.
|
||||
* Stores session key in sessions[tier] when a new session is spawned.
|
||||
* Stores session key in sessions[level] when a new session is spawned.
|
||||
*/
|
||||
export async function activateWorker(
|
||||
workspaceDir: string,
|
||||
@@ -156,7 +156,7 @@ export async function activateWorker(
|
||||
role: "dev" | "qa",
|
||||
params: {
|
||||
issueId: string;
|
||||
tier: string;
|
||||
level: string;
|
||||
sessionKey?: string;
|
||||
startTime?: string;
|
||||
},
|
||||
@@ -164,10 +164,10 @@ export async function activateWorker(
|
||||
const updates: Partial<WorkerState> = {
|
||||
active: true,
|
||||
issueId: params.issueId,
|
||||
tier: params.tier,
|
||||
level: params.level,
|
||||
};
|
||||
if (params.sessionKey !== undefined) {
|
||||
updates.sessions = { [params.tier]: params.sessionKey };
|
||||
updates.sessions = { [params.level]: params.sessionKey };
|
||||
}
|
||||
if (params.startTime !== undefined) {
|
||||
updates.startTime = params.startTime;
|
||||
@@ -177,7 +177,7 @@ export async function activateWorker(
|
||||
|
||||
/**
|
||||
* Mark a worker as inactive after task completion.
|
||||
* Preserves sessions map and tier for reuse via updateWorker's spread.
|
||||
* Preserves sessions map and level for reuse via updateWorker's spread.
|
||||
* Clears startTime to prevent stale timestamps on inactive workers.
|
||||
*/
|
||||
export async function deactivateWorker(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import {
|
||||
getSessionForTier,
|
||||
getSessionForLevel,
|
||||
getWorker,
|
||||
updateWorker,
|
||||
type Project,
|
||||
@@ -19,7 +19,7 @@ export type HealthIssue = {
|
||||
groupId: string;
|
||||
role: "dev" | "qa";
|
||||
message: string;
|
||||
tier?: string | null;
|
||||
level?: string | null;
|
||||
sessionKey?: string | null;
|
||||
hoursActive?: number;
|
||||
issueId?: string | null;
|
||||
@@ -46,7 +46,7 @@ export async function checkWorkerHealth(opts: {
|
||||
const { workspaceDir, groupId, project, role, activeSessions, autoFix, provider } = opts;
|
||||
const fixes: HealthFix[] = [];
|
||||
const worker = getWorker(project, role);
|
||||
const sessionKey = worker.tier ? getSessionForTier(worker, worker.tier) : null;
|
||||
const sessionKey = worker.level ? getSessionForLevel(worker, worker.level) : null;
|
||||
|
||||
const revertLabel: StateLabel = role === "dev" ? "To Do" : "To Test";
|
||||
const currentLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
@@ -62,14 +62,14 @@ export async function checkWorkerHealth(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// Check 1: Active but no session key for current tier
|
||||
// Check 1: Active but no session key for current level
|
||||
if (worker.active && !sessionKey) {
|
||||
const fix: HealthFix = {
|
||||
issue: {
|
||||
type: "active_no_session", severity: "critical",
|
||||
project: project.name, groupId, role,
|
||||
tier: worker.tier,
|
||||
message: `${role.toUpperCase()} active but no session for tier "${worker.tier}"`,
|
||||
level: worker.level,
|
||||
message: `${role.toUpperCase()} active but no session for level "${worker.level}"`,
|
||||
},
|
||||
fixed: false,
|
||||
};
|
||||
@@ -86,7 +86,7 @@ export async function checkWorkerHealth(opts: {
|
||||
issue: {
|
||||
type: "zombie_session", severity: "critical",
|
||||
project: project.name, groupId, role,
|
||||
sessionKey, tier: worker.tier,
|
||||
sessionKey, level: worker.level,
|
||||
message: `${role.toUpperCase()} session not in active sessions list`,
|
||||
},
|
||||
fixed: false,
|
||||
@@ -94,7 +94,7 @@ export async function checkWorkerHealth(opts: {
|
||||
if (autoFix) {
|
||||
await revertIssueLabel(fix);
|
||||
const sessions = { ...worker.sessions };
|
||||
if (worker.tier) sessions[worker.tier] = null;
|
||||
if (worker.level) sessions[worker.level] = null;
|
||||
await updateWorker(workspaceDir, groupId, role, { active: false, issueId: null, startTime: null, sessions });
|
||||
fix.fixed = true;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
import type { Issue, StateLabel } from "../providers/provider.js";
|
||||
import type { IssueProvider } from "../providers/provider.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { selectTier } from "../model-selector.js";
|
||||
import { getWorker, getSessionForTier, readProjects } from "../projects.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { ALL_TIERS, isDevTier, type Tier } from "../tiers.js";
|
||||
import { DEV_LEVELS, QA_LEVELS, isDevLevel } from "../tiers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared constants + helpers (used by tick, work-start, auto-pickup)
|
||||
@@ -20,9 +20,22 @@ export const DEV_LABELS: StateLabel[] = ["To Do", "To Improve"];
|
||||
export const QA_LABELS: StateLabel[] = ["To Test"];
|
||||
export const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
||||
|
||||
export function detectTierFromLabels(labels: string[]): Tier | null {
|
||||
export function detectLevelFromLabels(labels: string[]): string | null {
|
||||
const lower = labels.map((l) => l.toLowerCase());
|
||||
return (ALL_TIERS as readonly string[]).find((t) => lower.includes(t)) as Tier | undefined ?? null;
|
||||
|
||||
// Match role.level labels (e.g., "dev.senior", "qa.reviewer")
|
||||
for (const l of lower) {
|
||||
const dot = l.indexOf(".");
|
||||
if (dot === -1) continue;
|
||||
const role = l.slice(0, dot);
|
||||
const level = l.slice(dot + 1);
|
||||
if (role === "dev" && (DEV_LEVELS as readonly string[]).includes(level)) return level;
|
||||
if (role === "qa" && (QA_LEVELS as readonly string[]).includes(level)) return level;
|
||||
}
|
||||
|
||||
// Fallback: plain level name
|
||||
const all = [...DEV_LEVELS, ...QA_LEVELS] as readonly string[];
|
||||
return all.find((l) => lower.includes(l)) ?? null;
|
||||
}
|
||||
|
||||
export function detectRoleFromLabel(label: StateLabel): "dev" | "qa" | null {
|
||||
@@ -77,7 +90,7 @@ export type TickAction = {
|
||||
issueTitle: string;
|
||||
issueUrl: string;
|
||||
role: "dev" | "qa";
|
||||
tier: string;
|
||||
level: string;
|
||||
sessionAction: "spawn" | "send";
|
||||
announcement: string;
|
||||
};
|
||||
@@ -145,14 +158,14 @@ export async function projectTick(opts: {
|
||||
const { issue, label: currentLabel } = next;
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
|
||||
// Tier selection: label → heuristic
|
||||
const selectedTier = resolveTierForIssue(issue, role);
|
||||
// Level selection: label → heuristic
|
||||
const selectedLevel = resolveLevelForIssue(issue, role);
|
||||
|
||||
if (dryRun) {
|
||||
pickups.push({
|
||||
project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url,
|
||||
role, tier: selectedTier,
|
||||
sessionAction: getSessionForTier(worker, selectedTier) ? "send" : "spawn",
|
||||
role, level: selectedLevel,
|
||||
sessionAction: getSessionForLevel(worker, selectedLevel) ? "send" : "spawn",
|
||||
announcement: `[DRY RUN] Would pick up #${issue.iid}`,
|
||||
});
|
||||
} else {
|
||||
@@ -160,13 +173,13 @@ export async function projectTick(opts: {
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir, agentId, groupId, project: fresh, issueId: issue.iid,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey,
|
||||
});
|
||||
pickups.push({
|
||||
project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url,
|
||||
role, tier: dr.tier, sessionAction: dr.sessionAction, announcement: dr.announcement,
|
||||
role, level: dr.level, sessionAction: dr.sessionAction, announcement: dr.announcement,
|
||||
});
|
||||
} catch (err) {
|
||||
skipped.push({ role, reason: `Dispatch failed: ${(err as Error).message}` });
|
||||
@@ -184,16 +197,16 @@ export async function projectTick(opts: {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine the tier for an issue based on labels, role overrides, and heuristic fallback.
|
||||
* Determine the level for an issue based on labels, role overrides, and heuristic fallback.
|
||||
*/
|
||||
function resolveTierForIssue(issue: Issue, role: "dev" | "qa"): string {
|
||||
const labelTier = detectTierFromLabels(issue.labels);
|
||||
if (labelTier) {
|
||||
// QA role but label specifies a dev tier → heuristic picks the right QA tier
|
||||
if (role === "qa" && isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier;
|
||||
// DEV role but label specifies a QA tier → heuristic picks the right dev tier
|
||||
if (role === "dev" && !isDevTier(labelTier)) return selectTier(issue.title, issue.description ?? "", role).tier;
|
||||
return labelTier;
|
||||
function resolveLevelForIssue(issue: Issue, role: "dev" | "qa"): string {
|
||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||
if (labelLevel) {
|
||||
// QA role but label specifies a dev level → heuristic picks the right QA level
|
||||
if (role === "qa" && isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||
// DEV role but label specifies a QA level → heuristic picks the right dev level
|
||||
if (role === "dev" && !isDevLevel(labelLevel)) return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||
return labelLevel;
|
||||
}
|
||||
return selectTier(issue.title, issue.description ?? "", role).tier;
|
||||
return selectLevel(issue.title, issue.description ?? "", role).level;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
/**
|
||||
* setup/config.ts — Plugin config writer (openclaw.json).
|
||||
*
|
||||
* Handles: model tier config, devClawAgentIds, tool restrictions, subagent cleanup.
|
||||
* Handles: model level config, devClawAgentIds, tool restrictions, subagent cleanup.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DEV_TIERS, QA_TIERS, tierName, type Tier } from "../tiers.js";
|
||||
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
|
||||
|
||||
type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
|
||||
|
||||
function openclawConfigPath(): string {
|
||||
return path.join(process.env.HOME ?? "/home/lauren", ".openclaw", "openclaw.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert flat tier map to nested role-tier structure.
|
||||
*/
|
||||
function buildRoleTierModels(models: Record<Tier, string>): { dev: Record<string, string>; qa: Record<string, string> } {
|
||||
const dev: Record<string, string> = {};
|
||||
const qa: Record<string, string> = {};
|
||||
|
||||
for (const tier of DEV_TIERS) {
|
||||
dev[tierName(tier)] = models[tier];
|
||||
}
|
||||
for (const tier of QA_TIERS) {
|
||||
qa[tierName(tier)] = models[tier];
|
||||
}
|
||||
|
||||
return { dev, qa };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write DevClaw model tier config and devClawAgentIds to openclaw.json plugins section.
|
||||
* Write DevClaw model level config and devClawAgentIds to openclaw.json plugins section.
|
||||
*
|
||||
* Also configures:
|
||||
* - Tool restrictions (deny sessions_spawn, sessions_send) for DevClaw agents
|
||||
@@ -39,7 +23,7 @@ function buildRoleTierModels(models: Record<Tier, string>): { dev: Record<string
|
||||
* Read-modify-write to preserve existing config.
|
||||
*/
|
||||
export async function writePluginConfig(
|
||||
models: Record<Tier, string>,
|
||||
models: ModelConfig,
|
||||
agentId?: string,
|
||||
projectExecution?: "parallel" | "sequential",
|
||||
): Promise<void> {
|
||||
@@ -47,7 +31,7 @@ export async function writePluginConfig(
|
||||
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
|
||||
|
||||
ensurePluginStructure(config);
|
||||
config.plugins.entries.devclaw.config.models = buildRoleTierModels(models);
|
||||
config.plugins.entries.devclaw.config.models = models;
|
||||
|
||||
if (projectExecution) {
|
||||
config.plugins.entries.devclaw.config.projectExecution = projectExecution;
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
* Coordinates: agent creation → model config → workspace scaffolding.
|
||||
* Used by both the `setup` tool and the `openclaw devclaw setup` CLI command.
|
||||
*/
|
||||
import { ALL_TIERS, allDefaultModels, type Tier } from "../tiers.js";
|
||||
import { DEFAULT_MODELS } from "../tiers.js";
|
||||
import { migrateChannelBinding } from "../binding-manager.js";
|
||||
import { createAgent, resolveWorkspacePath } from "./agent.js";
|
||||
import { writePluginConfig } from "./config.js";
|
||||
import { scaffoldWorkspace } from "./workspace.js";
|
||||
|
||||
export type ModelConfig = { dev: Record<string, string>; qa: Record<string, string> };
|
||||
|
||||
export type SetupOpts = {
|
||||
/** Create a new agent with this name. Mutually exclusive with agentId. */
|
||||
newAgentName?: string;
|
||||
@@ -21,8 +23,8 @@ export type SetupOpts = {
|
||||
agentId?: string;
|
||||
/** Override workspace path (auto-detected from agent if not given). */
|
||||
workspacePath?: string;
|
||||
/** Model overrides per tier. Missing tiers use defaults. */
|
||||
models?: Partial<Record<Tier, string>>;
|
||||
/** Model overrides per role.level. Missing levels use defaults. */
|
||||
models?: { dev?: Partial<Record<string, string>>; qa?: Partial<Record<string, string>> };
|
||||
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
|
||||
projectExecution?: "parallel" | "sequential";
|
||||
};
|
||||
@@ -31,7 +33,7 @@ export type SetupResult = {
|
||||
agentId: string;
|
||||
agentCreated: boolean;
|
||||
workspacePath: string;
|
||||
models: Record<Tier, string>;
|
||||
models: ModelConfig;
|
||||
filesWritten: string[];
|
||||
warnings: string[];
|
||||
bindingMigrated?: {
|
||||
@@ -107,14 +109,20 @@ async function tryMigrateBinding(
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelConfig(overrides?: Partial<Record<Tier, string>>): Record<Tier, string> {
|
||||
const models = allDefaultModels();
|
||||
if (overrides) {
|
||||
for (const [tier, model] of Object.entries(overrides)) {
|
||||
if (model && (ALL_TIERS as readonly string[]).includes(tier)) {
|
||||
models[tier as Tier] = model;
|
||||
function buildModelConfig(overrides?: SetupOpts["models"]): ModelConfig {
|
||||
const dev: Record<string, string> = { ...DEFAULT_MODELS.dev };
|
||||
const qa: Record<string, string> = { ...DEFAULT_MODELS.qa };
|
||||
|
||||
if (overrides?.dev) {
|
||||
for (const [level, model] of Object.entries(overrides.dev)) {
|
||||
if (model) dev[level] = model;
|
||||
}
|
||||
}
|
||||
if (overrides?.qa) {
|
||||
for (const [level, model] of Object.entries(overrides.qa)) {
|
||||
if (model) qa[level] = model;
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
return { dev, qa };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import path from "node:path";
|
||||
import {
|
||||
AGENTS_MD_TEMPLATE,
|
||||
HEARTBEAT_MD_TEMPLATE,
|
||||
DEFAULT_DEV_INSTRUCTIONS,
|
||||
DEFAULT_QA_INSTRUCTIONS,
|
||||
} from "../templates.js";
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,20 @@ export async function scaffoldWorkspace(workspacePath: string): Promise<string[]
|
||||
filesWritten.push("projects/projects.json");
|
||||
}
|
||||
|
||||
// projects/roles/default/ (fallback role instructions)
|
||||
const defaultRolesDir = path.join(projectsDir, "roles", "default");
|
||||
await fs.mkdir(defaultRolesDir, { recursive: true });
|
||||
const devRolePath = path.join(defaultRolesDir, "dev.md");
|
||||
if (!await fileExists(devRolePath)) {
|
||||
await fs.writeFile(devRolePath, DEFAULT_DEV_INSTRUCTIONS, "utf-8");
|
||||
filesWritten.push("projects/roles/default/dev.md");
|
||||
}
|
||||
const qaRolePath = path.join(defaultRolesDir, "qa.md");
|
||||
if (!await fileExists(qaRolePath)) {
|
||||
await fs.writeFile(qaRolePath, DEFAULT_QA_INSTRUCTIONS, "utf-8");
|
||||
filesWritten.push("projects/roles/default/qa.md");
|
||||
}
|
||||
|
||||
// log/ directory (audit.log created on first write)
|
||||
const logDir = path.join(workspacePath, "log");
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
|
||||
@@ -101,7 +101,7 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
|
||||
| \`task_update\` | Update issue title, description, or labels |
|
||||
| \`status\` | Task queue and worker state per project (lightweight dashboard) |
|
||||
| \`health\` | Scan worker health: zombies, stale workers, orphaned state. Pass fix=true to auto-fix |
|
||||
| \`work_start\` | End-to-end: label transition, tier assignment, session create/reuse, dispatch with role instructions |
|
||||
| \`work_start\` | End-to-end: label transition, level assignment, session create/reuse, dispatch with role instructions |
|
||||
| \`work_finish\` | End-to-end: label transition, state update, issue close/reopen. Auto-ticks queue after completion. |
|
||||
|
||||
### Pipeline Flow
|
||||
@@ -118,19 +118,19 @@ Issue labels are the single source of truth for task state.
|
||||
|
||||
### Developer Assignment
|
||||
|
||||
Evaluate each task and pass the appropriate developer tier to \`work_start\`:
|
||||
Evaluate each task and pass the appropriate developer level to \`work_start\`:
|
||||
|
||||
- **junior** — trivial: typos, single-file fix, quick change
|
||||
- **medior** — standard: features, bug fixes, multi-file changes
|
||||
- **senior** — complex: architecture, system-wide refactoring, 5+ services
|
||||
- **qa** — review: code inspection, validation, test runs
|
||||
- **reviewer** — QA: code inspection, validation, test runs
|
||||
|
||||
### Picking Up Work
|
||||
|
||||
1. Use \`status\` to see what's available
|
||||
2. Priority: \`To Improve\` (fix failures) > \`To Test\` (QA) > \`To Do\` (new work)
|
||||
3. Evaluate complexity, choose developer tier
|
||||
4. Call \`work_start\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`model\` (tier name)
|
||||
3. Evaluate complexity, choose developer level
|
||||
4. Call \`work_start\` with \`issueId\`, \`role\`, \`projectGroupId\`, \`level\`
|
||||
5. Post the \`announcement\` from the tool response to Telegram
|
||||
|
||||
### When Work Completes
|
||||
@@ -146,7 +146,7 @@ The response includes \`tickPickups\` showing any tasks that were auto-dispatche
|
||||
|
||||
### Prompt Instructions
|
||||
|
||||
Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/prompts/<project-name>/<role>.md\` in the workspace. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
|
||||
Workers receive role-specific instructions appended to their task message. These are loaded from \`projects/roles/<project-name>/<role>.md\` in the workspace, falling back to \`projects/roles/default/<role>.md\` if no project-specific file exists. \`project_register\` scaffolds these files automatically — edit them to customize worker behavior per project.
|
||||
|
||||
### Heartbeats
|
||||
|
||||
|
||||
92
lib/tiers.ts
92
lib/tiers.ts
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* Developer tier definitions and model resolution.
|
||||
* Developer level definitions and model resolution.
|
||||
*
|
||||
* Tier names always include the role prefix: "dev.junior", "qa.reviewer", etc.
|
||||
* This makes tier names globally unique and self-documenting.
|
||||
* Level names are plain: "junior", "senior", "reviewer", etc.
|
||||
* Role context (dev/qa) is always provided by the caller or parent structure.
|
||||
*/
|
||||
|
||||
export const DEV_TIERS = ["dev.junior", "dev.medior", "dev.senior"] as const;
|
||||
export const QA_TIERS = ["qa.reviewer", "qa.tester"] as const;
|
||||
export const ALL_TIERS = [...DEV_TIERS, ...QA_TIERS] as const;
|
||||
export const DEV_LEVELS = ["junior", "medior", "senior"] as const;
|
||||
export const QA_LEVELS = ["reviewer", "tester"] as const;
|
||||
|
||||
export type DevTier = (typeof DEV_TIERS)[number];
|
||||
export type QaTier = (typeof QA_TIERS)[number];
|
||||
export type Tier = (typeof ALL_TIERS)[number];
|
||||
export type DevLevel = (typeof DEV_LEVELS)[number];
|
||||
export type QaLevel = (typeof QA_LEVELS)[number];
|
||||
export type Level = DevLevel | QaLevel;
|
||||
|
||||
/** Default models, nested by role. */
|
||||
export const DEFAULT_MODELS = {
|
||||
@@ -27,7 +26,7 @@ export const DEFAULT_MODELS = {
|
||||
};
|
||||
|
||||
/** Emoji used in announcements, nested by role. */
|
||||
export const TIER_EMOJI = {
|
||||
export const LEVEL_EMOJI = {
|
||||
dev: {
|
||||
junior: "⚡",
|
||||
medior: "🔧",
|
||||
@@ -39,77 +38,52 @@ export const TIER_EMOJI = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Check if a string is a valid tier name. */
|
||||
export function isTier(value: string): value is Tier {
|
||||
return (ALL_TIERS as readonly string[]).includes(value);
|
||||
/** Check if a level belongs to the dev role. */
|
||||
export function isDevLevel(value: string): value is DevLevel {
|
||||
return (DEV_LEVELS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Check if a tier belongs to the dev role. */
|
||||
export function isDevTier(value: string): value is DevTier {
|
||||
return (DEV_TIERS as readonly string[]).includes(value);
|
||||
/** Check if a level belongs to the qa role. */
|
||||
export function isQaLevel(value: string): value is QaLevel {
|
||||
return (QA_LEVELS 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";
|
||||
/** Determine the role a level belongs to. */
|
||||
export function levelRole(level: string): "dev" | "qa" | undefined {
|
||||
if (isDevLevel(level)) return "dev";
|
||||
if (isQaLevel(level)) 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;
|
||||
/** Get the default model for a role + level. */
|
||||
export function defaultModel(role: "dev" | "qa", level: string): string | undefined {
|
||||
return (DEFAULT_MODELS[role] as Record<string, string>)[level];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Get the emoji for a role + level. */
|
||||
export function levelEmoji(role: "dev" | "qa", level: string): string | undefined {
|
||||
return (LEVEL_EMOJI[role] as Record<string, string>)[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tier name to a full model ID.
|
||||
* Resolve a level to a full model ID.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Parse "role.name" → look up config `models.<role>.<name>`
|
||||
* 2. DEFAULT_MODELS[role][name]
|
||||
* 1. Plugin config `models.<role>.<level>`
|
||||
* 2. DEFAULT_MODELS[role][level]
|
||||
* 3. Passthrough (treat as raw model ID)
|
||||
*/
|
||||
export function resolveTierToModel(
|
||||
tier: string,
|
||||
export function resolveModel(
|
||||
role: "dev" | "qa",
|
||||
level: string,
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): string {
|
||||
const models = (pluginConfig as { models?: Record<string, unknown> })?.models;
|
||||
|
||||
if (models && typeof models === "object") {
|
||||
const role = tierRole(tier);
|
||||
const name = tierName(tier);
|
||||
if (role) {
|
||||
const roleModels = models[role] as Record<string, string> | undefined;
|
||||
if (roleModels?.[name]) return roleModels[name];
|
||||
}
|
||||
if (roleModels?.[level]) return roleModels[level];
|
||||
}
|
||||
|
||||
return defaultModel(tier) ?? tier;
|
||||
return defaultModel(role, level) ?? level;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { readProjects, writeProjects, emptyWorkerState } from "../projects.js";
|
||||
import { resolveRepoPath } from "../projects.js";
|
||||
import { createProvider } from "../providers/index.js";
|
||||
import { log as auditLog } from "../audit.js";
|
||||
import { DEV_TIERS, QA_TIERS } from "../tiers.js";
|
||||
import { DEV_LEVELS, QA_LEVELS } from "../tiers.js";
|
||||
import { DEFAULT_DEV_INSTRUCTIONS, DEFAULT_QA_INSTRUCTIONS } from "../templates.js";
|
||||
import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
|
||||
@@ -24,7 +24,7 @@ import { detectContext, generateGuardrails } from "../context-guard.js";
|
||||
* Returns true if files were created, false if they already existed.
|
||||
*/
|
||||
async function scaffoldPromptFiles(workspaceDir: string, projectName: string): Promise<boolean> {
|
||||
const projectDir = path.join(workspaceDir, "projects", "prompts", projectName);
|
||||
const projectDir = path.join(workspaceDir, "projects", "roles", projectName);
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const projectDev = path.join(projectDir, "dev.md");
|
||||
@@ -185,8 +185,8 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
||||
deployBranch,
|
||||
channel: context.channel,
|
||||
roleExecution,
|
||||
dev: emptyWorkerState([...DEV_TIERS]),
|
||||
qa: emptyWorkerState([...QA_TIERS]),
|
||||
dev: emptyWorkerState([...DEV_LEVELS]),
|
||||
qa: emptyWorkerState([...QA_LEVELS]),
|
||||
};
|
||||
|
||||
await writeProjects(workspaceDir, data);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* setup — Agent-driven DevClaw setup.
|
||||
*
|
||||
* Creates agent, configures model tiers, writes workspace files.
|
||||
* Creates agent, configures model levels, writes workspace files.
|
||||
* Thin wrapper around lib/setup/.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import { runSetup } from "../setup/index.js";
|
||||
import { ALL_TIERS, DEFAULT_MODELS, type Tier } from "../tiers.js";
|
||||
import { runSetup, type SetupOpts } from "../setup/index.js";
|
||||
import { DEV_LEVELS, QA_LEVELS, DEFAULT_MODELS } from "../tiers.js";
|
||||
|
||||
export function createSetupTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "setup",
|
||||
label: "Setup",
|
||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model tier config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||
description: `Execute DevClaw setup. Creates AGENTS.md, HEARTBEAT.md, projects/projects.json, and model level config. Optionally creates a new agent with channel binding. Called after onboard collects configuration.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -35,11 +35,11 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
models: {
|
||||
type: "object",
|
||||
description: "Model overrides per role and tier.",
|
||||
description: "Model overrides per role and level.",
|
||||
properties: {
|
||||
dev: {
|
||||
type: "object",
|
||||
description: "Developer tier models",
|
||||
description: "Developer level models",
|
||||
properties: {
|
||||
junior: {
|
||||
type: "string",
|
||||
@@ -57,7 +57,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
},
|
||||
qa: {
|
||||
type: "object",
|
||||
description: "QA tier models",
|
||||
description: "QA level models",
|
||||
properties: {
|
||||
reviewer: {
|
||||
type: "string",
|
||||
@@ -87,7 +87,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
migrateFrom: params.migrateFrom as string | undefined,
|
||||
agentId: params.newAgentName ? undefined : ctx.agentId,
|
||||
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
|
||||
models: params.models as Partial<Record<Tier, string>> | undefined,
|
||||
models: params.models as SetupOpts["models"],
|
||||
projectExecution: params.projectExecution as
|
||||
| "parallel"
|
||||
| "sequential"
|
||||
@@ -108,7 +108,8 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
||||
}
|
||||
lines.push(
|
||||
"Models:",
|
||||
...ALL_TIERS.map((t) => ` ${t}: ${result.models[t]}`),
|
||||
...DEV_LEVELS.map((t) => ` dev.${t}: ${result.models.dev[t]}`),
|
||||
...QA_LEVELS.map((t) => ` qa.${t}: ${result.models.qa[t]}`),
|
||||
"",
|
||||
"Files:",
|
||||
...result.filesWritten.map((f) => ` ${f}`),
|
||||
|
||||
@@ -62,8 +62,8 @@ export function createStatusTool(api: OpenClawPluginApi) {
|
||||
name: project.name,
|
||||
groupId: pid,
|
||||
roleExecution: project.roleExecution ?? "parallel",
|
||||
dev: { active: project.dev.active, issueId: project.dev.issueId, tier: project.dev.tier, startTime: project.dev.startTime },
|
||||
qa: { active: project.qa.active, issueId: project.qa.issueId, tier: project.qa.tier, startTime: project.qa.startTime },
|
||||
dev: { active: project.dev.active, issueId: project.dev.issueId, level: project.dev.level, startTime: project.dev.startTime },
|
||||
qa: { active: project.qa.active, issueId: project.qa.issueId, level: project.qa.level, startTime: project.qa.startTime },
|
||||
queue: { toImprove: count("To Improve"), toTest: count("To Test"), toDo: count("To Do") },
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -22,17 +22,17 @@ import type { StateLabel } from "../providers/provider.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INACTIVE_WORKER: WorkerState = {
|
||||
active: false, issueId: null, startTime: null, tier: null, sessions: {},
|
||||
active: false, issueId: null, startTime: null, level: null, sessions: {},
|
||||
};
|
||||
|
||||
const ACTIVE_DEV: WorkerState = {
|
||||
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "dev.medior",
|
||||
sessions: { "dev.medior": "session-dev-42" },
|
||||
active: true, issueId: "42", startTime: new Date().toISOString(), level: "medior",
|
||||
sessions: { medior: "session-dev-42" },
|
||||
};
|
||||
|
||||
const ACTIVE_QA: WorkerState = {
|
||||
active: true, issueId: "42", startTime: new Date().toISOString(), tier: "qa.reviewer",
|
||||
sessions: { "qa.reviewer": "session-qa-42" },
|
||||
active: true, issueId: "42", startTime: new Date().toISOString(), level: "reviewer",
|
||||
sessions: { reviewer: "session-qa-42" },
|
||||
};
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
@@ -280,11 +280,11 @@ describe("work_heartbeat: worker slot guards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("work_heartbeat: tier assignment", () => {
|
||||
describe("work_heartbeat: level assignment", () => {
|
||||
afterEach(async () => { if (tmpDir) await fs.rm(tmpDir, { recursive: true }).catch(() => {}); });
|
||||
|
||||
it("uses label-based tier when present", async () => {
|
||||
// Given: issue with "dev.senior" label → tier should be "dev.senior"
|
||||
it("uses label-based level when present", async () => {
|
||||
// Given: issue with "dev.senior" label → level should be "senior"
|
||||
const workspaceDir = await setupWorkspace({
|
||||
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
|
||||
});
|
||||
@@ -299,12 +299,12 @@ describe("work_heartbeat: tier assignment", () => {
|
||||
|
||||
const pickup = result.pickups.find((p) => p.role === "dev");
|
||||
assert.ok(pickup);
|
||||
assert.strictEqual(pickup.tier, "dev.senior", "Should use label-based tier");
|
||||
assert.strictEqual(pickup.level, "senior", "Should use label-based level");
|
||||
});
|
||||
|
||||
it("overrides to reviewer tier for qa role regardless of label", async () => {
|
||||
it("overrides to reviewer level for qa role regardless of label", async () => {
|
||||
// Given: issue with "dev.senior" label but picked up by QA
|
||||
// Expected: tier = "qa.reviewer" (QA always uses reviewer tier)
|
||||
// Expected: level = "reviewer" (QA always uses reviewer level)
|
||||
const workspaceDir = await setupWorkspace({
|
||||
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
|
||||
});
|
||||
@@ -319,11 +319,11 @@ describe("work_heartbeat: tier assignment", () => {
|
||||
|
||||
const qaPickup = result.pickups.find((p) => p.role === "qa");
|
||||
assert.ok(qaPickup);
|
||||
assert.strictEqual(qaPickup.tier, "qa.reviewer", "QA always uses reviewer tier regardless of issue label");
|
||||
assert.strictEqual(qaPickup.level, "reviewer", "QA always uses reviewer level regardless of issue label");
|
||||
});
|
||||
|
||||
it("falls back to heuristic when no tier label", async () => {
|
||||
// Given: issue with no tier label → heuristic selects based on title/description
|
||||
it("falls back to heuristic when no level label", async () => {
|
||||
// Given: issue with no level label → heuristic selects based on title/description
|
||||
const workspaceDir = await setupWorkspace({
|
||||
"-100": makeProject({ name: "Alpha", repo: "https://github.com/test/alpha" }),
|
||||
});
|
||||
@@ -339,7 +339,7 @@ describe("work_heartbeat: tier assignment", () => {
|
||||
const pickup = result.pickups.find((p) => p.role === "dev");
|
||||
assert.ok(pickup);
|
||||
// Heuristic should select junior for a typo fix
|
||||
assert.strictEqual(pickup.tier, "dev.junior", "Heuristic should assign junior for simple typo fix");
|
||||
assert.strictEqual(pickup.level, "junior", "Heuristic should assign junior for simple typo fix");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,7 +394,7 @@ describe("work_heartbeat: TickAction output shape", () => {
|
||||
assert.strictEqual(pickup.issueTitle, "Build feature");
|
||||
assert.strictEqual(pickup.issueUrl, "https://github.com/test/alpha/issues/10");
|
||||
assert.ok(["dev", "qa"].includes(pickup.role));
|
||||
assert.ok(typeof pickup.tier === "string");
|
||||
assert.ok(typeof pickup.level === "string");
|
||||
assert.ok(["spawn", "send"].includes(pickup.sessionAction));
|
||||
assert.ok(pickup.announcement.includes("[DRY RUN]"));
|
||||
});
|
||||
|
||||
@@ -2,33 +2,33 @@
|
||||
* work_start — Pick up a task from the issue queue.
|
||||
*
|
||||
* Context-aware: ONLY works in project group chats.
|
||||
* Auto-detects: projectGroupId, role, tier, issueId.
|
||||
* Auto-detects: projectGroupId, role, level, issueId.
|
||||
* After dispatch, ticks the project queue to fill parallel slots.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { jsonResult } from "openclaw/plugin-sdk";
|
||||
import type { ToolContext } from "../types.js";
|
||||
import type { StateLabel } from "../providers/provider.js";
|
||||
import { selectTier } from "../model-selector.js";
|
||||
import { selectLevel } from "../model-selector.js";
|
||||
import { getWorker } from "../projects.js";
|
||||
import { dispatchTask } from "../dispatch.js";
|
||||
import { notify, getNotificationConfig } from "../notify.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectTierFromLabels } from "../services/tick.js";
|
||||
import { isDevTier } from "../tiers.js";
|
||||
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
|
||||
import { isDevLevel } from "../tiers.js";
|
||||
import { requireWorkspaceDir, resolveContext, resolveProject, resolveProvider, groupOnlyError, getPluginConfig, tickAndNotify } from "../tool-helpers.js";
|
||||
|
||||
export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
return (ctx: ToolContext) => ({
|
||||
name: "work_start",
|
||||
label: "Work Start",
|
||||
description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, tier assignment, session creation, dispatch, audit, and ticks the queue to fill parallel slots.`,
|
||||
description: `Pick up a task from the issue queue. ONLY works in project group chats. Handles label transition, level assignment, session creation, dispatch, audit, and ticks the queue to fill parallel slots.`,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
issueId: { type: "number", description: "Issue ID. If omitted, picks next by priority." },
|
||||
role: { type: "string", enum: ["dev", "qa"], description: "Worker role. Auto-detected from label if omitted." },
|
||||
projectGroupId: { type: "string", description: "Project group ID. Auto-detected from group context." },
|
||||
tier: { type: "string", description: "Developer tier (junior/medior/senior/qa). Auto-detected if omitted." },
|
||||
level: { type: "string", description: "Developer level (junior/medior/senior/reviewer). Auto-detected if omitted." },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
const issueIdParam = params.issueId as number | undefined;
|
||||
const roleParam = params.role as "dev" | "qa" | undefined;
|
||||
const groupIdParam = params.projectGroupId as string | undefined;
|
||||
const tierParam = params.tier as string | undefined;
|
||||
const levelParam = (params.level ?? params.tier) as string | undefined;
|
||||
const workspaceDir = requireWorkspaceDir(ctx);
|
||||
|
||||
// Context guard: group only
|
||||
@@ -76,20 +76,20 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
if (getWorker(project, other).active) throw new Error(`Sequential roleExecution: ${other.toUpperCase()} is active`);
|
||||
}
|
||||
|
||||
// Select tier
|
||||
// Select level
|
||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||
let selectedTier: string, tierReason: string, tierSource: string;
|
||||
if (tierParam) {
|
||||
selectedTier = tierParam; tierReason = "LLM-selected"; tierSource = "llm";
|
||||
let selectedLevel: string, levelReason: string, levelSource: string;
|
||||
if (levelParam) {
|
||||
selectedLevel = levelParam; levelReason = "LLM-selected"; levelSource = "llm";
|
||||
} else {
|
||||
const labelTier = detectTierFromLabels(issue.labels);
|
||||
if (labelTier) {
|
||||
if (role === "qa" && isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = `QA overrides dev tier "${labelTier}"`; tierSource = "role-override"; }
|
||||
else if (role === "dev" && !isDevTier(labelTier)) { const s = selectTier(issue.title, issue.description ?? "", role); selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic"; }
|
||||
else { selectedTier = labelTier; tierReason = `Label: "${labelTier}"`; tierSource = "label"; }
|
||||
const labelLevel = detectLevelFromLabels(issue.labels);
|
||||
if (labelLevel) {
|
||||
if (role === "qa" && isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = `QA overrides dev level "${labelLevel}"`; levelSource = "role-override"; }
|
||||
else if (role === "dev" && !isDevLevel(labelLevel)) { const s = selectLevel(issue.title, issue.description ?? "", role); selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic"; }
|
||||
else { selectedLevel = labelLevel; levelReason = `Label: "${labelLevel}"`; levelSource = "label"; }
|
||||
} else {
|
||||
const s = selectTier(issue.title, issue.description ?? "", role);
|
||||
selectedTier = s.tier; tierReason = s.reason; tierSource = "heuristic";
|
||||
const s = selectLevel(issue.title, issue.description ?? "", role);
|
||||
selectedLevel = s.level; levelReason = s.reason; levelSource = "heuristic";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
const dr = await dispatchTask({
|
||||
workspaceDir, agentId: ctx.agentId, groupId, project, issueId: issue.iid,
|
||||
issueTitle: issue.title, issueDescription: issue.description ?? "", issueUrl: issue.web_url,
|
||||
role, tier: selectedTier, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
role, level: selectedLevel, fromLabel: currentLabel, toLabel: targetLabel,
|
||||
transitionLabel: (id, from, to) => provider.transitionLabel(id, from as StateLabel, to as StateLabel),
|
||||
pluginConfig, sessionKey: ctx.sessionKey,
|
||||
});
|
||||
@@ -106,7 +106,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
// Notify
|
||||
const notifyConfig = getNotificationConfig(pluginConfig);
|
||||
await notify(
|
||||
{ type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, tier: dr.tier, sessionAction: dr.sessionAction },
|
||||
{ type: "workerStart", project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title, issueUrl: issue.web_url, role, level: dr.level, sessionAction: dr.sessionAction },
|
||||
{ workspaceDir, config: notifyConfig, groupId, channel: context.channel },
|
||||
);
|
||||
|
||||
@@ -119,10 +119,10 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
|
||||
|
||||
const output: Record<string, unknown> = {
|
||||
success: true, project: project.name, groupId, issueId: issue.iid, issueTitle: issue.title,
|
||||
role, tier: dr.tier, model: dr.model, sessionAction: dr.sessionAction,
|
||||
role, level: dr.level, model: dr.model, sessionAction: dr.sessionAction,
|
||||
announcement: dr.announcement, labelTransition: `${currentLabel} → ${targetLabel}`,
|
||||
tierReason, tierSource,
|
||||
autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, tier: !tierParam },
|
||||
levelReason, levelSource,
|
||||
autoDetected: { projectGroupId: !groupIdParam, role: !roleParam, issueId: issueIdParam === undefined, level: !levelParam },
|
||||
};
|
||||
if (tickPickups.length) output.tickPickups = tickPickups;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user