diff --git a/docs/lifecycle-events.md b/docs/lifecycle-events.md new file mode 100644 index 0000000..ea2fc37 --- /dev/null +++ b/docs/lifecycle-events.md @@ -0,0 +1,80 @@ +# Lifecycle Events + +Lifecycle events are Agentic-owned vocabulary for semantic facts about primitive operations. They answer "what happened?" without deciding who handles it, where it is stored, or how side effects run. + +Agentic defines the event names and portable envelope in `src/types.ts`: + +- `LifecycleEventName` +- `LifecycleEvent` +- `LifecycleEventSubject` +- `LifecycleEventRef` +- `LIFECYCLE_EVENTS` +- `LIFECYCLE_EVENT_PRIMITIVES` + +## Boundary + +Agentic owns lifecycle event vocabulary. Hosts and harnesses own execution semantics. + +| Layer | Role | Owner | +| --- | --- | --- | +| Lifecycle event | Portable vocabulary for something that happened while using a primitive | Agentic | +| Local hook | CLI or harness convenience for running a local side effect | Harness/host | +| Dispatch | Portable message/routing shape a host may use to carry an event | Agentic shape, host execution | + +A host may map lifecycle events into logs, metrics, queues, dispatches, webhooks, indexes, audit records, user-visible progress, or nothing. Agentic does not define retries, persistence, idempotency, authorization, or UI. + +## Event Vocabulary + +Initial semantic events: + +- `artifact.created` +- `artifact.written` +- `artifact.locked` +- `persona.activated` +- `workflow.transitioned` +- `memory.remembered` +- `capability.requested` +- `capability.allowed` +- `capability.denied` +- `capability.completed` +- `approval.requested` +- `approval.granted` +- `approval.rejected` +- `approval.expired` + +## Envelope + +`LifecycleEvent` is intentionally small: + +```ts +type LifecycleEvent = { + id?: string + name: LifecycleEventName + primitive: LifecyclePrimitive + subject: LifecycleEventSubject + timestamp: string + correlation_id?: string + related?: LifecycleEventRef[] + data?: Record +} +``` + +`subject` identifies the primitive-owned thing the event is about, such as an artifact id, persona name, workflow run id, memory key, capability name, or approval request id. + +`correlation_id` lets a host group multiple primitive events produced by one operation. + +`related` lets composed operations remain explicit. For example, a workflow transition that also writes an artifact can emit or record a `workflow.transitioned` event related to an `artifact.written` event. Agentic does not require one event to contain the other's full payload. + +`data` is primitive-specific context. Keep it portable and avoid provider internals; hosts can store private execution details elsewhere. + +## Hooks Are Not Events + +`.agentic/hooks/artifact.written` is one local way to react to the semantic event `artifact.written`. The event name is Agentic vocabulary; hook lookup, process execution, timeout policy, stderr handling, and failure behavior are harness behavior. + +Existing CLI hooks continue to work as local dogfood. The lifecycle event vocabulary does not require hosts to use local hooks. + +## Dispatch Is Not Event Handling + +`Dispatch` is a portable inbound message shape and filter vocabulary. A host may choose to carry lifecycle events through dispatch, but Agentic does not provide queues, webhooks, schedulers, or handler execution. + +This keeps the boundary clear: Agentic can say `approval.requested` happened; the host decides whether that becomes a Slack message, database row, audit log, retryable job, or no-op. diff --git a/src/index.ts b/src/index.ts index b1a48bf..d6974be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,11 @@ export type { Dispatch, DispatchFilter, DispatchHandlerHooks, + LifecyclePrimitive, + LifecycleEventName, + LifecycleEventRef, + LifecycleEventSubject, + LifecycleEvent, HookInvocation, WorkflowRunStartedOutput, WorkflowRunTerminatedOutput, @@ -59,7 +64,12 @@ export type { CapabilityArtifacts, CapabilityDef, } from "./types.js" -export { CAPABILITY_EFFECTS, POLICY_ERRORS } from "./types.js" +export { + CAPABILITY_EFFECTS, + POLICY_ERRORS, + LIFECYCLE_EVENTS, + LIFECYCLE_EVENT_PRIMITIVES, +} from "./types.js" export type { MemoryAdapter, AdapterCapabilities } from "./memory/adapter.js" export { FilesystemAdapter } from "./memory/filesystem.js" diff --git a/src/lifecycle-events.test.ts b/src/lifecycle-events.test.ts new file mode 100644 index 0000000..f34aab3 --- /dev/null +++ b/src/lifecycle-events.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test" +import type { LifecycleEvent, LifecycleEventName } from "./types.js" +import { + LIFECYCLE_EVENTS, + LIFECYCLE_EVENT_PRIMITIVES, +} from "./types.js" + +describe("lifecycle event vocabulary", () => { + it("defines the semantic events from the framework boundary memo", () => { + const expected: readonly LifecycleEventName[] = [ + "artifact.created", + "artifact.written", + "artifact.locked", + "persona.activated", + "workflow.transitioned", + "memory.remembered", + "capability.requested", + "capability.allowed", + "capability.denied", + "capability.completed", + "approval.requested", + "approval.granted", + "approval.rejected", + "approval.expired", + ] + + expect(LIFECYCLE_EVENTS).toEqual(expected) + }) + + it("maps every event name to its owning primitive", () => { + expect(Object.keys(LIFECYCLE_EVENT_PRIMITIVES).sort()).toEqual( + [...LIFECYCLE_EVENTS].sort(), + ) + expect(LIFECYCLE_EVENT_PRIMITIVES["artifact.written"]).toBe("artifact") + expect(LIFECYCLE_EVENT_PRIMITIVES["approval.expired"]).toBe("approval") + }) + + it("supports related events for composed primitive operations", () => { + const event: LifecycleEvent<"workflow.transitioned"> = { + id: "01KNSAMPLEEVENT00000000000", + name: "workflow.transitioned", + primitive: "workflow", + subject: { + type: "workflow_run", + id: "01KNRUN000000000000000000", + }, + timestamp: "2026-06-04T13:45:00.000Z", + correlation_id: "01KNCORRELATION000000000", + related: [ + { + name: "artifact.written", + id: "01KNARTIFACTEVENT0000000", + }, + ], + data: { + graph_id: "briefing", + node_id: "write-brief", + to_status: "completed", + }, + } + + expect(event.related?.[0]?.name).toBe("artifact.written") + expect(event.data?.["node_id"]).toBe("write-brief") + }) +}) diff --git a/src/types.ts b/src/types.ts index c1276ca..3bbf5e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -363,6 +363,103 @@ export type DispatchHandlerHooks = { onUnregister?: () => Promise } +// --------------------------------------------------------------------------- +// Lifecycle event types +// +// Lifecycle events are semantic facts about primitive operations. Agentic owns +// the vocabulary and portable envelope; hosts own execution semantics: hooks, +// dispatch bridges, queues, persistence, retries, observability, and UI. +// --------------------------------------------------------------------------- + +export type LifecyclePrimitive = + | "artifact" + | "persona" + | "workflow" + | "memory" + | "capability" + | "approval" + +export type LifecycleEventName = + | "artifact.created" + | "artifact.written" + | "artifact.locked" + | "persona.activated" + | "workflow.transitioned" + | "memory.remembered" + | "capability.requested" + | "capability.allowed" + | "capability.denied" + | "capability.completed" + | "approval.requested" + | "approval.granted" + | "approval.rejected" + | "approval.expired" + +export const LIFECYCLE_EVENTS: readonly LifecycleEventName[] = [ + "artifact.created", + "artifact.written", + "artifact.locked", + "persona.activated", + "workflow.transitioned", + "memory.remembered", + "capability.requested", + "capability.allowed", + "capability.denied", + "capability.completed", + "approval.requested", + "approval.granted", + "approval.rejected", + "approval.expired", +] as const + +export const LIFECYCLE_EVENT_PRIMITIVES: Readonly> = { + "artifact.created": "artifact", + "artifact.written": "artifact", + "artifact.locked": "artifact", + "persona.activated": "persona", + "workflow.transitioned": "workflow", + "memory.remembered": "memory", + "capability.requested": "capability", + "capability.allowed": "capability", + "capability.denied": "capability", + "capability.completed": "capability", + "approval.requested": "approval", + "approval.granted": "approval", + "approval.rejected": "approval", + "approval.expired": "approval", +} as const + +export type LifecycleEventRef = { + name: LifecycleEventName + id?: string | undefined +} + +export type LifecycleEventSubject = { + /** Primitive-owned subject kind, e.g. `artifact`, `persona`, `workflow_run`. */ + type: string + /** Stable subject identifier when one exists, e.g. artifact id or run id. */ + id?: string | undefined + /** Human-readable or catalog name when the subject is name-addressed. */ + name?: string | undefined + /** Primitive version when relevant, e.g. artifact or graph version. */ + version?: string | number | undefined +} + +export type LifecycleEvent = { + /** Optional host-assigned event id. Agentic does not require persistence. */ + id?: string | undefined + name: TName + primitive: (typeof LIFECYCLE_EVENT_PRIMITIVES)[TName] + subject: LifecycleEventSubject + timestamp: string + /** Host/runtime correlation id for grouping related primitive events. */ + correlation_id?: string | undefined + /** Related lifecycle events, e.g. a workflow transition that wrote an artifact. */ + related?: LifecycleEventRef[] | undefined + /** Primitive-specific payload; hosts should keep provider internals out. */ + data?: Record | undefined +} + // --------------------------------------------------------------------------- // Artifact types //