From 2df921ca8833d4ec1de58fbcc2fb656f574e1af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 22:57:35 -0400 Subject: [PATCH] refactor(agents): share leadership role detector, use word-boundary match P2-23: 'lead' in system-prompt.ts matched 'cleaner' / 'leaderboard' via substring .includes(). Hoist LEADERSHIP_ROLE_KEYWORDS into a shared agent-role-helpers.ts module; tokenize input on non-alphanum to require whole-word match. P3-54: Same keyword list was duplicated in automation-skills with drift (missing 'ceo', different members). Both sites now call through the shared helper. --- src/main/agent-role-helpers.ts | 33 +++++++++++++++ src/main/api-server/routes-files.ts | 6 ++- src/main/automation/automation-skills.ts | 9 +++-- src/main/database.ts | 4 +- src/main/database/documents.ts | 51 +++++++++++++++++++----- src/main/prompt-builder/system-prompt.ts | 20 ++-------- 6 files changed, 89 insertions(+), 34 deletions(-) create mode 100644 src/main/agent-role-helpers.ts diff --git a/src/main/agent-role-helpers.ts b/src/main/agent-role-helpers.ts new file mode 100644 index 00000000..13f567e0 --- /dev/null +++ b/src/main/agent-role-helpers.ts @@ -0,0 +1,33 @@ +/** + * Role-classification helpers used across prompt construction and skill + * auto-assignment. Keeping a single keyword list here prevents drift + * between system-prompt.ts and automation-skills.ts. + * + * Matching uses word-boundary tokenization instead of substring search, + * so "lead" does not match "cleaner" or "leaderboard". + */ + +export const LEADERSHIP_ROLE_KEYWORDS = [ + "chief", + "cto", + "cmo", + "coo", + "cfo", + "ceo", + "vp", + "vice", + "president", + "head", + "director", + "manager", + "lead", +] as const; + +function tokenize(text: string): string[] { + return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; +} + +export function roleMatchesLeadershipKeywords(role: string, title?: string | null): boolean { + const tokens = new Set(tokenize(`${role} ${title ?? ""}`)); + return LEADERSHIP_ROLE_KEYWORDS.some((keyword) => tokens.has(keyword)); +} diff --git a/src/main/api-server/routes-files.ts b/src/main/api-server/routes-files.ts index 978f37e0..0dac2f7f 100644 --- a/src/main/api-server/routes-files.ts +++ b/src/main/api-server/routes-files.ts @@ -294,7 +294,8 @@ export function registerFileRoutes(server: AgentApiServer) { if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; } const parsed = updateWorkProductSchema.safeParse(body); if (!parsed.success) { server.json(res, 400, { error: "Invalid input", details: parsed.error.flatten() }); return; } - db.updateWorkProduct(wpId, token.companyId, parsed.data); + const changes = db.updateWorkProduct(wpId, token.companyId, parsed.data); + if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; } onDomainChanged(); server.json(res, 200, { updated: true }); }); @@ -302,7 +303,8 @@ export function registerFileRoutes(server: AgentApiServer) { server.addRoute("DELETE", "/api/work-products/:workProductId", (_req, res, params, token) => { const wpId = params.workProductId; if (!wpId) { server.json(res, 400, { error: "Missing workProductId" }); return; } - db.deleteWorkProduct(wpId, token.companyId); + const changes = db.deleteWorkProduct(wpId, token.companyId); + if (changes === 0) { server.json(res, 404, { error: "Work product not found" }); return; } onDomainChanged(); server.json(res, 200, { deleted: true }); }); diff --git a/src/main/automation/automation-skills.ts b/src/main/automation/automation-skills.ts index d2b51772..d947051e 100644 --- a/src/main/automation/automation-skills.ts +++ b/src/main/automation/automation-skills.ts @@ -1,4 +1,5 @@ import type { AppDatabase } from "../database"; +import { roleMatchesLeadershipKeywords } from "../agent-role-helpers"; const DEPARTMENT_SKILL_KEYWORDS: Record = { engineering: ["code", "engineer", "develop", "program", "software", "api", "backend", "frontend", "full-stack", "fullstack", "devops", "infrastructure", "system", "architect", "debug", "test", "ci", "cd", "deploy", "docker", "kubernetes", "database", "sql", "git", "typescript", "javascript", "python", "rust", "go", "java", "react", "node", "css", "html", "web"], @@ -52,10 +53,10 @@ function scoreSkillAgentMatch( } } - // Executives need awareness of all domains - const executiveRoles = ["ceo", "cto", "coo", "cfo", "cmo", "vp", "chief", "director", "head", "lead"]; - const roleLower = agent.role.toLowerCase(); - if (executiveRoles.some((r) => roleLower.includes(r))) { + // Executives need awareness of all domains. Use the shared leadership + // keyword list with word-boundary matching so "lead" does not match + // "cleaner" / "leaderboard" in agent role strings. + if (roleMatchesLeadershipKeywords(agent.role, agent.title)) { score += 1; } diff --git a/src/main/database.ts b/src/main/database.ts index 7490a4cc..9dcf6bb6 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -93,9 +93,9 @@ export class AppDatabase extends AppDatabaseBase { getDocumentLatestRevisionId(documentId: string) { return this.documentsMod.getDocumentLatestRevisionId(documentId); } listDocumentRevisions(documentId: string, companyId: string) { return this.documentsMod.listDocumentRevisions(documentId, companyId); } createWorkProduct(input: { companyId: string; taskId: string; projectId?: string | null; type: string; provider: string; externalId?: string | null; title: string; url?: string | null; status?: string; reviewState?: string; isPrimary?: boolean; summary?: string | null; metadataJson?: string | null; createdByRunId?: string | null }) { return this.documentsMod.createWorkProduct(input); } - updateWorkProduct(id: string, companyId: string, patch: Record) { this.documentsMod.updateWorkProduct(id, companyId, patch); } + updateWorkProduct(id: string, companyId: string, patch: Parameters["updateWorkProduct"]>[2]) { return this.documentsMod.updateWorkProduct(id, companyId, patch); } listWorkProductsForTask(taskId: string, companyId: string) { return this.documentsMod.listWorkProductsForTask(taskId, companyId); } - deleteWorkProduct(id: string, companyId: string) { this.documentsMod.deleteWorkProduct(id, companyId); } + deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, companyId); } linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); } unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); } listDocumentsForTask(taskId: string, companyId: string) { return this.documentsMod.listDocumentsForTask(taskId, companyId); } diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index dbb028f8..e0e53c2e 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -211,20 +211,48 @@ export function buildDocumentMethods( return id; }, - updateWorkProduct(id: string, companyId: string, patch: Record): void { + updateWorkProduct( + id: string, + companyId: string, + patch: { + type?: string; + provider?: string; + externalId?: string | null; + title?: string; + url?: string | null; + status?: string; + reviewState?: string; + isPrimary?: boolean; + summary?: string | null; + metadataJson?: string | null; + }, + ): number { const ts = nowIso(); const sets: string[] = ["updated_at = ?"]; - const values: unknown[] = [ts]; - const ALLOWED = new Set(["type", "provider", "external_id", "title", "url", "status", "review_state", "is_primary", "summary", "metadata_json"]); - for (const [key, value] of Object.entries(patch)) { - const col = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); - if (ALLOWED.has(col)) { + const values: SQLInputValue[] = [ts]; + const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [ + ["type", "type", (v) => (v as string) ?? null], + ["provider", "provider", (v) => (v as string) ?? null], + ["externalId", "external_id", (v) => (v as string | null) ?? null], + ["title", "title", (v) => (v as string) ?? null], + ["url", "url", (v) => (v as string | null) ?? null], + ["status", "status", (v) => (v as string) ?? null], + ["reviewState", "review_state", (v) => (v as string) ?? null], + ["isPrimary", "is_primary", (v) => (v ? 1 : 0)], + ["summary", "summary", (v) => (v as string | null) ?? null], + ["metadataJson", "metadata_json", (v) => (v as string | null) ?? null], + ]; + for (const [key, col, coerce] of map) { + if (key in patch) { sets.push(`${col} = ?`); - values.push(col === "is_primary" ? (value ? 1 : 0) : value ?? null); + values.push(coerce(patch[key])); } } values.push(id, companyId); - getDb().prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`).run(...(values as SQLInputValue[])); + const result = getDb() + .prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`) + .run(...values); + return Number(result.changes ?? 0); }, listWorkProductsForTask(taskId: string, companyId: string) { @@ -247,8 +275,11 @@ export function buildDocumentMethods( })); }, - deleteWorkProduct(id: string, companyId: string): void { - getDb().prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?").run(id, companyId); + deleteWorkProduct(id: string, companyId: string): number { + const result = getDb() + .prepare("DELETE FROM work_products WHERE id = ? AND company_id = ?") + .run(id, companyId); + return Number(result.changes ?? 0); }, // ── Task-Document Links ── diff --git a/src/main/prompt-builder/system-prompt.ts b/src/main/prompt-builder/system-prompt.ts index 13682956..b22d7a48 100644 --- a/src/main/prompt-builder/system-prompt.ts +++ b/src/main/prompt-builder/system-prompt.ts @@ -19,27 +19,15 @@ import { buildDepartmentSection, } from "./operating-instructions"; import type { PromptContext } from "./index"; - -const LEADERSHIP_KEYWORDS = [ - "chief", - "cto", - "cmo", - "coo", - "cfo", - "vp", - "vice president", - "head", - "director", - "manager", - "lead", -]; +import { roleMatchesLeadershipKeywords } from "../agent-role-helpers"; function isLeadershipRole(ctx: PromptContext): boolean { + // HR roles inherit leadership scope for manager-style prompts; + // other departments rely on role/title keyword tokenization. if (ctx.agent.department === "hr") { return true; } - const text = `${ctx.agent.role} ${ctx.agent.title ?? ""}`.toLowerCase(); - return LEADERSHIP_KEYWORDS.some((keyword) => text.includes(keyword)); + return roleMatchesLeadershipKeywords(ctx.agent.role, ctx.agent.title ?? null); } function buildSkillManifest(