diff --git a/src/main/services/org-governance-service.ts b/src/main/services/org-governance-service.ts index d065d614..a572ec43 100644 --- a/src/main/services/org-governance-service.ts +++ b/src/main/services/org-governance-service.ts @@ -62,6 +62,20 @@ export class OrgGovernanceService { return { reviewRequired: input.budgetCents > policy.require_approval_above_cents, departmentName }; } + /** c_suite is the CEO's rank and carries company-wide powers: one per company, and no edit grants or removes it. */ + assertRank(orgId: string, agentId: string | null, nextLevel: string): void { + const current = agentId + ? (this.db.prepare("select role_level from agents where id = ? and org_id = ?").get(agentId, orgId) as { role_level: string } | undefined)?.role_level ?? null + : null; + if (current === "c_suite" && nextLevel !== "c_suite") { + throw new Error("The CEO's rank carries company-wide powers (hiring officers, granting connectors and skills, closing goals) and cannot be changed by an edit."); + } + if (nextLevel !== "c_suite" || current === "c_suite") return; + if (agentId) throw new Error("The CEO's rank carries company-wide powers and cannot be given by an edit; officers are directors with a title."); + const ceo = this.db.prepare("select 1 from agents where org_id = ? and role_level = 'c_suite' and lifecycle in ('active', 'pending_hire')").get(orgId); + if (ceo) throw new Error("The company already has its CEO; officers are directors with a title."); + } + /** An existing agent moving department or changing budget meets the same limits a hire does. */ enforceStaffingChange(input: { orgId: string; agentId: string; departmentName: string; budgetCents: number }): void { const policy = this.db.prepare("select max_headcount, monthly_budget_limit_cents from hiring_policies where org_id = ? and department_name = ?") diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 93d8b461..8bb41525 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -95,6 +95,7 @@ export class OrganizationService { createAgent(input: CreateAgentInput): string { assertUsableMentionName(input.name); this.assertAgentNameAvailable(input.orgId, input.name, null); + this.governance.assertRank(input.orgId, null, input.roleLevel ?? "staff"); const hiringPolicy = this.governance.enforceHiringPolicy(input); const id = createId("agt"); const now = new Date().toISOString(); @@ -184,6 +185,7 @@ export class OrganizationService { .get(input.modelProviderId, input.orgId) as { id: string } | undefined; if (!provider) throw new Error("Agent provider was not found."); } + this.governance.assertRank(input.orgId, input.agentId, input.roleLevel.trim() || "staff"); const department = this.governance.requireCanonicalDepartment(input.orgId, input.department); const budgetCents = Math.max(0, Math.round(input.budgetCents)); this.governance.enforceStaffingChange({ orgId: input.orgId, agentId: input.agentId, departmentName: department, budgetCents }); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index c1e21bfb..36e8b4a4 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -391,6 +391,7 @@ export const zh: Record = { "Changed since approval": "批准后已改动", "Removed since approval": "批准后已删除", "Goal audit": "目标审查", + "CEO: hires officers, grants connectors and skills, and closes goals. This rank cannot be changed.": "CEO:招聘高管、授予连接器和技能、关闭目标。此职级不可更改。", "An independent model checks every goal before it closes. Pick one your agents do not use, ideally from another provider.": "每个目标关闭前都由一个独立模型检查。请选择 Agent 不使用的模型,最好来自另一家服务商。", "No auditor is set, so goals cannot be closed yet.": "尚未设置审查模型,目标暂时无法关闭。", "Choose a provider": "选择服务商", diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index 382ef095..8515ea81 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -162,7 +162,6 @@ export function TeamPage({ snapshot, actions }: PageProps) { - @@ -185,12 +184,19 @@ export function TeamPage({ snapshot, actions }: PageProps) { - + {/* The CEO's rank carries company-wide powers, so it is shown, never picked. */} + {editingAgent.roleLevel === "c_suite" ? ( + <> + +
{t("CEO: hires officers, grants connectors and skills, and closes goals. This rank cannot be changed.")}
+ + ) : ( + + )}
)} diff --git a/tests/unit/staffing-limits.test.ts b/tests/unit/staffing-limits.test.ts index daea6ab6..977a3c17 100644 --- a/tests/unit/staffing-limits.test.ts +++ b/tests/unit/staffing-limits.test.ts @@ -37,4 +37,30 @@ describe("staffing limits on edits", () => { expect(() => edit("Design", 1000)).toThrow("Design is at its headcount limit (1)."); connection.close(); }); + + test("the CEO's rank is one per company and no edit grants or removes it", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-rank-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + }); + const hire = (name: string, roleLevel: string) => organization.createAgent({ + orgId, name, role: name, department: "Executive", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, roleLevel, + }); + const ceo = hire("Chief", "c_suite"); + expect(() => hire("Rival", "c_suite")).toThrow("The company already has its CEO; officers are directors with a title."); + const cto = hire("Cora", "director"); + const edit = (agentId: string, roleLevel: string) => organization.updateAgent({ + orgId, agentId, role: "Exec", title: "Exec", roleLevel, department: "Executive", budgetCents: 1000, modelProviderId: null, modelId: null, + }); + expect(() => edit(cto, "c_suite")).toThrow("The CEO's rank carries company-wide powers and cannot be given by an edit; officers are directors with a title."); + expect(() => edit(ceo, "director")).toThrow("The CEO's rank carries company-wide powers (hiring officers, granting connectors and skills, closing goals) and cannot be changed by an edit."); + edit(ceo, "c_suite"); + edit(cto, "lead"); + expect(connection.db.prepare("select name, role_level from agents where org_id = ? order by name").all(orgId)).toEqual([ + { name: "Chief", role_level: "c_suite" }, { name: "Cora", role_level: "lead" }, + ]); + connection.close(); + }); });