refactor(controlplane): rename ceo→orchestrator, company→system, agent id=AGENT_NAME

Terminology overhaul for control plane naming:
- AgentRole 'ceo' → 'orchestrator', schema constraint updated
- DEFAULT_AGENTS → getDefaultAgents(agentName) function, main agent id = AGENT_NAME
- Identity file resolution: {AGENT_NAME}.md with SOUL.md fallback
- All test mocks updated: 'ceo' → 'clawdie', 'company' → 'system'
- SOUL.md + SYSADMIN.md docs updated (Paperclip→Control Plane)
- TypeScript build clean (tsc --noEmit passes)

Remaining: DBA.md, GIT_ADMIN.md, doc/* updates, CLAWDIE.md creation
See doc/NAMING-HANDOFF.md for task checklist (Sam & Claude)
This commit is contained in:
Sam & ZAI 2026-04-08 10:07:41 +02:00
parent 76368e1b3c
commit 061a25f98e
16 changed files with 212 additions and 106 deletions

10
SOUL.md
View file

@ -25,21 +25,21 @@ _You're not a chatbot. You're becoming someone._
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
## Control Plane (When You Run as CEO Agent)
## Control Plane (When You Run as Orchestrator Agent)
You are the orchestrator of the Clawdie control plane. When woken by the scheduler or Telegram:
**On wake, you do this:**
1. `GET /api/controlplane/state` → budget remaining? agents active?
2. `GET /api/controlplane/tasks?role=ceo` → what's pending?
3. Read `data/sessions/ceo.jsonl` → what did you decide last time?
2. `GET /api/controlplane/tasks?role=orchestrator` → what's pending?
3. Read `data/sessions/{agent_name}.jsonl` → what did you decide last time?
4. Decide: delegate to Sysadmin / DBA / Git Admin, or escalate to operator
5. `POST /api/controlplane/activity` → log your decision
**What you own:**
- Prioritization: which tasks get worked on first
- Delegation: create tasks assigned to specialists
- Escalation: post `approval_request` events when board sign-off is needed (>50k tokens, risky ops)
- Escalation: post `approval_request` events when operator sign-off is needed (>50k tokens, risky ops)
- Review: read specialist activity logs, catch failures early
**What you do NOT own:**
@ -48,7 +48,7 @@ You are the orchestrator of the Clawdie control plane. When woken by the schedul
- Git / releases → Git Admin
- You reason and delegate. Specialists execute.
**Cost discipline:** Your allocation is 80% of the daily budget — but that's a ceiling, not a target. A good CEO week costs ~5,000-10,000 tokens, mostly delegation messages. Reserve budget for real decisions.
**Cost discipline:** Your allocation is 80% of the daily budget — but that's a ceiling, not a target. A good orchestrator week costs ~5,000-10,000 tokens, mostly delegation messages. Reserve budget for real decisions.
See `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` for exact API shapes.
See `doc/CONTROLPLANE-AGENT-ROLES.md` for what each specialist owns.

View file

@ -16,14 +16,14 @@ _You keep the machines running. You're methodical, preventive, and paranoid abou
You wake once per day (default 86400 seconds). Your job is simple:
1. **Query Paperclip:** "What's my company state? Do I have budget? What's assigned to me?"
1. **Query the control plane:** "What's my company state? Do I have budget? What's assigned to me?"
2. **Query Clawdie:** "What's in my session history? What skills do I have?"
3. **Health Check:** Run 3-5 deterministic health checks:
- Are critical jails running? (`jail-status` skill)
- Is disk space OK? (`disk-usage` skill)
- Are key services healthy? (`system-stats` skill)
- Do recent backups exist? (check backup-db logs)
4. **Report:** Post activity events back to Paperclip. Outcome: "All systems nominal" or "Found issue X, executing skill Y."
4. **Report:** Post activity events back to the control plane. Outcome: "All systems nominal" or "Found issue X, executing skill Y."
**Cost:** ~500-1000 tokens per heartbeat (mostly skill summaries, not reasoning).
@ -38,7 +38,7 @@ CEO might wake you up with an urgent task:
- "Create a ZFS snapshot for recovery"
Same pattern:
1. Query Paperclip for the full task context
1. Query the control plane for the full task context
2. Query Clawdie for memory (did I do this before?)
3. Pattern-match to skill → execute
4. Post completion event
@ -107,18 +107,18 @@ You have access to 14 operational skills. Here's how you match tasks:
## Token Budget & Constraints
- **Daily allocation:** 10% of company budget (~10,000 tokens for "Clawdie" company)
- **Daily allocation:** 10% of system budget (~10,000 tokens for "Clawdie" system)
- **Skill execution cost:** 300-800 tokens per skill
- **Health check cost:** ~500 tokens for full daily suite
- **Expensive operations (>2,000 tokens):** Request board approval first
- **Hard limit:** If company budget is exhausted, all agents stop. Board user must approve new budget.
- **Expensive operations (>2,000 tokens):** Request operator approval first
- **Hard limit:** If system budget is exhausted, all agents stop. Operator must approve new budget.
### Budget Check Logic
```
On heartbeat:
company_state = GET /api/controlplane/state
if company_state.budget.remaining <= 0:
POST error event: "Company budget exhausted, cannot proceed"
system_state = GET /api/controlplane/state
if system_state.budget.remaining <= 0:
POST error event: "System budget exhausted, cannot proceed"
exit()
if my_allocated_budget.remaining < 1000:
@ -128,13 +128,13 @@ On heartbeat:
---
## Escalation (When to Ask CEO)
## Escalation (When to Ask the Orchestrator)
You escalate to CEO when:
You escalate to the orchestrator when:
1. **No skill pattern matches** — Task is outside your automation scope
2. **Skill execution fails** — System is in unexpected state
3. **Expensive operation** — Would use >2,000 tokens; needs board approval
3. **Expensive operation** — Would use >2,000 tokens; needs operator approval
4. **Conflict detected** — Two tasks conflict; need priority clarification
5. **Human judgment needed** — "Should we accept this level of disk usage?"
@ -188,9 +188,9 @@ This context flows into your system prompt, so you're **not starting from zero e
## What You're NOT
- You're not a developer. DBA owns database tuning, migrations, backups (not you, they own this).
- You're not a security auditor. You maintain firewalls, but policy decisions go to CEO.
- You're not a security auditor. You maintain firewalls, but policy decisions go to the orchestrator.
- You're not a git admin. Version control, branches, releases → Git Admin's domain.
- You're not a business decision maker. "Should we upgrade the database?" → CEO decides, you execute.
- You're not a business decision maker. "Should we upgrade the database?" → orchestrator decides, you execute.
---
@ -198,7 +198,7 @@ This context flows into your system prompt, so you're **not starting from zero e
- `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` — how you query the control plane API, how you post results
- `doc/CONTROLPLANE-AGENT-ROLES.md` — your role in the org chart
- `SOUL.md`CEO's identity (your boss)
- `SOUL.md`orchestrator's identity (your boss)
- `.agent/skills/*/SKILL.md` — the 14 operational skills you can invoke
---

87
doc/NAMING-HANDOFF.md Normal file
View file

@ -0,0 +1,87 @@
# Control Plane Naming Renaming Handoff
**From:** Claude (Linux)
**Date:** 08.apr.2026
**Status:** IN-PROGRESS
## Deletion Criteria
- [ ] All docs updated: DBA.md, GIT_ADMIN.md, MULTI-PROVIDER-ARCHITECTURE.md, CONTROLPLANE-*.md
- [ ] CLAWDIE.md created as primary orchestrator identity file
- [ ] `npx vitest run` passes on FreeBSD
- [ ] No remaining `ceo`, `company`, or `Paperclip` references in code/docs
## Summary
Renamed internal control plane terminology:
| Old | New |
|-----|-----|
| `ceo` (role) | `orchestrator` |
| `company` | `system` |
| Main agent id: `'ceo'` | Main agent id: `AGENT_NAME` (e.g., `clawdie`) |
| Identity file: `SOUL.md` (fixed) | `{AGENT_NAME}.md` with fallback to `SOUL.md` |
| `Paperclip` | `Control Plane` |
## Completed Tasks (by Claude, Linux)
- [x] `src/controlplane-db.ts` — AgentRole type, getDefaultAgents(agentName) function, schema CHECK constraint
- [x] `src/controlplane-runner.ts` — identity file map + resolveIdentityFile() with fallback
- [x] `src/controlplane-budget.ts``ceo: Infinity``orchestrator: Infinity`
- [x] `src/controlplane-heartbeat.ts``getTasksByRole('ceo')``('orchestrator')`, added `agentName` to config
- [x] `src/controlplane-telegram.ts` — fallback to `config.agentName` instead of `'ceo'`
- [x] `src/index.ts` — added `agentName: AGENT_NAME` to heartbeat config
- [x] `setup/controlplane.ts` — uses `getDefaultAgents(AGENT_NAME)` instead of static DEFAULT_AGENTS
- [x] `src/controlplane-setup.test.ts` — updated assertions
- [x] `src/controlplane-budget.test.ts``ceoBudget``mainBudget`, agent_id `'clawdie'`
- [x] `src/controlplane-api.test.ts` — mock agents/budgets updated
- [x] `src/controlplane.test.ts` — mock data + config updated
- [x] `src/controlplane-telegram.test.ts` — config + assertions updated
- [x] `src/agent-session.test.ts``'ceo'``'clawdie'`, filenames aligned
- [x] `SOUL.md` — terminology updated (orchestrator, system, operator)
- [x] `SYSADMIN.md` — Paperclip→Control Plane, CEO→orchestrator, company→system, board→operator (partial)
- [x] TypeScript build passes (`tsc --noEmit` clean)
## Remaining Tasks
### Docs (Linux or FreeBSD agent)
- [ ] `DBA.md` — Replace Paperclip→Control Plane, company→system, CEO→orchestrator, board→operator
- [ ] `GIT_ADMIN.md` — Same replacements as DBA.md
- [ ] `doc/MULTI-PROVIDER-ARCHITECTURE.md` — Update `agentRole === 'ceo'``'orchestrator'`
- [ ] `doc/CONTROLPLANE-IMPLEMENTATION-PLAN.md` — Update schema references, terminology
- [ ] `doc/CONTROLPLANE-MESSAGE-CONTRACT.md` — Update agent examples
- [ ] `doc/CONTROLPLANE-AGENT-ROLES.md` — Update role descriptions
- [ ] `doc/CONTROLPLANE-ARCHITECTURE.md` — Update terminology
- [ ] `docs/public/architecture/controlplane.md` — Update "company" references
- [ ] `docs/public/install/controlplane-install.md` — Update terminology
- [ ] `CHANGELOG.md` — Add entry for naming rename
### New File
- [ ] Create `CLAWDIE.md` — primary orchestrator identity file (copy of SOUL.md with AGENT_NAME-specific content)
- If AGENT_NAME changes, this file should be regenerated
- `resolveIdentityFile()` in `controlplane-runner.ts` looks for `{AGENT_NAME.toUpperCase()}.md` first, falls back to `SOUL.md`
### Testing (FreeBSD only)
- [ ] Run `npx vitest run` — verify all tests pass with new naming
- [ ] Run `npm run build` — verify production build works
## Key Design Decisions
1. **`getDefaultAgents(agentName)`** is a function now, not a static array. The main agent's `id` equals `AGENT_NAME`.
2. **`DEFAULT_AGENTS`** export still exists (calls `getDefaultAgents('clawdie')`) for backward compat in tests.
3. **Identity file resolution**: `{AGENT_NAME.toUpperCase()}.md` → fallback `SOUL.md`. The `resolveIdentityFile()` function handles this but is currently defined but not yet wired into `buildControlplaneRunCommand` — the FreeBSD agent should wire it in.
4. **Schema migration**: The CHECK constraint changed from `'ceo'` to `'orchestrator'`. Existing databases need a migration: `ALTER TABLE agents DROP CONSTRAINT agents_role_check, ADD CONSTRAINT agents_role_check CHECK (role IN ('orchestrator','sysadmin','dba','git_admin'));` and `UPDATE agents SET role='orchestrator' WHERE role='ceo';`.
## Results
- Build: pass (`tsc --noEmit` clean)
- Tests: NOT YET RUN (needs FreeBSD)
## Delete After
```bash
git rm doc/NAMING-HANDOFF.md
```

View file

@ -17,7 +17,7 @@ import pg from 'pg';
import { AGENT_NAME, MEMORY_DB_URL } from '../src/config.js';
import {
DEFAULT_AGENTS,
getDefaultAgents,
copySkills,
generatePassword,
getAgents,
@ -64,8 +64,9 @@ export async function run(_args: string[]): Promise<void> {
appendSetupLog('schema migration: ready');
// ── 2. Provision default agents ──────────────────────────────────
const defaultAgents = getDefaultAgents(AGENT_NAME);
logger.info('Provisioning default agents...');
for (const agent of DEFAULT_AGENTS) {
for (const agent of defaultAgents) {
await upsertAgent(pool, agent);
logger.info({ id: agent.id, role: agent.role }, 'Agent ready');
appendSetupLog(`agent ready: ${agent.id} (${agent.role})`);
@ -111,7 +112,7 @@ export async function run(_args: string[]): Promise<void> {
10,
);
for (const agent of DEFAULT_AGENTS) {
for (const agent of defaultAgents) {
const agentTokens = Math.floor((dailyTokens * agent.budget_allocation_pct) / 100);
await upsertBudget(pool, agent.id, agentTokens);
logger.info(

View file

@ -92,8 +92,8 @@ describe('Agent Session Persistence', () => {
});
it('parses timestamps as strings', () => {
writeSessionEntry(tmpDir, 'ceo', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'ceo', WORKSPACE);
writeSessionEntry(tmpDir, 'clawdie', makeEntry(), WORKSPACE);
const session = loadSession(tmpDir, 'clawdie', WORKSPACE);
expect(typeof session.entries[0].timestamp).toBe('string');
});
@ -126,14 +126,14 @@ describe('Agent Session Persistence', () => {
});
it('handles empty file without error', () => {
fs.writeFileSync(path.join(tmpDir, 'ceo.jsonl'), '', 'utf-8');
const session = loadSession(tmpDir, 'ceo', WORKSPACE);
fs.writeFileSync(path.join(tmpDir, 'clawdie.jsonl'), '', 'utf-8');
const session = loadSession(tmpDir, 'clawdie', WORKSPACE);
expect(session.entries).toHaveLength(0);
});
it('handles file with only newlines without error', () => {
fs.writeFileSync(path.join(tmpDir, 'ceo.jsonl'), '\n\n\n', 'utf-8');
const session = loadSession(tmpDir, 'ceo', WORKSPACE);
fs.writeFileSync(path.join(tmpDir, 'clawdie.jsonl'), '\n\n\n', 'utf-8');
const session = loadSession(tmpDir, 'clawdie', WORKSPACE);
expect(session.entries).toHaveLength(0);
});
});
@ -176,11 +176,11 @@ describe('Agent Session Persistence', () => {
it('prune rewrites file with only kept entries', () => {
for (let i = 0; i < 5; i++) {
writeSessionEntry(tmpDir, 'ceo', makeEntry({ task: `Task ${i}` }), WORKSPACE);
writeSessionEntry(tmpDir, 'clawdie', makeEntry({ task: `Task ${i}` }), WORKSPACE);
}
const session = loadSession(tmpDir, 'ceo', WORKSPACE);
const session = loadSession(tmpDir, 'clawdie', WORKSPACE);
pruneOldEntries(session, 2);
const reloaded = loadSession(tmpDir, 'ceo', WORKSPACE);
const reloaded = loadSession(tmpDir, 'clawdie', WORKSPACE);
expect(reloaded.entries).toHaveLength(2);
});
});

View file

@ -21,12 +21,12 @@ import {
// ── Mock pool ──────────────────────────────────────────────────────────────
const defaultAgents: Agent[] = [
{ id: 'ceo', role: 'ceo', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 80, api_key_hash: null, created_at: new Date() },
{ id: 'clawdie', role: 'orchestrator', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 80, api_key_hash: null, created_at: new Date() },
{ id: 'sysadmin', role: 'sysadmin', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: null, created_at: new Date() },
];
const defaultBudgets: Budget[] = [
{ agent_id: 'ceo', daily_tokens: 80000, spent_today: 20000, remaining: 60000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'clawdie', daily_tokens: 80000, spent_today: 20000, remaining: 60000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 5000, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
];
@ -176,7 +176,7 @@ describe('Control Plane HTTP API — contract tests', () => {
it('hard_limit_exceeded is true when remaining <= 0', async () => {
const { handler } = await setup({
budgets: [
{ agent_id: 'ceo', daily_tokens: 80000, spent_today: 85000, remaining: -5000, hard_limit_exceeded: true, reset_at: new Date() },
{ agent_id: 'clawdie', daily_tokens: 80000, spent_today: 85000, remaining: -5000, hard_limit_exceeded: true, reset_at: new Date() },
],
});
const res = await makeRequest(handler, 'GET', '/api/controlplane/state', undefined, validAuth);
@ -289,7 +289,7 @@ describe('Control Plane HTTP API — contract tests', () => {
const { handler } = await setup();
const res = await makeRequest(handler, 'POST', '/api/controlplane/activity', {
event_type: 'unknown_event',
agent_id: 'ceo',
agent_id: 'clawdie',
}, validAuth);
expect(res.statusCode).toBe(400);
});

View file

@ -36,8 +36,8 @@ function makePool(budgets: Budget[], updateResults?: { rowCount: number }) {
};
}
const ceoBudget = (spent = 0): Budget => ({
agent_id: 'ceo',
const mainBudget = (spent = 0): Budget => ({
agent_id: 'clawdie',
daily_tokens: 80000,
spent_today: spent,
remaining: 80000 - spent,
@ -140,9 +140,9 @@ describe('Control Plane Budget Enforcement', () => {
expect(result.dailyTokens).toBe(5000);
});
it('ceo allocation is 80% of daily_tokens', async () => {
const pool = makePool([ceoBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'ceo', 100);
it('orchestrator allocation is 80% of daily_tokens', async () => {
const pool = makePool([mainBudget(0)]);
const result = await checkBudget(pool as unknown as Pool, 'clawdie', 100);
expect(result.dailyTokens).toBe(80000);
});
});

View file

@ -32,7 +32,7 @@ export const DEFAULT_APPROVAL_THRESHOLDS: AgentThresholds = {
sysadmin: 2000,
dba: 3000,
git_admin: 2000,
ceo: Infinity,
orchestrator: Infinity,
};
// ── Check ──────────────────────────────────────────────────────────────────

View file

@ -13,7 +13,7 @@ import pg from 'pg';
// ── Types ──────────────────────────────────────────────────────────────────
export type AgentRole = 'ceo' | 'sysadmin' | 'dba' | 'git_admin';
export type AgentRole = 'orchestrator' | 'sysadmin' | 'dba' | 'git_admin';
export interface Agent {
id: string;
@ -64,47 +64,51 @@ export interface ActivityEvent {
// ── Default agent definitions ──────────────────────────────────────────────
export const DEFAULT_AGENTS: Omit<Agent, 'api_key_hash' | 'created_at'>[] = [
{
id: 'ceo',
role: 'ceo',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
budget_allocation_pct: 80,
},
{
id: 'sysadmin',
role: 'sysadmin',
adapter: 'pi-local',
heartbeat_enabled: true,
heartbeat_interval_sec: 86400,
budget_allocation_pct: 10,
},
{
id: 'dba',
role: 'dba',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
budget_allocation_pct: 5,
},
{
id: 'git_admin',
role: 'git_admin',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
budget_allocation_pct: 5,
},
];
export function getDefaultAgents(agentName: string): Omit<Agent, 'api_key_hash' | 'created_at'>[] {
return [
{
id: agentName,
role: 'orchestrator',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
budget_allocation_pct: 80,
},
{
id: 'sysadmin',
role: 'sysadmin',
adapter: 'pi-local',
heartbeat_enabled: true,
heartbeat_interval_sec: 86400,
budget_allocation_pct: 10,
},
{
id: 'dba',
role: 'dba',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
budget_allocation_pct: 5,
},
{
id: 'git_admin',
role: 'git_admin',
adapter: 'pi-local',
heartbeat_enabled: false,
heartbeat_interval_sec: null,
budget_allocation_pct: 5,
},
];
}
export const DEFAULT_AGENTS = getDefaultAgents('clawdie');
// ── Schema migration ───────────────────────────────────────────────────────
export const CONTROLPLANE_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('ceo','sysadmin','dba','git_admin')),
role TEXT NOT NULL CHECK (role IN ('orchestrator','sysadmin','dba','git_admin')),
adapter TEXT NOT NULL DEFAULT 'pi-local',
heartbeat_enabled BOOLEAN NOT NULL DEFAULT FALSE,
heartbeat_interval_sec INTEGER,

View file

@ -31,6 +31,7 @@ export interface ControlplaneHeartbeatConfig {
sessionCwd: string;
skillsDir: string;
apiKey: string;
agentName: string;
tickIntervalMs: number;
maxSessionEntries: number;
}
@ -180,9 +181,9 @@ export function startControlplaneHeartbeatLoop(config: ControlplaneHeartbeatConf
await runAgentHeartbeat(config, agent.id, 'interval_elapsed');
}
const onDemandTasks = await getTasksByRole(config.pool, 'ceo');
const onDemandTasks = await getTasksByRole(config.pool, 'orchestrator');
for (const task of onDemandTasks.filter((t) => t.status === 'pending')) {
const assignedTo = task.assigned_to ?? 'ceo';
const assignedTo = task.assigned_to ?? config.agentName;
await runAgentHeartbeat(config, assignedTo, 'assignment', task.id);
}
} catch (err) {

View file

@ -36,12 +36,23 @@ export interface RunResult {
const CONTROLPLANE_API_URL = 'http://localhost:3100';
const AGENT_IDENTITY_FILES: Record<string, string> = {
ceo: 'SOUL.md',
sysadmin: 'SYSADMIN.md',
dba: 'DBA.md',
git_admin: 'GIT_ADMIN.md',
};
export function resolveIdentityFile(agentId: string, agentName: string, workspaceCwd: string): string {
if (AGENT_IDENTITY_FILES[agentId]) {
return path.join(workspaceCwd, AGENT_IDENTITY_FILES[agentId]);
}
const fs = require('fs');
const primaryPath = path.join(workspaceCwd, `${agentName.toUpperCase()}.md`);
if (fs.existsSync(primaryPath)) {
return primaryPath;
}
return path.join(workspaceCwd, 'SOUL.md');
}
// ── Build ──────────────────────────────────────────────────────────────────
export function buildControlplaneRunCommand(opts: ControlplaneRunOptions): RunResult {
@ -60,7 +71,7 @@ export function buildControlplaneRunCommand(opts: ControlplaneRunOptions): RunRe
const sessionFile = path.join(absSessionCwd, `${agentId}.jsonl`);
const skillsDir = path.join(workspaceCwd, 'data', 'skills');
const identityFile = opts.identityFile ?? path.join(workspaceCwd, AGENT_IDENTITY_FILES[agentId] ?? 'SOUL.md');
const identityFile = opts.identityFile ?? path.join(workspaceCwd, 'SOUL.md');
const prompt = opts.prompt ?? `Control plane wake: ${wakeReason}`;
const args: string[] = [

View file

@ -43,10 +43,10 @@ beforeEach(() => {
describe('Control Plane Provisioning', () => {
describe('agent creation', () => {
it('DEFAULT_AGENTS contains CEO agent with role "ceo"', () => {
const ceo = DEFAULT_AGENTS.find((a) => a.id === 'ceo');
expect(ceo).toBeDefined();
expect(ceo?.role).toBe('ceo');
it('DEFAULT_AGENTS contains orchestrator agent with role "orchestrator"', () => {
const main = DEFAULT_AGENTS.find((a) => a.role === 'orchestrator');
expect(main).toBeDefined();
expect(main?.role).toBe('orchestrator');
});
it('DEFAULT_AGENTS contains Sysadmin agent with role "sysadmin"', () => {
@ -79,10 +79,10 @@ describe('Control Plane Provisioning', () => {
});
describe('heartbeat configuration', () => {
it('CEO has heartbeat_enabled = false', () => {
const ceo = DEFAULT_AGENTS.find((a) => a.id === 'ceo')!;
expect(ceo.heartbeat_enabled).toBe(false);
expect(ceo.heartbeat_interval_sec).toBeNull();
it('Orchestrator has heartbeat_enabled = false', () => {
const main = DEFAULT_AGENTS.find((a) => a.role === 'orchestrator')!;
expect(main.heartbeat_enabled).toBe(false);
expect(main.heartbeat_interval_sec).toBeNull();
});
it('Sysadmin has heartbeat_enabled = true and interval = 86400', () => {
@ -105,9 +105,9 @@ describe('Control Plane Provisioning', () => {
});
describe('budget allocation', () => {
it('CEO gets 80% allocation', () => {
const ceo = DEFAULT_AGENTS.find((a) => a.id === 'ceo')!;
expect(ceo.budget_allocation_pct).toBe(80);
it('Orchestrator gets 80% allocation', () => {
const main = DEFAULT_AGENTS.find((a) => a.role === 'orchestrator')!;
expect(main.budget_allocation_pct).toBe(80);
});
it('Sysadmin gets 10% allocation', () => {

View file

@ -74,6 +74,7 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
sessionCwd: sessionDir,
skillsDir,
apiKey: 'test-key',
agentName: 'clawdie',
tickIntervalMs: 30000,
maxSessionEntries: 100,
};
@ -157,11 +158,11 @@ describe('Telegram → Control Plane Bridge', () => {
expect(result?.assignedTo).toBe('git_admin');
});
it('routes unrecognised agent messages to ceo', async () => {
writeSkill('general', ['review company status', 'company overview']);
it('routes unrecognised agent messages to orchestrator', async () => {
writeSkill('general', ['review system status', 'system overview']);
const pool = makePool();
const result = await bridgeTelegramMessage(pool, makeConfig(pool), 'review company status');
expect(result?.assignedTo).toBe('ceo');
const result = await bridgeTelegramMessage(pool, makeConfig(pool), 'review system status');
expect(result?.assignedTo).toBe('clawdie');
});
});

View file

@ -31,25 +31,23 @@ export interface TelegramBridgeResult {
// ── Agent routing ──────────────────────────────────────────────────────────
// Maps skill category keywords to agent roles.
// Sysadmin handles infra; DBA handles data; git_admin handles code; CEO gets everything else.
// Sysadmin handles infra; DBA handles data; git_admin handles code; orchestrator gets everything else.
const ROLE_KEYWORDS: Array<{ role: string; patterns: RegExp[] }> = [
{ role: 'sysadmin', patterns: [/jail/i, /service/i, /network/i, /disk/i, /cpu/i, /memory/i, /log/i, /reboot/i, /cert/i, /nginx/i, /tailscale/i] },
{ role: 'dba', patterns: [/database/i, /postgres/i, /backup/i, /vacuum/i, /query/i, /db\b/i, /migration/i] },
{ role: 'git_admin', patterns: [/git\b/i, /repo/i, /commit/i, /merge/i, /branch/i, /pull request/i, /\bpr\b/i] },
];
function inferRole(message: string, skillName: string | undefined): string {
// Prefer skill-based routing if we have a match
function inferRole(message: string, skillName: string | undefined, fallbackAgent: string): string {
if (skillName) {
for (const { role, patterns } of ROLE_KEYWORDS) {
if (patterns.some((p) => p.test(skillName))) return role;
}
}
// Fall back to message content
for (const { role, patterns } of ROLE_KEYWORDS) {
if (patterns.some((p) => p.test(message))) return role;
}
return 'ceo';
return fallbackAgent;
}
// ── Bridge ─────────────────────────────────────────────────────────────────
@ -69,7 +67,7 @@ export async function bridgeTelegramMessage(
}
const skillName = match.skill?.name;
const assignedTo = inferRole(message, skillName);
const assignedTo = inferRole(message, skillName, config.agentName);
const taskId = `TG-${crypto.randomUUID().slice(0, 8).toUpperCase()}`;
await createTask(pool, {

View file

@ -52,14 +52,14 @@ afterEach(() => {
});
const defaultAgents: Agent[] = [
{ id: 'ceo', role: 'ceo', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 80, api_key_hash: null, created_at: new Date() },
{ id: 'clawdie', role: 'orchestrator', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 80, api_key_hash: null, created_at: new Date() },
{ id: 'sysadmin', role: 'sysadmin', adapter: 'pi-local', heartbeat_enabled: true, heartbeat_interval_sec: 86400, budget_allocation_pct: 10, api_key_hash: null, created_at: new Date() },
{ id: 'dba', role: 'dba', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 5, api_key_hash: null, created_at: new Date() },
{ id: 'git_admin', role: 'git_admin', adapter: 'pi-local', heartbeat_enabled: false, heartbeat_interval_sec: null, budget_allocation_pct: 5, api_key_hash: null, created_at: new Date() },
];
const defaultBudgets: Budget[] = [
{ agent_id: 'ceo', daily_tokens: 80000, spent_today: 0, remaining: 80000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'clawdie', daily_tokens: 80000, spent_today: 0, remaining: 80000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'sysadmin', daily_tokens: 10000, spent_today: 5000, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'dba', daily_tokens: 5000, spent_today: 0, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
{ agent_id: 'git_admin', daily_tokens: 5000, spent_today: 0, remaining: 5000, hard_limit_exceeded: false, reset_at: new Date() },
@ -99,6 +99,7 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
sessionCwd: sessionDir,
skillsDir,
apiKey: 'test-key',
agentName: 'clawdie',
tickIntervalMs: 30000,
maxSessionEntries: 100,
};

View file

@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import {
AGENT_NAME,
ASSISTANT_NAME,
CONTROLPLANE_API_PORT,
CONTROLPLANE_SHARED_SECRET,
@ -652,10 +653,11 @@ async function main(): Promise<void> {
sessionCwd: sessionDir,
skillsDir: PI_TUI_SKILLS_PATH,
apiKey: CONTROLPLANE_SHARED_SECRET || OPENAI_API_KEY,
agentName: AGENT_NAME,
tickIntervalMs: 30000,
maxSessionEntries: 100,
};
startControlplaneHeartbeatLoop(heartbeatConfig);
startControlplaneHeartbeatLoop(heartbeatConfig!);
startIpcWatcher({
sendMessage: (jid, text) => {