Skip to content

Commit fa3e5db

Browse files
Aegisclaude
authored andcommitted
feat: business_unit partitioning for agenda, goals, cc_tasks (#20)
Adds a per-BU partition key so operators running multiple verticals (e.g. Stackbilt + FoodFiles) can scope agenda views, goal runs, and task queues without forking AEGIS into separate instances. Backwards-compatible additive change: - New column business_unit TEXT NOT NULL DEFAULT 'stackbilt' on agent_agenda, agent_goals, cc_tasks - New indexes: idx_agenda_bu, idx_goals_bu, idx_cc_tasks_bu - All existing rows + callers default to 'stackbilt' — no breakage API surface: - addAgendaItem, addGoal: trailing optional businessUnit parameter - getActiveAgendaItems, getActiveGoals: optional businessUnit filter - MCP tools (aegis_agenda, aegis_list_goals, aegis_list_cc_tasks, aegis_add_agenda, aegis_add_goal, aegis_create_cc_task): accept optional business_unit arg - REST GET /api/cc-tasks: accepts ?business_unit=X query param - REST POST /api/cc-tasks: accepts business_unit in body - Agenda dedup check now scopes candidate matching to same BU Tests: - New tests/business-unit.test.ts: 10 tests covering default + custom BU, filtered queries, dedup scoping, DEFAULT_BUSINESS_UNIT export - Updated tests/mcp.test.ts: 6 handler tests assert new call signatures Prod D1 migration applied (203 agenda + 15 goals + 562 cc_tasks rows backfilled to 'stackbilt', 3 new indexes). Closes #20. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 39d85c3 commit fa3e5db

9 files changed

Lines changed: 293 additions & 58 deletions

File tree

web/schema.sql

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ CREATE TABLE IF NOT EXISTS agent_agenda (
102102
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
103103
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'done', 'dismissed')),
104104
created_at TEXT NOT NULL DEFAULT (datetime('now')),
105-
resolved_at TEXT
105+
resolved_at TEXT,
106+
business_unit TEXT NOT NULL DEFAULT 'stackbilt' -- partition key for multi-BU operators
106107
);
107108

108109
-- ─── Autonomous Goals (#14) ────────────────────────────────────
@@ -119,7 +120,8 @@ CREATE TABLE IF NOT EXISTS agent_goals (
119120
next_run_at TEXT,
120121
completed_at TEXT,
121122
run_count INTEGER NOT NULL DEFAULT 0,
122-
context_json TEXT
123+
context_json TEXT,
124+
business_unit TEXT NOT NULL DEFAULT 'stackbilt' -- partition key for multi-BU operators
123125
);
124126

125127
CREATE TABLE IF NOT EXISTS agent_actions (
@@ -179,8 +181,10 @@ CREATE INDEX IF NOT EXISTS idx_procedural_status ON procedural_memory(status);
179181
CREATE INDEX IF NOT EXISTS idx_heartbeat_created ON heartbeat_results(created_at);
180182
CREATE INDEX IF NOT EXISTS idx_agenda_status ON agent_agenda(status);
181183
CREATE INDEX IF NOT EXISTS idx_agenda_priority ON agent_agenda(priority);
184+
CREATE INDEX IF NOT EXISTS idx_agenda_bu ON agent_agenda(business_unit, status);
182185
CREATE INDEX IF NOT EXISTS idx_goals_status ON agent_goals(status);
183186
CREATE INDEX IF NOT EXISTS idx_goals_next_run ON agent_goals(next_run_at);
187+
CREATE INDEX IF NOT EXISTS idx_goals_bu ON agent_goals(business_unit, status);
184188
CREATE INDEX IF NOT EXISTS idx_actions_goal ON agent_actions(goal_id);
185189
CREATE INDEX IF NOT EXISTS idx_actions_created ON agent_actions(created_at);
186190

@@ -291,12 +295,14 @@ CREATE TABLE IF NOT EXISTS cc_tasks (
291295
pr_url TEXT, -- GitHub PR URL if one was created
292296
utility_json TEXT, -- PR utility scoring: {impact, novelty, signals[]}
293297
github_issue_repo TEXT, -- source issue repo (e.g. 'my-org/aegis')
294-
github_issue_number INTEGER -- source issue number (repo-scoped)
298+
github_issue_number INTEGER, -- source issue number (repo-scoped)
299+
business_unit TEXT NOT NULL DEFAULT 'stackbilt' -- partition key for multi-BU operators
295300
);
296301

297302
CREATE INDEX IF NOT EXISTS idx_cc_tasks_status ON cc_tasks(status, priority);
298303
CREATE INDEX IF NOT EXISTS idx_cc_tasks_depends ON cc_tasks(depends_on);
299304
CREATE INDEX IF NOT EXISTS idx_cc_tasks_created ON cc_tasks(created_at);
305+
CREATE INDEX IF NOT EXISTS idx_cc_tasks_bu ON cc_tasks(business_unit, status);
300306
CREATE INDEX IF NOT EXISTS idx_cc_tasks_authority ON cc_tasks(authority);
301307
CREATE INDEX IF NOT EXISTS idx_cc_tasks_gh_issue ON cc_tasks(github_issue_repo, github_issue_number);
302308
CREATE INDEX IF NOT EXISTS idx_cc_tasks_failure_kind ON cc_tasks(failure_kind, completed_at);

web/src/kernel/memory/agenda.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ export interface AgendaItem {
2727
status: AgendaStatus;
2828
created_at: string;
2929
resolved_at: string | null;
30+
business_unit: string;
3031
}
3132

33+
export const DEFAULT_BUSINESS_UNIT = 'stackbilt';
34+
3235
// ─── Heartbeat History (#6) ──────────────────────────────────
3336

3437
export interface HeartbeatResult {
@@ -47,10 +50,17 @@ export async function getRecentHeartbeats(db: D1Database, limit = 5): Promise<He
4750
return result.results as unknown as HeartbeatResult[];
4851
}
4952

50-
export async function getActiveAgendaItems(db: D1Database): Promise<AgendaItem[]> {
51-
const result = await db.prepare(
52-
"SELECT * FROM agent_agenda WHERE status = 'active' ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at ASC"
53-
).all();
53+
export async function getActiveAgendaItems(
54+
db: D1Database,
55+
businessUnit?: string,
56+
): Promise<AgendaItem[]> {
57+
const sql = businessUnit
58+
? "SELECT * FROM agent_agenda WHERE status = 'active' AND business_unit = ? ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at ASC"
59+
: "SELECT * FROM agent_agenda WHERE status = 'active' ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at ASC";
60+
const stmt = businessUnit
61+
? db.prepare(sql).bind(businessUnit)
62+
: db.prepare(sql);
63+
const result = await stmt.all();
5464
return result.results as unknown as AgendaItem[];
5565
}
5666

@@ -70,16 +80,19 @@ export async function addAgendaItem(
7080
item: string,
7181
context: string | undefined,
7282
priority: AgendaPriority,
83+
businessUnit: string = DEFAULT_BUSINESS_UNIT,
7384
): Promise<number> {
7485
// Dedup: check for existing active OR recently resolved items with similar text (#72)
7586
// Include items resolved within 7 days to prevent zombie re-creation loops
7687
// where a compliance item is re-flagged before the upstream source is updated.
7788
const keyPhrase = extractAgendaKeyPhrase(item);
7889
const words = keyPhrase.split(' ').filter(w => w.length > 3);
7990
if (words.length > 0) {
91+
// Dedup scoped to the same business_unit — different BUs can have
92+
// legitimately overlapping item text without being duplicates.
8093
const candidates = await db.prepare(
81-
"SELECT id, item, priority, status FROM agent_agenda WHERE status = 'active' OR (status IN ('done', 'dismissed') AND resolved_at >= datetime('now', '-7 days'))"
82-
).all<{ id: number; item: string; priority: string; status: string }>();
94+
"SELECT id, item, priority, status FROM agent_agenda WHERE business_unit = ? AND (status = 'active' OR (status IN ('done', 'dismissed') AND resolved_at >= datetime('now', '-7 days')))"
95+
).bind(businessUnit).all<{ id: number; item: string; priority: string; status: string }>();
8396

8497
for (const existing of candidates.results) {
8598
const existingKey = extractAgendaKeyPhrase(existing.item);
@@ -102,8 +115,8 @@ export async function addAgendaItem(
102115
}
103116

104117
const result = await db.prepare(
105-
'INSERT INTO agent_agenda (item, context, priority) VALUES (?, ?, ?)'
106-
).bind(item, context ?? null, priority).run();
118+
'INSERT INTO agent_agenda (item, context, priority, business_unit) VALUES (?, ?, ?, ?)'
119+
).bind(item, context ?? null, priority, businessUnit).run();
107120
return result.meta.last_row_id as number;
108121
}
109122

web/src/kernel/memory/goals.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ export interface AgentGoal {
1818
completed_at: string | null;
1919
run_count: number;
2020
context_json: string | null;
21+
business_unit: string;
2122
}
2223

24+
const DEFAULT_BUSINESS_UNIT = 'stackbilt';
25+
2326
export interface AgentAction {
2427
id: string;
2528
goal_id: string | null;
@@ -34,10 +37,18 @@ export interface AgentAction {
3437
created_at: string;
3538
}
3639

37-
export async function getActiveGoals(db: D1Database): Promise<AgentGoal[]> {
38-
const result = await db.prepare(
39-
"SELECT * FROM agent_goals WHERE status = 'active' ORDER BY created_at ASC"
40-
).all();
40+
export async function getActiveGoals(
41+
db: D1Database,
42+
businessUnit?: string,
43+
): Promise<AgentGoal[]> {
44+
const stmt = businessUnit
45+
? db.prepare(
46+
"SELECT * FROM agent_goals WHERE status = 'active' AND business_unit = ? ORDER BY created_at ASC"
47+
).bind(businessUnit)
48+
: db.prepare(
49+
"SELECT * FROM agent_goals WHERE status = 'active' ORDER BY created_at ASC"
50+
);
51+
const result = await stmt.all();
4152
return result.results as unknown as AgentGoal[];
4253
}
4354

@@ -46,11 +57,12 @@ export async function addGoal(
4657
title: string,
4758
description?: string,
4859
scheduleHours = 6,
60+
businessUnit: string = DEFAULT_BUSINESS_UNIT,
4961
): Promise<string> {
5062
const id = crypto.randomUUID();
5163
await db.prepare(
52-
'INSERT INTO agent_goals (id, title, description, schedule_hours) VALUES (?, ?, ?, ?)'
53-
).bind(id, title, description ?? null, scheduleHours).run();
64+
'INSERT INTO agent_goals (id, title, description, schedule_hours, business_unit) VALUES (?, ?, ?, ?, ?)'
65+
).bind(id, title, description ?? null, scheduleHours, businessUnit).run();
5466
return id;
5567
}
5668

web/src/mcp/handlers.ts

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,9 @@ export async function toolAegisConversationHistory(args: Record<string, unknown>
9494
return { content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }] };
9595
}
9696

97-
export async function toolAegisAgenda(env: EdgeEnv): Promise<ToolResult> {
98-
const items = await getActiveAgendaItems(env.db);
97+
export async function toolAegisAgenda(args: Record<string, unknown>, env: EdgeEnv): Promise<ToolResult> {
98+
const businessUnit = typeof args.business_unit === 'string' ? args.business_unit : undefined;
99+
const items = await getActiveAgendaItems(env.db, businessUnit);
99100
return { content: [{ type: 'text', text: JSON.stringify({ count: items.length, items }, null, 2) }] };
100101
}
101102

@@ -216,8 +217,11 @@ export async function toolAegisAddAgenda(args: Record<string, unknown>, env: Edg
216217
if (!item) return { content: [{ type: 'text', text: 'Error: item is required' }], isError: true };
217218
const context = args.context as string | undefined;
218219
const priority = (args.priority as 'low' | 'medium' | 'high') ?? 'medium';
219-
const id = await addAgendaItem(env.db, item, context, priority);
220-
return { content: [{ type: 'text', text: `Added agenda item #${id}: "${item}" (${priority})` }] };
220+
const businessUnit = (typeof args.business_unit === 'string' && args.business_unit.trim())
221+
? args.business_unit.trim()
222+
: 'stackbilt';
223+
const id = await addAgendaItem(env.db, item, context, priority, businessUnit);
224+
return { content: [{ type: 'text', text: `Added agenda item #${id}: "${item}" (${priority}, ${businessUnit})` }] };
221225
}
222226

223227
export async function toolAegisResolveAgenda(args: Record<string, unknown>, env: EdgeEnv): Promise<ToolResult> {
@@ -233,8 +237,11 @@ export async function toolAegisAddGoal(args: Record<string, unknown>, env: EdgeE
233237
if (!title) return { content: [{ type: 'text', text: 'Error: title is required' }], isError: true };
234238
const description = args.description as string | undefined;
235239
const scheduleHours = (args.schedule_hours as number) ?? 6;
236-
const id = await addGoal(env.db, title, description, scheduleHours);
237-
return { content: [{ type: 'text', text: `Created goal "${title}" (ID: ${id}, every ${scheduleHours}h)` }] };
240+
const businessUnit = (typeof args.business_unit === 'string' && args.business_unit.trim())
241+
? args.business_unit.trim()
242+
: 'stackbilt';
243+
const id = await addGoal(env.db, title, description, scheduleHours, businessUnit);
244+
return { content: [{ type: 'text', text: `Created goal "${title}" (ID: ${id}, every ${scheduleHours}h, ${businessUnit})` }] };
238245
}
239246

240247
export async function toolAegisUpdateGoal(args: Record<string, unknown>, env: EdgeEnv): Promise<ToolResult> {
@@ -245,8 +252,9 @@ export async function toolAegisUpdateGoal(args: Record<string, unknown>, env: Ed
245252
return { content: [{ type: 'text', text: `Goal ${id} marked as ${status}` }] };
246253
}
247254

248-
export async function toolAegisListGoals(env: EdgeEnv): Promise<ToolResult> {
249-
const goals = await getActiveGoals(env.db);
255+
export async function toolAegisListGoals(args: Record<string, unknown>, env: EdgeEnv): Promise<ToolResult> {
256+
const businessUnit = typeof args.business_unit === 'string' ? args.business_unit : undefined;
257+
const goals = await getActiveGoals(env.db, businessUnit);
250258
return { content: [{ type: 'text', text: JSON.stringify({ count: goals.length, goals }, null, 2) }] };
251259
}
252260

@@ -281,30 +289,39 @@ export async function toolAegisCreateCcTask(args: Record<string, unknown>, env:
281289

282290
const category = validateEnum(TASK_CATEGORIES, args.category, 'feature');
283291
const authority = validateEnum(TASK_AUTHORITIES, args.authority, 'operator');
292+
const businessUnit = (typeof args.business_unit === 'string' && args.business_unit.trim())
293+
? args.business_unit.trim()
294+
: 'stackbilt';
284295

285296
await env.db.prepare(`
286-
INSERT INTO cc_tasks (id, title, repo, prompt, completion_signal, priority, depends_on, blocked_by, max_turns, created_by, authority, category)
287-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'aegis', ?, ?)
288-
`).bind(id, title.trim(), repo.trim(), prompt.trim(), completionSignal, priority, dependsOn, blockedBy ? JSON.stringify(blockedBy) : null, maxTurns, authority, category).run();
297+
INSERT INTO cc_tasks (id, title, repo, prompt, completion_signal, priority, depends_on, blocked_by, max_turns, created_by, authority, category, business_unit)
298+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'aegis', ?, ?, ?)
299+
`).bind(id, title.trim(), repo.trim(), prompt.trim(), completionSignal, priority, dependsOn, blockedBy ? JSON.stringify(blockedBy) : null, maxTurns, authority, category, businessUnit).run();
289300

290-
return { content: [{ type: 'text', text: `Queued task "${title}" → ${repo} (ID: ${id}, priority: ${priority}, authority: ${authority}, category: ${category})` }] };
301+
return { content: [{ type: 'text', text: `Queued task "${title}" → ${repo} (ID: ${id}, priority: ${priority}, authority: ${authority}, category: ${category}, business_unit: ${businessUnit})` }] };
291302
}
292303

293304
export async function toolAegisListCcTasks(args: Record<string, unknown>, env: EdgeEnv): Promise<ToolResult> {
294305
const status = args.status as string | undefined;
306+
const businessUnit = typeof args.business_unit === 'string' ? args.business_unit : undefined;
295307
const limit = Math.min(Math.max(1, (args.limit as number) || 20), 50);
296308

297-
let tasks;
298-
const cols = 'id, title, repo, status, priority, authority, category, created_at, started_at, completed_at, exit_code, error, failure_kind, retryable';
309+
const cols = 'id, title, repo, status, priority, authority, category, business_unit, created_at, started_at, completed_at, exit_code, error, failure_kind, retryable';
310+
const conditions: string[] = [];
311+
const bindings: unknown[] = [];
299312
if (status) {
300-
tasks = await env.db.prepare(
301-
`SELECT ${cols} FROM cc_tasks WHERE status = ? ORDER BY created_at DESC LIMIT ?`
302-
).bind(status, limit).all();
303-
} else {
304-
tasks = await env.db.prepare(
305-
`SELECT ${cols} FROM cc_tasks ORDER BY created_at DESC LIMIT ?`
306-
).bind(limit).all();
313+
conditions.push('status = ?');
314+
bindings.push(status);
315+
}
316+
if (businessUnit) {
317+
conditions.push('business_unit = ?');
318+
bindings.push(businessUnit);
307319
}
320+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
321+
bindings.push(limit);
322+
const tasks = await env.db.prepare(
323+
`SELECT ${cols} FROM cc_tasks ${where} ORDER BY created_at DESC LIMIT ?`
324+
).bind(...bindings).all();
308325

309326
return { content: [{ type: 'text', text: JSON.stringify({ count: tasks.results.length, tasks: tasks.results }, null, 2) }] };
310327
}

web/src/mcp/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async function executeTool(name: string, args: Record<string, unknown>, env: Edg
5757
case 'aegis_chat': return toolAegisChat(args, env);
5858
case 'aegis_conversations': return toolAegisConversations(args, env);
5959
case 'aegis_conversation_history': return toolAegisConversationHistory(args, env);
60-
case 'aegis_agenda': return toolAegisAgenda(env);
60+
case 'aegis_agenda': return toolAegisAgenda(args, env);
6161
case 'aegis_health': return toolAegisHealth(env);
6262
case 'aegis_memory': return toolAegisMemory(args, env);
6363
case 'aegis_record_memory': return toolAegisRecordMemory(args, env);
@@ -66,7 +66,7 @@ async function executeTool(name: string, args: Record<string, unknown>, env: Edg
6666
case 'aegis_resolve_agenda': return toolAegisResolveAgenda(args, env);
6767
case 'aegis_add_goal': return toolAegisAddGoal(args, env);
6868
case 'aegis_update_goal': return toolAegisUpdateGoal(args, env);
69-
case 'aegis_list_goals': return toolAegisListGoals(env);
69+
case 'aegis_list_goals': return toolAegisListGoals(args, env);
7070
case 'aegis_cc_sessions': return toolAegisCcSessions(args, env);
7171
case 'aegis_create_cc_task': return toolAegisCreateCcTask(args, env);
7272
case 'aegis_list_cc_tasks': return toolAegisListCcTasks(args, env);

web/src/mcp/tools.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ export const TOOLS = [
4545
description: 'List active AEGIS agenda items including proposed actions awaiting approval.',
4646
inputSchema: {
4747
type: 'object',
48-
properties: {},
48+
properties: {
49+
business_unit: { type: 'string', description: 'Filter to a single business unit (e.g. "stackbilt", "foodfiles"). Omit for all.' },
50+
},
4951
},
5052
},
5153
{
@@ -103,6 +105,7 @@ export const TOOLS = [
103105
item: { type: 'string', description: 'The action item — concise and actionable' },
104106
context: { type: 'string', description: 'Brief context: why this was added' },
105107
priority: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Priority level (default: medium)' },
108+
business_unit: { type: 'string', description: 'Business unit this item belongs to (e.g. "stackbilt", "foodfiles"). Default: "stackbilt".' },
106109
},
107110
required: ['item'],
108111
},
@@ -128,6 +131,7 @@ export const TOOLS = [
128131
title: { type: 'string', description: 'Short goal title' },
129132
description: { type: 'string', description: 'What to check and what to do if action is needed' },
130133
schedule_hours: { type: 'number', description: 'How often to evaluate in hours (default: 6)' },
134+
business_unit: { type: 'string', description: 'Business unit this goal belongs to (e.g. "stackbilt", "foodfiles"). Default: "stackbilt".' },
131135
},
132136
required: ['title'],
133137
},
@@ -149,7 +153,9 @@ export const TOOLS = [
149153
description: 'List all active AEGIS autonomous goals with schedule and run count.',
150154
inputSchema: {
151155
type: 'object',
152-
properties: {},
156+
properties: {
157+
business_unit: { type: 'string', description: 'Filter to a single business unit (e.g. "stackbilt", "foodfiles"). Omit for all.' },
158+
},
153159
},
154160
},
155161
{
@@ -179,6 +185,7 @@ export const TOOLS = [
179185
max_turns: { type: 'number', description: 'Max agentic turns for safety (default: 25)' },
180186
category: { type: 'string', enum: ['docs', 'tests', 'research', 'bugfix', 'feature', 'refactor', 'deploy'], description: 'Task category for governance routing (default: feature)' },
181187
authority: { type: 'string', enum: ['proposed', 'auto_safe', 'operator'], description: 'Authority level: operator=run immediately, auto_safe=safe auto-execute, proposed=needs approval (default: operator)' },
188+
business_unit: { type: 'string', description: 'Business unit this task belongs to (e.g. "stackbilt", "foodfiles"). Default: "stackbilt".' },
182189
},
183190
required: ['title', 'repo', 'prompt'],
184191
},
@@ -201,6 +208,7 @@ export const TOOLS = [
201208
type: 'object',
202209
properties: {
203210
status: { type: 'string', enum: ['pending', 'running', 'completed', 'failed', 'cancelled'], description: 'Filter by status. Omit for all.' },
211+
business_unit: { type: 'string', description: 'Filter to a single business unit (e.g. "stackbilt", "foodfiles"). Omit for all.' },
204212
limit: { type: 'number', description: 'Max tasks to return (default 20)' },
205213
},
206214
},

0 commit comments

Comments
 (0)