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.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-16 22:57:35 -04:00
1 parent ba6ba8360b
commit 2df921ca88
6 files changed
+89 -34

No files matched your search

+33
View File
@@ -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));
}
+4 -2
View File
@@ -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 });
});
+5 -4
View File
@@ -1,4 +1,5 @@
import type { AppDatabase } from "../database";
import { roleMatchesLeadershipKeywords } from "../agent-role-helpers";
const DEPARTMENT_SKILL_KEYWORDS: Record<string, string[]> = {
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;
}
+2 -2
View File
@@ -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<string, unknown>) { this.documentsMod.updateWorkProduct(id, companyId, patch); }
updateWorkProduct(id: string, companyId: string, patch: Parameters<ReturnType<typeof import("./database/documents").buildDocumentMethods>["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); }
+41 -10
View File
@@ -211,20 +211,48 @@ export function buildDocumentMethods(
return id;
},
updateWorkProduct(id: string, companyId: string, patch: Record<string, unknown>): 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 ──
+4 -16
View File
@@ -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(