Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ceauto",
"version": "0.1.0",
"version": "0.16.0",
"description": "Autonomous CEO Agent — multi-agent orchestration, episodic memory, executive decision frameworks",
"author": "CEAuto",
"mcpServer": {
Expand All @@ -11,13 +11,23 @@
"tools": [
"ceo_boot",
"ceo_delegate",
"ceo_run_task",
"ceo_run_cycle",
"ceo_decide",
"ceo_generate_standup",
"ceo_create_directive",
"ceo_report_blocker",
"ceo_complete_task",
"ceo_recall",
"ceo_workflow"
"ceo_workflow",
"ceo_metrics",
"ceo_org",
"ceo_audit",
"ceo_insights",
"ceo_sources",
"ceo_request_approval",
"ceo_resolve_approval",
"ceo_list_approvals"
],
"requiredFiles": [
"memory/context.md",
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ db/*.sqlite-shm
db/*.sqlite-wal
db/*.sqlite-journal

# Stale v1.0 file-based prototype (superseded by the SQLite source of truth)
temp/

# Throwaway demo workspace (examples/demo.mjs default path)
.demo-workspace/

Expand Down
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,77 @@
# Changelog

## [0.16.0] - 2026-07-16
### Added
- **Pursue goals autonomously (`lib/strategist.js`).** When the heartbeat's ready
queue is idle, CEAuto now reads `strategy/goals.md` + `strategy/priorities.md` and
originates the next tasks itself, so the daemon keeps working toward your goals
instead of only draining what you hand it. Generated tasks flow through the same
budget / approval / veto gates as any task. Default **OFF** (`autonomy.pursue_goals`).
- **Dry-run by default** (`auto_run_generated: false`): generated tasks are
`needs_approval`, so you approve what it wants to do before it runs. Rejecting a
generated task is now terminal (it's blocked, not re-proposed every cycle).
- Self-bounded: `max_generated_tasks_per_day`, `max_plans_per_day` (bounds spend
even on zero-yield plans), and `strategy_min_interval_minutes` (re-plan cooldown).
The planning call itself is budget-gated and recorded; guard counters fail closed.
- **Kill switch (`comms/STOP`).** A single file halts **all** dispatch immediately —
the heartbeat (cycle start + mid-drain), `ceo_run_task` / `execute:true`, and
`ceo_workflow`. Works for an unattended run where you can't reach the terminal;
delete the file to resume. (`lib/killswitch.js`.)
- Autonomy audit trail: `strategy.generated` / `strategy.planned` events, and the
cycle log/`ceo_run_cycle` now report generated + halted state.

### Fixed
- Pending dry-run (awaiting-approval) tasks no longer consume the per-cycle
`max_tasks_per_cycle` budget, so a standing pool of unapproved tasks can't starve
real work of its slots.
- `approvals` picks the latest request by `(requested_at, id)` so same-second
requests are unambiguous.
- `comms/STOP` is re-checked per workflow step (not just at start), so a stop that
lands mid-run halts the remaining steps.
- Resolving an approval no longer flips a task that has since changed state: a
rejection only blocks a still-pending task, and a budget-approve only requeues a
still-blocked one (never resurrects a completed task).

## [0.15.0] - 2026-07-16
### Security
- Reactive-source tasks (file-watch / inbound-webhook / @mention) now default to
`needs_approval` — external input can no longer trigger an agent's executor
autonomously without a human ack (opt out with `sources.*.auto_approve: true`).
- New opt-in `executors.require_approval` list: a task routed to a high-blast
runtime (shell/webhook/claude-code/mcp-tool) is held for approval even if it
wasn't flagged — this is what makes the documented shell/webhook approval gate
real (empty by default; behaviour unchanged until enabled).
- `ceo_workflow` steps are now budget-gated and recorded like every other runtime
(previously they bypassed budget/pause entirely and were invisible to metrics).
- Caller-supplied task ids and workflow names are rejected if they contain path
separators or `..` (prevented writing/reading outside the workspace).
- OpenAI-compatible `base_url` must be https unless the host is loopback (an
`http://` remote base_url would have leaked the API key). Optional `mcp-tool`
command allowlist added.

### Fixed
- A failure AFTER a task completed (e.g. in self-eval or projection) no longer
re-runs the LLM and double-charges the task: only the dispatch is retried;
record/complete/eval run exactly once.
- Stale-claim recovery: a task stranded `in-progress` by a crashed worker is
reclaimed each cycle (the 10-min TTL was previously unreachable by the daemon).
- Approving a budget hold now requeues the blocked task (it stayed blocked forever).
- Per-session token cap is windowed (was a process-lifetime cap that eventually
paused a long-running daemon globally).
- Non-LLM runtimes (shell/webhook/mcp) record $0 instead of phantom USD at default
LLM pricing (claude-code/composite stay under the USD cap).
- OpenAI/Ollama fetches now abort on timeout instead of running past the runner.
- Daemon skips a heartbeat tick while the previous cycle is still running; a single
task error no longer aborts the whole cycle; the audit feed is never truncated.
- Google provider gives a clear "install the optional dependency" error instead of
a raw module-not-found crash.

### Changed
- `settings.yaml` agent roster completed to the real 7 agents (was 4, which
crippled the planner and inbound-webhook routing for ops/security/comms).
- Committed runtime state (`db/*.sqlite`) and the stale `temp/` v1.0 prototype are
no longer tracked; `.claude-plugin/plugin.json` lists all 19 tools.

## [0.14.2] - 2026-06-11
### Changed
- `openai` provider now reaches any OpenAI-compatible `/chat/completions` endpoint
Expand Down
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ token budget, then self-evaluates and logs metrics.
- Default OFF and approval-first: nothing runs the LLM until you `execute:true`,
call `ceo_run_task`, or start the daemon.

### Pursue goals (generative autonomy, default OFF)
With `autonomy.pursue_goals: true`, when the ready queue is idle the daemon reads
`strategy/goals.md` + `strategy/priorities.md` and originates the next tasks itself
(`lib/strategist.js`) — so it keeps working toward your goals, not just draining what
you hand it. Generated tasks pass the same budget/approval/veto gates. They are
**dry-run** (`needs_approval`) unless `autonomy.auto_run_generated: true`; rejecting
one blocks it. Bounded by `max_generated_tasks_per_day`, `max_plans_per_day`, and a
`strategy_min_interval_minutes` cooldown.

### Kill switch
Create a file at `comms/STOP` to halt **all** dispatch immediately (heartbeat,
`ceo_run_task`/`execute`, `ceo_workflow`); delete it to resume. Distinct from a
per-task veto (`comms/vetos.md`) and from a budget pause.

### Cost & governance guardrails
- **Budget** (`lib/budget.js`): per-agent / per-session / global daily token +
USD caps in `config/providers.yaml`. A breach pauses autonomous spend and
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CEAuto

![version](https://img.shields.io/badge/version-0.14.2-blue)
![version](https://img.shields.io/badge/version-0.16.0-blue)
![node](https://img.shields.io/badge/node-18%2B-339933?logo=node.js&logoColor=white)
![MCP](https://img.shields.io/badge/MCP-compatible-blueviolet)
![platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
Expand Down Expand Up @@ -386,7 +386,7 @@ After `ceo_boot`, CEAuto loads your context and strategy docs. The 19 tools:
| `ceo_audit` | Audit + replay the append-only event log |
| `ceo_insights` | Learning loop: playbooks/lessons + dispatch policy |
| `ceo_sources` | Reactive source status: file-watch / webhook / @mention |
| `ceo_recall` | Semantic search across session memory |
| `ceo_recall` | Full-text (FTS5) search across session memory |
| `ceo_workflow` | Run a multi-step YAML workflow |

### Sample usage
Expand Down Expand Up @@ -511,7 +511,7 @@ CEAuto/
├── memory/context.md # your company/project context (fill this in)
├── strategy/goals.md # current strategic goals (fill this in)
├── workflows/ # YAML workflow definitions
├── test/ # unit + integration + e2e (204 tests, mock provider)
├── test/ # unit + integration + e2e (232 tests, mock provider)
├── install.sh # installer (Linux/macOS/Git Bash)
└── install.bat # installer (Windows)
```
Expand Down
18 changes: 17 additions & 1 deletion bin/ceauto-daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ async function startSources(settings) {
const stoppers = [];

if (cfg.file_watch && cfg.file_watch.enabled && (cfg.file_watch.paths || []).length) {
const w = sources.watchFiles({ paths: cfg.file_watch.paths, agent: cfg.file_watch.agent || 'ops' });
const w = sources.watchFiles({
paths: cfg.file_watch.paths,
agent: cfg.file_watch.agent || 'ops',
needsApproval: !cfg.file_watch.auto_approve,
});
stoppers.push(() => w.stop());
process.stderr.write(`CEAuto sources: watching ${cfg.file_watch.paths.join(', ')}\n`);
}
Expand All @@ -82,6 +86,7 @@ async function startSources(settings) {
secret: wh.secret,
agent: wh.agent || 'ops',
map: (cfg.mention && cfg.mention.map) || {},
needsApproval: !wh.auto_approve,
...(agents.length ? { agents } : {}),
}),
},
Expand Down Expand Up @@ -161,11 +166,22 @@ async function main() {
const sourcesHandle = await startSources(settings);
const dashboardHandle = await startDashboard(settings);

// node-cron doesn't await the async callback, so a cycle slower than the
// interval would overlap the next — doubling the spend rate and racing the
// budget gate. Skip a tick while the previous cycle is still running.
let cycleRunning = false;
const job = cron.schedule(expr, async () => {
if (cycleRunning) {
process.stderr.write('cycle skipped: previous cycle still running\n');
return;
}
cycleRunning = true;
try {
await heartbeat.runCycle(deps);
} catch (e) {
process.stderr.write(`cycle error: ${e.message}\n`);
} finally {
cycleRunning = false;
}
});

Expand Down
35 changes: 34 additions & 1 deletion config/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## General
name: "Autonomous CEO Agent"
version: "1.0.0"
version: "0.16.0"
framework: "MCP-compatible"

## Autonomy
Expand Down Expand Up @@ -30,6 +30,19 @@ autonomy:
max_delegation_depth: 3 # how deep a subtask chain may go
max_subtasks_per_task: 5 # max children one task may spawn

# Pursue goals autonomously (lib/strategist.js). When the ready queue is idle,
# turn strategy/goals.md + priorities.md into the next tasks so the daemon keeps
# working toward your goals instead of just draining what you hand it.
pursue_goals: false # master switch (default OFF)
auto_run_generated: false # false = generated tasks need approval first (dry-run); true = run them
max_generated_tasks_per_day: 10 # hard cap on tasks the strategist may originate / 24h
max_plans_per_day: 24 # hard cap on planning CALLS/24h (bounds spend even on zero-yield plans)
strategy_min_interval_minutes: 30 # cooldown so it can't re-plan (and re-spend) every cycle
max_tasks_per_plan: 5 # fan-out cap per planning call
strategy_agent: analyst # whose persona/budget the planning call uses
# Kill switch: create a file at comms/STOP to halt ALL dispatch immediately
# (checked at cycle start and before each task); delete it to resume.

## Executors (Pillar 1 + G1) — how each agent's work is actually run.
## Default is `llm`. Map an agent to another runtime via by_agent; the high-blast-
## radius ones (shell, webhook, claude-code) are gated and default-off.
Expand All @@ -41,10 +54,16 @@ executors:
default: llm
by_agent: {} # e.g. { ops: shell, research-bot: mcp-tool, builder: composite }
agent_params: {} # e.g. { ops: { command: node, args: ["worker.js"] } }
# Executors that require a human approval before a task may run through them,
# even if the task wasn't flagged needs_approval. Empty = opt-in (unchanged).
# Recommended for unattended daemons: [shell, webhook, http-webhook, claude-code, mcp-tool]
require_approval: []
shell:
allowlist: [] # basenames permitted, e.g. ["node", "git"]
webhook:
allowlist: [] # outbound hosts permitted, e.g. ["worker.internal", "n8n.example.com"]
mcp-tool:
allowlist: [] # command basenames permitted (empty = allow; non-empty locks it down)
claude_code:
enabled: false # default OFF — claude-code can edit files; enable deliberately
command: claude # the Claude Code CLI binary
Expand All @@ -58,12 +77,14 @@ sources:
enabled: false
paths: [] # dirs/globs to watch; a change creates a task
agent: ops
auto_approve: false # externally-triggered tasks need a human ack by default; true = run autonomously
webhook:
enabled: false
host: "127.0.0.1" # local-first; do not bind 0.0.0.0 without a proxy
port: 8787
secret: "" # required X-CEAuto-Secret header (empty = no auth — keep local)
agent: ops
auto_approve: false # inbound-webhook tasks need a human ack by default; true = run autonomously
mention:
map: {} # @alias -> agent override, e.g. { eng: coder }

Expand Down Expand Up @@ -140,3 +161,15 @@ agents:
name: "Writer Agent"
capability: "writing"
definition: "subagents/writer.md"
ops:
name: "Ops Agent"
capability: "operations"
definition: "subagents/ops.md"
security:
name: "Security Agent"
capability: "security"
definition: "subagents/security.md"
comms:
name: "Comms Agent"
capability: "communications"
definition: "subagents/comms.md"
Binary file removed db/memory.sqlite
Binary file not shown.
Binary file removed db/test.sqlite
Binary file not shown.
14 changes: 12 additions & 2 deletions lib/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,21 @@ function hasPending(kind, refId) {

/** Is there an approved record for this (kind, ref_id)? */
function isApproved(kind, refId) {
return latestStatus(kind, refId) === 'approved';
}

/** Was the latest request for this (kind, ref_id) rejected? (terminal — don't reopen) */
function isRejected(kind, refId) {
return latestStatus(kind, refId) === 'rejected';
}

function latestStatus(kind, refId) {
const row = db().prepare(`
SELECT status FROM approvals
WHERE kind = ? AND ref_id = ?
ORDER BY requested_at DESC LIMIT 1
ORDER BY requested_at DESC, id DESC LIMIT 1
`).get(kind, refId);
return Boolean(row && row.status === 'approved');
return row ? row.status : null;
}

function renderApprovals(workspace) {
Expand Down Expand Up @@ -145,6 +154,7 @@ module.exports = {
approve,
reject,
isApproved,
isRejected,
approverCount,
renderApprovals,
};
18 changes: 15 additions & 3 deletions lib/budget.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ function resetConfig() {
CONFIG = null;
}

// Runtimes that don't spend our LLM API dollars — a local command (shell), an
// external worker (webhook), or another MCP server (mcp). Pricing their proxy
// token counts at pricing.default would accrue phantom USD against the global USD
// cap, so their spend is $0. NOTE: claude-code and composite are deliberately
// EXCLUDED — both can drive real LLM spend and must stay under the USD cap
// (composite is priced conservatively via pricing.default).
const NON_LLM_PROVIDERS = new Set(['shell', 'webhook', 'mcp']);

function priceFor(model, inputTokens, outputTokens) {
const { pricing } = getConfig();
const p = pricing[model] || pricing.default || { input: 0, output: 0 };
Expand All @@ -70,7 +78,7 @@ function db() {
* Record one LLM call's usage. Returns { usd, input_tokens, output_tokens }.
*/
function record({ agent, task_id, session_id, provider, model, input_tokens = 0, output_tokens = 0 }) {
const usd = priceFor(model, input_tokens, output_tokens);
const usd = NON_LLM_PROVIDERS.has(provider) ? 0 : priceFor(model, input_tokens, output_tokens);
db().prepare(`
INSERT INTO budget_ledger (agent, task_id, session_id, provider, model, input_tokens, output_tokens, usd)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
Expand Down Expand Up @@ -99,8 +107,12 @@ function spentByAgents(agents, days = 1) {
return agg(`WHERE agent IN (${placeholders}) AND created_at >= datetime('now', ?)`, [...agents, `-${days} days`]);
}

function spentBySession(sessionId) {
return agg('WHERE session_id = ?', [sessionId]);
// Windowed by default: a long-lived daemon/server keeps ONE session_id for its
// whole life, so an all-time sum would eventually trip the per-session cap and
// globally pause autonomy. days=null keeps the all-time sum for callers that want it.
function spentBySession(sessionId, days = 1) {
if (days == null) return agg('WHERE session_id = ?', [sessionId]);
return agg("WHERE session_id = ? AND created_at >= datetime('now', ?)", [sessionId, `-${days} days`]);
}

function spentTotal(days = 1) {
Expand Down
3 changes: 3 additions & 0 deletions lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ function reduce(events) {
case 'task.requeued':
if (tasks[id]) { tasks[id].status = 'backlog'; tasks[id].claimed_by = null; }
break;
case 'task.reclaimed':
if (tasks[id]) { tasks[id].claimed_by = null; }
break;
default:
break;
}
Expand Down
10 changes: 9 additions & 1 deletion lib/executors/mcp-tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@
* arguments defaults to { task, context }.
*/

const path = require('path');
const { spawn } = require('child_process');
const readline = require('readline');
const { estimateTokens } = require('../llm-adapter');

async function mcpTool(ctx, _deps = {}) {
async function mcpTool(ctx, deps = {}) {
const params = ctx.params || {};
const { command, args = [], env = {}, tool } = params;
if (!command || !tool) throw new Error('mcp-tool executor: params.command and params.tool are required');

// Optional command allowlist (basenames). Empty/absent = allow (the command is
// operator config, not task data); a non-empty list locks it down like shell.
const allowlist = deps.mcpAllowlist || [];
if (allowlist.length && !allowlist.map(a => path.basename(String(a))).includes(path.basename(String(command)))) {
throw new Error(`mcp-tool executor: "${command}" is not in the allowlist`);
}

const timeoutMs = params.timeoutMs || 60000;
const child = spawn(command, args, {
env: { ...process.env, ...env },
Expand Down
Loading
Loading