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
1 change: 1 addition & 0 deletions .charter/telemetry/events.ndjson
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
{"version":1,"timestamp":"2026-03-23T08:16:04.910Z","commandPath":"bootstrap","flags":["--yes","--preset","--ci"],"format":"text","ciMode":true,"durationMs":1913,"exitCode":0,"success":true}
{"version":1,"timestamp":"2026-03-23T12:30:47.732Z","commandPath":"adf.populate","flags":[],"format":"text","ciMode":false,"durationMs":87,"exitCode":0,"success":true}
{"version":1,"timestamp":"2026-06-24T20:59:33.766Z","commandPath":"audit","flags":[],"format":"text","ciMode":false,"durationMs":1896,"exitCode":0,"success":true}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ C:*
node_modules/
.pnpm-store/
__pycache__/
.agent-memory/
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Added
- OTDD contract test suites for all five bounded contexts — AgendaItem (48 tests), CCTask (53 tests), Goal (65 tests), ExecutorRouter I1/I2 violation paths (4 tests), and MemoryEntry/CRIX pipeline (33 tests). Tests are derived directly from `.contract.ts` schema, state machine, invariant, and authority declarations with no mocks.

### Fixed
- `executor-router.ts` — added `isDefault?: true` to `ExecutorRoute` interface to match `ExecutorRouteShapeSchema` in the contract; removes TS2353 on the `workers_ai` route.
- `workers-ai-chat.ts` — narrowed `Ai.run()` overload cast via explicit function-signature cast to resolve TS2769/TS2352; removes the `as Record<string, unknown>` workaround that leaked the intersection mismatch.
- `kernel/memory/agenda.ts` — `resolveAgendaItem` now adds `AND status = 'active'` to its UPDATE and throws when 0 rows are affected, enforcing the contract's terminal-state invariant for `done` and `dismissed`.

## 0.8.0 (2026-06-02)

### Added
Expand Down
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,20 @@ interface Env extends CoreEnv {
- `.dev.vars`, `.env` — secrets
- Account IDs, API tokens, or internal URLs

## Contract tests (OTDD)

Domain contracts live in `web/src/contracts/*.contract.ts`. Each contract is the authoritative source of truth for its bounded context — schema, operations, state machine, invariants, and authority rules.

When adding or modifying a contract, also update (or create) the corresponding `web/tests/*-contract.test.ts` file to cover:

- **Schema constraints** — one passing and one failing fixture per field constraint
- **Operation inputs** — valid and invalid inputs per operation
- **State machine** — every valid transition, every invalid attempt on terminal states, multi-source transitions
- **Invariants** — both the happy path and the violation path for each named invariant
- **Authority** — assert which roles are permitted and excluded per operation

Derive all fixtures directly from the contract — no mocks, no D1. Import the contract object and call `.schema.parse()`, `.operations.*.input.parse()`, `.states.transitions`, and `.invariants[].check()` directly.

## Pull Requests

1. Fork the repo and create a feature branch from `main`
Expand Down
23 changes: 23 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,29 @@ AEGIS exposes its capabilities via the [Model Context Protocol](https://modelcon
- **MCP Server** — Other AI tools can call AEGIS tools (memory, agenda, goals, chat)
- **MCP Client** — AEGIS can call external MCP services (configured via service bindings)

## Contract ontology

AEGIS uses a formal domain ontology defined in `web/src/contracts/`. Each `.contract.ts` file is the authoritative source of truth for a bounded context — its schema, operations, state machine, invariants, and authority rules. Tests are derived directly from these contracts (OTDD — Ontology-Driven Test Development).

| Contract | Domain | Key invariants |
|---|---|---|
| `agenda-item.contract.ts` | AgendaItem | `resolved_has_timestamp` — done items require `resolvedAt` |
| `cc-task.contract.ts` | CCTask | `proposed_task_needs_approval`, `completed_has_timestamp` |
| `goal.contract.ts` | Goal | `completed_has_timestamp` — completed goals require `completedAt` |
| `memory-entry.contract.ts` | MemoryEntry (CRIX) | `refuted_entry_not_canonical`, `high_confidence_for_canonical` (≥0.9) |
| `executor-router.contract.ts` | ExecutorRouter | I1–I6: fallback coverage, tier ordering, single default, acyclic DAG |
| `issue-68.contract.ts` | WasmGraph (blueprint) | Snapshot invariants for BFS spreading activation — impl pending |

Contracts define:
- **Schema** — Zod shapes for the entity and all operation inputs
- **Operations** — named mutations with input schema, output, and emitted events
- **State machine** — field, initial state, and allowed transitions per state
- **Invariants** — named `check()` predicates scoped to specific operations via `appliesTo`
- **Authority** — which roles may invoke each operation
- **Surfaces** — API route shapes and D1 table/index declarations

> **Note**: Contract invariant `check()` functions are currently specification-only. Runtime enforcement wiring (calling invariants before D1 writes) is tracked in aegis-oss#75.

## Key design decisions

**Edge-native**: Everything runs in V8 isolates on Cloudflare's edge. No containers, no servers, no cold starts beyond single-digit milliseconds.
Expand Down
6 changes: 5 additions & 1 deletion web/src/claude-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,11 @@ export async function handleInProcessTool(
}
if (name === 'resolve_agenda_item') {
const i = input as { id: number; status: 'done' | 'dismissed' };
await resolveAgendaItem(db, i.id, i.status);
try {
await resolveAgendaItem(db, i.id, i.status);
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
return `Resolved agenda item #${i.id} as ${i.status}`;
}
if (name === 'lookup_cc_session') {
Expand Down
4 changes: 4 additions & 0 deletions web/src/kernel/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
buildMcpRegistry,
EXECUTOR_FNS,
} from './executors/index.js';
import { getExecutorRoute, type LLMExecutor } from './executor-router.js';
// ─── Edge Environment ────────────────────────────────────────

export interface EdgeEnv {
Expand Down Expand Up @@ -346,6 +347,9 @@ async function probeAndExecute(
}
} else {
// Non-streaming or non-Claude executors
// Placeholder guard runs before the switch so named cases can't bypass it.
const route = getExecutorRoute(plan.executor as LLMExecutor);
if (route?.placeholder) throw new Error(`Executor '${plan.executor}' is a placeholder and cannot be dispatched`);
switch (plan.executor) {
case 'claude':
case 'claude_opus': {
Expand Down
7 changes: 5 additions & 2 deletions web/src/kernel/executor-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface ExecutorRoute {
// Cost classification: premium > standard > free.
// Fallback invariant: fallback.tier ≤ this.tier (never upgrade cost on failure).
tier: ExecutorTier;
// isDefault=true: the nominal default executor for the dispatch layer.
// Exactly one route may carry this flag (enforced by I5 in the contract).
isDefault?: true;
// placeholder=true: executor is forward-declared but not yet wired.
// Consumers must skip dispatch for placeholder routes.
placeholder?: true;
Expand Down Expand Up @@ -87,8 +90,8 @@ export const EXECUTOR_ROUTES: Record<LLMExecutor, ExecutorRoute> = {
// groqResponseModel = 8B (llama-3.1-8b-instant) — fast/cheap for greetings.
// Intentionally NOT groqModel (70B). See executors/groq.ts:12.
model: (env) => env.groqResponseModel,
// Falls back to CF Workers AI (free tier) on Groq API failure.
fallback: 'workers_ai',
// No fallback declared: dispatch does not yet read route.fallback at runtime.
// Wiring groq → workers_ai fallback is tracked in aegis-oss#75.
},
cerebras_mid: {
provider: 'cerebras',
Expand Down
8 changes: 6 additions & 2 deletions web/src/kernel/memory/agenda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,13 @@ export async function resolveAgendaItem(
id: number,
status: 'done' | 'dismissed',
): Promise<void> {
await db.prepare(
"UPDATE agent_agenda SET status = ?, resolved_at = datetime('now') WHERE id = ?"
// Contract: resolve/dismiss are only valid from 'active'. Terminal states have no outgoing transitions.
const result = await db.prepare(
"UPDATE agent_agenda SET status = ?, resolved_at = datetime('now') WHERE id = ? AND status = 'active'"
).bind(status, id).run();
if (result.meta.changes === 0) {
throw new Error(`Agenda item ${id} is not in 'active' state or does not exist`);
}
}

export const PROPOSED_ACTION_PREFIX = '[PROPOSED ACTION]';
Expand Down
14 changes: 13 additions & 1 deletion web/src/kernel/scheduled/dreaming/agenda-triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,19 @@ export async function triageAgendaToIssues(env: EdgeEnv): Promise<number> {
`${item.body ?? ''}\n\n---\n_Promoted from AEGIS agenda item #${item.id} by dreaming triage._`,
labels,
);
await resolveAgendaItem(env.db, item.id, 'done');

// Resolve separately so a DB hiccup after a successful createIssue doesn't
// leave the item active (and cause a duplicate issue on the next cycle).
try {
await resolveAgendaItem(env.db, item.id, 'done');
} catch (resolveErr) {
console.error(`[dreaming:triage] Issue #${number} created but resolve failed for agenda #${item.id}; attempting dismiss to prevent duplicate:`, resolveErr instanceof Error ? resolveErr.message : String(resolveErr));
try {
await resolveAgendaItem(env.db, item.id, 'dismissed');
} catch {
console.error(`[dreaming:triage] Agenda #${item.id} could not be resolved or dismissed after issue creation — item will remain active and may generate a duplicate next cycle`);
}
}

const projectIdRow = await env.db.prepare(
"SELECT received_at FROM web_events WHERE event_id = 'board_project_id'"
Expand Down
32 changes: 17 additions & 15 deletions web/src/workers-ai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ export function toOpenAiTools(anthropicTools: unknown[]): OpenAiFunctionTool[] {

const GPT_OSS_RATES = { input: 0.35, output: 0.75 }; // $/MTok

// Ai.run() overloads can't be unified when the model string is dynamic.
// Cast to a loose signature once here; callers re-assert the return as AiChatResponse.
function callAiRun(ai: Ai, model: string, input: unknown): Promise<unknown> {
return (ai.run as (m: Parameters<Ai['run']>[0], i: unknown) => Promise<unknown>)(
model as Parameters<Ai['run']>[0],
input,
);
}

// ─── Main executor ──────────────────────────────────────────

const MAX_TOOL_ROUNDS = 10;
Expand Down Expand Up @@ -207,14 +216,10 @@ export async function executeWorkersAiChat(

// Phase 1: Tool execution rounds (0 to TOOL_ROUNDS-1)
for (let round = 0; round < TOOL_ROUNDS; round++) {
const result = await config.ai.run(config.model as Parameters<Ai['run']>[0], {
messages,
tools,
max_tokens: 4096,
temperature: 0.2,
top_p: 0.9,
frequency_penalty: 0.3,
} as Record<string, unknown>) as AiChatResponse;
const result = await callAiRun(
config.ai, config.model,
{ messages, tools, max_tokens: 4096, temperature: 0.2, top_p: 0.9, frequency_penalty: 0.3 },
) as AiChatResponse;

const usage = extractUsage(result);
if (usage) {
Expand Down Expand Up @@ -300,13 +305,10 @@ export async function executeWorkersAiChat(

let summaryText: string | undefined;
try {
const summaryResult = await config.ai.run(config.model as Parameters<Ai['run']>[0], {
messages: condensed,
max_tokens: 4096,
temperature: 0.2,
top_p: 0.9,
frequency_penalty: 0.3,
} as Record<string, unknown>) as AiChatResponse;
const summaryResult = await callAiRun(
config.ai, config.model,
{ messages: condensed, max_tokens: 4096, temperature: 0.2, top_p: 0.9, frequency_penalty: 0.3 },
) as AiChatResponse;

const summaryUsage = extractUsage(summaryResult);
if (summaryUsage) {
Expand Down
Loading
Loading