CEO can answer progress and rewrite goals from live data
Two c_suite tools in agent-goal-tools: company_status reports every goal with real-work task counts (wake tasks and review checkpoints stay out), work in flight, recent completions, and team headcount, so progress answers come from the board instead of the model's memory; update_goal rewrites an active goal's title/description when the founder redirects the company, audited, CEO-or-owner gated. Both route through the sync dispatcher; complete_goal now shares the same permission helper.
This commit is contained in:
1 parent
62ce1570cd
commit
4a7fac654e
3 files changed
+277
-6
No files matched your search
@@ -1,13 +1,36 @@
|
||||
import { AuditService } from "./audit-service";
|
||||
import { executeWorkspaceTool, WORKSPACE_TOOLS } from "./agent-workspace-tools";
|
||||
import { requireString } from "./agent-tool-args";
|
||||
import { optionalString, requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
import type { ProviderService } from "./provider-service";
|
||||
import type { AgentTurnMessage, ToolCall, ToolDefinition } from "./provider-tool-turn";
|
||||
|
||||
const AUDIT_MAX_ITERATIONS = 6;
|
||||
const STATUS_TASK_LINES = 8;
|
||||
|
||||
export const GOAL_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: "company_status",
|
||||
description:
|
||||
"Company-wide progress snapshot: every goal with its task counts, work in flight, recent completions, and team headcount. Officers only. Pull this before reporting progress to the founder so you answer from live data, and use its goal_id values with update_goal.",
|
||||
parameters: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
{
|
||||
name: "update_goal",
|
||||
description:
|
||||
"Rewrite an active goal's title and/or description when the founder redirects the company — revised direction, scope, tech stack, or market focus. CEO or the goal's owner only. Goal ids come from company_status.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
goal_id: { type: "string", description: "The goal to change." },
|
||||
title: { type: "string", description: "The new goal title. Omit to keep the current one." },
|
||||
description: { type: "string", description: "The new goal description. Omit to keep the current one." },
|
||||
},
|
||||
required: ["goal_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete_goal",
|
||||
description: "Ask to close the goal your current task belongs to as achieved. CEO or the goal's owner only. An independent auditor inspects the workspace before the goal actually closes — scaffolds, plans, and documents about the work do not pass; working results do.",
|
||||
@@ -48,9 +71,7 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise<string
|
||||
const goal = deps.db.prepare("select id, title, description, status, owner_agent_id from goals where id = ? and org_id = ?")
|
||||
.get(ctx.taskGoalId, ctx.orgId) as { id: string; title: string; description: string; status: string; owner_agent_id: string | null } | undefined;
|
||||
if (!goal) throw new Error("Goal was not found.");
|
||||
const acting = deps.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||
.get(ctx.agent.id, ctx.orgId) as { role_level: string } | undefined;
|
||||
if (acting?.role_level !== "c_suite" && ctx.agent.id !== goal.owner_agent_id) {
|
||||
if (!actorIsCeoOrGoalOwner(deps, ctx, goal.owner_agent_id)) {
|
||||
return "ERROR: Only the CEO or the goal's owner can complete a goal. Report your evidence to them instead.";
|
||||
}
|
||||
if (goal.status !== "active") return `Goal "${goal.title}" is already ${goal.status}.`;
|
||||
@@ -132,3 +153,135 @@ function parseVerdict(text: string): AuditVerdict {
|
||||
if (/<approved\s*\/>/i.test(text)) return { approved: true, reason: reason || "no gaps found" };
|
||||
return { approved: false, reason: reason || "the auditor did not state an explicit approval" };
|
||||
}
|
||||
|
||||
function actorIsCeoOrGoalOwner(deps: AgentToolDeps, ctx: AgentToolContext, ownerAgentId: string | null): boolean {
|
||||
const acting = deps.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||
.get(ctx.agent.id, ctx.orgId) as { role_level: string } | undefined;
|
||||
return acting?.role_level === "c_suite" || ctx.agent.id === ownerAgentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction changes are first-class: when the founder redirects the company in
|
||||
* chat, the CEO rewrites the goal itself instead of narrating the change into
|
||||
* a message nobody executes. Tasks keep pointing at the same goal id, so work
|
||||
* in flight follows the new wording.
|
||||
*/
|
||||
export function updateGoalTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const goalId = requireString(args.goal_id, "goal_id");
|
||||
const title = optionalString(args.title);
|
||||
const description = optionalString(args.description);
|
||||
if (!title && !description) throw new Error("Provide a new title and/or description — nothing to change.");
|
||||
const goal = deps.db.prepare("select id, title, description, status, owner_agent_id from goals where id = ? and org_id = ?")
|
||||
.get(goalId, ctx.orgId) as { id: string; title: string; description: string; status: string; owner_agent_id: string | null } | undefined;
|
||||
if (!goal) throw new Error(`Goal ${goalId} was not found — pull company_status for current goal ids.`);
|
||||
if (!actorIsCeoOrGoalOwner(deps, ctx, goal.owner_agent_id)) {
|
||||
return "ERROR: Only the CEO or the goal's owner can update a goal. Bring the request to them instead.";
|
||||
}
|
||||
if (goal.status !== "active") return `Goal "${goal.title}" is ${goal.status}; only active goals can be updated.`;
|
||||
const nextTitle = title ?? goal.title;
|
||||
const nextDescription = description ?? goal.description;
|
||||
deps.db.prepare("update goals set title = ?, description = ?, updated_at = ? where id = ? and org_id = ?")
|
||||
.run(nextTitle, nextDescription, new Date().toISOString(), goal.id, ctx.orgId);
|
||||
new AuditService(deps.db).record({
|
||||
orgId: ctx.orgId,
|
||||
entityType: "goal",
|
||||
entityId: goal.id,
|
||||
actorType: "agent",
|
||||
actorId: ctx.agent.id,
|
||||
action: "updated",
|
||||
payload: { previousTitle: goal.title, title: nextTitle, descriptionChanged: description !== null },
|
||||
});
|
||||
const renamed = nextTitle !== goal.title ? ` (was "${goal.title}")` : "";
|
||||
return `Goal updated: "${nextTitle}"${renamed}. Tell the team what changed and re-point their tasks if the new direction demands it.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The founder asks "how are we doing" in chat; the answer must come from the
|
||||
* live board, not the model's memory of it. Wake tasks and goal-review
|
||||
* checkpoints (tasks.origin != null) are bookkeeping, not product — they stay
|
||||
* out of every count here.
|
||||
*/
|
||||
export function companyStatusTool(deps: AgentToolDeps, ctx: AgentToolContext): string {
|
||||
const acting = deps.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||
.get(ctx.agent.id, ctx.orgId) as { role_level: string } | undefined;
|
||||
if (acting?.role_level !== "c_suite") {
|
||||
return "ERROR: Only company officers can pull the company-wide status. Ask the CEO for a progress report instead.";
|
||||
}
|
||||
|
||||
const goals = deps.db.prepare(`
|
||||
select g.id, g.title, g.status, a.name as owner_name
|
||||
from goals g
|
||||
left join agents a on a.id = g.owner_agent_id
|
||||
where g.org_id = ?
|
||||
order by case g.status when 'active' then 0 else 1 end, g.created_at asc
|
||||
`).all(ctx.orgId) as Array<{ id: string; title: string; status: string; owner_name: string | null }>;
|
||||
const perGoal = new Map<string, Map<string, number>>();
|
||||
const rows = deps.db.prepare(`
|
||||
select goal_id, status, count(*) as count
|
||||
from tasks
|
||||
where org_id = ? and origin is null and goal_id is not null
|
||||
group by goal_id, status
|
||||
`).all(ctx.orgId) as Array<{ goal_id: string; status: string; count: number }>;
|
||||
for (const row of rows) {
|
||||
const byStatus = perGoal.get(row.goal_id) ?? new Map<string, number>();
|
||||
byStatus.set(row.status, row.count);
|
||||
perGoal.set(row.goal_id, byStatus);
|
||||
}
|
||||
|
||||
const totals = new Map<string, number>();
|
||||
for (const row of deps.db.prepare("select status, count(*) as count from tasks where org_id = ? and origin is null group by status")
|
||||
.all(ctx.orgId) as Array<{ status: string; count: number }>) {
|
||||
totals.set(row.status, row.count);
|
||||
}
|
||||
const inFlight = deps.db.prepare(`
|
||||
select t.title, t.status, a.name as assignee_name
|
||||
from tasks t
|
||||
left join agents a on a.id = t.assignee_agent_id
|
||||
where t.org_id = ? and t.origin is null and t.status in ('in_progress', 'review')
|
||||
order by t.updated_at desc
|
||||
limit ${STATUS_TASK_LINES}
|
||||
`).all(ctx.orgId) as Array<{ title: string; status: string; assignee_name: string | null }>;
|
||||
const recentlyDone = deps.db.prepare(`
|
||||
select t.title, t.updated_at, a.name as assignee_name
|
||||
from tasks t
|
||||
left join agents a on a.id = t.assignee_agent_id
|
||||
where t.org_id = ? and t.origin is null and t.status = 'done'
|
||||
order by t.updated_at desc
|
||||
limit ${STATUS_TASK_LINES}
|
||||
`).all(ctx.orgId) as Array<{ title: string; updated_at: string; assignee_name: string | null }>;
|
||||
const team = deps.db.prepare(`
|
||||
select department, count(*) as count
|
||||
from agents
|
||||
where org_id = ? and lifecycle = 'active'
|
||||
group by department
|
||||
order by count desc, department asc
|
||||
`).all(ctx.orgId) as Array<{ department: string; count: number }>;
|
||||
|
||||
const count = (status: string) => totals.get(status) ?? 0;
|
||||
const taskTotal = [...totals.values()].reduce((sum, value) => sum + value, 0);
|
||||
const lines: string[] = [`Company status — ${new Date().toISOString().slice(0, 10)}`, "", "Goals:"];
|
||||
if (goals.length === 0) lines.push("- none yet");
|
||||
for (const goal of goals) {
|
||||
const byStatus = perGoal.get(goal.id);
|
||||
const goalCount = (status: string) => byStatus?.get(status) ?? 0;
|
||||
lines.push(
|
||||
`- [${goal.status}] "${goal.title}" (goal_id=${goal.id}, owner: ${goal.owner_name ?? "unassigned"}) — ` +
|
||||
`${goalCount("backlog")} backlog, ${goalCount("in_progress") + goalCount("review")} in flight, ${goalCount("done")} done`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
`Real work tasks: ${taskTotal} total — ${count("backlog")} backlog, ${count("in_progress")} in progress, ${count("review")} in review, ${count("done")} done`,
|
||||
);
|
||||
if (inFlight.length > 0) {
|
||||
lines.push("In flight:");
|
||||
for (const task of inFlight) lines.push(`- "${task.title}" — ${task.status}, ${task.assignee_name ?? "unassigned"}`);
|
||||
}
|
||||
if (recentlyDone.length > 0) {
|
||||
lines.push(`Recently completed (latest ${recentlyDone.length}):`);
|
||||
for (const task of recentlyDone) lines.push(`- "${task.title}" — done ${task.updated_at.slice(0, 10)}, ${task.assignee_name ?? "unassigned"}`);
|
||||
}
|
||||
const teamTotal = team.reduce((sum, row) => sum + row.count, 0);
|
||||
lines.push("", `Team: ${teamTotal} active agents (${team.map((row) => `${row.department} ${row.count}`).join(", ") || "none"})`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { readMessagesTool } from "./agent-read-messages";
|
||||
import { grantConnectorTool, listConnectorsTool } from "./agent-connector-tools";
|
||||
import { bindSkillSlugs, grantSkillTool, listSkillsTool } from "./agent-skill-tools";
|
||||
import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools";
|
||||
import { GOAL_TOOLS } from "./agent-goal-tools";
|
||||
import { companyStatusTool, GOAL_TOOLS, updateGoalTool } from "./agent-goal-tools";
|
||||
import { describeSend } from "./agent-message-presence";
|
||||
import { optionalString, optionalStringArray, requireString } from "./agent-tool-args";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
@@ -248,6 +248,10 @@ export function dispatchAgentTool(
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "grant");
|
||||
case "revoke_connector":
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "revoke");
|
||||
case "company_status":
|
||||
return companyStatusTool(deps, ctx);
|
||||
case "update_goal":
|
||||
return updateGoalTool(deps, ctx, call.arguments);
|
||||
case "list_skills":
|
||||
return listSkillsTool(deps, ctx);
|
||||
case "grant_skill":
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { completeGoalTool, type GoalCompletionArgs } from "../../src/main/services/agent-goal-tools";
|
||||
import type { AgentToolContext, AgentToolDeps } from "../../src/main/services/agent-tools";
|
||||
import { dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "../../src/main/services/agent-tools";
|
||||
import type { ProviderService } from "../../src/main/services/provider-service";
|
||||
import type { ToolCall } from "../../src/main/services/provider-tool-turn";
|
||||
|
||||
@@ -152,3 +152,117 @@ describe("complete_goal with independent audit", () => {
|
||||
h.connection.close();
|
||||
});
|
||||
});
|
||||
|
||||
function toolCtx(h: Harness, agentId: string): AgentToolContext {
|
||||
return {
|
||||
orgId: h.orgId,
|
||||
agent: { id: agentId, modelProviderId: h.providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
workspace: null,
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(h: Harness, agentId: string, name: string, args: Record<string, unknown>): string {
|
||||
return dispatchAgentTool(h.deps, toolCtx(h, agentId), { id: `call_${randomUUID()}`, name, arguments: args });
|
||||
}
|
||||
|
||||
describe("update_goal", () => {
|
||||
test("the CEO rewrites an active goal and the change is audited", () => {
|
||||
const h = createHarness();
|
||||
const output = dispatch(h, h.ceoId, "update_goal", {
|
||||
goal_id: h.goalId,
|
||||
title: "Ship the SMB dashboard",
|
||||
description: "Pivot from consumer to SMB analytics.",
|
||||
});
|
||||
expect(output).toContain(`Goal updated: "Ship the SMB dashboard"`);
|
||||
expect(output).toContain(`(was "Ship it")`);
|
||||
|
||||
const goal = h.connection.db.prepare("select title, description, status from goals where id = ?").get(h.goalId) as
|
||||
{ title: string; description: string; status: string };
|
||||
expect(goal).toEqual({ title: "Ship the SMB dashboard", description: "Pivot from consumer to SMB analytics.", status: "active" });
|
||||
|
||||
const audit = h.connection.db.prepare(
|
||||
"select count(*) as count from audit_events where org_id = ? and entity_type = 'goal' and entity_id = ? and action = 'updated'",
|
||||
).get(h.orgId, h.goalId) as { count: number };
|
||||
expect(audit.count).toBe(1);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a description-only change keeps the title", () => {
|
||||
const h = createHarness();
|
||||
dispatch(h, h.ceoId, "update_goal", { goal_id: h.goalId, description: "Same goal, sharper scope." });
|
||||
const goal = h.connection.db.prepare("select title, description from goals where id = ?").get(h.goalId) as
|
||||
{ title: string; description: string };
|
||||
expect(goal).toEqual({ title: "Ship it", description: "Same goal, sharper scope." });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("staff who do not own the goal are refused and nothing changes", () => {
|
||||
const h = createHarness();
|
||||
const output = dispatch(h, h.staffId, "update_goal", { goal_id: h.goalId, title: "Hijacked" });
|
||||
expect(output).toContain("ERROR");
|
||||
const goal = h.connection.db.prepare("select title from goals where id = ?").get(h.goalId) as { title: string };
|
||||
expect(goal.title).toBe("Ship it");
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("an unknown goal id and an empty change are both loud errors", () => {
|
||||
const h = createHarness();
|
||||
expect(dispatch(h, h.ceoId, "update_goal", { goal_id: "gol_missing", title: "x" })).toContain("company_status");
|
||||
expect(dispatch(h, h.ceoId, "update_goal", { goal_id: h.goalId })).toContain("ERROR");
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a non-active goal reports its state instead of changing", () => {
|
||||
const h = createHarness();
|
||||
h.connection.db.prepare("update goals set status = 'achieved' where id = ?").run(h.goalId);
|
||||
const output = dispatch(h, h.ceoId, "update_goal", { goal_id: h.goalId, title: "Too late" });
|
||||
expect(output).toContain("achieved");
|
||||
const goal = h.connection.db.prepare("select title from goals where id = ?").get(h.goalId) as { title: string };
|
||||
expect(goal.title).toBe("Ship it");
|
||||
h.connection.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("company_status", () => {
|
||||
test("reports goals with real-work task counts and hides wake-task bookkeeping", () => {
|
||||
const h = createHarness();
|
||||
h.connection.transaction(() => {
|
||||
h.deps.organization.createTask({
|
||||
orgId: h.orgId, title: "Build the ingestion pipeline", description: "d", status: "in_progress",
|
||||
assigneeAgentId: h.staffId, risk: "medium", projectId: null, goalId: h.goalId,
|
||||
workspaceId: null, priority: "high", requiresFilesystem: false,
|
||||
});
|
||||
h.deps.organization.createTask({
|
||||
orgId: h.orgId, title: "Write the launch post", description: "d", status: "done",
|
||||
assigneeAgentId: h.staffId, risk: "low", projectId: null, goalId: h.goalId,
|
||||
workspaceId: null, priority: "normal", requiresFilesystem: false,
|
||||
});
|
||||
h.deps.organization.createTask({
|
||||
orgId: h.orgId, title: "Reply to CEO", description: "d", status: "backlog",
|
||||
assigneeAgentId: h.staffId, risk: "low", projectId: null, goalId: null,
|
||||
workspaceId: null, priority: "high", requiresFilesystem: false, origin: "chat_wake",
|
||||
});
|
||||
});
|
||||
|
||||
// createGoal materializes a kickoff task, so the goal starts with one
|
||||
// backlog item of real work before the two tasks this test adds.
|
||||
const output = dispatch(h, h.ceoId, "company_status", {});
|
||||
expect(output).toContain(`[active] "Ship it" (goal_id=${h.goalId}, owner: CEO) — 1 backlog, 1 in flight, 1 done`);
|
||||
expect(output).toContain("Real work tasks: 3 total — 1 backlog, 1 in progress, 0 in review, 1 done");
|
||||
expect(output).toContain(`"Build the ingestion pipeline" — in_progress, Dev`);
|
||||
expect(output).toContain(`"Write the launch post"`);
|
||||
expect(output).not.toContain("Reply to CEO");
|
||||
expect(output).toContain("Team: 2 active agents");
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("only officers can pull the company-wide status", () => {
|
||||
const h = createHarness();
|
||||
const output = dispatch(h, h.staffId, "company_status", {});
|
||||
expect(output).toContain("ERROR");
|
||||
expect(output).toContain("Ask the CEO");
|
||||
h.connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user