From b74796cd770c2577047d024ef95f6b563f88b8ae Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:22:36 -0500 Subject: [PATCH 01/16] fix(executor-router): add isDefault to ExecutorRoute interface Contract ExecutorRouteShapeSchema declared isDefault as a valid field but the ExecutorRoute interface omitted it, causing TS2353 on the workers_ai route. Aligns interface with contract. Co-Authored-By: Claude Sonnet 4.6 --- web/src/kernel/executor-router.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/kernel/executor-router.ts b/web/src/kernel/executor-router.ts index 95e3314..7db4665 100644 --- a/web/src/kernel/executor-router.ts +++ b/web/src/kernel/executor-router.ts @@ -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; From 244c6bd8c29ffe76c81d2067abcaf2f5594b0214 Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:22:45 -0500 Subject: [PATCH 02/16] fix(workers-ai): narrow Ai.run() overload cast to silence TS2769/TS2352 CF Workers AI's Ai.run() overload union is too wide for direct casts. Wrap the call via an explicit function signature cast so TypeScript resolves a single unambiguous overload, then cast the return to AiChatResponse. Removes the previous as-Record hack that still leaked the intersection mismatch. Co-Authored-By: Claude Sonnet 4.6 --- web/src/workers-ai-chat.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/web/src/workers-ai-chat.ts b/web/src/workers-ai-chat.ts index 882ef18..4edca17 100755 --- a/web/src/workers-ai-chat.ts +++ b/web/src/workers-ai-chat.ts @@ -207,14 +207,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[0], { - messages, - tools, - max_tokens: 4096, - temperature: 0.2, - top_p: 0.9, - frequency_penalty: 0.3, - } as Record) as AiChatResponse; + const result = await (config.ai.run as (m: Parameters[0], i: unknown) => Promise)( + config.model as Parameters[0], + { messages, tools, max_tokens: 4096, temperature: 0.2, top_p: 0.9, frequency_penalty: 0.3 }, + ) as AiChatResponse; const usage = extractUsage(result); if (usage) { @@ -300,13 +296,10 @@ export async function executeWorkersAiChat( let summaryText: string | undefined; try { - const summaryResult = await config.ai.run(config.model as Parameters[0], { - messages: condensed, - max_tokens: 4096, - temperature: 0.2, - top_p: 0.9, - frequency_penalty: 0.3, - } as Record) as AiChatResponse; + const summaryResult = await (config.ai.run as (m: Parameters[0], i: unknown) => Promise)( + config.model as Parameters[0], + { messages: condensed, max_tokens: 4096, temperature: 0.2, top_p: 0.9, frequency_penalty: 0.3 }, + ) as AiChatResponse; const summaryUsage = extractUsage(summaryResult); if (summaryUsage) { From 51a9036fc8c267228a04bed1588fbac554e7811f Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:22:55 -0500 Subject: [PATCH 03/16] fix(agenda): guard terminal states in resolveAgendaItem The UPDATE had no status check, allowing done/dismissed items to be re-resolved or re-dismissed. Contract declares both as terminal with no outgoing transitions. Add AND status='active' to the WHERE clause and throw when 0 rows are affected (item missing or already terminal). Co-Authored-By: Claude Sonnet 4.6 --- web/src/kernel/memory/agenda.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/kernel/memory/agenda.ts b/web/src/kernel/memory/agenda.ts index 3261265..d6efcaf 100755 --- a/web/src/kernel/memory/agenda.ts +++ b/web/src/kernel/memory/agenda.ts @@ -125,9 +125,13 @@ export async function resolveAgendaItem( id: number, status: 'done' | 'dismissed', ): Promise { - 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]'; From 2dd0eab43e075ad8106bc7870026916bb1fd5995 Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:23:03 -0500 Subject: [PATCH 04/16] =?UTF-8?q?test(agenda):=20OTDD=20contract=20tests?= =?UTF-8?q?=20=E2=80=94=20schema,=20state=20machine,=20invariants,=20autho?= =?UTF-8?q?rity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 48 tests derived from agenda-item.contract.ts. Covers all schema field constraints, all operation input schemas, valid/invalid state machine transitions (done/dismissed as terminals), the resolved_has_timestamp invariant, and authority rules per operation. Co-Authored-By: Claude Sonnet 4.6 --- web/tests/agenda-item-contract.test.ts | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 web/tests/agenda-item-contract.test.ts diff --git a/web/tests/agenda-item-contract.test.ts b/web/tests/agenda-item-contract.test.ts new file mode 100644 index 0000000..2d75b7a --- /dev/null +++ b/web/tests/agenda-item-contract.test.ts @@ -0,0 +1,250 @@ +// Contract conformance tests for the AgendaItem bounded context. +// Derived directly from src/contracts/agenda-item.contract.ts — the source of truth. +// Fixtures are inline objects built from the contract schema; nothing is mocked. + +import { describe, it, expect } from 'vitest'; +import { AgendaItemContract } from '../src/contracts/agenda-item.contract.js'; + +const { schema, operations, states, invariants, authority } = AgendaItemContract; + +// A fully-valid entity, used as a base for targeted mutations. +const validEntity = { + id: 1, + item: 'Review Q3 compliance filing', + context: 'Upstream registry not yet updated', + priority: 'high' as const, + status: 'active' as const, + createdAt: '2026-06-24T10:00:00.000Z', + resolvedAt: null, + businessUnit: 'stackbilt', +}; + +describe('AgendaItem contract — metadata', () => { + it('is named AgendaItem and versioned', () => { + expect(AgendaItemContract.name).toBe('AgendaItem'); + expect(AgendaItemContract.version).toBe('1.0.0'); + }); +}); + +describe('AgendaItem schema — entity validation', () => { + it('accepts a fully-valid entity', () => { + const parsed = schema.parse(validEntity); + expect(parsed.id).toBe(1); + expect(parsed.status).toBe('active'); + }); + + // ── id: positive int ────────────────────────────────────── + it('rejects a non-positive id', () => { + expect(schema.safeParse({ ...validEntity, id: 0 }).success).toBe(false); + expect(schema.safeParse({ ...validEntity, id: -3 }).success).toBe(false); + }); + + it('rejects a non-integer id', () => { + expect(schema.safeParse({ ...validEntity, id: 1.5 }).success).toBe(false); + }); + + // ── item: min(1) ────────────────────────────────────────── + it('rejects an empty item string', () => { + expect(schema.safeParse({ ...validEntity, item: '' }).success).toBe(false); + }); + + it('accepts a single-character item', () => { + expect(schema.safeParse({ ...validEntity, item: 'x' }).success).toBe(true); + }); + + // ── context: nullable ───────────────────────────────────── + it('accepts a null context', () => { + expect(schema.safeParse({ ...validEntity, context: null }).success).toBe(true); + }); + + it('rejects a missing context (nullable, not optional)', () => { + const { context: _omit, ...withoutContext } = validEntity; + expect(schema.safeParse(withoutContext).success).toBe(false); + }); + + // ── priority: enum + default ────────────────────────────── + it('rejects an out-of-enum priority', () => { + expect(schema.safeParse({ ...validEntity, priority: 'urgent' }).success).toBe(false); + }); + + it('defaults priority to medium when omitted', () => { + const { priority: _omit, ...withoutPriority } = validEntity; + expect(schema.parse(withoutPriority).priority).toBe('medium'); + }); + + // ── status: enum + default ──────────────────────────────── + it('rejects an out-of-enum status', () => { + expect(schema.safeParse({ ...validEntity, status: 'archived' }).success).toBe(false); + }); + + it('defaults status to active when omitted', () => { + const { status: _omit, ...withoutStatus } = validEntity; + expect(schema.parse(withoutStatus).status).toBe('active'); + }); + + // ── createdAt: datetime ─────────────────────────────────── + it('rejects a non-ISO createdAt', () => { + expect(schema.safeParse({ ...validEntity, createdAt: '2026-06-24' }).success).toBe(false); + }); + + // ── resolvedAt: nullable datetime ───────────────────────── + it('accepts a null resolvedAt', () => { + expect(schema.safeParse({ ...validEntity, resolvedAt: null }).success).toBe(true); + }); + + it('rejects a non-ISO resolvedAt', () => { + expect(schema.safeParse({ ...validEntity, resolvedAt: 'yesterday' }).success).toBe(false); + }); + + // ── businessUnit: min(1) + default ──────────────────────── + it('rejects an empty businessUnit', () => { + expect(schema.safeParse({ ...validEntity, businessUnit: '' }).success).toBe(false); + }); + + it('defaults businessUnit to stackbilt when omitted', () => { + const { businessUnit: _omit, ...withoutBu } = validEntity; + expect(schema.parse(withoutBu).businessUnit).toBe('stackbilt'); + }); +}); + +describe('AgendaItem operations — input validation', () => { + // ── add ─────────────────────────────────────────────────── + it('add accepts minimal valid input (item only)', () => { + expect(operations.add.input.safeParse({ item: 'Do the thing' }).success).toBe(true); + }); + + it('add rejects an empty item', () => { + expect(operations.add.input.safeParse({ item: '' }).success).toBe(false); + }); + + it('add rejects an out-of-enum priority', () => { + expect(operations.add.input.safeParse({ item: 'x', priority: 'critical' }).success).toBe(false); + }); + + it('add rejects an empty businessUnit when provided', () => { + expect(operations.add.input.safeParse({ item: 'x', businessUnit: '' }).success).toBe(false); + }); + + // ── resolve / dismiss / escalate share the id-only input ── + for (const op of ['resolve', 'dismiss', 'escalate'] as const) { + it(`${op} accepts a positive integer id`, () => { + expect(operations[op].input.safeParse({ id: 42 }).success).toBe(true); + }); + + it(`${op} rejects a non-positive id`, () => { + expect(operations[op].input.safeParse({ id: 0 }).success).toBe(false); + }); + + it(`${op} rejects a missing id`, () => { + expect(operations[op].input.safeParse({}).success).toBe(false); + }); + } +}); + +describe('AgendaItem operations — declared transitions & events', () => { + it('resolve declares active → done', () => { + expect(operations.resolve.transition).toEqual({ from: 'active', to: 'done' }); + expect(operations.resolve.emits).toContain('agenda_item.resolved'); + }); + + it('dismiss declares active → dismissed', () => { + expect(operations.dismiss.transition).toEqual({ from: 'active', to: 'dismissed' }); + expect(operations.dismiss.emits).toContain('agenda_item.dismissed'); + }); + + it('escalate declares no state transition', () => { + expect(operations.escalate.transition).toBeUndefined(); + expect(operations.escalate.emits).toContain('agenda_item.escalated'); + }); + + it('add emits agenda_item.added', () => { + expect(operations.add.emits).toContain('agenda_item.added'); + }); +}); + +describe('AgendaItem state machine', () => { + const transitions = states!.transitions; + + it('starts in the active state', () => { + expect(states!.initial).toBe('active'); + expect(states!.field).toBe('status'); + }); + + // ── valid transitions ───────────────────────────────────── + it('allows active → done via resolve', () => { + expect(transitions.active.resolve).toBe('done'); + }); + + it('allows active → dismissed via dismiss', () => { + expect(transitions.active.dismiss).toBe('dismissed'); + }); + + // ── invalid transitions: terminal states ────────────────── + it('done is terminal — no resolve out of it', () => { + expect(transitions.done.resolve).toBeUndefined(); + }); + + it('done is terminal — no dismiss out of it', () => { + expect(transitions.done.dismiss).toBeUndefined(); + }); + + it('dismissed is terminal — no resolve out of it', () => { + expect(transitions.dismissed.resolve).toBeUndefined(); + }); + + it('dismissed is terminal — no dismiss out of it', () => { + expect(transitions.dismissed.dismiss).toBeUndefined(); + }); + + it('every declared state is reachable from the transition map', () => { + const targets = new Set([states!.initial]); + for (const fromState of Object.values(transitions)) { + for (const to of Object.values(fromState)) { + if (to) targets.add(to); + } + } + expect(targets).toEqual(new Set(['active', 'done', 'dismissed'])); + }); +}); + +describe('AgendaItem invariants — resolved_has_timestamp', () => { + const inv = invariants!.find((i) => i.name === 'resolved_has_timestamp')!; + + it('is declared and applies to resolve', () => { + expect(inv).toBeDefined(); + expect(inv.appliesTo).toContain('resolve'); + }); + + it('passes when a done item has a resolvedAt', () => { + const check = inv.check({ status: 'done', resolvedAt: '2026-06-24T11:00:00.000Z' }); + expect(check).toBe(true); + }); + + it('triggers when a done item lacks a resolvedAt', () => { + const check = inv.check({ status: 'done', resolvedAt: null }); + expect(typeof check).toBe('string'); + expect(check).toBe('Done agenda item requires resolvedAt'); + }); + + it('passes for non-done items regardless of resolvedAt', () => { + expect(inv.check({ status: 'active', resolvedAt: null })).toBe(true); + expect(inv.check({ status: 'dismissed', resolvedAt: null })).toBe(true); + }); +}); + +describe('AgendaItem authority', () => { + it('add / resolve / dismiss are open to operator and system roles', () => { + for (const op of ['add', 'resolve', 'dismiss'] as const) { + const rule = authority[op]; + expect(rule.requires).toBe('role'); + expect((rule as { roles: string[] }).roles).toEqual(['operator', 'system']); + } + }); + + it('escalate is restricted to the system role', () => { + const rule = authority.escalate; + expect(rule.requires).toBe('role'); + expect((rule as { roles: string[] }).roles).toEqual(['system']); + expect((rule as { roles: string[] }).roles).not.toContain('operator'); + }); +}); From 0fd71684014b97d8b17542b970c382ce772fef30 Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:23:10 -0500 Subject: [PATCH 05/16] =?UTF-8?q?test(cc-task):=20OTDD=20contract=20tests?= =?UTF-8?q?=20=E2=80=94=20schema,=20state=20machine,=20invariants,=20autho?= =?UTF-8?q?rity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 53 tests derived from cc-task.contract.ts. Covers all schema constraints, operation inputs, multi-source cancel transition (pending|running→cancelled), proposed_task_needs_approval and completed_has_timestamp invariants, and authority rules. Co-Authored-By: Claude Sonnet 4.6 --- web/tests/cc-task-contract.test.ts | 284 +++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 web/tests/cc-task-contract.test.ts diff --git a/web/tests/cc-task-contract.test.ts b/web/tests/cc-task-contract.test.ts new file mode 100644 index 0000000..7f30f85 --- /dev/null +++ b/web/tests/cc-task-contract.test.ts @@ -0,0 +1,284 @@ +// Contract-level tests for the CCTask bounded context. +// Validates the declarative contract artifact directly — schema constraints, +// operation input schemas, the status state machine, and runtime invariants. +// No mocks: fixtures are inline objects parsed through the contract itself. + +import { describe, it, expect } from 'vitest'; +import { CCTaskContract } from '../src/contracts/cc-task.contract.js'; + +const { schema, operations, states, invariants } = CCTaskContract; + +// Minimal valid entity that satisfies every required field of the schema. +function validEntity(overrides: Record = {}) { + return { + id: 't1', + title: 'Do the thing', + repo: 'aegis', + prompt: 'Make it so', + completionSignal: null, + dependsOn: null, + blockedBy: null, + allowedTools: null, + sessionId: null, + result: null, + error: null, + exitCode: null, + preflightJson: null, + failureKind: null, + autopsyJson: null, + createdAt: '2026-06-24T00:00:00.000Z', + startedAt: null, + completedAt: null, + branch: null, + prUrl: null, + utilityJson: null, + githubIssueRepo: null, + githubIssueNumber: null, + ...overrides, + }; +} + +function inv(name: string) { + const found = invariants?.find((i) => i.name === name); + if (!found) throw new Error(`invariant ${name} not found in contract`); + return found; +} + +// ─── Schema field constraints ───────────────────────────────── + +describe('CCTask schema', () => { + it('parses a minimal valid entity and applies defaults', () => { + const parsed = schema.parse(validEntity()); + expect(parsed.status).toBe('pending'); + expect(parsed.priority).toBe(50); + expect(parsed.maxTurns).toBe(25); + expect(parsed.retryable).toBe(false); + expect(parsed.createdBy).toBe('operator'); + expect(parsed.authority).toBe('operator'); + expect(parsed.category).toBe('feature'); + expect(parsed.businessUnit).toBe('stackbilt'); + }); + + it.each(['id', 'title', 'repo', 'prompt'])('rejects empty %s', (field) => { + expect(() => schema.parse(validEntity({ [field]: '' }))).toThrow(); + }); + + it('rejects a missing required field', () => { + const e = validEntity(); + delete (e as Record).repo; + expect(() => schema.parse(e)).toThrow(); + }); + + it('accepts every TaskStatus enum value', () => { + for (const s of ['pending', 'running', 'completed', 'failed', 'cancelled']) { + expect(schema.parse(validEntity({ status: s })).status).toBe(s); + } + }); + + it('rejects an unknown status', () => { + expect(() => schema.parse(validEntity({ status: 'paused' }))).toThrow(); + }); + + it('accepts every TaskAuthority enum value', () => { + for (const a of ['proposed', 'auto_safe', 'operator']) { + expect(schema.parse(validEntity({ authority: a })).authority).toBe(a); + } + }); + + it('rejects an unknown authority', () => { + expect(() => schema.parse(validEntity({ authority: 'admin' }))).toThrow(); + }); + + it('accepts every TaskCategory enum value', () => { + for (const c of ['docs', 'tests', 'research', 'bugfix', 'feature', 'refactor', 'deploy']) { + expect(schema.parse(validEntity({ category: c })).category).toBe(c); + } + }); + + it('rejects an unknown category', () => { + expect(() => schema.parse(validEntity({ category: 'yolo' }))).toThrow(); + }); + + it('accepts priority at both bounds', () => { + expect(schema.parse(validEntity({ priority: 0 })).priority).toBe(0); + expect(schema.parse(validEntity({ priority: 100 })).priority).toBe(100); + }); + + it.each([-1, 101])('rejects out-of-range priority %i', (p) => { + expect(() => schema.parse(validEntity({ priority: p }))).toThrow(); + }); + + it('rejects a non-integer priority', () => { + expect(() => schema.parse(validEntity({ priority: 12.5 }))).toThrow(); + }); + + it('rejects a non-positive maxTurns', () => { + expect(() => schema.parse(validEntity({ maxTurns: 0 }))).toThrow(); + }); + + it('rejects a non-positive githubIssueNumber but accepts a positive one', () => { + expect(() => schema.parse(validEntity({ githubIssueNumber: 0 }))).toThrow(); + expect(schema.parse(validEntity({ githubIssueNumber: 72 })).githubIssueNumber).toBe(72); + }); + + it('rejects a non-datetime createdAt', () => { + expect(() => schema.parse(validEntity({ createdAt: 'yesterday' }))).toThrow(); + }); + + it('allows nullable timestamps but rejects malformed ones', () => { + expect(schema.parse(validEntity({ startedAt: null })).startedAt).toBeNull(); + expect(() => schema.parse(validEntity({ startedAt: 'soon' }))).toThrow(); + }); +}); + +// ─── Operation input schemas ────────────────────────────────── + +describe('CCTask operation inputs', () => { + it('create accepts the minimal required input', () => { + const parsed = operations.create.input.parse({ + id: 't1', title: 'T', repo: 'aegis', prompt: 'go', + }); + expect(parsed.id).toBe('t1'); + }); + + it('create rejects missing prompt', () => { + expect(() => operations.create.input.parse({ + id: 't1', title: 'T', repo: 'aegis', + })).toThrow(); + }); + + it('create rejects out-of-range priority', () => { + expect(() => operations.create.input.parse({ + id: 't1', title: 'T', repo: 'aegis', prompt: 'go', priority: 200, + })).toThrow(); + }); + + it('start requires a non-empty sessionId', () => { + expect(operations.start.input.parse({ id: 't1', sessionId: 's1' }).sessionId).toBe('s1'); + expect(() => operations.start.input.parse({ id: 't1', sessionId: '' })).toThrow(); + expect(() => operations.start.input.parse({ id: 't1' })).toThrow(); + }); + + it('fail requires a non-empty error', () => { + expect(operations.fail.input.parse({ id: 't1', error: 'boom' }).error).toBe('boom'); + expect(() => operations.fail.input.parse({ id: 't1', error: '' })).toThrow(); + expect(() => operations.fail.input.parse({ id: 't1' })).toThrow(); + }); + + it('complete accepts optional result/exitCode/prUrl', () => { + const parsed = operations.complete.input.parse({ + id: 't1', result: 'done', exitCode: 0, prUrl: 'http://pr', + }); + expect(parsed.exitCode).toBe(0); + }); + + it('cancel and approve require only an id', () => { + expect(operations.cancel.input.parse({ id: 't1' }).id).toBe('t1'); + expect(operations.approve.input.parse({ id: 't1' }).id).toBe('t1'); + expect(() => operations.cancel.input.parse({ id: '' })).toThrow(); + }); +}); + +// ─── State machine ──────────────────────────────────────────── + +describe('CCTask state machine', () => { + const transitions = states!.transitions; + + it('declares pending as the initial state', () => { + expect(states!.initial).toBe('pending'); + expect(states!.field).toBe('status'); + }); + + // Valid transitions, derived from contract.states.transitions. + it.each([ + ['pending', 'start', 'running'], + ['pending', 'cancel', 'cancelled'], + ['running', 'complete', 'completed'], + ['running', 'fail', 'failed'], + ['running', 'cancel', 'cancelled'], + ])('allows %s --%s--> %s', (from, op, to) => { + expect(transitions[from][op]).toBe(to); + }); + + // Invalid transition attempts: operation not defined for that state. + it.each([ + ['pending', 'complete'], + ['pending', 'fail'], + ['running', 'start'], + ['completed', 'start'], + ['completed', 'cancel'], + ['failed', 'complete'], + ['cancelled', 'start'], + ])('rejects %s --%s-->', (from, op) => { + expect(transitions[from][op]).toBeUndefined(); + }); + + it('marks completed, failed, and cancelled as terminal', () => { + expect(transitions.completed).toEqual({}); + expect(transitions.failed).toEqual({}); + expect(transitions.cancelled).toEqual({}); + }); + + it('cancel operation declares a multi-source transition', () => { + expect(operations.cancel.transition).toEqual({ + from: ['pending', 'running'], + to: 'cancelled', + }); + }); + + it('every operation transition agrees with the states map', () => { + for (const [op, def] of Object.entries(operations)) { + const t = def.transition; + if (!t) continue; + const froms = Array.isArray(t.from) ? t.from : [t.from]; + for (const from of froms) { + expect(transitions[from][op]).toBe(t.to); + } + } + }); +}); + +// ─── Invariants ─────────────────────────────────────────────── + +describe('CCTask invariant: proposed_task_needs_approval', () => { + const check = inv('proposed_task_needs_approval').check; + + it('applies to the start operation', () => { + expect(inv('proposed_task_needs_approval').appliesTo).toContain('start'); + }); + + it('violates when a proposed task is set running', () => { + const result = check({ authority: 'proposed', status: 'running' }); + expect(result).toBe('Proposed tasks require approval before execution'); + }); + + it('passes when an operator task is set running', () => { + expect(check({ authority: 'operator', status: 'running' })).toBe(true); + }); + + it('passes when a proposed task is still pending', () => { + expect(check({ authority: 'proposed', status: 'pending' })).toBe(true); + }); +}); + +describe('CCTask invariant: completed_has_timestamp', () => { + const check = inv('completed_has_timestamp').check; + + it('applies to complete and fail operations', () => { + expect(inv('completed_has_timestamp').appliesTo).toEqual( + expect.arrayContaining(['complete', 'fail']), + ); + }); + + it.each(['completed', 'failed'])('violates when %s has no completedAt', (status) => { + expect(check({ status, completedAt: null })).toBe('Terminal tasks require completedAt'); + }); + + it.each(['completed', 'failed'])('passes when %s has a completedAt', (status) => { + expect(check({ status, completedAt: '2026-06-24T00:00:00.000Z' })).toBe(true); + }); + + it('passes for a running task with no completedAt', () => { + expect(check({ status: 'running', completedAt: null })).toBe(true); + }); +}); From 64c262b49bc419f9449686ff95f998aa055d9d96 Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:23:22 -0500 Subject: [PATCH 06/16] =?UTF-8?q?test(goal):=20OTDD=20contract=20tests=20?= =?UTF-8?q?=E2=80=94=20schema,=20state=20machine,=20invariants,=20authorit?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 65 tests derived from goal.contract.ts. Covers schema constraints, all valid transitions including multi-source (active|paused→completed/failed), terminal state enforcement, the completed_has_timestamp invariant, and authority rules (terminal ops and recordRun are system-only). Co-Authored-By: Claude Sonnet 4.6 --- web/tests/goal-contract.test.ts | 304 ++++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 web/tests/goal-contract.test.ts diff --git a/web/tests/goal-contract.test.ts b/web/tests/goal-contract.test.ts new file mode 100644 index 0000000..a17a56c --- /dev/null +++ b/web/tests/goal-contract.test.ts @@ -0,0 +1,304 @@ +// Contract-derived tests for the Goal bounded context. +// Fixtures are derived directly from GoalContract — no mocks. +// Source of truth: src/contracts/goal.contract.ts + +import { describe, it, expect } from 'vitest'; +import { GoalContract } from '../src/contracts/goal.contract.js'; + +// ─── Helpers derived from the contract ─────────────────────── + +const schema = GoalContract.schema; + +/** Minimal valid entity built from the contract's defaults + required fields. */ +function validGoal(overrides: Record = {}) { + return { + id: 'goal-1', + title: 'Ship the thing', + description: null, + status: 'active', + authorityLevel: 'propose', + scheduleHours: 6, + createdAt: '2026-06-24T00:00:00.000Z', + lastRunAt: null, + nextRunAt: null, + completedAt: null, + runCount: 0, + contextJson: null, + businessUnit: 'stackbilt', + ...overrides, + }; +} + +/** Resolve the target state for an operation given the current state, per the contract. */ +function transitionTarget(from: string, operation: string): string | null | undefined { + return GoalContract.states?.transitions[from]?.[operation]; +} + +const invariant = (name: string) => + GoalContract.invariants?.find((i) => i.name === name); + +// ─── Schema validation ─────────────────────────────────────── + +describe('Goal schema', () => { + it('accepts a fully-specified valid entity', () => { + expect(schema.safeParse(validGoal()).success).toBe(true); + }); + + it('applies declared defaults for omitted optional fields', () => { + const parsed = schema.parse({ + id: 'goal-1', + title: 'Ship it', + description: null, + createdAt: '2026-06-24T00:00:00.000Z', + lastRunAt: null, + nextRunAt: null, + completedAt: null, + contextJson: null, + }); + expect(parsed.status).toBe('active'); + expect(parsed.authorityLevel).toBe('propose'); + expect(parsed.scheduleHours).toBe(6); + expect(parsed.runCount).toBe(0); + expect(parsed.businessUnit).toBe('stackbilt'); + }); + + it('rejects an empty id', () => { + expect(schema.safeParse(validGoal({ id: '' })).success).toBe(false); + }); + + it('rejects an empty title', () => { + expect(schema.safeParse(validGoal({ title: '' })).success).toBe(false); + }); + + it('allows a null description but rejects a missing one', () => { + expect(schema.safeParse(validGoal({ description: null })).success).toBe(true); + const { description: _omit, ...withoutDescription } = validGoal(); + expect(schema.safeParse(withoutDescription).success).toBe(false); + }); + + describe('scheduleHours', () => { + it('rejects zero (must be positive)', () => { + expect(schema.safeParse(validGoal({ scheduleHours: 0 })).success).toBe(false); + }); + it('rejects negative values', () => { + expect(schema.safeParse(validGoal({ scheduleHours: -3 })).success).toBe(false); + }); + it('rejects non-integer values', () => { + expect(schema.safeParse(validGoal({ scheduleHours: 1.5 })).success).toBe(false); + }); + it('accepts a positive integer', () => { + expect(schema.safeParse(validGoal({ scheduleHours: 12 })).success).toBe(true); + }); + }); + + describe('runCount', () => { + it('accepts zero (non-negative)', () => { + expect(schema.safeParse(validGoal({ runCount: 0 })).success).toBe(true); + }); + it('rejects negative values', () => { + expect(schema.safeParse(validGoal({ runCount: -1 })).success).toBe(false); + }); + it('rejects non-integer values', () => { + expect(schema.safeParse(validGoal({ runCount: 2.5 })).success).toBe(false); + }); + }); + + describe('status enum', () => { + for (const status of ['active', 'paused', 'completed', 'failed']) { + it(`accepts "${status}"`, () => { + expect(schema.safeParse(validGoal({ status })).success).toBe(true); + }); + } + it('rejects an unknown status', () => { + expect(schema.safeParse(validGoal({ status: 'archived' })).success).toBe(false); + }); + }); + + describe('authorityLevel enum', () => { + for (const level of ['propose', 'auto_low', 'auto_high']) { + it(`accepts "${level}"`, () => { + expect(schema.safeParse(validGoal({ authorityLevel: level })).success).toBe(true); + }); + } + it('rejects an unknown authority level', () => { + expect(schema.safeParse(validGoal({ authorityLevel: 'auto_full' })).success).toBe(false); + }); + }); + + describe('datetime fields', () => { + it('rejects a non-ISO createdAt', () => { + expect(schema.safeParse(validGoal({ createdAt: '2026-06-24' })).success).toBe(false); + }); + it('accepts a null completedAt', () => { + expect(schema.safeParse(validGoal({ completedAt: null })).success).toBe(true); + }); + it('rejects a non-ISO completedAt', () => { + expect(schema.safeParse(validGoal({ completedAt: 'yesterday' })).success).toBe(false); + }); + }); + + it('rejects an empty businessUnit', () => { + expect(schema.safeParse(validGoal({ businessUnit: '' })).success).toBe(false); + }); +}); + +// ─── Operation input schemas ───────────────────────────────── + +describe('create operation input', () => { + const input = GoalContract.operations.create.input; + + it('accepts minimal valid input (id + title)', () => { + expect(input.safeParse({ id: 'g1', title: 'Do a thing' }).success).toBe(true); + }); + + it('rejects missing title', () => { + expect(input.safeParse({ id: 'g1' }).success).toBe(false); + }); + + it('rejects empty id', () => { + expect(input.safeParse({ id: '', title: 'x' }).success).toBe(false); + }); + + it('rejects a non-positive scheduleHours', () => { + expect(input.safeParse({ id: 'g1', title: 'x', scheduleHours: 0 }).success).toBe(false); + }); + + it('rejects an invalid authorityLevel', () => { + expect(input.safeParse({ id: 'g1', title: 'x', authorityLevel: 'nope' }).success).toBe(false); + }); +}); + +describe('lifecycle operation inputs require an id', () => { + for (const op of ['pause', 'resume', 'complete', 'fail', 'recordRun']) { + it(`${op} rejects empty id`, () => { + expect(GoalContract.operations[op].input.safeParse({ id: '' }).success).toBe(false); + }); + it(`${op} accepts a valid id`, () => { + expect(GoalContract.operations[op].input.safeParse({ id: 'g1' }).success).toBe(true); + }); + } + + it('recordRun accepts an optional nextRunAt', () => { + const input = GoalContract.operations.recordRun.input; + expect(input.safeParse({ id: 'g1', nextRunAt: '2026-06-24T06:00:00.000Z' }).success).toBe(true); + expect(input.safeParse({ id: 'g1', nextRunAt: 'soon' }).success).toBe(false); + }); +}); + +// ─── State machine ─────────────────────────────────────────── + +describe('Goal state machine', () => { + it('starts in the active state', () => { + expect(GoalContract.states?.initial).toBe('active'); + }); + + describe('valid transitions', () => { + const valid: Array<[string, string, string]> = [ + ['active', 'pause', 'paused'], + ['active', 'complete', 'completed'], + ['active', 'fail', 'failed'], + ['paused', 'resume', 'active'], + ['paused', 'complete', 'completed'], + ['paused', 'fail', 'failed'], + ]; + for (const [from, op, to] of valid) { + it(`${from} --${op}--> ${to}`, () => { + expect(transitionTarget(from, op)).toBe(to); + }); + } + }); + + describe('invalid transitions', () => { + const invalid: Array<[string, string]> = [ + ['active', 'resume'], // can only resume from paused + ['paused', 'pause'], // already paused + ['completed', 'resume'], // terminal + ['completed', 'pause'], + ['completed', 'complete'], + ['failed', 'resume'], // terminal + ['failed', 'complete'], + ]; + for (const [from, op] of invalid) { + it(`${from} cannot ${op}`, () => { + expect(transitionTarget(from, op)).toBeUndefined(); + }); + } + }); + + it('completed is a terminal state (no outgoing transitions)', () => { + expect(GoalContract.states?.transitions.completed).toEqual({}); + }); + + it('failed is a terminal state (no outgoing transitions)', () => { + expect(GoalContract.states?.transitions.failed).toEqual({}); + }); + + describe('multi-source operations agree with the operation transition declarations', () => { + it('complete accepts both active and paused as sources', () => { + const t = GoalContract.operations.complete.transition; + expect(t?.from).toEqual(['active', 'paused']); + expect(t?.to).toBe('completed'); + // every declared source must reach the declared target in the states map + for (const from of t!.from as string[]) { + expect(transitionTarget(from, 'complete')).toBe('completed'); + } + }); + + it('fail accepts both active and paused as sources', () => { + const t = GoalContract.operations.fail.transition; + expect(t?.from).toEqual(['active', 'paused']); + expect(t?.to).toBe('failed'); + for (const from of t!.from as string[]) { + expect(transitionTarget(from, 'fail')).toBe('failed'); + } + }); + }); +}); + +// ─── Invariant: completed_has_timestamp ────────────────────── + +describe('invariant: completed_has_timestamp', () => { + const inv = invariant('completed_has_timestamp'); + + it('is declared and applies to the complete operation', () => { + expect(inv).toBeDefined(); + expect(inv!.appliesTo).toContain('complete'); + }); + + it('fails when a completed goal has no completedAt', () => { + const result = inv!.check(validGoal({ status: 'completed', completedAt: null })); + expect(result).toBe('Completed goal requires completedAt'); + }); + + it('passes when a completed goal has a completedAt', () => { + const result = inv!.check( + validGoal({ status: 'completed', completedAt: '2026-06-24T00:00:00.000Z' }), + ); + expect(result).toBe(true); + }); + + it('passes for non-completed goals regardless of completedAt', () => { + expect(inv!.check(validGoal({ status: 'active', completedAt: null }))).toBe(true); + expect(inv!.check(validGoal({ status: 'paused', completedAt: null }))).toBe(true); + expect(inv!.check(validGoal({ status: 'failed', completedAt: null }))).toBe(true); + }); +}); + +// ─── Authority rules ───────────────────────────────────────── + +describe('Goal authority rules', () => { + it('restricts terminal transitions (complete/fail) and recordRun to system', () => { + for (const op of ['complete', 'fail', 'recordRun']) { + const auth = GoalContract.authority[op]; + expect(auth).toEqual({ requires: 'role', roles: ['system'] }); + } + }); + + it('allows operators and system to create, pause, and resume', () => { + for (const op of ['create', 'pause', 'resume']) { + const auth = GoalContract.authority[op] as { requires: string; roles: string[] }; + expect(auth.requires).toBe('role'); + expect(auth.roles).toEqual(['operator', 'system']); + } + }); +}); From db7282827130db67988ebd6a80362c112e998a6b Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:23:29 -0500 Subject: [PATCH 07/16] test(executor-router): OTDD I1/I2 violation path tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 tests filling the only gaps in executor-router-semantic.test.ts: the negative cases for I1 (anthropic routes must have a fallback) and I2 (free-tier routes are terminal). I3–I6 violation paths were already covered. All six invariants now have both happy and violation coverage. Co-Authored-By: Claude Sonnet 4.6 --- web/tests/executor-router-contract.test.ts | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 web/tests/executor-router-contract.test.ts diff --git a/web/tests/executor-router-contract.test.ts b/web/tests/executor-router-contract.test.ts new file mode 100644 index 0000000..a5045e6 --- /dev/null +++ b/web/tests/executor-router-contract.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { EXECUTOR_ROUTES } from '../src/kernel/executor-router.js'; +import { + PROVIDER_REQUIRES_FALLBACK, + TIER_IS_TERMINAL, +} from '../src/kernel/executor-router.contract.js'; + +// ─── Contract-Derived Invariant Suite ───────────────────────── +// Complements executor-router-semantic.test.ts. Adds the violation-path +// coverage for I1 (anthropic requires fallback) and I2 (free-tier terminal), +// which the semantic suite only checks on the happy path against the live table. + +describe('executor-router contract invariants', () => { + // ── I1: anthropic-provider routes require a fallback ────────── + describe('I1: anthropic routes require a fallback', () => { + it('every anthropic route in the live table defines a fallback', () => { + const anthropicRoutes = Object.entries(EXECUTOR_ROUTES).filter( + ([, r]) => PROVIDER_REQUIRES_FALLBACK[r.provider], + ); + expect(anthropicRoutes.length).toBeGreaterThan(0); + for (const [name, route] of anthropicRoutes) { + expect(route.fallback, `anthropic route '${name}' must define a fallback`).toBeDefined(); + } + }); + + it('detects an anthropic route missing its required fallback', () => { + // A route guard derived from the contract map: any provider flagged in + // PROVIDER_REQUIRES_FALLBACK with no fallback is a violation. + const offending = { provider: 'anthropic' as const, tier: 'premium' as const }; + const isViolation = + PROVIDER_REQUIRES_FALLBACK[offending.provider] && + (offending as { fallback?: string }).fallback === undefined; + expect(isViolation).toBe(true); + }); + }); + + // ── I2: free-tier routes are terminal ──────────────────────── + describe('I2: free-tier routes are terminal', () => { + it('every free-tier route in the live table has no fallback', () => { + const freeRoutes = Object.entries(EXECUTOR_ROUTES).filter( + ([, r]) => TIER_IS_TERMINAL[r.tier], + ); + expect(freeRoutes.length).toBeGreaterThan(0); + for (const [name, route] of freeRoutes) { + expect(route.fallback, `free-tier route '${name}' must be terminal`).toBeUndefined(); + } + }); + + it('detects a free-tier route that defines a fallback', () => { + const offending = { tier: 'free' as const, fallback: 'gpt_oss' }; + const isViolation = + Boolean(TIER_IS_TERMINAL[offending.tier]) && offending.fallback !== undefined; + expect(isViolation).toBe(true); + }); + }); +}); From 742a875bd16064f86127e8aeccae49c9d5700366 Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:23:36 -0500 Subject: [PATCH 08/16] =?UTF-8?q?test(memory):=20OTDD=20contract=20tests?= =?UTF-8?q?=20=E2=80=94=20CRIX=20pipeline,=20invariants,=20authority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 33 tests derived from memory-entry.contract.ts. Covers CRIX validation pipeline transitions (candidate→validated→expert→canonical), refute terminal edges, refuted_entry_not_canonical and high_confidence_for_canonical invariants (including 0.9 boundary), terminal stage enforcement, and authority rules. Co-Authored-By: Claude Sonnet 4.6 --- web/tests/memory-entry-contract.test.ts | 227 ++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 web/tests/memory-entry-contract.test.ts diff --git a/web/tests/memory-entry-contract.test.ts b/web/tests/memory-entry-contract.test.ts new file mode 100644 index 0000000..2d9ebfe --- /dev/null +++ b/web/tests/memory-entry-contract.test.ts @@ -0,0 +1,227 @@ +// Contract-derived fixture tests for the MemoryEntry bounded context. +// These exercise the contract object itself — its schema constraints, the +// CRIX validation state machine, and the declared invariants — independent +// of the runtime CRIX engine in insights.ts. No DB, no mocks. + +import { describe, it, expect } from 'vitest'; +import { MemoryEntryContract } from '../src/contracts/memory-entry.contract.js'; + +const { schema, operations, states, invariants } = MemoryEntryContract; + +function invariant(name: string) { + const inv = invariants?.find((i) => i.name === name); + if (!inv) throw new Error(`invariant ${name} not found in contract`); + return inv; +} + +// ─── Schema constraints ────────────────────────────────────────── + +describe('MemoryEntry schema', () => { + const base = { + id: 1, + topic: 'aegis', + fact: 'a meaningful fact', + source: 'test', + createdAt: '2026-06-24T00:00:00.000Z', + updatedAt: '2026-06-24T00:00:00.000Z', + expiresAt: null, + validUntil: null, + supersededBy: null, + lastRecalledAt: null, + validators: null, + }; + + it('applies declared defaults (factHash, confidence, strength, validationStage)', () => { + const parsed = schema.parse(base); + expect(parsed.factHash).toBe(''); + expect(parsed.confidence).toBe(0.8); + expect(parsed.strength).toBe(1); + expect(parsed.validationStage).toBe('candidate'); + }); + + it('accepts confidence at the 0 and 1 boundaries', () => { + expect(schema.parse({ ...base, confidence: 0 }).confidence).toBe(0); + expect(schema.parse({ ...base, confidence: 1 }).confidence).toBe(1); + }); + + it('rejects confidence below 0 or above 1', () => { + expect(() => schema.parse({ ...base, confidence: -0.01 })).toThrow(); + expect(() => schema.parse({ ...base, confidence: 1.01 })).toThrow(); + }); + + it('rejects empty topic, fact, or source', () => { + expect(() => schema.parse({ ...base, topic: '' })).toThrow(); + expect(() => schema.parse({ ...base, fact: '' })).toThrow(); + expect(() => schema.parse({ ...base, source: '' })).toThrow(); + }); + + it('rejects non-positive or non-integer id', () => { + expect(() => schema.parse({ ...base, id: 0 })).toThrow(); + expect(() => schema.parse({ ...base, id: -1 })).toThrow(); + expect(() => schema.parse({ ...base, id: 1.5 })).toThrow(); + }); + + it('rejects negative strength but accepts zero', () => { + expect(schema.parse({ ...base, strength: 0 }).strength).toBe(0); + expect(() => schema.parse({ ...base, strength: -1 })).toThrow(); + }); + + it('constrains validationStage to the known enum', () => { + for (const stage of ['candidate', 'validated', 'expert', 'canonical', 'refuted']) { + expect(schema.parse({ ...base, validationStage: stage }).validationStage).toBe(stage); + } + expect(() => schema.parse({ ...base, validationStage: 'unknown' })).toThrow(); + }); + + it('rejects non-datetime createdAt/updatedAt', () => { + expect(() => schema.parse({ ...base, createdAt: 'not-a-date' })).toThrow(); + }); +}); + +// ─── CRIX state machine: valid transitions ─────────────────────── + +describe('CRIX validation pipeline — valid transitions', () => { + it('starts at candidate', () => { + expect(states?.initial).toBe('candidate'); + }); + + it('candidate → validated via validate', () => { + expect(states?.transitions.candidate.validate).toBe('validated'); + }); + + it('validated → expert via promoteToExpert', () => { + expect(states?.transitions.validated.promoteToExpert).toBe('expert'); + }); + + it('expert → canonical via promoteToCanonical', () => { + expect(states?.transitions.expert.promoteToCanonical).toBe('canonical'); + }); + + it('candidate → refuted via refute', () => { + expect(states?.transitions.candidate.refute).toBe('refuted'); + }); + + it('validated → refuted via refute', () => { + expect(states?.transitions.validated.refute).toBe('refuted'); + }); + + it('expert → refuted via refute', () => { + expect(states?.transitions.expert.refute).toBe('refuted'); + }); + + it('operation transition declarations agree with the state map', () => { + expect(operations.validate.transition).toEqual({ from: 'candidate', to: 'validated' }); + expect(operations.promoteToExpert.transition).toEqual({ from: 'validated', to: 'expert' }); + expect(operations.promoteToCanonical.transition).toEqual({ from: 'expert', to: 'canonical' }); + expect(operations.refute.transition).toEqual({ + from: ['candidate', 'validated', 'expert'], + to: 'refuted', + }); + }); +}); + +// ─── CRIX state machine: terminal stages & invalid transitions ─── + +describe('CRIX validation pipeline — terminal stages & invalid transitions', () => { + it('canonical is terminal (no outgoing transitions)', () => { + expect(states?.transitions.canonical).toEqual({}); + }); + + it('refuted is terminal (no outgoing transitions)', () => { + expect(states?.transitions.refuted).toEqual({}); + }); + + it('canonical cannot be refuted (no refute edge from canonical)', () => { + expect(states?.transitions.canonical.refute).toBeUndefined(); + expect(operations.refute.transition?.from).not.toContain('canonical'); + }); + + it('candidate cannot be promoted directly to canonical (no such edge)', () => { + expect(states?.transitions.candidate.promoteToCanonical).toBeUndefined(); + // The only edge into canonical originates from expert. + expect(operations.promoteToCanonical.transition?.from).toBe('expert'); + }); + + it('candidate cannot skip to expert', () => { + expect(states?.transitions.candidate.promoteToExpert).toBeUndefined(); + }); +}); + +// ─── Invariant: refuted_entry_not_canonical ────────────────────── + +describe("invariant refuted_entry_not_canonical", () => { + const check = invariant('refuted_entry_not_canonical').check; + + it('applies to the refute operation', () => { + expect(invariant('refuted_entry_not_canonical').appliesTo).toEqual(['refute']); + }); + + it('fails when a canonical entry is checked against refute', () => { + const result = check({ validationStage: 'canonical' }); + expect(typeof result).toBe('string'); + expect(result).toContain('cannot transition to refuted'); + }); + + it('passes for non-canonical stages', () => { + for (const stage of ['candidate', 'validated', 'expert']) { + expect(check({ validationStage: stage })).toBe(true); + } + }); +}); + +// ─── Invariant: high_confidence_for_canonical ──────────────────── + +describe('invariant high_confidence_for_canonical', () => { + const check = invariant('high_confidence_for_canonical').check; + + it('applies to the promoteToCanonical operation', () => { + expect(invariant('high_confidence_for_canonical').appliesTo).toEqual(['promoteToCanonical']); + }); + + it('fails a canonical entity with confidence 0.5', () => { + const result = check({ validationStage: 'canonical', confidence: 0.5 }); + expect(typeof result).toBe('string'); + expect(result).toContain('confidence >= 0.9'); + }); + + it('passes a canonical entity with confidence 0.95', () => { + expect(check({ validationStage: 'canonical', confidence: 0.95 })).toBe(true); + }); + + it('passes a canonical entity exactly at the 0.9 boundary', () => { + expect(check({ validationStage: 'canonical', confidence: 0.9 })).toBe(true); + }); + + it('ignores confidence for non-canonical stages', () => { + expect(check({ validationStage: 'expert', confidence: 0.1 })).toBe(true); + expect(check({ validationStage: 'candidate', confidence: 0 })).toBe(true); + }); + + it('treats missing confidence on a canonical entry as failing', () => { + const result = check({ validationStage: 'canonical' }); + expect(typeof result).toBe('string'); + }); +}); + +// ─── Authority rules ───────────────────────────────────────────── + +describe('MemoryEntry authority', () => { + const authority = MemoryEntryContract.authority; + + it('allows operator and system to record and refute', () => { + expect(authority.record).toEqual({ requires: 'role', roles: ['operator', 'system'] }); + expect(authority.refute).toEqual({ requires: 'role', roles: ['operator', 'system'] }); + }); + + it('restricts pipeline promotions to system only', () => { + for (const op of ['validate', 'promoteToExpert', 'promoteToCanonical', 'recall', 'expire']) { + expect(authority[op]).toEqual({ requires: 'role', roles: ['system'] }); + } + }); + + it('never grants an operation a public/authenticated requirement', () => { + for (const rule of Object.values(authority)) { + expect(rule.requires).toBe('role'); + } + }); +}); From c04c41d4f84642b5df65a0376ef46d59d986f2a8 Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:23:44 -0500 Subject: [PATCH 09/16] chore(governance): record charter audit event (score 50/100) Appends the audit telemetry entry from the OTDD baseline governance check. Trailer coverage at 0% is the primary drag; pattern definitions and policy docs are at full score. Co-Authored-By: Claude Sonnet 4.6 --- .charter/telemetry/events.ndjson | 1 + 1 file changed, 1 insertion(+) diff --git a/.charter/telemetry/events.ndjson b/.charter/telemetry/events.ndjson index 0822617..3ffca48 100644 --- a/.charter/telemetry/events.ndjson +++ b/.charter/telemetry/events.ndjson @@ -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} From 273e25512d59396b2c00d1e9fd61dceea51da75c Mon Sep 17 00:00:00 2001 From: Aegis Date: Wed, 24 Jun 2026 16:25:53 -0500 Subject: [PATCH 10/16] docs: document OTDD contract ontology and update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md: add Unreleased entry for OTDD test suites and three bug fixes (executor-router interface, workers-ai cast, agenda terminal guard) - docs/architecture.md: add Contract Ontology section documenting all six .contract.ts files, their invariants, and the OTDD approach; notes #75 for runtime enforcement wiring - CONTRIBUTING.md: add OTDD section explaining how to write contract tests (schema, state machine, invariants, authority — no mocks) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++++++++++ CONTRIBUTING.md | 14 ++++++++++++++ docs/architecture.md | 23 +++++++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e51c2..61b9eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ee763a..66e118e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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` diff --git a/docs/architecture.md b/docs/architecture.md index 5d8c1ec..5e3eab7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. From e7879b209752024aec122064879e4e4c28a67d0d Mon Sep 17 00:00:00 2001 From: Aegis Date: Fri, 26 Jun 2026 03:44:25 -0500 Subject: [PATCH 11/16] chore: ignore .agent-memory/ directories Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f95872c..ba19b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ C:* node_modules/ .pnpm-store/ __pycache__/ +.agent-memory/ From ae138f0e12155d6f89bed3eace1e6ebbe03009e9 Mon Sep 17 00:00:00 2001 From: Aegis Date: Fri, 26 Jun 2026 04:02:45 -0500 Subject: [PATCH 12/16] =?UTF-8?q?fix(review):=20address=20PR=20#76=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20dispatch,=20tool=20handler,=20ai-chat=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove undeclared groq → workers_ai fallback from EXECUTOR_ROUTES (dispatch never reads route.fallback; false resilience signal removed; wiring tracked in #75) - Guard placeholder routes in dispatch default branch before EXECUTOR_FNS lookup so cerebras_mid/reasoning produce a clear error rather than 'Unknown executor' - Catch resolveAgendaItem throw in handleInProcessTool's resolve_agenda_item branch so an already-resolved item returns an error string to the model instead of killing the tool-call loop - Isolate resolveAgendaItem in agenda-triage into its own try/catch so a DB failure after a successful createIssue doesn't leave the item active and trigger duplicate issue creation on the next cycle - Add two missing tests for resolveAgendaItem: AND status='active' SQL guard and throw-on-changes=0 coverage - Extract duplicated Ai.run() overload cast into callAiRun() helper Co-Authored-By: Claude Sonnet 4.6 --- web/src/claude-tools/index.ts | 6 +++++- web/src/kernel/dispatch.ts | 3 +++ web/src/kernel/executor-router.ts | 4 ++-- .../kernel/scheduled/dreaming/agenda-triage.ts | 9 ++++++++- web/src/workers-ai-chat.ts | 17 +++++++++++++---- web/tests/memory.test.ts | 13 +++++++++++++ 6 files changed, 44 insertions(+), 8 deletions(-) diff --git a/web/src/claude-tools/index.ts b/web/src/claude-tools/index.ts index 81a34d4..0d9a386 100755 --- a/web/src/claude-tools/index.ts +++ b/web/src/claude-tools/index.ts @@ -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') { diff --git a/web/src/kernel/dispatch.ts b/web/src/kernel/dispatch.ts index 04787e8..ee395f8 100755 --- a/web/src/kernel/dispatch.ts +++ b/web/src/kernel/dispatch.ts @@ -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 { @@ -367,6 +368,8 @@ async function probeAndExecute( result = await executeTarotScript(intent, env); break; default: { + const route = getExecutorRoute(plan.executor as LLMExecutor); + if (route?.placeholder) throw new Error(`Executor '${plan.executor}' is a placeholder and cannot be dispatched`); const fn = EXECUTOR_FNS[plan.executor as Executor]; if (!fn) throw new Error(`Unknown executor: ${plan.executor}`); result = await fn(intent, env); diff --git a/web/src/kernel/executor-router.ts b/web/src/kernel/executor-router.ts index 7db4665..1ed0821 100644 --- a/web/src/kernel/executor-router.ts +++ b/web/src/kernel/executor-router.ts @@ -90,8 +90,8 @@ export const EXECUTOR_ROUTES: Record = { // 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', diff --git a/web/src/kernel/scheduled/dreaming/agenda-triage.ts b/web/src/kernel/scheduled/dreaming/agenda-triage.ts index 988dc88..a5b239f 100644 --- a/web/src/kernel/scheduled/dreaming/agenda-triage.ts +++ b/web/src/kernel/scheduled/dreaming/agenda-triage.ts @@ -92,7 +92,14 @@ export async function triageAgendaToIssues(env: EdgeEnv): Promise { `${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.warn(`[dreaming:triage] Issue created but failed to resolve agenda #${item.id}:`, resolveErr instanceof Error ? resolveErr.message : String(resolveErr)); + } const projectIdRow = await env.db.prepare( "SELECT received_at FROM web_events WHERE event_id = 'board_project_id'" diff --git a/web/src/workers-ai-chat.ts b/web/src/workers-ai-chat.ts index 4edca17..0da41fc 100755 --- a/web/src/workers-ai-chat.ts +++ b/web/src/workers-ai-chat.ts @@ -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 { + return (ai.run as (m: Parameters[0], i: unknown) => Promise)( + model as Parameters[0], + input, + ); +} + // ─── Main executor ────────────────────────────────────────── const MAX_TOOL_ROUNDS = 10; @@ -207,8 +216,8 @@ 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 as (m: Parameters[0], i: unknown) => Promise)( - config.model as Parameters[0], + 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; @@ -296,8 +305,8 @@ export async function executeWorkersAiChat( let summaryText: string | undefined; try { - const summaryResult = await (config.ai.run as (m: Parameters[0], i: unknown) => Promise)( - config.model as Parameters[0], + 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; diff --git a/web/tests/memory.test.ts b/web/tests/memory.test.ts index 678620e..ade31c5 100755 --- a/web/tests/memory.test.ts +++ b/web/tests/memory.test.ts @@ -1777,11 +1777,24 @@ describe('agenda.ts', () => { expect(db._queries[0].bindings[1]).toBe(42); }); + it('guards against terminal-state transitions via AND status = active', async () => { + const db = createMockDb({ runMeta: [{ changes: 1 }] }); + await resolveAgendaItem(db, 42, 'done'); + expect(db._queries[0].sql).toContain("AND status = 'active'"); + }); + it('accepts dismissed status', async () => { const db = createMockDb({ runMeta: [{ changes: 1 }] }); await resolveAgendaItem(db, 42, 'dismissed'); expect(db._queries[0].bindings[0]).toBe('dismissed'); }); + + it('throws when item is not active or does not exist (changes === 0)', async () => { + const db = createMockDb({ runMeta: [{ changes: 0 }] }); + await expect(resolveAgendaItem(db, 42, 'done')).rejects.toThrow( + "Agenda item 42 is not in 'active' state or does not exist", + ); + }); }); // ─── getActiveAgendaItems ───────────────────────────────── From d004881171628fe8154de1227e8a5f5d5ee7045b Mon Sep 17 00:00:00 2001 From: Aegis Date: Fri, 26 Jun 2026 04:45:17 -0500 Subject: [PATCH 13/16] fix(review): harden triage dedup guard and dispatch placeholder check agenda-triage: inner catch now attempts dismiss as a fallback sentinel when resolveAgendaItem('done') fails after a successful createIssue, preventing the item from staying active and generating a duplicate GitHub issue on the next cron cycle. dispatch: hoist placeholder guard before the switch so any future named case for a placeholder executor cannot silently bypass the check. Co-Authored-By: Claude Sonnet 4.6 --- web/src/kernel/dispatch.ts | 5 +++-- web/src/kernel/scheduled/dreaming/agenda-triage.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/web/src/kernel/dispatch.ts b/web/src/kernel/dispatch.ts index ee395f8..6042881 100755 --- a/web/src/kernel/dispatch.ts +++ b/web/src/kernel/dispatch.ts @@ -347,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': { @@ -368,8 +371,6 @@ async function probeAndExecute( result = await executeTarotScript(intent, env); break; default: { - const route = getExecutorRoute(plan.executor as LLMExecutor); - if (route?.placeholder) throw new Error(`Executor '${plan.executor}' is a placeholder and cannot be dispatched`); const fn = EXECUTOR_FNS[plan.executor as Executor]; if (!fn) throw new Error(`Unknown executor: ${plan.executor}`); result = await fn(intent, env); diff --git a/web/src/kernel/scheduled/dreaming/agenda-triage.ts b/web/src/kernel/scheduled/dreaming/agenda-triage.ts index a5b239f..8e41de4 100644 --- a/web/src/kernel/scheduled/dreaming/agenda-triage.ts +++ b/web/src/kernel/scheduled/dreaming/agenda-triage.ts @@ -98,7 +98,12 @@ export async function triageAgendaToIssues(env: EdgeEnv): Promise { try { await resolveAgendaItem(env.db, item.id, 'done'); } catch (resolveErr) { - console.warn(`[dreaming:triage] Issue created but failed to resolve agenda #${item.id}:`, resolveErr instanceof Error ? resolveErr.message : String(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( From 8c2a2aeb1f36b2eadcaead50e18b31797d9e3fa4 Mon Sep 17 00:00:00 2001 From: Aegis Date: Sun, 28 Jun 2026 05:40:03 -0500 Subject: [PATCH 14/16] =?UTF-8?q?feat(email):=20modularize=20co-founder=20?= =?UTF-8?q?brief=20=E2=80=94=20signal-dense=20daily=20digest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the monolithic daily email with a focused co-founder brief: - Add lead signal banner (single highest-priority item: critical alerts, payment failures, high alerts, fail rate, deadlines, revenue) - Drop: Cognitive Scorecard, Operator's Log, ARGUS Events, Developer Activity, Memory Reflection, canned Co-Founder's Take - Filter service alerts to high/critical only (remove medium telemetry) - Filter health checks to high/critical only - Strip auto-generated analytics commentary (data tables stay) - Awaiting Action: collapse skill-evolution proposals to one row, cap other proposed tasks at 3, cap agenda items at 3, move sentinel alarms to a dimmed "Chronic" footnote - Reorder: Lead → Work Shipped → Alerts → Health → Traffic → Actions - Bump version to 0.8.5 Co-Authored-By: Claude Sonnet 4.6 --- web/package.json | 2 +- web/src/email.ts | 492 +++++++++++++++++------------------------------ 2 files changed, 177 insertions(+), 317 deletions(-) diff --git a/web/package.json b/web/package.json index f010716..7d7cbe6 100755 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@stackbilt/aegis-core", - "version": "0.8.4", + "version": "0.8.5", "description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.", "license": "Apache-2.0", "publishConfig": { diff --git a/web/src/email.ts b/web/src/email.ts index 635467d..ace634d 100755 --- a/web/src/email.ts +++ b/web/src/email.ts @@ -280,7 +280,47 @@ export async function sendDailyDigest( return `${s.toUpperCase()}`; }; - // ── Section 1: WORK SHIPPED ── + // ── LEAD SIGNAL ── + // Single highest-priority banner. Only fires when truly actionable. + let leadSignal: { label: string; text: string; color: string } | null = null; + + const critAlerts = sections.serviceAlerts.filter(a => a.severity === 'critical'); + const highAlerts = sections.serviceAlerts.filter(a => a.severity === 'high'); + const hasFailedPayment = sections.eventNotifications.some(e => + e.event_type.includes('payment') && e.event_type.includes('fail'), + ); + const revenueEvents = sections.eventNotifications.filter(e => + e.event_type.includes('invoice.paid') || e.event_type.includes('checkout.session.completed'), + ); + const deadlineAgenda = sections.agendaItems.filter(a => /^upcoming_deadlines:/i.test(a.item)); + const failRate = sections.failedTasks.length / Math.max(1, sections.completedTasks.length + sections.failedTasks.length); + const critHealthChecks = sections.healthChecks.filter(h => h.severity === 'critical'); + + if (critAlerts.length > 0) { + leadSignal = { label: 'CRITICAL', text: critAlerts[0].summary, color: '#ef4444' }; + } else if (critHealthChecks.length > 0) { + leadSignal = { label: 'CRITICAL', text: `System health: ${critHealthChecks[0].checks.find(c => c.status !== 'ok')?.name ?? 'check failed'}`, color: '#ef4444' }; + } else if (hasFailedPayment) { + leadSignal = { label: 'PAYMENT FAILURE', text: 'Failed payment detected — check Stripe', color: '#ef4444' }; + } else if (highAlerts.length > 0) { + leadSignal = { label: 'ALERT', text: highAlerts[0].summary, color: '#f5a623' }; + } else if (failRate > 0.5 && sections.failedTasks.length >= 2) { + leadSignal = { label: 'FAIL RATE', text: `${Math.round(failRate * 100)}% task failure — review before queuing more`, color: '#f5a623' }; + } else if (deadlineAgenda.length > 0) { + const text = deadlineAgenda[0].item.replace(/^upcoming_deadlines:\s*/i, ''); + leadSignal = { label: 'DEADLINE', text, color: '#f5a623' }; + } else if (revenueEvents.length > 0) { + leadSignal = { label: 'REVENUE', text: `${revenueEvents.length} payment${revenueEvents.length !== 1 ? 's' : ''} received`, color: '#2dd4a0' }; + } + + const leadHtml = leadSignal + ? `
+ ${leadSignal.label} +

${leadSignal.text}

+
` + : ''; + + // ── WORK SHIPPED ── let workShippedHtml = ''; if (sections.failedTasks.length > 0 || sections.completedTasks.length > 0) { const taskRow = (t: DigestTask) => { @@ -303,122 +343,72 @@ export async function sendDailyDigest( `; } - // ── Section 2: OPERATOR'S LOG ── - let operatorLogHtml = ''; - if (sections.operatorLog) { - const contentHtml = sections.operatorLog - .split('\n\n') - .filter(p => p.trim()) - .map(p => { - if (p.startsWith('## ')) return `

${p.slice(3)}

`; - if (p.startsWith('### ')) return `

${p.slice(4)}

`; - const formatted = p.replace(/\*\*(.+?)\*\*/g, '$1'); - return `

${formatted}

`; - }) - .join(''); - - operatorLogHtml = ` -
-

Operator's Log

-
- ${contentHtml} -
-
`; - } - - // ── Section 3: SYSTEM HEALTH ── - let healthHtml = ''; - if (sections.healthChecks.length > 0) { - const allChecks = sections.healthChecks.flatMap(h => h.checks.map(c => ({ ...c, severity: h.severity }))); - const checksRows = allChecks.map(c => { - const color = c.status === 'alert' ? '#ff6b6b' : '#ffd93d'; - return ` + // ── SERVICE ALERTS (high/critical only) ── + let serviceAlertsHtml = ''; + const actionableAlerts = sections.serviceAlerts.filter(a => + a.severity === 'critical' || a.severity === 'high', + ); + if (actionableAlerts.length > 0) { + const sevColor = (s: string) => s === 'critical' ? '#ef4444' : '#f5a623'; + const alertRows = actionableAlerts.map(a => ` - ${c.name} - ${c.detail} - `; - }).join(''); - - healthHtml = ` -
-

System Health

- - - - - - - - ${checksRows} -
CheckDetail
-
`; - } + ${a.severity.toUpperCase()} + ${a.source} + ${a.summary} + `).join(''); - // ── Section 3b: ARGUS EVENTS ── - let eventsHtml = ''; - if (sections.eventNotifications.length > 0) { - const eventRows = sections.eventNotifications.map(e => { - const color = e.priority === 'high' ? '#f5a623' : '#888'; - return ` - - ${e.source} - ${e.event_type} - ${e.summary} - ${e.ts.slice(0, 16)} - `; - }).join(''); - - eventsHtml = ` + serviceAlertsHtml = `
-

ARGUS Events (${sections.eventNotifications.length})

+

Alerts (${actionableAlerts.length})

+ - - - ${eventRows} + ${alertRows}
Sev SourceEvent SummaryTime
`; } - // ── Section 3c: COGNITIVE SCORECARD ── - let metricsHtml = ''; - if (sections.cognitiveMetrics) { - const m = sections.cognitiveMetrics; - const arrow = m.score_delta > 0 ? '▲' : m.score_delta < 0 ? '▼' : '▬'; - const arrowColor = m.score_delta > 0 ? '#2dd4a0' : m.score_delta < 0 ? '#ef4444' : '#888'; - const scoreColor = m.cognitive_score >= 75 ? '#2dd4a0' : m.cognitive_score >= 50 ? '#f5a623' : '#ef4444'; - const costDelta = m.avg_cost_prior_7d > 0 - ? ((m.avg_cost_7d - m.avg_cost_prior_7d) / m.avg_cost_prior_7d * 100).toFixed(0) - : '0'; - const costArrow = Number(costDelta) <= 0 ? '#2dd4a0' : '#ef4444'; - - metricsHtml = ` -
-

Cognitive Scorecard

-
-
- ${m.cognitive_score} - ${arrow} ${Math.abs(m.score_delta)} - /100 -
- - - - - - - ${m.top_failure_kind ? `` : ''} + // ── SYSTEM HEALTH (critical/high only) ── + let healthHtml = ''; + const actionableHealthChecks = sections.healthChecks.filter(h => + h.severity === 'critical' || h.severity === 'high', + ); + if (actionableHealthChecks.length > 0) { + const nonOkChecks = actionableHealthChecks.flatMap(h => + h.checks.filter(c => c.status !== 'ok').map(c => ({ ...c, severity: h.severity })), + ); + if (nonOkChecks.length > 0) { + const checksRows = nonOkChecks.map(c => { + const color = c.status === 'alert' ? '#ff6b6b' : '#ffd93d'; + return ` + + + + `; + }).join(''); + + healthHtml = ` +
+

System Health

+
Dispatch success${Math.round(m.dispatch_success_rate_7d * 100)}%
Procedures learned${Math.round(m.procedure_convergence_rate * 100)}%
Tasks shipped (7d)${m.tasks_completed_7d} / ${m.tasks_completed_7d + m.tasks_failed_7d}
Avg cost/dispatch$${m.avg_cost_7d.toFixed(4)} (${Number(costDelta) <= 0 ? '' : '+'}${costDelta}%)
Memory entries${m.memory_count}
Top failure mode${m.top_failure_kind}
${c.name}${c.detail}
+ + + + + + + ${checksRows}
CheckDetail
-
-
`; + `; + } } - // ── Section 3c2: ANALYTICS ── + // ── TRAFFIC (GA4) — numbers + tables, no auto-commentary ── let analyticsHtml = ''; if (sections.analytics && sections.analytics.sessions_7d > 0) { const a = sections.analytics; @@ -442,12 +432,6 @@ export async function sendDailyDigest( ${s.sessions} `).join(''); - const insightsHtml = a.insights.length > 0 - ? `
-
    ${a.insights.map(i => `
  • ${i}
  • `).join('')}
-
` - : ''; - analyticsHtml = `

Traffic (GA4)

@@ -469,242 +453,118 @@ export async function sendDailyDigest( Medium Sessions ${sourcesRows}` : ''} - ${insightsHtml} -
- `; - } - - // ── Section 3c3: DEVELOPER ACTIVITY ── - let devActivityHtml = ''; - if (sections.devActivity) { - const d = sections.devActivity; - const tierBadges = d.tier_breakdown.map(t => { - const colors: Record = { free: '#888', hobby: '#3dd6c8', pro: '#8b8bff', enterprise: '#f5a623' }; - return `${t.tier} ${t.count}`; - }).join(''); - - const signupRows = d.recent_signups.slice(0, 10).map(s => ` - - ${s.name || '(no name)'} - ${s.email} - ${s.tier} - ${s.created_at.slice(0, 10)} - `).join(''); - - devActivityHtml = ` -
-

Developer Activity

-
-
-
${d.total_users} users
-
${d.total_tenants} tenants
-
${d.keys_active_24h} active (24h)
-
- - - - - -
Keys created (24h)${d.keys_created_24h}
Keys created (7d)${d.keys_created_7d}
Keys created (all-time)${d.keys_created_all_time}
Active keys (7d)${d.keys_active_7d}
-
${tierBadges || 'No active keys'}
- ${signupRows ? `

Recent Signups (7d)

- - - - - - - ${signupRows}
NameEmailTierJoined
` : ''} -
-
`; - } - - // ── Section 3b2: SERVICE ALERTS ── - let serviceAlertsHtml = ''; - if (sections.serviceAlerts.length > 0) { - const sevColor = (s: string) => s === 'critical' ? '#ef4444' : s === 'high' ? '#f5a623' : s === 'medium' ? '#ffd93d' : '#888'; - const alertRows = sections.serviceAlerts.map(a => ` - - ${a.severity.toUpperCase()} - ${a.source} - ${a.summary}${a.findingsCount > 0 ? ` (${a.findingsCount} findings)` : ''} - `).join(''); - - serviceAlertsHtml = ` -
-

Service Alerts (${sections.serviceAlerts.length})

- - - - - - - - - ${alertRows} -
SeveritySourceSummary
-
`; - } - - // ── Section 3d: CO-FOUNDER'S TAKE ── - // Opinionated synthesis: what matters, what's off track, what nobody's working on - let cofounderHtml = ''; - { - const takes: string[] = []; - - // Revenue signal - const hasRevenue = sections.eventNotifications.some(e => e.event_type.includes('payment') || e.event_type.includes('checkout') || e.event_type.includes('invoice.paid')); - const hasFailedPayment = sections.eventNotifications.some(e => e.event_type.includes('failed')); - if (hasRevenue && !hasFailedPayment) { - takes.push('Revenue is flowing. Keep shipping.'); - } else if (hasFailedPayment) { - takes.push('Payment failures detected. Check Stripe dashboard before anything else today.'); - } - - // Task health - const failRate = sections.failedTasks.length / Math.max(1, sections.completedTasks.length + sections.failedTasks.length); - if (failRate > 0.4 && sections.failedTasks.length >= 3) { - takes.push(`Task failure rate is ${Math.round(failRate * 100)}% — the taskrunner is burning cycles. Review failed tasks before queuing more.`); - } - - // Stale proposals - const staleProposals = sections.agendaItems.filter(a => { - if (!a.item.startsWith('[PROPOSED ACTION]') || !a.created_at) return false; - const ageDays = (Date.now() - new Date(a.created_at).getTime()) / 86_400_000; - return ageDays > 4; - }); - if (staleProposals.length > 0) { - takes.push(`${staleProposals.length} proposed action${staleProposals.length > 1 ? 's' : ''} aging out. Approve or dismiss — stale proposals mean I'm doing work you're not reviewing.`); - } - - // High-priority agenda items piling up - const highItems = sections.agendaItems.filter(a => a.priority === 'high' && !a.item.startsWith('[PROPOSED')); - if (highItems.length >= 4) { - takes.push(`${highItems.length} high-priority agenda items. That's too many "high" items — either some aren't really high, or we need a focused triage session.`); - } - - // Nothing shipped - if (sections.completedTasks.length === 0 && sections.failedTasks.length === 0) { - takes.push('No tasks ran in the last 24h. Is the taskrunner down, or is this intentional?'); - } - - // Health checks surfacing - if (sections.healthChecks.length > 0) { - const critChecks = sections.healthChecks.filter(h => h.severity === 'critical'); - if (critChecks.length > 0) { - takes.push('Critical health checks in the system. This takes priority over feature work.'); - } - } - - // Service alerts - const critAlerts = sections.serviceAlerts.filter(a => a.severity === 'critical'); - if (critAlerts.length > 0) { - takes.push(`${critAlerts.length} critical service alert${critAlerts.length > 1 ? 's' : ''} from ${[...new Set(critAlerts.map(a => a.source))].join(', ')}. Review immediately.`); - } - - // Developer activity signals - if (sections.devActivity) { - if (sections.devActivity.recent_signups.length > 0) { - takes.push(`${sections.devActivity.recent_signups.length} new signup${sections.devActivity.recent_signups.length !== 1 ? 's' : ''} this week. People are finding us.`); - } - if (sections.devActivity.keys_active_24h === 0 && sections.devActivity.keys_created_all_time > 0) { - takes.push('No active API keys in the last 24h. Users signed up but aren\'t hitting endpoints — check onboarding friction.'); - } - } - - // Memory reflection available - if (sections.memoryReflection) { - takes.push('Weekly reflection attached below — worth a 2-minute read to see what I\'m learning.'); - } - - if (takes.length > 0) { - const takesHtml = takes.map(t => `
  • ${t}
  • `).join(''); - cofounderHtml = ` -
    -

    Co-Founder's Take

    -
    -
      ${takesHtml}
    `; - } } - // ── Section 3d: MEMORY REFLECTION ── - let reflectionHtml = ''; - if (sections.memoryReflection) { - const formatted = sections.memoryReflection - .split('\n\n') - .filter(p => p.trim()) - .map(p => `

    ${p.replace(/\*\*(.+?)\*\*/g, '$1')}

    `) - .join(''); - reflectionHtml = ` -
    -

    Weekly Reflection

    -
    ${formatted}
    -
    `; - } - - // ── Section 4: AWAITING ACTION ── + // ── AWAITING ACTION — capped, grouped ── let awaitingHtml = ''; - // Split agenda into proposed actions vs regular high-priority items - const proposedAgenda = sections.agendaItems.filter(a => a.item.startsWith('[PROPOSED ACTION]')); - const highAgenda = sections.agendaItems.filter(a => a.priority === 'high' && !a.item.startsWith('[PROPOSED ACTION]')); - if (sections.proposedTasks.length > 0 || proposedAgenda.length > 0 || highAgenda.length > 0) { - const proposedTaskRows = sections.proposedTasks.map(t => ` + { + const isSkillEvo = (t: DigestTask) => + /\[skill-evolution\]|prism synthesis|promote mindspring/i.test(t.title); + const skillEvoTasks = sections.proposedTasks.filter(isSkillEvo); + const otherProposedTasks = sections.proposedTasks.filter(t => !isSkillEvo(t)).slice(0, 3); + + const isSentinel = (a: DigestAgendaItem) => /^\[SENTINEL/i.test(a.item); + const isDeadline = (a: DigestAgendaItem) => /^upcoming_deadlines:/i.test(a.item); + const isProposedAction = (a: DigestAgendaItem) => a.item.startsWith('[PROPOSED ACTION]'); + + const sentinelItems = sections.agendaItems.filter(isSentinel); + const proposedActionItems = sections.agendaItems.filter(isProposedAction).slice(0, 2); + const regularHigh = sections.agendaItems + .filter(a => a.priority === 'high' && !isSentinel(a) && !isDeadline(a) && !isProposedAction(a)) + .slice(0, 3); + + const hasContent = + otherProposedTasks.length > 0 || skillEvoTasks.length > 0 || + sentinelItems.length > 0 || deadlineAgenda.length > 0 || + proposedActionItems.length > 0 || regularHigh.length > 0; + + if (hasContent) { + // Deadlines first — time-sensitive + const deadlineRows = deadlineAgenda.map(a => { + const text = a.item.replace(/^upcoming_deadlines:\s*/i, ''); + return ` +
    + DEADLINE +

    ${text}

    +
    `; + }).join(''); + + // Non-skill-evo proposed tasks (capped at 3) + const proposedTaskRows = otherProposedTasks.map(t => `
    ${statusBadge('pending')}${t.repo} · ${t.category}

    ${t.title}

    Awaiting approval · ID: ${t.id.slice(0, 8)}

    `).join(''); - // Proposed action agenda items with expiry countdown (auto-expire at 7d) - const proposedAgendaRows = proposedAgenda.map(a => { - const ageDays = Math.floor((Date.now() - new Date(a.created_at ?? Date.now()).getTime()) / 86_400_000); - const daysLeft = Math.max(0, 7 - ageDays); - const expiryNote = daysLeft <= 2 ? ` expires in ${daysLeft}d` : ` ${daysLeft}d until auto-expire`; - return ` -
    - #${a.id} · proposed${expiryNote} -

    ${a.item.replace('[PROPOSED ACTION] ', '')}

    -
    `; - }).join(''); - - const agendaRows = highAgenda.map(a => ` + // Skill-evolution collapsed to one row + const skillEvoRow = skillEvoTasks.length > 0 + ? `
    +
    ${statusBadge('pending')}skill-evolution · batch
    +

    ${skillEvoTasks.length} skill-evolution proposal${skillEvoTasks.length !== 1 ? 's' : ''} pending — use aegis_batch_approve to review

    +
    ` + : ''; + + // Proposed action agenda items (capped at 2) + const proposedActionRows = proposedActionItems.map(a => { + const ageDays = Math.floor((Date.now() - new Date(a.created_at ?? Date.now()).getTime()) / 86_400_000); + const daysLeft = Math.max(0, 7 - ageDays); + const expiryNote = daysLeft <= 2 + ? ` expires in ${daysLeft}d` + : ` ${daysLeft}d left`; + return ` +
    + #${a.id} · proposed${expiryNote} +

    ${a.item.replace('[PROPOSED ACTION] ', '')}

    +
    `; + }).join(''); + + // Regular high-priority items (capped at 3) + const regularRows = regularHigh.map(a => `
    - #${a.id} · ${a.priority} + #${a.id} · high

    ${a.item}

    `).join(''); - // Review prompt only when proposals exist - const reviewPrompt = proposedAgenda.length > 0 - ? `

    ${proposedAgenda.length} proposed action${proposedAgenda.length !== 1 ? 's' : ''} awaiting review. Unapproved proposals auto-expire after 7 days.

    ` - : ''; - - awaitingHtml = ` -
    -

    Awaiting Action

    - ${proposedTaskRows} - ${proposedAgendaRows} - ${agendaRows} - ${reviewPrompt} -
    `; + // Sentinel items — shown as chronic, not daily action items + const sentinelRows = sentinelItems.length > 0 + ? `
    +

    Chronic (${sentinelItems.length})

    + ${sentinelItems.map(a => { + const ageDays = Math.floor((Date.now() - new Date(a.created_at ?? Date.now()).getTime()) / 86_400_000); + const label = a.item.replace(/^\[SENTINEL[^\]]*\]\s*/i, '').slice(0, 80); + return `

    #${a.id} · day ${ageDays} · ${label}

    `; + }).join('')} +
    ` + : ''; + + awaitingHtml = ` +
    +

    Awaiting Action

    + ${deadlineRows} + ${proposedTaskRows} + ${skillEvoRow} + ${proposedActionRows} + ${regularRows} + ${sentinelRows} +
    `; + } } - // ── Section 5: STATS BAR ── + // ── STATS BAR ── const stats = [ `${sections.completedTasks.length + sections.failedTasks.length} tasks`, `${sections.proposedTasks.length} proposed`, `${sections.agendaItems.length} agenda`, - `${sections.healthChecks.length} health checks`, ]; - if (sections.eventNotifications.length > 0) stats.push(`${sections.eventNotifications.length} events`); if (sections.serviceAlerts.length > 0) stats.push(`${sections.serviceAlerts.length} alerts`); if (sections.devActivity) stats.push(`${sections.devActivity.total_users} users`); - if (sections.bizopsInteractions !== null) stats.push(`${sections.bizopsInteractions} interactions`); const statsHtml = `

    ${stats.join(' · ')}

    `; - const hasContent = metricsHtml || cofounderHtml || workShippedHtml || operatorLogHtml || healthHtml || eventsHtml || serviceAlertsHtml || analyticsHtml || devActivityHtml || reflectionHtml || awaitingHtml; + const hasContent = leadHtml || workShippedHtml || serviceAlertsHtml || healthHtml || analyticsHtml || awaitingHtml; const html = ` @@ -715,7 +575,7 @@ export async function sendDailyDigest(

    AEGIS — Co-Founder Brief

    ${date}

    - ${hasContent ? `${metricsHtml}${cofounderHtml}${workShippedHtml}${operatorLogHtml}${healthHtml}${eventsHtml}${serviceAlertsHtml}${analyticsHtml}${devActivityHtml}${reflectionHtml}${awaitingHtml}` : '

    No significant activity in the last 24 hours.

    '} + ${hasContent ? `${leadHtml}${workShippedHtml}${serviceAlertsHtml}${healthHtml}${analyticsHtml}${awaitingHtml}` : '

    No significant activity in the last 24 hours.

    '} ${statsHtml}

    aegis-web · daily digest · ${new Date().toISOString()}

    From ce52db6b930874ad1ffaaf33a0de1bd08300574a Mon Sep 17 00:00:00 2001 From: Aegis Date: Sun, 28 Jun 2026 07:04:32 -0500 Subject: [PATCH 15/16] fix(wasm): lazy WasmGraph import to survive CF Workers deploy validation (v0.8.6) @stackbilt/wasm-core calls wasm.__wbindgen_start() at module init time. CF Workers validation runs before WASM instantiation, so the call fails with 'wasm.__wbindgen_start is not a function'. Moved WasmGraph import inside getWasmGraph() so the wasm-bindgen init defers until activateGraph() is actually called under the live CF runtime. aegis-core@0.8.5 was never deployable; 0.8.6 supersedes it. Co-Authored-By: Claude Sonnet 4.6 --- web/package.json | 2 +- web/src/kernel/memory/graph.ts | 14 +++++++++++++- web/src/version.ts | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/web/package.json b/web/package.json index 7d7cbe6..99b06db 100755 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@stackbilt/aegis-core", - "version": "0.8.5", + "version": "0.8.6", "description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.", "license": "Apache-2.0", "publishConfig": { diff --git a/web/src/kernel/memory/graph.ts b/web/src/kernel/memory/graph.ts index e068072..1ba2821 100755 --- a/web/src/kernel/memory/graph.ts +++ b/web/src/kernel/memory/graph.ts @@ -9,7 +9,18 @@ // ─── Node Type Classification ──────────────────────────────────────────────── -import { WasmGraph } from '@stackbilt/wasm-core'; +// Lazy import: @stackbilt/wasm-core calls wasm.__wbindgen_start() at module +// init time, which fails CF Workers validation (wasm is a Module, not an +// instantiated Instance, during the validation pass). Dynamic import defers +// initialization until the function body runs under the actual CF runtime. +let _WasmGraph: typeof import('@stackbilt/wasm-core').WasmGraph | null = null; +async function getWasmGraph(): Promise { + if (!_WasmGraph) { + const mod = await import('@stackbilt/wasm-core'); + _WasmGraph = mod.WasmGraph; + } + return _WasmGraph; +} import type { NodeType, SourceSystem } from '../../schema-enums.js'; export type { SourceSystem } from '../../schema-enums.js'; @@ -216,6 +227,7 @@ export async function activateGraph( if (nodesResult.results.length === 0) return []; + const WasmGraph = await getWasmGraph(); const graph = WasmGraph.fromSnapshotArrays(nodesResult.results, edgesResult.results); try { const raw = graph.spreadActivation(query, hops, 10); diff --git a/web/src/version.ts b/web/src/version.ts index ac4f50a..c40f136 100755 --- a/web/src/version.ts +++ b/web/src/version.ts @@ -1,3 +1,3 @@ // Semantic version - bump on each deploy // Used in /health endpoint and changelog tracking -export const VERSION = '0.8.0'; +export const VERSION = '0.8.6'; From aef2e7c7faa819cbdbd34e9c4c348fc81e626d07 Mon Sep 17 00:00:00 2001 From: Aegis Date: Tue, 30 Jun 2026 19:31:41 -0500 Subject: [PATCH 16/16] fix(memory): route consolidation through dreams, drop broken CRIX publisher (aegis-oss#78, v0.8.7) consolidateEpisodicToSemantic wrote raw fragments straight into the memory-worker store, bypassing the wiki SoT the #457 unification established - the original decision doc explicitly deferred this as needing design work, which never happened until now. It now writes through writeDreamFact() into scope:dreams pages, matching the pattern the daemon's synthesis.ts (PRISM) already proves in production. Dropped the ADD/UPDATE/DELETE-by-fragment-id model in favor of pure additive writes - dream pages don't support patch-by-id, and the dreams lifecycle (promote on corroboration, archive if stale) is the intended mechanism for handling staleness under the unified model. Also removed publishInsightsFromMemory() from the consolidation scheduled task: it called publishInsight()/validateInsight() against a D1 table named `memory` that no migration in this repo (or aegis-daemon) ever creates. Every invocation was silently failing every cycle, caught and logged as a warning. insights.ts's public API is untouched - only this broken internal call site is gone. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 4 + web/package.json | 2 +- web/src/kernel/memory/consolidation.ts | 303 ++++++++-------------- web/src/kernel/memory/dream-write.ts | 63 +++++ web/src/kernel/scheduled/consolidation.ts | 107 +------- web/tests/memory.test.ts | 160 +++++------- 6 files changed, 242 insertions(+), 397 deletions(-) create mode 100644 web/src/kernel/memory/dream-write.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b9eac..9658f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed +- **Memory unification follow-up (aegis-oss#78)**: `consolidateEpisodicToSemantic` (`kernel/memory/consolidation.ts`) redesigned to write through `writeDreamFact()` (new `kernel/memory/dream-write.ts`) into the wiki's `dreams` scope, instead of writing raw fragments straight into the memory-worker fragment store — this was the deferred piece of the #457 wiki unification the original decision doc flagged as needing design work. Consolidation is now purely additive (extract 0-3 genuine facts per cycle, write each as its own dream page); the old ADD/UPDATE/DELETE-by-fragment-id model doesn't map cleanly onto wiki pages, so update/delete semantics are dropped in favor of the dreams lifecycle (promote on corroboration, archive if stale) already used by PRISM's cross-domain synthesis. +- Removed `publishInsightsFromMemory()` from `kernel/scheduled/consolidation.ts` — this CRIX insight-publishing step called `publishInsight()`/`validateInsight()` against a D1 table literally named `memory`, which is never created by any migration in this repo or in aegis-daemon. Every invocation was silently failing (caught and logged as a warning) on every consolidation cycle. `insights.ts`'s public API (`publishInsight`, `validateInsight`, `promoteInsight`, `listInsights`, etc.) is left intact as OSS surface for downstream consumers — only the internal, broken call site was removed. + ### 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. diff --git a/web/package.json b/web/package.json index 99b06db..4b58d2f 100755 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "@stackbilt/aegis-core", - "version": "0.8.6", + "version": "0.8.7", "description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.", "license": "Apache-2.0", "publishConfig": { diff --git a/web/src/kernel/memory/consolidation.ts b/web/src/kernel/memory/consolidation.ts index d68f092..623e490 100755 --- a/web/src/kernel/memory/consolidation.ts +++ b/web/src/kernel/memory/consolidation.ts @@ -1,194 +1,109 @@ -import { askGroq } from '../../groq.js'; -import { normalizeTopic, factHash } from './semantic.js'; -import { extractNodes, createEdges } from './graph.js'; - -// ─── Memory Consolidation ─────────────────────────────────── - -const CONSOLIDATION_SYSTEM = `You are AEGIS memory consolidation. Analyze recent episodes against existing memory and produce structured operations. - -Operations: -- ADD: a genuinely new fact not covered by existing memory -- UPDATE: an existing fact that needs correction or has new information (reference its id via supersedes_id) -- DELETE: an existing fact that is no longer accurate (reference its id via target_id, provide reason) -- NOOP: nothing to do — return [] - -Rules: -1. Every fact MUST contain at least one specific detail: a name, date, number, ID, URL, or version. Never write vague observations. -2. If episodes merely confirm existing facts, return []. -3. Prefer UPDATE over ADD when a fact evolves (e.g., "Phase 2 planned" → "Phase 2 complete"). -4. DELETE facts that are provably wrong or obsolete based on episode evidence. -5. Max 3 operations per run. Quality over quantity. -6. Good facts: "Delaware PBC franchise tax filed 2026-03-03, 2 days late" or "BizOps dashboard_summary tool fails when org has no projects (undefined.id)". -7. Bad facts: "Financial metrics are important" or "Document gaps exist and require attention". - -KNOWN TOPICS — reuse these whenever the fact fits. Only create a new topic if genuinely none of these apply: -- aegis: cognitive kernel internals, architecture, infrastructure, versioning, deployment -- img_forge: img-forge product, economics, integrations, API -- bizops: BizOps tool, MCP tools, operational improvements -- content: roundtable, dispatch, column generation pipelines -- self_improvement: self-improvement analysis, outcomes, patterns -- organization: org-level priorities, governance, go-to-market -- auth: auth product, Better Auth, API key formats, middleware chain, auth-contract -- product_strategy: product positioning, competitive landscape -- compliance: legal, tax, compliance deadlines, regulatory -- finance: financial metrics, costs, revenue, billing -- operator_preferences: operator preferences, workflow choices -- milestones: key dates, launches, completed phases -- mcp_strategy: MCP protocol, OAuth, remote MCP, tool design - -Return ONLY a JSON array (no markdown): -[ - { "operation": "ADD", "topic": "aegis", "fact": "specific fact", "confidence": 0.8 }, - { "operation": "UPDATE", "supersedes_id": 42, "topic": "auth", "fact": "updated fact", "confidence": 0.9 }, - { "operation": "DELETE", "target_id": 37, "reason": "no longer accurate" } -] - -Return [] if nothing needs to change.`; - -export async function consolidateEpisodicToSemantic( - db: D1Database, - groqApiKey: string, - groqModel: string, - groqBaseUrl?: string, - memoryBinding?: import('../../types.js').MemoryServiceBinding, -): Promise { - // High-water mark: only process episodes since last consolidation (not a rolling 24h window) - const lastRun = await db.prepare( - "SELECT received_at FROM web_events WHERE event_id = 'last_consolidation_at'" - ).first<{ received_at: string }>(); - const since = lastRun?.received_at ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); - - const result = await db.prepare( - 'SELECT id, intent_class, channel, summary, outcome, cost FROM episodic_memory WHERE created_at > ? ORDER BY created_at DESC LIMIT 20' - ).bind(since).all(); - - const episodes = result.results as unknown as Array<{ - id: number; - intent_class: string; - channel: string; - summary: string; - outcome: string; - cost: number; - }>; - - // Guard: skip if not enough new signal - if (episodes.length < 3) return; - - // Fetch existing active memory with IDs so the model can reference them for UPDATE/DELETE - // Memory Worker is the sole knowledge store — D1 reads removed - let existingMemoryList: Array<{ id: string | number; topic: string; fact: string }> = []; - if (memoryBinding) { - try { - const fragments = await memoryBinding.recall('aegis', { limit: 40 }); - existingMemoryList = fragments.map(f => ({ id: f.id, topic: f.topic, fact: f.content })); - } catch (err) { - console.error('[consolidation] Memory Worker recall failed:', err instanceof Error ? err.message : String(err)); - } - } - - const memoryContext = existingMemoryList.length > 0 - ? `\n\nExisting memory (reference by id for UPDATE/DELETE operations):\n${existingMemoryList.map(m => `- [id=${m.id}] [${m.topic}] ${m.fact}`).join('\n')}` - : ''; - - const userPrompt = `Recent agent episodes (since last consolidation):\n\n${episodes.map((e, i) => - `${i + 1}. [${e.intent_class}/${e.outcome}] ${e.summary}` - ).join('\n')}${memoryContext}`; - - let rawResponse: string; - try { - rawResponse = await askGroq(groqApiKey, groqModel, CONSOLIDATION_SYSTEM, userPrompt, groqBaseUrl); - } catch { - return; // Groq failure — skip silently, will retry next cron cycle - } - - if (!rawResponse) return; - const cleaned = rawResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); - let ops: Array<{ - operation: 'ADD' | 'UPDATE' | 'DELETE' | 'NOOP'; - topic?: string; fact?: string; confidence?: number; - supersedes_id?: string | number; - target_id?: string | number; reason?: string; - }>; - try { - ops = JSON.parse(cleaned); - if (!Array.isArray(ops)) return; - } catch { - return; - } - - for (const op of ops.slice(0, 3)) { // Hard cap: max 3 per run - switch (op.operation) { - case 'ADD': { - if (!op.topic || !op.fact) continue; - if (op.fact.length < 30) continue; - if (!/\d/.test(op.fact) && !/[A-Z][a-z]/.test(op.fact.slice(1))) continue; - if (!memoryBinding) continue; - await memoryBinding.store('aegis', [{ content: op.fact, topic: op.topic, confidence: op.confidence ?? 0.7, source: 'episodic_consolidation' }]); - console.log(`[consolidation] ADD [${normalizeTopic(op.topic)}] "${op.fact.slice(0, 80)}" (conf:${op.confidence ?? 0.7})`); - // Phase 2: Extract knowledge graph nodes + edges from new fact - try { - const nodeIds = await extractNodes(db, op.fact, normalizeTopic(op.topic)); - if (nodeIds.length >= 2) { - await createEdges(db, nodeIds); - } - } catch (err) { - console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err)); - } - break; - } - case 'UPDATE': { - if (!op.supersedes_id || !op.topic || !op.fact) continue; - if (op.fact.length < 30) continue; - if (!/\d/.test(op.fact) && !/[A-Z][a-z]/.test(op.fact.slice(1))) continue; - if (memoryBinding) { - // Forget old → store new - await memoryBinding.forget('aegis', { ids: [String(op.supersedes_id)] }); - await memoryBinding.store('aegis', [{ content: op.fact, topic: op.topic, confidence: op.confidence ?? 0.8, source: 'episodic_consolidation' }]); - } else { - await db.prepare( - "UPDATE memory_entries SET valid_until = datetime('now'), updated_at = datetime('now') WHERE id = ? AND valid_until IS NULL" - ).bind(op.supersedes_id).run(); - const topic = normalizeTopic(op.topic); - const hash = factHash(topic, op.fact); - const insertResult = await db.prepare( - 'INSERT INTO memory_entries (topic, fact, fact_hash, confidence, source, superseded_by) VALUES (?, ?, ?, ?, ?, ?)' - ).bind(topic, op.fact, hash, op.confidence ?? 0.8, 'episodic_consolidation', op.supersedes_id).run(); - if (insertResult.meta.last_row_id) { - await db.prepare( - 'UPDATE memory_entries SET superseded_by = ? WHERE id = ?' - ).bind(insertResult.meta.last_row_id, op.supersedes_id).run(); - } - } - console.log(`[consolidation] UPDATE ${op.supersedes_id} → [${normalizeTopic(op.topic)}] "${op.fact.slice(0, 80)}" (conf:${op.confidence ?? 0.8})`); - // Phase 2: Extract knowledge graph nodes + edges from updated fact - try { - const nodeIds = await extractNodes(db, op.fact, normalizeTopic(op.topic)); - if (nodeIds.length >= 2) { - await createEdges(db, nodeIds); - } - } catch (err) { - console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err)); - } - break; - } - case 'DELETE': { - if (!op.target_id) continue; - if (memoryBinding) { - await memoryBinding.forget('aegis', { ids: [String(op.target_id)] }); - } else { - await db.prepare( - "UPDATE memory_entries SET valid_until = datetime('now'), updated_at = datetime('now') WHERE id = ? AND valid_until IS NULL" - ).bind(op.target_id).run(); - } - console.log(`[consolidation] Soft-deleted memory ${op.target_id}: ${op.reason ?? 'no reason'}`); - break; - } - // NOOP — skip - } - } - - // Advance the high-water mark — these episodes won't be re-processed - await db.prepare( - "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_consolidation_at', datetime('now'))" - ).run(); -} +import { askGroq } from '../../groq.js'; +import { extractNodes, createEdges } from './graph.js'; +import { writeDreamFact } from './dream-write.js'; +import type { WikiClientEnv } from '../../wiki/client.js'; + +// ─── Memory Consolidation ─────────────────────────────────── +// Redesigned for the #457 wiki unification (aegis-oss#78): writes go to +// the wiki's `dreams` scope instead of the memory-worker fragment store. +// Dreams are additive, provisional pages (confidence: drifting) with their +// own lifecycle rules (promote on corroboration, archive if stale) — that +// supersedes the old ADD/UPDATE/DELETE-by-id fragment model, which relied +// on reading back an existing fragment list to pick a target id. There is +// no equivalent "patch this specific prior fact" operation for wiki pages +// written this way; letting the dreams lifecycle handle staleness is the +// same tradeoff PRISM's synthesis.ts already makes in production. + +const CONSOLIDATION_SYSTEM = `You are AEGIS memory consolidation. Analyze recent agent episodes and extract genuinely new, specific facts worth remembering long-term. + +Rules: +1. Every fact MUST contain at least one specific detail: a name, date, number, ID, URL, or version. Never write vague observations. +2. Max 3 facts per run. Quality over quantity. +3. Good facts: "Delaware PBC franchise tax filed 2026-03-03, 2 days late" or "BizOps dashboard_summary tool fails when org has no projects (undefined.id)". +4. Bad facts: "Financial metrics are important" or "Document gaps exist and require attention". +5. If episodes contain nothing worth remembering, return []. + +Return ONLY a JSON array (no markdown): +[ + { "topic": "aegis", "fact": "specific fact", "confidence": 0.8 } +] + +Return [] if nothing needs to change.`; + +export async function consolidateEpisodicToSemantic( + db: D1Database, + groqApiKey: string, + groqModel: string, + groqBaseUrl?: string, + wikiEnv?: WikiClientEnv, +): Promise { + if (!wikiEnv?.wikiBinding || !wikiEnv.wikiToken) return; + + // High-water mark: only process episodes since last consolidation (not a rolling 24h window) + const lastRun = await db.prepare( + "SELECT received_at FROM web_events WHERE event_id = 'last_consolidation_at'" + ).first<{ received_at: string }>(); + const since = lastRun?.received_at ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19); + + const result = await db.prepare( + 'SELECT id, intent_class, channel, summary, outcome, cost FROM episodic_memory WHERE created_at > ? ORDER BY created_at DESC LIMIT 20' + ).bind(since).all(); + + const episodes = result.results as unknown as Array<{ + id: number; + intent_class: string; + channel: string; + summary: string; + outcome: string; + cost: number; + }>; + + // Guard: skip if not enough new signal + if (episodes.length < 3) return; + + const userPrompt = `Recent agent episodes (since last consolidation):\n\n${episodes.map((e, i) => + `${i + 1}. [${e.intent_class}/${e.outcome}] ${e.summary}` + ).join('\n')}`; + + let rawResponse: string; + try { + rawResponse = await askGroq(groqApiKey, groqModel, CONSOLIDATION_SYSTEM, userPrompt, groqBaseUrl); + } catch { + return; // Groq failure — skip silently, will retry next cron cycle + } + + if (!rawResponse) return; + const cleaned = rawResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); + let facts: Array<{ topic?: string; fact?: string; confidence?: number }>; + try { + facts = JSON.parse(cleaned); + if (!Array.isArray(facts)) return; + } catch { + return; + } + + for (const item of facts.slice(0, 3)) { // Hard cap: max 3 per run + if (!item.fact || item.fact.length < 30) continue; + if (!/\d/.test(item.fact) && !/[A-Z][a-z]/.test(item.fact.slice(1))) continue; + + const topic = item.topic || 'aegis'; + const confidence = item.confidence ?? 0.7; + await writeDreamFact(wikiEnv, { fact: item.fact, confidence, source: 'episodic_consolidation' }); + console.log(`[consolidation] Wrote dream fact [${topic}] "${item.fact.slice(0, 80)}" (conf:${confidence})`); + + // Knowledge graph extraction — decoupled from storage backend, keep as-is + try { + const nodeIds = await extractNodes(db, item.fact, topic); + if (nodeIds.length >= 2) { + await createEdges(db, nodeIds); + } + } catch (err) { + console.warn('[consolidation] Graph extraction failed:', err instanceof Error ? err.message : String(err)); + } + } + + // Advance the high-water mark — these episodes won't be re-processed + await db.prepare( + "INSERT OR REPLACE INTO web_events (event_id, received_at) VALUES ('last_consolidation_at', datetime('now'))" + ).run(); +} diff --git a/web/src/kernel/memory/dream-write.ts b/web/src/kernel/memory/dream-write.ts new file mode 100644 index 0000000..c3c1d74 --- /dev/null +++ b/web/src/kernel/memory/dream-write.ts @@ -0,0 +1,63 @@ +// ─── Dream-scope fact writer (aegis#684 / aegis-oss#78) ──────── +// Writes a fact into the wiki's `dreams` scope instead of the legacy +// memory-worker fragment store. Mirrors the pattern the daemon's +// synthesis.ts (PRISM) already proves in production: one wiki page per +// fact, scope: dreams, type: synthesis, confidence: drifting, unique +// timestamped slug — no merge/lint conflicts, subject to the same dreams +// lifecycle rules (archive if uncorroborated). +// +// Never throws — write failures log and fall through, matching the +// grounding-layer invariant that non-critical writes must not block the +// caller's scheduled task. + +import { writePage } from '../../wiki/client.js'; +import type { WikiClientEnv } from '../../wiki/client.js'; + +export interface DreamFactInput { + fact: string; + confidence: number; + source: string; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +export async function writeDreamFact(env: WikiClientEnv, input: DreamFactInput): Promise { + if (!env.wikiBinding || !env.wikiToken) return; + + const { fact, confidence, source } = input; + const sourceSlug = slugify(source); + const slug = `dream-${sourceSlug}-${Date.now()}`; + + try { + await writePage(env, { + slug, + scope: 'dreams', + type: 'synthesis', + title: `${source}: ${fact.slice(0, 60)}`, + summary: fact.slice(0, 200), + body: [ + '## Fact', + '', + fact, + '', + '## Confidence', + '', + `${(confidence * 100).toFixed(0)}%`, + '', + '## Source', + '', + source, + ].join('\n'), + confidence: 'drifting', + canonical: false, + sources: [{ type: 'core_scheduled_task', ref: source, verified_date: new Date().toISOString().slice(0, 10) }], + }); + } catch (err) { + console.warn(`[dream-write] Failed to write dream page for source "${source}":`, err instanceof Error ? err.message : String(err)); + } +} diff --git a/web/src/kernel/scheduled/consolidation.ts b/web/src/kernel/scheduled/consolidation.ts index 069d4bc..13ee267 100755 --- a/web/src/kernel/scheduled/consolidation.ts +++ b/web/src/kernel/scheduled/consolidation.ts @@ -1,7 +1,6 @@ import { type EdgeEnv } from '../dispatch.js'; -import { consolidateEpisodicToSemantic, maintainProcedures, getAllProcedures, PROCEDURE_MIN_SUCCESSES, PROCEDURE_MIN_SUCCESS_RATE } from '../memory/index.js'; +import { consolidateEpisodicToSemantic, maintainProcedures } from '../memory/index.js'; import { garbageCollectTools, promoteHighUsageTools } from '../dynamic-tools.js'; -import { publishInsight, type InsightType } from '../memory/insights.js'; import { pruneMemory } from '../memory-adapter.js'; import { runCrossDomainSynthesis } from '../memory/synthesis.js'; import { maintainNarratives, detectStaleNarratives, precomputeCognitiveState, pruneNarratives, getCognitiveState, type ProductPortfolioEntry } from '../cognition.js'; @@ -13,7 +12,10 @@ import { McpClient } from '../../mcp-client.js'; import { operatorConfig } from '../../operator/index.js'; export async function runMemoryConsolidation(env: EdgeEnv): Promise { - await consolidateEpisodicToSemantic(env.db, env.groqApiKey, env.groqModel, env.groqBaseUrl, env.memoryBinding); + await consolidateEpisodicToSemantic(env.db, env.groqApiKey, env.groqModel, env.groqBaseUrl, { + wikiBinding: env.wikiBinding, + wikiToken: env.wikiToken, + }); if (env.memoryBinding) { await pruneMemory(env.memoryBinding, env.db); } @@ -49,9 +51,6 @@ export async function runMemoryConsolidation(env: EdgeEnv): Promise { // Update active_context block from freshly computed CognitiveState await refreshActiveContextBlock(env.db); - - // ─── CRIX Phase 2c: Publish insights from procedural + semantic memory ─── - await publishInsightsFromMemory(env); } export async function fetchProductPortfolio(env: EdgeEnv): Promise { @@ -80,102 +79,6 @@ export async function fetchProductPortfolio(env: EdgeEnv): Promise { - let published = 0; - - // Source 1: Learned procedures — patterns with ≥70% success rate and 3+ successes - try { - const procedures = await getAllProcedures(env.db); - for (const proc of procedures) { - if (published >= INSIGHT_RATE_LIMIT) break; - if (proc.status !== 'learned') continue; - if (proc.success_count < PROCEDURE_MIN_SUCCESSES) continue; - - const successRate = proc.success_count / (proc.success_count + proc.fail_count); - if (successRate < PROCEDURE_MIN_SUCCESS_RATE) continue; - - // Already published check: handled by publishInsight() fact hash gate - const fact = `Learned procedure: "${proc.task_pattern}" routes to ${proc.executor} executor with ${(successRate * 100).toFixed(0)}% success rate (${proc.success_count} successes). Config: ${proc.executor_config?.slice(0, 200) ?? 'default'}`; - - const result = await publishInsight(env.db, { - fact, - insight_type: 'pattern', - origin_repo: 'aegis', - keywords: [proc.task_pattern, proc.executor, 'routing', 'procedure'], - confidence: Math.min(0.95, 0.75 + successRate * 0.2), - }, env.memoryBinding); - - if (result.published) { - published++; - console.log(`[crix] Published procedure insight: ${proc.task_pattern}`); - } - } - } catch (err) { - console.warn('[crix] Procedure scan failed:', err instanceof Error ? err.message : String(err)); - } - - // Source 2: High-confidence memory entries from the last 24h via Memory Worker - // Look for entries tagged with bug/perf/arch topics that could be cross-repo insights - if (env.memoryBinding) { - try { - const INSIGHT_TOPICS = new Map([ - ['bug_signature', 'pattern'], - ['perf_pattern', 'heuristic'], - ['arch_improvement', 'principle'], - ]); - - const fragments = await env.memoryBinding.recall('aegis', { limit: 20 }); - // Filter to high-confidence entries suitable for cross-repo insight publishing - const recentHighConf = fragments.filter(f => f.confidence >= 0.85).slice(0, 10); - - for (const entry of recentHighConf) { - if (published >= INSIGHT_RATE_LIMIT) break; - - // Infer insight type from topic - let insightType: InsightType = 'pattern'; - for (const [topicFragment, type] of INSIGHT_TOPICS) { - if (entry.topic.includes(topicFragment)) { - insightType = type; - break; - } - } - - // Extract keywords from the fact text - const keywords = entry.content.toLowerCase() - .replace(/[^a-z0-9\s]/g, ' ') - .split(/\s+/) - .filter(w => w.length > 4) - .slice(0, 8); - - if (keywords.length < 2) continue; // Not enough signal - - const result = await publishInsight(env.db, { - fact: entry.content, - insight_type: insightType, - origin_repo: 'aegis', - keywords, - confidence: entry.confidence, - }, env.memoryBinding); - - if (result.published) { - published++; - } - } - } catch (err) { - console.warn('[crix] Semantic scan failed:', err instanceof Error ? err.message : String(err)); - } - } - - if (published > 0) { - console.log(`[crix] Published ${published} insights during consolidation cycle`); - } -} - // ─── Active Context Block Refresh ──────────────────────────── async function refreshActiveContextBlock(db: D1Database): Promise { diff --git a/web/tests/memory.test.ts b/web/tests/memory.test.ts index ade31c5..b8c1887 100755 --- a/web/tests/memory.test.ts +++ b/web/tests/memory.test.ts @@ -1174,8 +1174,11 @@ describe('insights.ts', () => { // consolidation.ts // ════════════════════════════════════════════════════════════════ -// consolidateEpisodicToSemantic depends on askGroq, so we test -// the guards and parsing logic by controlling the DB mock returns. +// consolidateEpisodicToSemantic depends on askGroq + writeDreamFact (wiki), +// so we test the guards and parsing logic by controlling the DB mock +// returns and mocking the dream-write module. Redesigned for the #457 +// wiki unification (aegis-oss#78) — writes route through scope:dreams +// wiki pages instead of the memory-worker fragment store. import { consolidateEpisodicToSemantic } from '../src/kernel/memory/consolidation.js'; @@ -1187,12 +1190,29 @@ vi.mock('../src/groq.js', () => ({ import { askGroq } from '../src/groq.js'; const mockAskGroq = vi.mocked(askGroq); +vi.mock('../src/kernel/memory/dream-write.js', () => ({ + writeDreamFact: vi.fn(), +})); + +import { writeDreamFact } from '../src/kernel/memory/dream-write.js'; +const mockWriteDreamFact = vi.mocked(writeDreamFact); + +const testWikiEnv = { wikiBinding: {} as any, wikiToken: 'test-token' }; + describe('consolidation.ts', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('consolidateEpisodicToSemantic', () => { + it('skips when no wiki env is configured', async () => { + const db = createMockDb({}); + + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + + expect(mockAskGroq).not.toHaveBeenCalled(); + }); + it('skips when fewer than 3 episodes since last run', async () => { const db = createMockDb({ // last consolidation watermark @@ -1203,7 +1223,7 @@ describe('consolidation.ts', () => { ]], }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); expect(mockAskGroq).not.toHaveBeenCalled(); }); @@ -1218,59 +1238,20 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], // no watermark - allResults: [ - episodes, // episodes query - [], // existing memory - ], + allResults: [episodes], // episodes query runMeta: [{ changes: 1 }], // watermark update }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); expect(mockAskGroq).toHaveBeenCalledOnce(); }); - it('processes ADD operations from Groq response', async () => { - const addOps = JSON.stringify([ - { operation: 'ADD', topic: 'aegis', fact: 'Version 1.30.1 deployed successfully on 2026-03-10', confidence: 0.85 }, + it('writes a dream page for genuine facts from Groq response', async () => { + const facts = JSON.stringify([ + { topic: 'aegis', fact: 'Version 1.30.1 deployed successfully on 2026-03-10', confidence: 0.85 }, ]); - mockAskGroq.mockResolvedValue(addOps); - - const episodes = Array.from({ length: 5 }, (_, i) => ({ - id: i + 1, intent_class: 'chat', channel: 'web', - summary: `Episode ${i}`, outcome: 'success', cost: 0.01, - })); - - const db = createMockDb({ - firstResults: [ - null, // watermark - ], - allResults: [ - episodes, // episodes query - ], - runMeta: [ - { changes: 1 }, // watermark update - ], - }); - - // ADD operations require memoryBinding (source uses memoryBinding.store) - const mockStore = vi.fn().mockResolvedValue(undefined); - const mockRecallFn = vi.fn().mockResolvedValue([]); - const memBinding = { store: mockStore, recall: mockRecallFn, forget: vi.fn(), health: vi.fn() }; - - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, memBinding as any); - - // Should have stored via memoryBinding - expect(mockStore).toHaveBeenCalledWith('aegis', expect.arrayContaining([ - expect.objectContaining({ content: expect.stringContaining('Version 1.30.1'), topic: 'aegis' }), - ])); - }); - - it('processes DELETE operations from Groq response', async () => { - const deleteOps = JSON.stringify([ - { operation: 'DELETE', target_id: 42, reason: 'obsolete' }, - ]); - mockAskGroq.mockResolvedValue(deleteOps); + mockAskGroq.mockResolvedValue(facts); const episodes = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, intent_class: 'chat', channel: 'web', @@ -1279,27 +1260,23 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], // watermark - allResults: [ - episodes, - [], // existing memory - ], - runMeta: [ - { changes: 1 }, // soft delete - { changes: 1 }, // watermark - ], + allResults: [episodes], // episodes query + runMeta: [{ changes: 1 }], // watermark update }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); - const deleteQuery = db._queries.find(q => q.sql.includes('valid_until') && q.sql.includes('UPDATE')); - expect(deleteQuery).toBeDefined(); + expect(mockWriteDreamFact).toHaveBeenCalledWith(testWikiEnv, expect.objectContaining({ + fact: expect.stringContaining('Version 1.30.1'), + source: 'episodic_consolidation', + })); }); - it('skips ADD operations with short facts (< 30 chars)', async () => { - const ops = JSON.stringify([ - { operation: 'ADD', topic: 'aegis', fact: 'Too short', confidence: 0.8 }, + it('skips facts with short text (< 30 chars)', async () => { + const facts = JSON.stringify([ + { topic: 'aegis', fact: 'Too short', confidence: 0.8 }, ]); - mockAskGroq.mockResolvedValue(ops); + mockAskGroq.mockResolvedValue(facts); const episodes = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, intent_class: 'chat', channel: 'web', @@ -1308,15 +1285,13 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], - allResults: [episodes, []], - runMeta: [{ changes: 1 }], // only watermark + allResults: [episodes], + runMeta: [{ changes: 1 }], // watermark only }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); - // Should NOT have inserted any memory (short fact filtered) - const insertQuery = db._queries.find(q => q.sql.includes('INSERT INTO memory_entries')); - expect(insertQuery).toBeUndefined(); + expect(mockWriteDreamFact).not.toHaveBeenCalled(); }); it('handles malformed Groq response gracefully', async () => { @@ -1329,55 +1304,40 @@ describe('consolidation.ts', () => { const db = createMockDb({ firstResults: [null], - allResults: [episodes, []], + allResults: [episodes], }); // Should not throw await expect( - consolidateEpisodicToSemantic(db, 'fake-key', 'llama3') + consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv) ).resolves.not.toThrow(); + + expect(mockWriteDreamFact).not.toHaveBeenCalled(); }); - it('caps operations at 3 per run', async () => { - const ops = JSON.stringify([ - { operation: 'ADD', topic: 'a', fact: 'Fact about system A with version 1.0 deployed', confidence: 0.8 }, - { operation: 'ADD', topic: 'b', fact: 'Fact about system B with version 2.0 deployed', confidence: 0.8 }, - { operation: 'ADD', topic: 'c', fact: 'Fact about system C with version 3.0 deployed', confidence: 0.8 }, - { operation: 'ADD', topic: 'd', fact: 'Fact about system D with version 4.0 should be skipped', confidence: 0.8 }, + it('caps facts at 3 per run', async () => { + const facts = JSON.stringify([ + { topic: 'a', fact: 'Fact about system A with version 1.0 deployed', confidence: 0.8 }, + { topic: 'b', fact: 'Fact about system B with version 2.0 deployed', confidence: 0.8 }, + { topic: 'c', fact: 'Fact about system C with version 3.0 deployed', confidence: 0.8 }, + { topic: 'd', fact: 'Fact about system D with version 4.0 should be skipped', confidence: 0.8 }, ]); - mockAskGroq.mockResolvedValue(ops); + mockAskGroq.mockResolvedValue(facts); const episodes = Array.from({ length: 5 }, (_, i) => ({ id: i + 1, intent_class: 'chat', channel: 'web', summary: `Episode ${i}`, outcome: 'success', cost: 0.01, })); - // We need enough mock results for 3 recordMemory calls const db = createMockDb({ - firstResults: [ - null, // watermark - null, null, null, // 3x recordMemory phase 1 (no hash dup) - ], - allResults: [ - episodes, - [], // existing memory - [], [], [], // 3x recordMemory phase 2 - ], - runMeta: [ - { changes: 1, last_row_id: 50 }, - { changes: 1, last_row_id: 51 }, - { changes: 1, last_row_id: 52 }, - { changes: 1 }, // watermark - ], + firstResults: [null], // watermark + allResults: [episodes], + runMeta: [{ changes: 1 }], // watermark update }); - await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3'); + await consolidateEpisodicToSemantic(db, 'fake-key', 'llama3', undefined, testWikiEnv); - // Count INSERT queries for memory_entries (not watermark) - const inserts = db._queries.filter(q => - q.sql.includes('INSERT INTO memory_entries') - ); - expect(inserts.length).toBeLessThanOrEqual(3); + expect(mockWriteDreamFact).toHaveBeenCalledTimes(3); }); }); });