SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
19.8 KB · 738 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/session/events.ts4 * Description: The complete typed event catalog — envelopes, durable/ephemeral split (EVENT_MODEL.md), including the v2 phase-gate, verify, memory, and subtask events.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910/** Schema version written on every durable JSONL line (EVENT_MODEL.md §5.4). */11export const EVENT_SCHEMA_VERSION = 1 as const;12export type EventSchemaVersion = typeof EVENT_SCHEMA_VERSION;1314// ───────────────────────────── envelopes ─────────────────────────────1516/** Durable envelope — one JSONL line per event. */17export interface Durable<T extends string, P> {18  v: EventSchemaVersion; // schema version of this event line19  id: string; // ULID — globally unique, time-ordered20  sessionId: string;21  seq: number; // monotonic per session, gapless, assigned at append time22  ts: number; // epoch milliseconds23  parentId?: string; // reserved, always absent in V1 (ADR-3: linear log, tree-ready)24  type: T;25  payload: P;26}2728/** Ephemeral envelope — bus-only, never persisted. No seq (no log position), no v. */29export interface Ephemeral<T extends string, P> {30  id: string; // ULID (correlation/debugging)31  sessionId: string;32  ts: number;33  type: T;34  payload: P;35}3637// ───────────────────────── shared payload types ─────────────────────────3839export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "refusal";4041export interface ModelUsage {42  // real API fields only43  inputTokens: number;44  outputTokens: number;45  cacheReadTokens: number;46  cacheWriteTokens: number;47}4849export interface DiffStats {50  added: number;51  removed: number;52}5354export interface CheckResult {55  command: string;56  exitCode: number;57  summary: string; // e.g. "148 passed", "typecheck passed"58  durationMs: number;59}6061export interface CompletionEvidence {62  // CLAUDE.md §1763  objective: string;64  changedFiles: string[];65  checks: CheckResult[];66  unresolvedIssues: string[];67}6869export interface GitBaseline {70  branch: string;71  dirtyFiles: string[];72  untrackedFiles: string[];73  diffHash: string; // hash of `git diff` output at capture time74}7576export type ModelErrorKind =77  | "retryable"78  | "context-overflow"79  | "auth"80  | "invalid-request"81  | "cancelled";8283export type ToolName =84  | "read"85  | "write"86  | "edit"87  | "grep"88  | "glob"89  | "bash"90  | "process"91  | "design"92  | "remember"93  | "symbols"94  | "refs";9596// ─────────────────────── phase gates (durable, v2 §1) ───────────────────────9798export type Phase = "understand" | "design" | "implement";99100/** The structured design the agent must produce before implement is unlocked. */101export interface DesignArtifact {102  /** Reformulation of the need by the agent. */103  goal: string;104  /** Files it plans to modify. */105  filesTouched: string[];106  /** Technical approach, 5–15 lines. */107  approach: string;108  /** Identified risks. */109  risks: string[];110  /** How it will prove the change works. */111  verification: string;112  /** What it will NOT do. */113  outOfScope: string[];114}115116// ─────────────────────── session lifecycle (durable) ───────────────────────117118export type SessionStarted = Durable<119  "session.started",120  {121    title: string;122    projectHash: string;123    workingDirectory: string;124    gitBranch: string | null;125    model: string;126    auxModel: string;127    khaelorVersion: string;128  }129>;130131export type SessionResumed = Durable<132  "session.resumed",133  {134    khaelorVersion: string;135    replayedSeq: number; // highest seq replayed136    model: string; // active model after resume (may differ — swappable)137    toolNames: ToolName[]; // verified add-only vs. original set (ADR-3 resume contract)138  }139>;140141export type SessionRenamed = Durable<"session.renamed", { title: string }>;142143export type ModelChanged = Durable<144  "session.model-changed",145  {146    from: string;147    to: string;148    reason: "user" | "config";149  }150>;151152export type BaselineRecorded = Durable<153  "git.baseline-recorded",154  {155    when: "session-start" | "pre-first-edit";156    baseline: GitBaseline;157  }158>;159160// ───────────────────────── user input (durable) ─────────────────────────161162export type UserMessageCreated = Durable<163  "user.message-created",164  {165    text: string; // byte-exact — enters LLM history verbatim166    mentions: { path: string; range?: { start: number; end: number } }[];167  }168>;169170export type SteeringQueued = Durable<"user.steering-queued", { text: string }>;171172export type SteeringInjected = Durable<173  "user.steering-injected",174  {175    queuedEventId: string; // id of the SteeringQueued event176    seam: "post-tool-batch" | "pre-model-call";177    afterSeq: number; // injection position in history — makes replay exact (ADR-7)178  }179>;180181export type Interrupted = Durable<182  "user.interrupted",183  {184    scope: "turn"; // V1: Esc aborts the turn (model stream + in-flight tools)185    pendingToolUseIds: string[]; // tools that will be closed via ToolCancelled186  }187>;188189// ──────────────────────────── model stream ────────────────────────────190191export type ModelRequestStarted = Durable<192  "model.request-started",193  {194    requestId: string; // correlates all blocks/usage of this call195    model: string;196    purpose: "main" | "compaction" | "verification-nudge";197    contextStats: {198      // for /context history — estimates labeled as such199      estimatedInputTokens: number;200      sections: { name: string; estimatedTokens: number }[];201    };202  }203>;204205export type ModelTextDelta = Ephemeral<206  "model.text-delta",207  {208    requestId: string;209    blockIndex: number;210    text: string;211  }212>;213214export type ModelThinkingDelta = Ephemeral<215  "model.thinking-delta",216  {217    requestId: string;218    blockIndex: number;219    text: string;220  }221>;222223export type ToolCallStarted = Ephemeral<224  "model.tool-call-started",225  {226    requestId: string;227    blockIndex: number;228    toolUseId: string;229    toolName: ToolName;230  }231>;232233export type ToolInputDelta = Ephemeral<234  "model.tool-input-delta",235  {236    requestId: string;237    blockIndex: number;238    toolUseId: string;239    partialJson: string;240  }241>;242243export type ModelTextBlockCompleted = Durable<244  "model.text-block-completed",245  {246    requestId: string;247    blockIndex: number;248    text: string; // byte-exact settled block249  }250>;251252export type ModelThinkingBlockCompleted = Durable<253  "model.thinking-block-completed",254  {255    requestId: string;256    blockIndex: number;257    thinking: string;258    signature: string; // required for byte-exact API replay in tool loops259  }260>;261262export type ToolRequested = Durable<263  "tool.requested",264  {265    requestId: string;266    blockIndex: number;267    toolUseId: string; // Anthropic tool_use id — pairing key (§6.5)268    toolName: ToolName;269    input: unknown; // complete parsed input — byte-exact via canonical JSON (§5.1)270  }271>;272273export type ModelResponseCompleted = Durable<274  "model.response-completed",275  {276    requestId: string;277    stopReason: StopReason;278    usage: ModelUsage; // REAL API usage — sole source for cost/compaction279    durationMs: number;280  }281>;282283export type ModelRequestFailed = Durable<284  "model.request-failed",285  {286    requestId: string;287    kind: ModelErrorKind;288    message: string; // redacted — never headers/keys289    status?: number;290    retriesExhausted: boolean;291  }292>;293294// ─────────────────────── permissions (durable) ───────────────────────295296export type PermissionRequested = Durable<297  "permission.requested",298  {299    permissionRequestId: string;300    toolUseId: string;301    capability: string; // Capability302    descriptor: string; // human-meaningful: "Run `npm install` in ~/dev/project"303    suggestion?: { capability: string; pattern: string }; // "always allow git push *" (ADR-9)304  }305>;306307export type PermissionGranted = Durable<308  "permission.granted",309  {310    permissionRequestId: string;311    scope: "once" | "always-project"; // "always" also persists a config rule (ADR-9)312  }313>;314315export type PermissionDenied = Durable<316  "permission.denied",317  {318    permissionRequestId: string;319    source: "user" | "policy" | "hardline" | "timeout"; // silence is not consent (ADR-9)320    feedback: string; // returned to the model as the tool observation321  }322>;323324// ─────────────── tool execution (durable + one ephemeral) ───────────────325326export type ToolApproved = Durable<327  "tool.approved",328  {329    toolUseId: string;330    via: "policy-allow" | "user-once" | "user-always" | "rule";331  }332>;333334export type ToolStarted = Durable<335  "tool.started",336  {337    toolUseId: string;338    toolName: ToolName;339  }340>;341342export type ToolOutput = Ephemeral<343  "tool.output",344  {345    toolUseId: string;346    chunk: string; // live chunk, ANSI-stripped for TUI347  }348>;349350export type ToolCompleted = Durable<351  "tool.completed",352  {353    toolUseId: string;354    modelText: string; // byte-exact tool_result content (budget-truncated w/ markers)355    spillFile?: string; // full output under ~/.khaelor/spill/ (ADR-8)356    durationMs: number;357    ui: {358      // presentation DATA, not presentation (Rule 3)359      kind: "read" | "search" | "edit" | "exec" | "process";360      summary: string; // e.g. `Search "ContextEngine" · 14 matches`361      diffStats?: DiffStats;362      exitCode?: number;363      matchCount?: number;364    };365  }366>;367368export type ToolFailed = Durable<369  "tool.failed",370  {371    toolUseId: string;372    modelText: string; // byte-exact is_error tool_result content373    errorKind:374      | "invalid-input"375      | "not-found"376      | "ambiguous-edit"377      | "exec-error"378      | "timeout"379      | "permission-denied"380      | "phase-blocked"381      | "internal";382    durationMs: number;383  }384>;385386export type ToolCancelled = Durable<387  "tool.cancelled",388  {389    toolUseId: string;390    reason: "interrupted" | "resume-recovery" | "shutdown";391    modelText: string; // synthetic result, e.g. "[Tool execution cancelled by user]"392  }393>;394395// ─────────────────────── file activity (durable) ───────────────────────396397export type FileRead = Durable<398  "file.read",399  {400    path: string; // relative to workspace cwd401    range?: { start: number; end: number };402    bytes: number;403    mtimeMs: number; // external-modification detection on later writes404    toolUseId: string;405  }406>;407408export type FileModified = Durable<409  "file.modified",410  {411    path: string;412    operation: "write" | "edit";413    diffStats: DiffStats;414    diff?: string; // unified diff, capped (default 32 KiB) with truncation marker415    toolUseId: string;416  }417>;418419// ─────────────── processes (durable + one ephemeral) ───────────────420421export type ProcessStarted = Durable<422  "process.started",423  {424    processId: string; // KHAELOR id (stable across PID reuse)425    pid: number;426    command: string;427    cwd: string;428    name?: string;429    toolUseId: string;430  }431>;432433export type ProcessOutput = Ephemeral<434  "process.output",435  {436    processId: string;437    stream: "stdout" | "stderr";438    chunk: string;439  }440>;441442export type ProcessExited = Durable<443  "process.exited",444  {445    processId: string;446    exitCode: number | null; // null = signal-killed447    cause: "exited" | "stopped-by-tool" | "khaelor-shutdown" | "crashed";448    durationMs: number;449  }450>;451452// ─────────────────────── context engine (durable) ───────────────────────453454export type ContextPruned = Durable<455  "context.pruned",456  {457    toolUseIds: string[]; // results blanked with the FIXED placeholder string458    placeholder: string; // recorded so replay is byte-exact even if default changes459    tokensReclaimedEstimate: number;460  }461>;462463export type ContextCompacted = Durable<464  "context.compacted",465  {466    checkpointYaml: string; // the structured checkpoint (ARCHITECTURE.md §6.4), verbatim467    cut: { fromSeq: number; toSeq: number }; // replaced range — pairing-safe boundary (§6.5)468    trigger: "proactive-token-budget" | "reactive-overflow" | "user-command";469    tokensBefore: number; // from real usage accounting470    summaryModel: string; // the auxModel used471  }472>;473474// ─────────────────────── completion (durable) ───────────────────────475476export type VerificationRequested = Durable<477  "task.verification-requested",478  {479    attempt: 1 | 2;480    detectedChecks: string[]; // e.g. ["npm test", "npx tsc --noEmit"]481    withheldCandidateSeq: number; // seq of the withheld answer's final text block (ADR-12)482  }483>;484485export type TaskCompleted = Durable<486  "task.completed",487  {488    evidence: CompletionEvidence;489  }490>;491492export type TaskFailed = Durable<493  "task.failed",494  {495    reason: "model-fatal-error" | "iteration-budget-exhausted" | "user-abandoned";496    detail: string;497  }498>;499500// ─────────────────────── phase gate events (durable, v2 §1) ───────────────────────501502export type PhaseEntered = Durable<503  "phase.entered",504  {505    phase: Phase;506    /** What caused the transition — the workflow, an approval, or a user override. */507    via: "session-start" | "design-submitted" | "approval" | "user-override";508  }509>;510511export type PhaseArtifact = Durable<512  "phase.artifact",513  {514    artifactId: string;515    artifact: DesignArtifact;516  }517>;518519export type PhaseApproved = Durable<520  "phase.approved",521  {522    phase: Phase;523    approvedBy: "user" | "auto-policy" | "user-override";524    artifactId?: string;525  }526>;527528export type PhaseRejected = Durable<529  "phase.rejected",530  {531    phase: Phase;532    reason: string;533    artifactId?: string;534  }535>;536537// ─────────────────────── native verification (durable, v2 §4) ───────────────────────538539export type VerifyResult = Durable<540  "verify.result",541  {542    /** Check name from verify config: "typecheck", "test", "lint", … */543    check: string;544    command: string;545    ok: boolean;546    exitCode: number | null;547    /** Intelligently truncated output — errors first. */548    output: string;549    durationMs: number;550  }551>;552553// ─────────────────────── project memory (durable, v2 §5) ───────────────────────554555export type MemoryWritten = Durable<556  "memory.written",557  {558    section: string;559    entry: string;560    confidence: "high" | "medium" | "low";561    toolUseId: string;562  }563>;564565// ─────────────────────── parallel subtasks (durable, v2 §6) ───────────────────────566567export type SubtaskCreated = Durable<568  "subtask.created",569  {570    taskId: string;571    description: string;572    /** Child session id (its own JSONL, meta.parent points here). */573    childSessionId: string;574    worktreePath: string;575    branch: string;576  }577>;578579export type SubtaskCompleted = Durable<580  "subtask.completed",581  {582    taskId: string;583    outcome: "done" | "failed" | "interrupted";584    diffStats: DiffStats;585    verifyOk: boolean | null;586    detail: string;587  }588>;589590// ───────────────────────────── unions ─────────────────────────────591592export type DurableEvent =593  | SessionStarted594  | SessionResumed595  | SessionRenamed596  | ModelChanged597  | BaselineRecorded598  | UserMessageCreated599  | SteeringQueued600  | SteeringInjected601  | Interrupted602  | ModelRequestStarted603  | ModelTextBlockCompleted604  | ModelThinkingBlockCompleted605  | ToolRequested606  | ModelResponseCompleted607  | ModelRequestFailed608  | PermissionRequested609  | PermissionGranted610  | PermissionDenied611  | ToolApproved612  | ToolStarted613  | ToolCompleted614  | ToolFailed615  | ToolCancelled616  | FileRead617  | FileModified618  | ProcessStarted619  | ProcessExited620  | ContextPruned621  | ContextCompacted622  | VerificationRequested623  | TaskCompleted624  | TaskFailed625  | PhaseEntered626  | PhaseArtifact627  | PhaseApproved628  | PhaseRejected629  | VerifyResult630  | MemoryWritten631  | SubtaskCreated632  | SubtaskCompleted;633634export type EphemeralEvent =635  | ModelTextDelta636  | ModelThinkingDelta637  | ToolCallStarted638  | ToolInputDelta639  | ToolOutput640  | ProcessOutput;641642export type KhaelorEvent = DurableEvent | EphemeralEvent;643644export type DurableEventType = DurableEvent["type"];645export type EphemeralEventType = EphemeralEvent["type"];646647/** What producers hand to the bus/store; envelope fields are assigned at append time. */648export type DurableEventInput = {649  [T in DurableEventType]: { type: T; payload: Extract<DurableEvent, { type: T }>["payload"] };650}[DurableEventType];651652/** What producers hand to the bus for ephemeral publication. */653export type EphemeralEventInput = {654  [T in EphemeralEventType]: { type: T; payload: Extract<EphemeralEvent, { type: T }>["payload"] };655}[EphemeralEventType];656657// ─────────────── durable/ephemeral classification (runtime) ───────────────658659const DURABLE_TYPE_LIST = [660  "session.started",661  "session.resumed",662  "session.renamed",663  "session.model-changed",664  "git.baseline-recorded",665  "user.message-created",666  "user.steering-queued",667  "user.steering-injected",668  "user.interrupted",669  "model.request-started",670  "model.text-block-completed",671  "model.thinking-block-completed",672  "tool.requested",673  "model.response-completed",674  "model.request-failed",675  "permission.requested",676  "permission.granted",677  "permission.denied",678  "tool.approved",679  "tool.started",680  "tool.completed",681  "tool.failed",682  "tool.cancelled",683  "file.read",684  "file.modified",685  "process.started",686  "process.exited",687  "context.pruned",688  "context.compacted",689  "task.verification-requested",690  "task.completed",691  "task.failed",692  "phase.entered",693  "phase.artifact",694  "phase.approved",695  "phase.rejected",696  "verify.result",697  "memory.written",698  "subtask.created",699  "subtask.completed",700] as const satisfies readonly DurableEventType[];701702const EPHEMERAL_TYPE_LIST = [703  "model.text-delta",704  "model.thinking-delta",705  "model.tool-call-started",706  "model.tool-input-delta",707  "tool.output",708  "process.output",709] as const satisfies readonly EphemeralEventType[];710711type AssertNever<T extends never> = T;712/** Compile-time exhaustiveness guards: fail to compile if a union member is missing from a list. */713export type _DurableListIsExhaustive = AssertNever<714  Exclude<DurableEventType, (typeof DURABLE_TYPE_LIST)[number]>715>;716export type _EphemeralListIsExhaustive = AssertNever<717  Exclude<EphemeralEventType, (typeof EPHEMERAL_TYPE_LIST)[number]>718>;719720/** The durable event types (EVENT_MODEL.md §3 + v2 additions). */721export const DURABLE_EVENT_TYPES: ReadonlySet<DurableEventType> = new Set(DURABLE_TYPE_LIST);722723/** The 6 ephemeral event types (EVENT_MODEL.md §3). */724export const EPHEMERAL_EVENT_TYPES: ReadonlySet<EphemeralEventType> = new Set(EPHEMERAL_TYPE_LIST);725726export function isDurableEventType(type: string): type is DurableEventType {727  return DURABLE_EVENT_TYPES.has(type as DurableEventType);728}729730export function isEphemeralEventType(type: string): type is EphemeralEventType {731  return EPHEMERAL_EVENT_TYPES.has(type as EphemeralEventType);732}733734/** Runtime classification of a full event by its `type` string. */735export function isDurableEvent(event: KhaelorEvent): event is DurableEvent {736  return isDurableEventType(event.type);737}738