/** * KHAELOR * File: src/session/events.ts * Description: The complete typed event catalog — envelopes, durable/ephemeral split (EVENT_MODEL.md), including the v2 phase-gate, verify, memory, and subtask events. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ /** Schema version written on every durable JSONL line (EVENT_MODEL.md §5.4). */ export const EVENT_SCHEMA_VERSION = 1 as const; export type EventSchemaVersion = typeof EVENT_SCHEMA_VERSION; // ───────────────────────────── envelopes ───────────────────────────── /** Durable envelope — one JSONL line per event. */ export interface Durable { v: EventSchemaVersion; // schema version of this event line id: string; // ULID — globally unique, time-ordered sessionId: string; seq: number; // monotonic per session, gapless, assigned at append time ts: number; // epoch milliseconds parentId?: string; // reserved, always absent in V1 (ADR-3: linear log, tree-ready) type: T; payload: P; } /** Ephemeral envelope — bus-only, never persisted. No seq (no log position), no v. */ export interface Ephemeral { id: string; // ULID (correlation/debugging) sessionId: string; ts: number; type: T; payload: P; } // ───────────────────────── shared payload types ───────────────────────── export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal"; export interface ModelUsage { // real API fields only inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; } export interface DiffStats { added: number; removed: number; } export interface CheckResult { command: string; exitCode: number; summary: string; // e.g. "148 passed", "typecheck passed" durationMs: number; } export interface CompletionEvidence { // CLAUDE.md §17 objective: string; changedFiles: string[]; checks: CheckResult[]; unresolvedIssues: string[]; } export interface GitBaseline { branch: string; dirtyFiles: string[]; untrackedFiles: string[]; diffHash: string; // hash of `git diff` output at capture time } export type ModelErrorKind = | "retryable" | "context-overflow" | "auth" | "invalid-request" | "cancelled"; export type ToolName = | "read" | "write" | "edit" | "grep" | "glob" | "bash" | "process" | "design" | "remember" | "symbols" | "refs"; // ─────────────────────── phase gates (durable, v2 §1) ─────────────────────── export type Phase = "understand" | "design" | "implement"; /** The structured design the agent must produce before implement is unlocked. */ export interface DesignArtifact { /** Reformulation of the need by the agent. */ goal: string; /** Files it plans to modify. */ filesTouched: string[]; /** Technical approach, 5–15 lines. */ approach: string; /** Identified risks. */ risks: string[]; /** How it will prove the change works. */ verification: string; /** What it will NOT do. */ outOfScope: string[]; } // ─────────────────────── session lifecycle (durable) ─────────────────────── export type SessionStarted = Durable< "session.started", { title: string; projectHash: string; workingDirectory: string; gitBranch: string | null; model: string; auxModel: string; khaelorVersion: string; } >; export type SessionResumed = Durable< "session.resumed", { khaelorVersion: string; replayedSeq: number; // highest seq replayed model: string; // active model after resume (may differ — swappable) toolNames: ToolName[]; // verified add-only vs. original set (ADR-3 resume contract) } >; export type SessionRenamed = Durable<"session.renamed", { title: string }>; export type ModelChanged = Durable< "session.model-changed", { from: string; to: string; reason: "user" | "config"; } >; export type BaselineRecorded = Durable< "git.baseline-recorded", { when: "session-start" | "pre-first-edit"; baseline: GitBaseline; } >; // ───────────────────────── user input (durable) ───────────────────────── export type UserMessageCreated = Durable< "user.message-created", { text: string; // byte-exact — enters LLM history verbatim mentions: { path: string; range?: { start: number; end: number } }[]; } >; export type SteeringQueued = Durable<"user.steering-queued", { text: string }>; export type SteeringInjected = Durable< "user.steering-injected", { queuedEventId: string; // id of the SteeringQueued event seam: "post-tool-batch" | "pre-model-call"; afterSeq: number; // injection position in history — makes replay exact (ADR-7) } >; export type Interrupted = Durable< "user.interrupted", { scope: "turn"; // V1: Esc aborts the turn (model stream + in-flight tools) pendingToolUseIds: string[]; // tools that will be closed via ToolCancelled } >; // ──────────────────────────── model stream ──────────────────────────── export type ModelRequestStarted = Durable< "model.request-started", { requestId: string; // correlates all blocks/usage of this call model: string; purpose: "main" | "compaction" | "verification-nudge"; contextStats: { // for /context history — estimates labeled as such estimatedInputTokens: number; sections: { name: string; estimatedTokens: number }[]; }; } >; export type ModelTextDelta = Ephemeral< "model.text-delta", { requestId: string; blockIndex: number; text: string; } >; export type ModelThinkingDelta = Ephemeral< "model.thinking-delta", { requestId: string; blockIndex: number; text: string; } >; export type ToolCallStarted = Ephemeral< "model.tool-call-started", { requestId: string; blockIndex: number; toolUseId: string; toolName: ToolName; } >; export type ToolInputDelta = Ephemeral< "model.tool-input-delta", { requestId: string; blockIndex: number; toolUseId: string; partialJson: string; } >; export type ModelTextBlockCompleted = Durable< "model.text-block-completed", { requestId: string; blockIndex: number; text: string; // byte-exact settled block } >; export type ModelThinkingBlockCompleted = Durable< "model.thinking-block-completed", { requestId: string; blockIndex: number; thinking: string; signature: string; // required for byte-exact API replay in tool loops } >; export type ToolRequested = Durable< "tool.requested", { requestId: string; blockIndex: number; toolUseId: string; // Anthropic tool_use id — pairing key (§6.5) toolName: ToolName; input: unknown; // complete parsed input — byte-exact via canonical JSON (§5.1) } >; export type ModelResponseCompleted = Durable< "model.response-completed", { requestId: string; stopReason: StopReason; usage: ModelUsage; // REAL API usage — sole source for cost/compaction durationMs: number; } >; export type ModelRequestFailed = Durable< "model.request-failed", { requestId: string; kind: ModelErrorKind; message: string; // redacted — never headers/keys status?: number; retriesExhausted: boolean; } >; // ─────────────────────── permissions (durable) ─────────────────────── export type PermissionRequested = Durable< "permission.requested", { permissionRequestId: string; toolUseId: string; capability: string; // Capability descriptor: string; // human-meaningful: "Run `npm install` in ~/dev/project" suggestion?: { capability: string; pattern: string }; // "always allow git push *" (ADR-9) } >; export type PermissionGranted = Durable< "permission.granted", { permissionRequestId: string; scope: "once" | "always-project"; // "always" also persists a config rule (ADR-9) } >; export type PermissionDenied = Durable< "permission.denied", { permissionRequestId: string; source: "user" | "policy" | "hardline" | "timeout"; // silence is not consent (ADR-9) feedback: string; // returned to the model as the tool observation } >; // ─────────────── tool execution (durable + one ephemeral) ─────────────── export type ToolApproved = Durable< "tool.approved", { toolUseId: string; via: "policy-allow" | "user-once" | "user-always" | "rule"; } >; export type ToolStarted = Durable< "tool.started", { toolUseId: string; toolName: ToolName; } >; export type ToolOutput = Ephemeral< "tool.output", { toolUseId: string; chunk: string; // live chunk, ANSI-stripped for TUI } >; export type ToolCompleted = Durable< "tool.completed", { toolUseId: string; modelText: string; // byte-exact tool_result content (budget-truncated w/ markers) spillFile?: string; // full output under ~/.khaelor/spill/ (ADR-8) durationMs: number; ui: { // presentation DATA, not presentation (Rule 3) kind: "read" | "search" | "edit" | "exec" | "process"; summary: string; // e.g. `Search "ContextEngine" · 14 matches` diffStats?: DiffStats; exitCode?: number; matchCount?: number; }; } >; export type ToolFailed = Durable< "tool.failed", { toolUseId: string; modelText: string; // byte-exact is_error tool_result content errorKind: | "invalid-input" | "not-found" | "ambiguous-edit" | "exec-error" | "timeout" | "permission-denied" | "phase-blocked" | "internal"; durationMs: number; } >; export type ToolCancelled = Durable< "tool.cancelled", { toolUseId: string; reason: "interrupted" | "resume-recovery" | "shutdown"; modelText: string; // synthetic result, e.g. "[Tool execution cancelled by user]" } >; // ─────────────────────── file activity (durable) ─────────────────────── export type FileRead = Durable< "file.read", { path: string; // relative to workspace cwd range?: { start: number; end: number }; bytes: number; mtimeMs: number; // external-modification detection on later writes toolUseId: string; } >; export type FileModified = Durable< "file.modified", { path: string; operation: "write" | "edit"; diffStats: DiffStats; diff?: string; // unified diff, capped (default 32 KiB) with truncation marker toolUseId: string; } >; // ─────────────── processes (durable + one ephemeral) ─────────────── export type ProcessStarted = Durable< "process.started", { processId: string; // KHAELOR id (stable across PID reuse) pid: number; command: string; cwd: string; name?: string; toolUseId: string; } >; export type ProcessOutput = Ephemeral< "process.output", { processId: string; stream: "stdout" | "stderr"; chunk: string; } >; export type ProcessExited = Durable< "process.exited", { processId: string; exitCode: number | null; // null = signal-killed cause: "exited" | "stopped-by-tool" | "khaelor-shutdown" | "crashed"; durationMs: number; } >; // ─────────────────────── context engine (durable) ─────────────────────── export type ContextPruned = Durable< "context.pruned", { toolUseIds: string[]; // results blanked with the FIXED placeholder string placeholder: string; // recorded so replay is byte-exact even if default changes tokensReclaimedEstimate: number; } >; export type ContextCompacted = Durable< "context.compacted", { checkpointYaml: string; // the structured checkpoint (ARCHITECTURE.md §6.4), verbatim cut: { fromSeq: number; toSeq: number }; // replaced range — pairing-safe boundary (§6.5) trigger: "proactive-token-budget" | "reactive-overflow" | "user-command"; tokensBefore: number; // from real usage accounting summaryModel: string; // the auxModel used } >; // ─────────────────────── completion (durable) ─────────────────────── export type VerificationRequested = Durable< "task.verification-requested", { attempt: 1 | 2; detectedChecks: string[]; // e.g. ["npm test", "npx tsc --noEmit"] withheldCandidateSeq: number; // seq of the withheld answer's final text block (ADR-12) } >; export type TaskCompleted = Durable< "task.completed", { evidence: CompletionEvidence; } >; export type TaskFailed = Durable< "task.failed", { reason: "model-fatal-error" | "iteration-budget-exhausted" | "user-abandoned"; detail: string; } >; // ─────────────────────── phase gate events (durable, v2 §1) ─────────────────────── export type PhaseEntered = Durable< "phase.entered", { phase: Phase; /** What caused the transition — the workflow, an approval, or a user override. */ via: "session-start" | "design-submitted" | "approval" | "user-override"; } >; export type PhaseArtifact = Durable< "phase.artifact", { artifactId: string; artifact: DesignArtifact; } >; export type PhaseApproved = Durable< "phase.approved", { phase: Phase; approvedBy: "user" | "auto-policy" | "user-override"; artifactId?: string; } >; export type PhaseRejected = Durable< "phase.rejected", { phase: Phase; reason: string; artifactId?: string; } >; // ─────────────────────── native verification (durable, v2 §4) ─────────────────────── export type VerifyResult = Durable< "verify.result", { /** Check name from verify config: "typecheck", "test", "lint", … */ check: string; command: string; ok: boolean; exitCode: number | null; /** Intelligently truncated output — errors first. */ output: string; durationMs: number; } >; // ─────────────────────── project memory (durable, v2 §5) ─────────────────────── export type MemoryWritten = Durable< "memory.written", { section: string; entry: string; confidence: "high" | "medium" | "low"; toolUseId: string; } >; // ─────────────────────── parallel subtasks (durable, v2 §6) ─────────────────────── export type SubtaskCreated = Durable< "subtask.created", { taskId: string; description: string; /** Child session id (its own JSONL, meta.parent points here). */ childSessionId: string; worktreePath: string; branch: string; } >; export type SubtaskCompleted = Durable< "subtask.completed", { taskId: string; outcome: "done" | "failed" | "interrupted"; diffStats: DiffStats; verifyOk: boolean | null; detail: string; } >; // ───────────────────────────── unions ───────────────────────────── export type DurableEvent = | SessionStarted | SessionResumed | SessionRenamed | ModelChanged | BaselineRecorded | UserMessageCreated | SteeringQueued | SteeringInjected | Interrupted | ModelRequestStarted | ModelTextBlockCompleted | ModelThinkingBlockCompleted | ToolRequested | ModelResponseCompleted | ModelRequestFailed | PermissionRequested | PermissionGranted | PermissionDenied | ToolApproved | ToolStarted | ToolCompleted | ToolFailed | ToolCancelled | FileRead | FileModified | ProcessStarted | ProcessExited | ContextPruned | ContextCompacted | VerificationRequested | TaskCompleted | TaskFailed | PhaseEntered | PhaseArtifact | PhaseApproved | PhaseRejected | VerifyResult | MemoryWritten | SubtaskCreated | SubtaskCompleted; export type EphemeralEvent = | ModelTextDelta | ModelThinkingDelta | ToolCallStarted | ToolInputDelta | ToolOutput | ProcessOutput; export type KhaelorEvent = DurableEvent | EphemeralEvent; export type DurableEventType = DurableEvent["type"]; export type EphemeralEventType = EphemeralEvent["type"]; /** What producers hand to the bus/store; envelope fields are assigned at append time. */ export type DurableEventInput = { [T in DurableEventType]: { type: T; payload: Extract["payload"] }; }[DurableEventType]; /** What producers hand to the bus for ephemeral publication. */ export type EphemeralEventInput = { [T in EphemeralEventType]: { type: T; payload: Extract["payload"] }; }[EphemeralEventType]; // ─────────────── durable/ephemeral classification (runtime) ─────────────── const DURABLE_TYPE_LIST = [ "session.started", "session.resumed", "session.renamed", "session.model-changed", "git.baseline-recorded", "user.message-created", "user.steering-queued", "user.steering-injected", "user.interrupted", "model.request-started", "model.text-block-completed", "model.thinking-block-completed", "tool.requested", "model.response-completed", "model.request-failed", "permission.requested", "permission.granted", "permission.denied", "tool.approved", "tool.started", "tool.completed", "tool.failed", "tool.cancelled", "file.read", "file.modified", "process.started", "process.exited", "context.pruned", "context.compacted", "task.verification-requested", "task.completed", "task.failed", "phase.entered", "phase.artifact", "phase.approved", "phase.rejected", "verify.result", "memory.written", "subtask.created", "subtask.completed", ] as const satisfies readonly DurableEventType[]; const EPHEMERAL_TYPE_LIST = [ "model.text-delta", "model.thinking-delta", "model.tool-call-started", "model.tool-input-delta", "tool.output", "process.output", ] as const satisfies readonly EphemeralEventType[]; type AssertNever = T; /** Compile-time exhaustiveness guards: fail to compile if a union member is missing from a list. */ export type _DurableListIsExhaustive = AssertNever< Exclude >; export type _EphemeralListIsExhaustive = AssertNever< Exclude >; /** The durable event types (EVENT_MODEL.md §3 + v2 additions). */ export const DURABLE_EVENT_TYPES: ReadonlySet = new Set(DURABLE_TYPE_LIST); /** The 6 ephemeral event types (EVENT_MODEL.md §3). */ export const EPHEMERAL_EVENT_TYPES: ReadonlySet = new Set(EPHEMERAL_TYPE_LIST); export function isDurableEventType(type: string): type is DurableEventType { return DURABLE_EVENT_TYPES.has(type as DurableEventType); } export function isEphemeralEventType(type: string): type is EphemeralEventType { return EPHEMERAL_EVENT_TYPES.has(type as EphemeralEventType); } /** Runtime classification of a full event by its `type` string. */ export function isDurableEvent(event: KhaelorEvent): event is DurableEvent { return isDurableEventType(event.type); }