diff --git a/agentic-organization/.dockerignore b/agentic-organization/.dockerignore new file mode 100644 index 0000000000..917cfbbcbc --- /dev/null +++ b/agentic-organization/.dockerignore @@ -0,0 +1,7 @@ +node_modules +**/*.test.ts +docs +.git +.gitignore +*.md +deploy diff --git a/agentic-organization/Dockerfile b/agentic-organization/Dockerfile new file mode 100644 index 0000000000..f046b9f224 --- /dev/null +++ b/agentic-organization/Dockerfile @@ -0,0 +1,26 @@ +# Agentic Organization worker runtime. +# +# The code runs directly via `node --experimental-strip-types` (no build step, +# no bundler) per the repo convention. node:24-slim (Debian) is used over Alpine +# so the `pg` and @nats-io native paths resolve against glibc cleanly. +FROM node:24-slim + +WORKDIR /app + +# Install only production deps first for layer caching. package-lock.json is the +# pinned dependency graph (@nats-io/jetstream, @nats-io/transport-node, pg). +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +# Copy the source. No compile — strip-types runs the .ts directly. +COPY tsconfig.json ./ +COPY packages ./packages +COPY apps ./apps + +# Run as the non-root node user (defense in depth; the worker needs no root). +USER node + +# The worker is a long-running process driven by the always-on loop. SIGTERM is +# handled by the entrypoint (graceful shutdown of cockroach + nats ports). +ENV NODE_ENV=production +ENTRYPOINT ["node", "--experimental-strip-types", "apps/workers/src/main.ts"] diff --git a/agentic-organization/apps/workers/src/adapters/ollama-chat-port.ts b/agentic-organization/apps/workers/src/adapters/ollama-chat-port.ts new file mode 100644 index 0000000000..2a39326d71 --- /dev/null +++ b/agentic-organization/apps/workers/src/adapters/ollama-chat-port.ts @@ -0,0 +1,64 @@ +/** + * Ollama chat adapter — a real ChatCompletionPort backed by a model served by + * Ollama (https://ollama.com). Runs entirely in-cluster (no external + * credentials): the worker POSTs to the in-cluster Ollama service and the model + * returns its choice. This is the agent's live decision backend. + * + * Bounded by an AbortController timeout so a slow/hung model never stalls the + * agent run (the model-backed composer falls back to the deterministic policy + * when this throws — the agent stays alive). + */ + +import type { ChatCompletionPort, ChatCompletionRequest } from "../../../../packages/application/src/index.ts"; + +export type CreateOllamaChatPortInput = { + baseUrl: string; + model: string; + timeoutMs?: number; + /** injected for testability; defaults to global fetch */ + fetchImpl?: typeof fetch; +}; + +type OllamaChatResponse = { + message?: { content?: string }; +}; + +export function createOllamaChatPort(input: CreateOllamaChatPortInput): ChatCompletionPort { + const fetchImpl = input.fetchImpl ?? fetch; + const timeoutMs = input.timeoutMs ?? 20_000; + const url = `${input.baseUrl.replace(/\/+$/, "")}/api/chat`; + + return { + complete: async (request: ChatCompletionRequest): Promise => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: input.model, + stream: false, + options: { temperature: 0 }, + messages: [ + { role: "system", content: request.system }, + { role: "user", content: request.user }, + ], + }), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`ollama chat failed: ${response.status} ${response.statusText}`); + } + const body = (await response.json()) as OllamaChatResponse; + const content = body.message?.content; + if (typeof content !== "string") { + throw new Error("ollama chat response had no message content"); + } + return content; + } finally { + clearTimeout(timer); + } + }, + }; +} diff --git a/agentic-organization/apps/workers/src/adapters/subprocess-sandbox.ts b/agentic-organization/apps/workers/src/adapters/subprocess-sandbox.ts new file mode 100644 index 0000000000..b4fac490c7 --- /dev/null +++ b/agentic-organization/apps/workers/src/adapters/subprocess-sandbox.ts @@ -0,0 +1,70 @@ +/** + * Subprocess sandbox adapter — a real SandboxToolPort that runs a tool as a + * bounded, isolated child process: + * - executed in a throwaway temp dir (isolated cwd), + * - with a stripped environment (only PATH), so the tool cannot read worker + * secrets/config, + * - killed (SIGKILL) on timeout, with output size capped, + * - never inherits the parent stdio. + * + * Result-as-DU: failures (non-zero exit, timeout, spawn error) come back as + * { ok:false, reason } — the agent run treats tool failure as supplementary, + * never fatal. + */ + +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { SandboxToolPort, SandboxToolRequest, SandboxToolResult } from "../../../../packages/application/src/index.ts"; + +const MAX_OUTPUT_BYTES = 64 * 1024; + +/** POSIX "/..." or Windows "C:\..." / "C:/...". */ +function isAbsolutePath(command: string): boolean { + return command.startsWith("/") || /^[A-Za-z]:[\\/]/.test(command); +} + +export function createSubprocessSandbox(): SandboxToolPort { + return { + run: async (request: SandboxToolRequest): Promise => { + // The contract requires an absolute command path. A relative command would + // be resolved via PATH and could execute an unexpected binary, so reject + // it as a Result rather than spawning it. + if (!isAbsolutePath(request.command)) { + return { ok: false, reason: `sandbox command must be an absolute path, got '${request.command}'` }; + } + const workdir = mkdtempSync(join(tmpdir(), "agentic-sandbox-")); + try { + return await new Promise((resolve) => { + execFile( + request.command, + [...request.args], + { + cwd: workdir, + timeout: request.timeoutMs, + killSignal: "SIGKILL", + maxBuffer: MAX_OUTPUT_BYTES, + encoding: "utf8", + // stripped env: only PATH, so the tool cannot read worker secrets + env: { PATH: process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin" }, + windowsHide: true, + }, + (error, stdout) => { + if (error !== null) { + resolve({ ok: false, reason: error.message }); + return; + } + resolve({ ok: true, stdout }); + }, + ); + }); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + }, + }; +} diff --git a/agentic-organization/apps/workers/src/composition.ts b/agentic-organization/apps/workers/src/composition.ts index 4347648f2a..aa8842d247 100644 --- a/agentic-organization/apps/workers/src/composition.ts +++ b/agentic-organization/apps/workers/src/composition.ts @@ -1,3 +1,4 @@ +import type { KeepAliveLane } from "../../../packages/keepalive/src/index.ts"; import type { NatsJetStreamEventConsumer } from "../../../packages/messaging-nats/src/index.ts"; import type { OrganizationWorkerHost } from "../../../packages/workers/src/index.ts"; import { @@ -11,6 +12,8 @@ export type WorkerRuntimePorts = { organizationWorkerHost: OrganizationWorkerHost; natsEventConsumer: NatsJetStreamEventConsumer; telemetrySink: WorkerRuntimeTelemetrySink; + /** optional so in-memory composition paths can omit the durable keep-alive lane */ + keepAliveLane?: KeepAliveLane; }; export type ComposeWorkerRuntimeInput = { @@ -24,5 +27,6 @@ export function composeWorkerRuntime(input: ComposeWorkerRuntimeInput): WorkerRu organizationWorkerHost: input.ports.organizationWorkerHost, natsEventConsumer: input.ports.natsEventConsumer, telemetrySink: input.ports.telemetrySink, + ...(input.ports.keepAliveLane === undefined ? {} : { keepAliveLane: input.ports.keepAliveLane }), }); } diff --git a/agentic-organization/apps/workers/src/config.ts b/agentic-organization/apps/workers/src/config.ts index 1cbb3729d1..29919f0e15 100644 --- a/agentic-organization/apps/workers/src/config.ts +++ b/agentic-organization/apps/workers/src/config.ts @@ -2,6 +2,17 @@ import { WorkerRuntimeConfigError, WorkerRuntimeConfigErrorCode, type WorkerRunt const decimalIntegerPattern = /^[0-9]+$/; +/** + * Default deadline (ms) past which the deterministic keep-alive engine treats + * the org as flatlining and raises a self-heal alert. Optional env override; + * defaulted so existing deployments do not need a new variable. + */ +export const WorkerKeepAliveConfigDefault = { + OrgHeartbeatDeadlineMs: 30_000, + /** cadence of the independent keep-alive loop (decoupled from the work cycle) */ + LoopIntervalMs: 5_000, +} as const; + export const WorkerProcessEnvName = { AgenticOrgEnv: "AGENTIC_ORG_ENV", AgenticOrgId: "AGENTIC_ORG_ID", @@ -14,6 +25,10 @@ export const WorkerProcessEnvName = { WorkerOutboxBatchSize: "WORKER_OUTBOX_BATCH_SIZE", WorkerReactionPlanBatchSize: "WORKER_REACTION_PLAN_BATCH_SIZE", WorkerReactionPlanLeaseMs: "WORKER_REACTION_PLAN_LEASE_MS", + WorkerKeepAliveOrgHeartbeatDeadlineMs: "WORKER_KEEP_ALIVE_ORG_HEARTBEAT_DEADLINE_MS", + /** when set, the agent's composer makes real model calls to this in-cluster endpoint */ + LlmBaseUrl: "LLM_BASE_URL", + LlmModel: "LLM_MODEL", } as const; export type WorkerProcessEnvName = (typeof WorkerProcessEnvName)[keyof typeof WorkerProcessEnvName]; @@ -27,6 +42,11 @@ export type WorkerDurableRuntimeConfig = { workerOutboxBatchSize: number; workerReactionPlanBatchSize: number; workerReactionPlanLeaseMs: number; + workerKeepAliveOrgHeartbeatDeadlineMs: number; + /** in-cluster model endpoint for the agent's decision backend (optional) */ + llmBaseUrl?: string; + /** model name to ask the endpoint for (optional; required when llmBaseUrl is set) */ + llmModel?: string; }; export type WorkerProcessConfig = WorkerRuntimeConfig & WorkerDurableRuntimeConfig; @@ -66,9 +86,56 @@ export function parseWorkerRuntimeConfigFromEnv(env: WorkerProcessEnvironment): env[WorkerProcessEnvName.WorkerReactionPlanBatchSize], ), workerReactionPlanLeaseMs: parseWorkerReactionPlanLeaseMs(env[WorkerProcessEnvName.WorkerReactionPlanLeaseMs]), + workerKeepAliveOrgHeartbeatDeadlineMs: parseWorkerKeepAliveOrgHeartbeatDeadlineMs( + env[WorkerProcessEnvName.WorkerKeepAliveOrgHeartbeatDeadlineMs], + ), + // optional model backend — only set when both URL and model are provided + ...parseLlmConfig(env), }; } +function parseLlmConfig(env: WorkerProcessEnvironment): { llmBaseUrl?: string; llmModel?: string } { + const baseUrl = nonEmpty(env[WorkerProcessEnvName.LlmBaseUrl]); + const model = nonEmpty(env[WorkerProcessEnvName.LlmModel]); + if (baseUrl === undefined && model === undefined) { + // neither set → the deterministic composer is used (a valid, intended mode) + return {}; + } + if (baseUrl === undefined || model === undefined) { + // exactly one set → a misconfiguration; fail loudly rather than silently + // dropping the model backend, which would be hard to diagnose in-cluster. + throw new WorkerRuntimeConfigError(WorkerRuntimeConfigErrorCode.PartialLlmConfig); + } + return { llmBaseUrl: baseUrl, llmModel: model }; +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined; +} + +function parseWorkerKeepAliveOrgHeartbeatDeadlineMs(value: string | undefined): number { + return parseOptionalPositiveDecimalInteger( + value, + WorkerKeepAliveConfigDefault.OrgHeartbeatDeadlineMs, + WorkerRuntimeConfigErrorCode.InvalidWorkerKeepAliveOrgHeartbeatDeadlineMs, + ); +} + +function parseOptionalPositiveDecimalInteger( + value: string | undefined, + defaultValue: number, + errorCode: WorkerRuntimeConfigErrorCode, +): number { + const trimmedValue = value?.trim(); + + if (trimmedValue === undefined || trimmedValue.length === 0) { + return defaultValue; + } + + return parsePositiveDecimalInteger(trimmedValue, errorCode); +} + function readRequiredEnvValue( env: WorkerProcessEnvironment, name: WorkerProcessEnvName, diff --git a/agentic-organization/apps/workers/src/durable-composition.ts b/agentic-organization/apps/workers/src/durable-composition.ts index ac95ae6b50..8f728902e6 100644 --- a/agentic-organization/apps/workers/src/durable-composition.ts +++ b/agentic-organization/apps/workers/src/durable-composition.ts @@ -16,9 +16,13 @@ import { type EventPayloadHashCalculator, type ReactionPlanActionExecutorPort, } from "../../../packages/runtime/src/index.ts"; +import { createKeepAliveLane, type KeepAliveLane } from "../../../packages/keepalive/src/index.ts"; import { InboundEventConsumerName } from "../../../packages/state/src/index.ts"; import { + createCockroachControlPlaneStateStore, createCockroachDurableStateAdapters, + createCockroachKeepAliveActionSink, + createCockroachKeepAliveSnapshotSource, type CockroachOrganizationSqlExecutor, } from "../../../packages/state-cockroach/src/index.ts"; import { @@ -49,6 +53,7 @@ export type DurableWorkerRuntimePorts = { organizationWorkerHost: OrganizationWorkerHost; natsEventConsumer: NatsJetStreamEventConsumer; telemetrySink: WorkerRuntimeTelemetrySink; + keepAliveLane: KeepAliveLane; }; export type ComposeDurableWorkerRuntimePortsInput = { @@ -92,6 +97,7 @@ export function composeDurableWorkerRuntimePorts( eventIngestionProcessor, deadLetterPublisher: input.durableAdapters.natsDeadLetterPublisher, }); + const keepAliveLane = composeDurableKeepAliveLane(input); return { organizationWorkerHost: createOrganizationWorkerHost({ @@ -104,5 +110,34 @@ export function composeDurableWorkerRuntimePorts( }), natsEventConsumer, telemetrySink: input.durableAdapters.telemetrySink, + keepAliveLane, }; } + +/** + * Compose the deterministic keep-alive control plane on durable Cockroach state: + * a control-plane store (org proof-of-life + alert log) -> snapshot source -> + * action sink -> lane. The lane ticks the org heartbeat every worker cycle and + * routes self-heal signals to durable state — the operator's #1 tenet, real. + */ +function composeDurableKeepAliveLane(input: ComposeDurableWorkerRuntimePortsInput): KeepAliveLane { + const controlPlaneStore = createCockroachControlPlaneStateStore({ + executor: input.durableAdapters.cockroachExecutor, + }); + + return createKeepAliveLane({ + source: createCockroachKeepAliveSnapshotSource({ + store: controlPlaneStore, + organizationId: input.config.organizationId, + orgHeartbeatDeadlineMs: input.config.workerKeepAliveOrgHeartbeatDeadlineMs, + // the engine measures org age via the DB clock; this clock only feeds the + // (v1-empty) lease-expiry branch — derive epoch ms from the injected ISO now + clock: { now: () => Date.parse(input.runtimeUtilities.now()) }, + }), + sink: createCockroachKeepAliveActionSink({ + store: controlPlaneStore, + organizationId: input.config.organizationId, + generateAlertId: () => input.runtimeUtilities.createId("ctrl-alert"), + }), + }); +} diff --git a/agentic-organization/apps/workers/src/index.ts b/agentic-organization/apps/workers/src/index.ts index a86762a193..d3697d100f 100644 --- a/agentic-organization/apps/workers/src/index.ts +++ b/agentic-organization/apps/workers/src/index.ts @@ -1,4 +1,5 @@ export { + WorkerKeepAliveConfigDefault, WorkerProcessEnvName, parseWorkerRuntimeConfigFromEnv, type WorkerDurableRuntimeConfig, @@ -93,6 +94,8 @@ export { WorkerRuntimeTelemetryEventName, createWorkerRuntime, type CreateWorkerRuntimeInput, + type WorkerKeepAliveLane, + type WorkerKeepAliveLaneResult, type WorkerRuntime, type WorkerRuntimeConfig, type WorkerRuntimeFailure, @@ -161,3 +164,15 @@ export { createCockroachWorkItemStateHistoryMetadataMigration, type CockroachAnySqlStatement, } from "../../../packages/state-cockroach/src/index.ts"; +export { + WorkerMainDefault, + WorkerMainLogStream, + runMain, + type RunMainDependencies, + type WorkerMainClock, + type WorkerMainConstructors, + type WorkerMainDurablePorts, + type WorkerMainLogRecord, + type WorkerMainLogger, + type WorkerMainSignalRegistrar, +} from "./main.ts"; diff --git a/agentic-organization/apps/workers/src/keep-alive-loop.ts b/agentic-organization/apps/workers/src/keep-alive-loop.ts new file mode 100644 index 0000000000..0709b9525f --- /dev/null +++ b/agentic-organization/apps/workers/src/keep-alive-loop.ts @@ -0,0 +1,89 @@ +/** + * Independent keep-alive loop — runs the deterministic keep-alive lane on its + * OWN fixed cadence, decoupled from the work loop. + * + * Why independent: when keep-alive ticks once per work cycle, a single long work + * cycle (e.g. a slow agent run, or a 30s idle NATS long-poll) delays the org + * heartbeat. The org's proof of life must not depend on work-cycle timing. This + * loop ticks the heartbeat on a short fixed interval regardless of what the work + * loop is doing — that is what makes "drive the organization to stay alive" + * deterministic. + * + * Failure discipline mirrors the lane itself: a degraded tick or a thrown lane is + * captured, never propagated — the heartbeat loop must keep running. + */ + +export type KeepAliveLoopLane = { + runOnce: () => Promise<{ status: string; failures: readonly { message: string }[] }>; +}; + +export type KeepAliveLoopTickRecord = { + tick: number; + status: string; + failureCount: number; +}; + +export type KeepAliveLoopObserver = { + record: (record: KeepAliveLoopTickRecord) => void; +}; + +export type RunKeepAliveLoopInput = { + lane: KeepAliveLoopLane; + intervalMs: number; + isStopRequested: () => boolean; + sleep: (ms: number) => Promise; + observer?: KeepAliveLoopObserver; + /** bound the loop for tests; unbounded in production */ + maxTicks?: number; +}; + +export type KeepAliveLoopResult = { + ticks: number; + degradedTicks: number; + thrownTicks: number; +}; + +const KeepAliveLoopTickStatus = { + Threw: "threw", +} as const; + +export async function runKeepAliveLoop(input: RunKeepAliveLoopInput): Promise { + let ticks = 0; + let degradedTicks = 0; + let thrownTicks = 0; + + while (!input.isStopRequested() && !hasReachedMaxTicks(ticks, input.maxTicks)) { + ticks += 1; + + let status: string; + let failureCount: number; + try { + const result = await input.lane.runOnce(); + status = result.status; + failureCount = result.failures.length; + } catch { + // the lane should never throw, but the heartbeat loop survives if it does + status = KeepAliveLoopTickStatus.Threw; + failureCount = 1; + thrownTicks += 1; + } + + if (failureCount > 0 || status !== "ticked") { + degradedTicks += 1; + } + + input.observer?.record({ tick: ticks, status, failureCount }); + + if (input.isStopRequested() || hasReachedMaxTicks(ticks, input.maxTicks)) { + break; + } + + await input.sleep(input.intervalMs); + } + + return { ticks, degradedTicks, thrownTicks }; +} + +function hasReachedMaxTicks(ticks: number, maxTicks: number | undefined): boolean { + return maxTicks !== undefined && ticks >= maxTicks; +} diff --git a/agentic-organization/apps/workers/src/main.ts b/agentic-organization/apps/workers/src/main.ts new file mode 100644 index 0000000000..93a92a7f09 --- /dev/null +++ b/agentic-organization/apps/workers/src/main.ts @@ -0,0 +1,597 @@ +import { createHash, randomUUID } from "node:crypto"; +import { argv } from "node:process"; +import { fileURLToPath } from "node:url"; + +import type { AgenticEventEnvelope } from "../../../packages/domain/src/index.ts"; +import type { EventPublisher } from "../../../packages/messaging/src/index.ts"; +import type { + NatsDeadLetterPublisher, + NatsJetStreamPullConsumer, +} from "../../../packages/messaging-nats/src/index.ts"; +import { + ReactionPlanExecutionStatus, + type EventPayloadHashCalculator, + type ReactionPlanActionExecutorPort, +} from "../../../packages/runtime/src/index.ts"; +import { + createCockroachControlPlaneStateStore, + createCockroachHermesRuntime, + createCockroachMemory, + createCockroachSqlExecutor, +} from "../../../packages/state-cockroach/src/index.ts"; +import { + createFirstLegalOptionComposer, + createHermesReactionPlanActionExecutor, + createModelBackedComposer, + toAsyncComposer, + type AsyncEphemeralComposerPort, +} from "../../../packages/application/src/index.ts"; +import { composeOrganizationReactionPlanActionExecutor } from "./organization-executor-composition.ts"; +import { createOllamaChatPort } from "./adapters/ollama-chat-port.ts"; +import { createSubprocessSandbox } from "./adapters/subprocess-sandbox.ts"; +import type { InboundEventSource } from "../../../packages/workers/src/index.ts"; +import { + createCockroachMigrationBootstrapper, + type CreateCockroachMigrationBootstrapperInput, +} from "./adapters/cockroach-migration-bootstrapper.ts"; +import { + createCockroachReadinessProbe, + type CreateCockroachReadinessProbeInput, +} from "./adapters/cockroach-readiness.ts"; +import { + createCockroachWorkerShutdownPort, + createCockroachWorkerSqlClient, + type CockroachWorkerShutdownPool, + type CreateCockroachWorkerSqlClientInput, +} from "./adapters/cockroach-worker-client.ts"; +import { + createJsonWorkerTelemetrySink, + type CreateJsonWorkerTelemetrySinkInput, + type JsonWorkerTelemetryRecord, +} from "./adapters/json-worker-telemetry-sink.ts"; +import { + connectNatsWorkerAdapters, + type ConnectNatsWorkerAdaptersInput, + type NatsWorkerAdapters, +} from "./adapters/nats-worker-connection.ts"; +import { + createNatsJsTransportConnectionFactory, + type CreateNatsJsTransportConnectionFactoryInput, +} from "./adapters/nats-js-transport-connection.ts"; +import { + createPgCockroachWorkerPool, + type CreatePgCockroachWorkerPoolInput, + type PgCockroachWorkerPool, +} from "./adapters/pg-cockroach-worker-pool.ts"; +import { + WorkerKeepAliveConfigDefault, + parseWorkerRuntimeConfigFromEnv, + type WorkerProcessConfig, + type WorkerProcessEnvironment, +} from "./config.ts"; +import { runKeepAliveLoop } from "./keep-alive-loop.ts"; +import { composeDurableWorkerRuntimePorts } from "./durable-composition.ts"; +import { composeWorkerRuntime } from "./composition.ts"; +import { + WorkerEntrypointExitCode, + WorkerEntrypointSignalName, + createWorkerProcessEntrypoint, + type WorkerEntrypointSignalListener, + type WorkerEntrypointSignalSource, + type WorkerEntrypointSignalSubscription, + type WorkerEntrypointSleeper, +} from "./worker-process-entrypoint.ts"; +import { + WorkerProcessLoopEventName, + type WorkerProcessLoopObserver, +} from "./worker-process-loop.ts"; +import { createWorkerProcess, type WorkerProcessShutdownPort } from "./worker-process.ts"; + +export const WorkerMainDefault = { + IterationDelayMs: 1000, + Sha256: "sha256", +} as const; + +export const WorkerMainLogStream = { + Stderr: "stderr", + Stdout: "stdout", +} as const; + +export type WorkerMainLogStream = (typeof WorkerMainLogStream)[keyof typeof WorkerMainLogStream]; + +export type WorkerMainLogRecord = { + stream: WorkerMainLogStream; + message: string; +}; + +export type WorkerMainLogger = { + log: (record: WorkerMainLogRecord) => void; +}; + +export type WorkerMainSignalRegistrar = { + on: (signal: WorkerEntrypointSignalName, listener: () => void) => void; + off: (signal: WorkerEntrypointSignalName, listener: () => void) => void; +}; + +export type WorkerMainClock = { + now: () => string; + setTimeout: (callback: () => void, durationMs: number) => unknown; + clearTimeout: (handle: unknown) => void; +}; + +export type WorkerMainConstructors = { + createPgPool: (input: CreatePgCockroachWorkerPoolInput) => Promise; + createSqlClient: (input: CreateCockroachWorkerSqlClientInput) => ReturnType; + connectNatsAdapters: (input: ConnectNatsWorkerAdaptersInput) => Promise; + createNatsTransportFactory: ( + input?: CreateNatsJsTransportConnectionFactoryInput, + ) => ReturnType; + createTelemetrySink: (input: CreateJsonWorkerTelemetrySinkInput) => ReturnType; + createMigrationBootstrapper: ( + input: CreateCockroachMigrationBootstrapperInput, + ) => ReturnType; + createReadinessProbe: (input: CreateCockroachReadinessProbeInput) => ReturnType; + createCockroachShutdownPort: (input: { + pool: CockroachWorkerShutdownPool; + }) => ReturnType; +}; + +export type WorkerMainDurablePorts = { + inboundEventSource: InboundEventSource; + reactionPlanActionExecutor: ReactionPlanActionExecutorPort; + calculatePayloadHash: EventPayloadHashCalculator; + createId: (prefix: string) => string; +}; + +export type RunMainDependencies = { + env: WorkerProcessEnvironment; + logger: WorkerMainLogger; + signalRegistrar: WorkerMainSignalRegistrar; + clock: WorkerMainClock; + constructors: WorkerMainConstructors; + durablePorts: WorkerMainDurablePorts; + iterationDelayMs: number; + maxCycles?: number | undefined; +}; + +export async function runMain(overrides?: Partial): Promise { + const deps = resolveRunMainDependencies(overrides); + + let config: WorkerProcessConfig; + + try { + config = parseWorkerRuntimeConfigFromEnv(deps.env); + } catch (error) { + deps.logger.log({ + stream: WorkerMainLogStream.Stderr, + message: `worker startup aborted: ${extractMainErrorMessage(error)}`, + }); + + return WorkerEntrypointExitCode.Degraded; + } + + return await runWorkerWithResolvedConfig({ + config, + deps, + }); +} + +type RunWorkerWithResolvedConfigInput = { + config: WorkerProcessConfig; + deps: RunMainDependencies; +}; + +async function runWorkerWithResolvedConfig(input: RunWorkerWithResolvedConfigInput): Promise { + const { config, deps } = input; + const telemetrySink = deps.constructors.createTelemetrySink({ + writer: createLoggerJsonLineWriter(deps.logger), + now: deps.clock.now, + }); + + const pool = await deps.constructors.createPgPool({ + databaseUrl: config.cockroachDatabaseUrl, + }); + const sqlClient = deps.constructors.createSqlClient({ + pool, + }); + const cockroachExecutor = createCockroachSqlExecutor({ + client: sqlClient, + }); + const natsAdapters = await deps.constructors.connectNatsAdapters({ + config: { + durableName: config.natsDurableName, + environment: config.environment, + organizationId: config.organizationId, + servers: config.natsServers, + streamName: config.natsStreamName, + }, + deadLetterMessageIdFactory: { + createId: (message) => createSha256Digest(message.payload), + }, + transportFactory: deps.constructors.createNatsTransportFactory(), + }); + + // The autonomous data plane: reaction-plan actions run through a Hermes run, + // whose heartbeat persists agent liveness to the durable control-plane store so + // the deterministic keep-alive engine watches the agent. (The Hermes runtime is + // in-process today; a real agent backend swaps in behind the same port.) + const controlPlaneStore = createCockroachControlPlaneStateStore({ executor: cockroachExecutor }); + + // The agent's decision intelligence. When an in-cluster model endpoint is + // configured the agent makes REAL model calls (falling back to the + // deterministic policy if the model is unreachable or picks an illegal move — + // the decision kernel re-checks every choice, so the model never widens the + // rules). Otherwise the deterministic first-legal-option policy is used. + const agentComposer: AsyncEphemeralComposerPort = + config.llmBaseUrl !== undefined && config.llmModel !== undefined + ? createModelBackedComposer({ + chat: createOllamaChatPort({ baseUrl: config.llmBaseUrl, model: config.llmModel }), + fallback: createFirstLegalOptionComposer(), + }) + : toAsyncComposer(createFirstLegalOptionComposer()); + + const hermesExecutor = createHermesReactionPlanActionExecutor({ + // durable Hermes runs — every agent run is a durable, auditable row + createHermesRuntime: () => createCockroachHermesRuntime({ executor: cockroachExecutor }), + // durable Hindsight memory — what the agent retains/recalls persists across restarts + createMemory: () => createCockroachMemory({ executor: cockroachExecutor }), + agentHeartbeatWriter: controlPlaneStore, + agentHeartbeatDeadlineMs: config.workerKeepAliveOrgHeartbeatDeadlineMs, + generateId: deps.durablePorts.createId, + // the live decision backend (model calls) + real sandboxed tool execution + composer: agentComposer, + sandbox: createSubprocessSandbox(), + nodeBinary: argv[0] ?? "node", + }); + + // The entire organizational structure: each reaction-plan action runs the + // Hermes agent (above) AND produces a durable, auditable org artifact — a + // supervisor-triage discussion anchor created through the command pipeline, + // anchored to a real work item. Agent autonomy meets org substrate. + const reactionPlanActionExecutor = composeOrganizationReactionPlanActionExecutor({ + cockroachExecutor, + agentExecutor: hermesExecutor, + createId: deps.durablePorts.createId, + now: deps.clock.now, + }); + + try { + const runtimePorts = composeDurableWorkerRuntimePorts({ + config, + durableAdapters: { + cockroachExecutor, + eventPublisher: natsAdapters.eventPublisher satisfies EventPublisher, + inboundEventSource: deps.durablePorts.inboundEventSource, + natsDeadLetterPublisher: natsAdapters.deadLetterPublisher satisfies NatsDeadLetterPublisher, + natsPullConsumer: natsAdapters.pullConsumer satisfies NatsJetStreamPullConsumer, + reactionPlanActionExecutor, + telemetrySink, + }, + runtimeUtilities: { + calculatePayloadHash: deps.durablePorts.calculatePayloadHash, + createId: deps.durablePorts.createId, + now: deps.clock.now, + }, + }); + const runtime = composeWorkerRuntime({ + config: { + environment: config.environment, + organizationId: config.organizationId, + natsStreamName: config.natsStreamName, + natsDurableName: config.natsDurableName, + natsInboundBatchSize: config.natsInboundBatchSize, + }, + // keepAliveLane is intentionally omitted here — it runs on its OWN independent + // loop (below) so the org heartbeat cadence is decoupled from the work cycle + ports: { + organizationWorkerHost: runtimePorts.organizationWorkerHost, + natsEventConsumer: runtimePorts.natsEventConsumer, + telemetrySink: runtimePorts.telemetrySink, + }, + }); + const process = createWorkerProcess({ + bootstrappers: [ + deps.constructors.createMigrationBootstrapper({ + executor: cockroachExecutor, + }), + ], + readinessProbes: [ + deps.constructors.createReadinessProbe({ + client: sqlClient, + }), + natsAdapters.readinessProbe, + ], + runtime, + shutdownPorts: collectShutdownPorts({ + deps, + pool, + natsAdapters, + }), + }); + + const entrypoint = createWorkerProcessEntrypoint({ + process, + observer: createMainLoopObserver(deps.logger), + signalSource: createProcessSignalSource(deps.signalRegistrar), + sleeper: createMainSleeper(deps.clock), + iterationDelayMs: deps.iterationDelayMs, + maxCycles: deps.maxCycles, + }); + + // Run the deterministic keep-alive on its own cadence, concurrently with the + // work loop. The org heartbeat ticks every LoopIntervalMs regardless of what + // the work loop is doing (a slow agent run or a 30s idle NATS poll can no + // longer delay the org's proof of life). When the work loop stops (signal), + // we stop the keep-alive loop and let its current tick finish. + const keepAliveStopped = { value: false }; + const keepAliveLoop = runKeepAliveLoop({ + lane: runtimePorts.keepAliveLane, + intervalMs: WorkerKeepAliveConfigDefault.LoopIntervalMs, + isStopRequested: () => keepAliveStopped.value, + sleep: (ms) => sleepUnlessStopped(deps.clock, ms, () => keepAliveStopped.value), + observer: { + record: (record) => + deps.logger.log({ + stream: WorkerMainLogStream.Stdout, + message: JSON.stringify({ + eventName: "agentic.worker.keep_alive.tick", + tick: record.tick, + status: record.status, + failureCount: record.failureCount, + }), + }), + }, + }); + + const result = await entrypoint.run(); + + keepAliveStopped.value = true; + await keepAliveLoop; + + return result.exitCode; + } catch (error) { + deps.logger.log({ + stream: WorkerMainLogStream.Stderr, + message: `worker run failed: ${extractMainErrorMessage(error)}`, + }); + + await disposeAfterFailureBestEffort({ + deps, + pool, + natsAdapters, + }); + + return WorkerEntrypointExitCode.Degraded; + } +} + +type CollectShutdownPortsInput = { + deps: RunMainDependencies; + pool: PgCockroachWorkerPool; + natsAdapters: NatsWorkerAdapters; +}; + +function collectShutdownPorts(input: CollectShutdownPortsInput): readonly WorkerProcessShutdownPort[] { + return [ + input.natsAdapters.shutdown, + input.deps.constructors.createCockroachShutdownPort({ + pool: input.pool, + }), + ]; +} + +type DisposeAfterFailureBestEffortInput = { + deps: RunMainDependencies; + pool: PgCockroachWorkerPool; + natsAdapters: NatsWorkerAdapters; +}; + +async function disposeAfterFailureBestEffort(input: DisposeAfterFailureBestEffortInput): Promise { + try { + await input.natsAdapters.shutdown.shutdown(); + } catch (error) { + input.deps.logger.log({ + stream: WorkerMainLogStream.Stderr, + message: `nats shutdown after failure failed: ${extractMainErrorMessage(error)}`, + }); + } + + try { + await input.deps.constructors + .createCockroachShutdownPort({ + pool: input.pool, + }) + .shutdown(); + } catch (error) { + input.deps.logger.log({ + stream: WorkerMainLogStream.Stderr, + message: `cockroach shutdown after failure failed: ${extractMainErrorMessage(error)}`, + }); + } +} + +function sleepUnlessStopped(clock: WorkerMainClock, durationMs: number, isStopped: () => boolean): Promise { + return new Promise((resolve) => { + if (isStopped()) { + resolve(); + return; + } + clock.setTimeout(() => resolve(), durationMs); + }); +} + +function createProcessSignalSource(registrar: WorkerMainSignalRegistrar): WorkerEntrypointSignalSource { + return { + subscribe: (signal, listener): WorkerEntrypointSignalSubscription => { + const handler = () => { + (listener satisfies WorkerEntrypointSignalListener)(signal); + }; + registrar.on(signal, handler); + + return { + dispose: () => { + registrar.off(signal, handler); + }, + }; + }, + }; +} + +function createMainSleeper(clock: WorkerMainClock): WorkerEntrypointSleeper { + return { + sleep: async (sleepInput) => + await new Promise((resolve) => { + if (sleepInput.stopSignal.isStopRequested()) { + resolve(); + return; + } + + const handle = clock.setTimeout(() => { + resolve(); + }, sleepInput.durationMs); + + if (sleepInput.stopSignal.isStopRequested()) { + clock.clearTimeout(handle); + resolve(); + } + }), + }; +} + +function createMainLoopObserver(logger: WorkerMainLogger): WorkerProcessLoopObserver { + return { + record: async (record) => { + logger.log({ + stream: + record.eventName === WorkerProcessLoopEventName.IterationFailed + ? WorkerMainLogStream.Stderr + : WorkerMainLogStream.Stdout, + message: `worker loop ${JSON.stringify(record)}`, + }); + }, + }; +} + +function createLoggerJsonLineWriter(logger: WorkerMainLogger): CreateJsonWorkerTelemetrySinkInput["writer"] { + return { + write: async (record: JsonWorkerTelemetryRecord) => { + logger.log({ + stream: WorkerMainLogStream.Stdout, + message: JSON.stringify(record), + }); + }, + }; +} + +function resolveRunMainDependencies(overrides: Partial | undefined): RunMainDependencies { + return { + env: overrides?.env ?? (process.env satisfies WorkerProcessEnvironment), + logger: overrides?.logger ?? createDefaultWorkerMainLogger(), + signalRegistrar: overrides?.signalRegistrar ?? createDefaultSignalRegistrar(), + clock: overrides?.clock ?? createDefaultWorkerMainClock(), + constructors: overrides?.constructors ?? createDefaultWorkerMainConstructors(), + durablePorts: overrides?.durablePorts ?? createDefaultWorkerMainDurablePorts(), + iterationDelayMs: overrides?.iterationDelayMs ?? WorkerMainDefault.IterationDelayMs, + maxCycles: overrides?.maxCycles, + }; +} + +function createDefaultWorkerMainLogger(): WorkerMainLogger { + return { + log: (record) => { + if (record.stream === WorkerMainLogStream.Stderr) { + process.stderr.write(`${record.message}\n`); + return; + } + + process.stdout.write(`${record.message}\n`); + }, + }; +} + +function createDefaultSignalRegistrar(): WorkerMainSignalRegistrar { + return { + on: (signal, listener) => { + process.on(signal, listener); + }, + off: (signal, listener) => { + process.off(signal, listener); + }, + }; +} + +function createDefaultWorkerMainClock(): WorkerMainClock { + return { + now: () => new Date().toISOString(), + setTimeout: (callback, durationMs) => setTimeout(callback, durationMs), + clearTimeout: (handle) => { + clearTimeout(handle as ReturnType); + }, + }; +} + +function createDefaultWorkerMainConstructors(): WorkerMainConstructors { + return { + createPgPool: (input) => createPgCockroachWorkerPool(input), + createSqlClient: (input) => createCockroachWorkerSqlClient(input), + connectNatsAdapters: (input) => connectNatsWorkerAdapters(input), + createNatsTransportFactory: (input) => createNatsJsTransportConnectionFactory(input), + createTelemetrySink: (input) => createJsonWorkerTelemetrySink(input), + createMigrationBootstrapper: (input) => createCockroachMigrationBootstrapper(input), + createReadinessProbe: (input) => createCockroachReadinessProbe(input), + createCockroachShutdownPort: (input) => createCockroachWorkerShutdownPort(input), + }; +} + +function createDefaultWorkerMainDurablePorts(): WorkerMainDurablePorts { + return { + inboundEventSource: { + pullNextBatch: async () => [], + }, + reactionPlanActionExecutor: { + executeReactionPlanAction: async () => ({ + status: ReactionPlanExecutionStatus.Succeeded, + result: { + message: "worker host accepted reaction plan action", + createdWorkItemIds: [], + createdDiscussionAnchorIds: [], + }, + }), + }, + calculatePayloadHash: (envelope: AgenticEventEnvelope) => + `${WorkerMainDefault.Sha256}:${createSha256Digest(JSON.stringify(envelope))}`, + createId: (prefix) => `${prefix}-${randomUUID()}`, + }; +} + +function createSha256Digest(value: string): string { + return createHash(WorkerMainDefault.Sha256).update(value).digest("hex"); +} + +function extractMainErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +function isMainModule(): boolean { + const entryPoint = process.argv[1]; + + if (entryPoint === undefined) { + return false; + } + + try { + return fileURLToPath(new URL(import.meta.url)) === entryPoint; + } catch { + return false; + } +} + +if (isMainModule()) { + void runMain().then((code) => { + process.exit(code); + }); +} diff --git a/agentic-organization/apps/workers/src/node-process-host.d.ts b/agentic-organization/apps/workers/src/node-process-host.d.ts new file mode 100644 index 0000000000..07d9efe5f2 --- /dev/null +++ b/agentic-organization/apps/workers/src/node-process-host.d.ts @@ -0,0 +1,15 @@ +type NodeProcessWritableStream = { + write: (data: string) => boolean; +}; + +type NodeProcessHost = { + argv: string[]; + env: Record; + stderr: NodeProcessWritableStream; + stdout: NodeProcessWritableStream; + exit: (code: number) => never; + on: (event: string, listener: () => void) => void; + off: (event: string, listener: () => void) => void; +}; + +declare const process: NodeProcessHost; diff --git a/agentic-organization/apps/workers/src/organization-executor-composition.ts b/agentic-organization/apps/workers/src/organization-executor-composition.ts new file mode 100644 index 0000000000..d33d10ae58 --- /dev/null +++ b/agentic-organization/apps/workers/src/organization-executor-composition.ts @@ -0,0 +1,155 @@ +/** + * Compose the organization reaction-plan executor for the deployed worker: the + * autonomous data plane (a Hermes agent run) + the durable org structure (a + * supervisor-triage discussion anchor created through the command pipeline, + * anchored to a real work item). This is where "the agents doing autonomous + * work" meets "the entire organizational structure." + * + * Authorization is permissive in this composition (a hat-authority-backed port + * swaps in behind the same CommandAuthorizationPort), and the actor is + * synthesized from the action's required hat — the seams a real hat-assignment + * system fills later. + */ + +import { + ProjectStatus, + WorkItemState, + WorkItemType, + type AgenticActor, + type ReactionPlanAction, +} from "../../../packages/domain/src/index.ts"; +import { + CommandResultStatus, + createApplicationReactionPlanActionExecutor, + createCommandHandlerRegistry, + createCommandPipeline, + createCreateDiscussionAnchorHandler, + createOrganizationReactionPlanActionExecutor, + type CommandResult, + type EnsureWorkItemPort, +} from "../../../packages/application/src/index.ts"; +import { PolicyDecisionStatus, createPolicyDecisionObservationPort, type CommandAuthorizationPort } from "../../../packages/policy/src/index.ts"; +import { + createCockroachDurableStateAdapters, + type CockroachOrganizationSqlExecutor, +} from "../../../packages/state-cockroach/src/index.ts"; +import type { ReactionPlanActionExecutorPort } from "../../../packages/runtime/src/index.ts"; + +export type ComposeOrganizationReactionPlanActionExecutorInput = { + cockroachExecutor: CockroachOrganizationSqlExecutor; + /** the Hermes agent-run executor (durable run + memory + agent liveness) */ + agentExecutor: ReactionPlanActionExecutorPort; + createId: (prefix: string) => string; + now: () => string; +}; + +export function composeOrganizationReactionPlanActionExecutor( + input: ComposeOrganizationReactionPlanActionExecutorInput, +): ReactionPlanActionExecutorPort { + const stateAdapters = createCockroachDurableStateAdapters({ executor: input.cockroachExecutor }); + + const commandPipeline = createCommandPipeline({ + stateStoreFactory: stateAdapters.commandStateStoreFactory, + commandAuthorizationPort: createPermissiveCommandAuthorizationPort(input.createId), + policyDecisionObservationPort: createPolicyDecisionObservationPort({ + store: stateAdapters.policyDecisionObservationStore, + }), + handlerRegistry: createCommandHandlerRegistry([createCreateDiscussionAnchorHandler()]), + workAnchorStateReader: stateAdapters.workAnchorStateStore, + now: input.now, + createId: input.createId, + }); + + const organizationExecutor = createApplicationReactionPlanActionExecutor({ + commandPipeline, + actorResolver: { resolveReactionActor: async (request) => synthesizeActor(request.action, input.createId) }, + createId: input.createId, + }); + + const ensureWorkItem = createCockroachWorkItemSeeder({ + store: stateAdapters.workAnchorStateStore, + now: input.now, + createId: input.createId, + }); + + return createOrganizationReactionPlanActionExecutor({ + agentExecutor: input.agentExecutor, + ensureWorkItem, + organizationExecutor, + }); +} + +/** Allow-all authorization — a hat-authority-backed port swaps in behind this seam. */ +function createPermissiveCommandAuthorizationPort(createId: (prefix: string) => string): CommandAuthorizationPort { + return { + authorizeCommand: async () => ({ + status: PolicyDecisionStatus.Allowed, + decisionId: createId("decision"), + policyVersion: "v0", + }), + }; +} + +function synthesizeActor(action: ReactionPlanAction, createId: (prefix: string) => string): AgenticActor { + return { + agentId: createId(`agent-${action.requiredHat}`), + hatAssignmentId: createId(`hat-${action.requiredHat}`), + }; +} + +type WorkAnchorSeederStore = ReturnType>["workAnchorStateStore"]; + +function createCockroachWorkItemSeeder(input: { + store: WorkAnchorSeederStore; + now: () => string; + createId: (prefix: string) => string; +}): EnsureWorkItemPort { + return { + ensureWorkItem: async (action: ReactionPlanAction) => { + const existing = await input.store.findWorkItem(action.workItemId); + if (existing !== undefined) { + return; + } + + const actor = synthesizeActor(action, input.createId); + const ts = input.now(); + const metadata = { + updatedAt: ts, + version: 1, + correlationId: action.triggerEventId, + causationId: action.triggerEventId, + traceId: action.triggerEventId, + }; + + // idempotent-best-effort: tolerate a concurrent seeder winning the race + await input.store + .createProject({ + projectId: action.projectId, + organizationId: action.organizationId, + name: `Project ${action.projectId}`, + status: ProjectStatus.Active, + createdAt: ts, + createdBy: actor, + metadata, + }) + .catch(() => undefined); + + await input.store + .createWorkItem({ + workItemId: action.workItemId, + organizationId: action.organizationId, + projectId: action.projectId, + workItemType: WorkItemType.Task, + title: `Work item ${action.workItemId}`, + description: "Seeded so the supervisor-triage discussion anchor has a valid work-item anchor.", + state: WorkItemState.Created, + createdAt: ts, + createdBy: actor, + metadata, + }) + .catch(() => undefined); + }, + }; +} + +export { CommandResultStatus }; diff --git a/agentic-organization/apps/workers/src/worker-runtime.ts b/agentic-organization/apps/workers/src/worker-runtime.ts index 6f34ed1fc2..143591ad7a 100644 --- a/agentic-organization/apps/workers/src/worker-runtime.ts +++ b/agentic-organization/apps/workers/src/worker-runtime.ts @@ -24,6 +24,7 @@ export const WorkerRuntimeStatus = { export type WorkerRuntimeStatus = (typeof WorkerRuntimeStatus)[keyof typeof WorkerRuntimeStatus]; export const WorkerRuntimeFailureStage = { + KeepAlive: "keep_alive", NatsConsumer: "nats_consumer", OrganizationWorker: "organization_worker", Telemetry: "telemetry", @@ -36,6 +37,7 @@ export const WorkerRuntimeConfigErrorCode = { InvalidWorkerOutboxBatchSize: "invalid_worker_outbox_batch_size", InvalidWorkerReactionPlanBatchSize: "invalid_worker_reaction_plan_batch_size", InvalidWorkerReactionPlanLeaseMs: "invalid_worker_reaction_plan_lease_ms", + InvalidWorkerKeepAliveOrgHeartbeatDeadlineMs: "invalid_worker_keep_alive_org_heartbeat_deadline_ms", InvalidNatsServers: "invalid_nats_servers", MissingCockroachDatabaseUrl: "missing_cockroach_database_url", InvalidNatsInboundBatchSize: "invalid_nats_inbound_batch_size", @@ -44,6 +46,8 @@ export const WorkerRuntimeConfigErrorCode = { MissingNatsServers: "missing_nats_servers", MissingNatsStreamName: "missing_nats_stream_name", MissingOrganizationId: "missing_organization_id", + /** exactly one of LLM_BASE_URL / LLM_MODEL was provided — a misconfiguration */ + PartialLlmConfig: "partial_llm_config", } as const; export type WorkerRuntimeConfigErrorCode = @@ -90,8 +94,28 @@ export type WorkerRuntimeFailure = { message: string; }; +/** + * Minimal structural port for the deterministic keep-alive lane. Defined here + * (not imported from the keepalive package) so the low-level worker runtime + * stays ignorant of the high-level control-plane package — dependency inversion. + * The concrete `KeepAliveLane` from packages/keepalive satisfies it structurally. + */ +export type WorkerKeepAliveLaneResult = { + status: string; + appliedCount: number; + failures: readonly { message: string }[]; +}; + +export type WorkerKeepAliveLane = { + runOnce: () => Promise; +}; + +/** the status a keep-alive lane reports when it ran cleanly and the org is alive */ +const WorkerKeepAliveTickedStatus = "ticked"; + export type WorkerRuntimeRunResult = { status: WorkerRuntimeStatus; + keepAlive: WorkerKeepAliveLaneResult | undefined; workerCycle: WorkerCycleResult | undefined; natsConsumerBatch: NatsJetStreamConsumeBatchResult | undefined; failures: readonly WorkerRuntimeFailure[]; @@ -106,6 +130,13 @@ export type CreateWorkerRuntimeInput = { organizationWorkerHost: OrganizationWorkerHost; natsEventConsumer: NatsJetStreamEventConsumer; telemetrySink: WorkerRuntimeTelemetrySink; + /** + * Optional deterministic keep-alive lane. When provided it ticks FIRST every + * cycle (liveness is the most safety-critical lane — the org heartbeat must + * fire even if work-processing later degrades). Omitted in tests/contexts + * that only exercise the work + ingestion lanes. + */ + keepAliveLane?: WorkerKeepAliveLane; }; export function createWorkerRuntime(input: CreateWorkerRuntimeInput): WorkerRuntime { @@ -114,6 +145,12 @@ export function createWorkerRuntime(input: CreateWorkerRuntimeInput): WorkerRunt return { runOnce: async () => { const failures: WorkerRuntimeFailure[] = []; + // Keep-alive ticks first: the org heartbeat is the most safety-critical + // lane and must fire even if work-processing later degrades. + const keepAlive = await runKeepAliveLane({ + keepAliveLane: input.keepAliveLane, + failures, + }); const workerCycle = await runOrganizationWorker({ organizationWorkerHost: input.organizationWorkerHost, telemetrySink: input.telemetrySink, @@ -128,10 +165,12 @@ export function createWorkerRuntime(input: CreateWorkerRuntimeInput): WorkerRunt return { status: resolveWorkerRuntimeStatus({ + keepAlive, workerCycle, natsConsumerBatch, failures, }), + keepAlive, workerCycle, natsConsumerBatch, failures, @@ -140,6 +179,36 @@ export function createWorkerRuntime(input: CreateWorkerRuntimeInput): WorkerRunt }; } +type RunKeepAliveLaneInput = { + keepAliveLane: WorkerKeepAliveLane | undefined; + failures: WorkerRuntimeFailure[]; +}; + +async function runKeepAliveLane(input: RunKeepAliveLaneInput): Promise { + if (input.keepAliveLane === undefined) { + return undefined; + } + + try { + const result = await input.keepAliveLane.runOnce(); + for (const failure of result.failures) { + input.failures.push({ + stage: WorkerRuntimeFailureStage.KeepAlive, + message: failure.message, + }); + } + return result; + } catch (error) { + // the keep-alive lane should never throw, but if it does the loop survives — + // driving the org to stay alive must not be killed by one bad tick + input.failures.push({ + stage: WorkerRuntimeFailureStage.KeepAlive, + message: extractErrorMessage(error), + }); + return undefined; + } +} + function validateWorkerRuntimeConfig(config: WorkerRuntimeConfig): void { assertNonEmptyConfigValue(config.environment, WorkerRuntimeConfigErrorCode.MissingEnvironment); assertNonEmptyConfigValue(config.organizationId, WorkerRuntimeConfigErrorCode.MissingOrganizationId); @@ -267,6 +336,7 @@ async function recordTelemetry(input: RecordTelemetryInput): Promise { } type ResolveWorkerRuntimeStatusInput = { + keepAlive: WorkerKeepAliveLaneResult | undefined; workerCycle: WorkerCycleResult | undefined; natsConsumerBatch: NatsJetStreamConsumeBatchResult | undefined; failures: readonly WorkerRuntimeFailure[]; @@ -275,6 +345,7 @@ type ResolveWorkerRuntimeStatusInput = { function resolveWorkerRuntimeStatus(input: ResolveWorkerRuntimeStatusInput): WorkerRuntimeStatus { if ( input.failures.length > 0 || + isKeepAliveLaneDegraded(input.keepAlive) || input.workerCycle?.status === WorkerCycleStatus.Degraded || isNatsConsumerBatchDegraded(input.natsConsumerBatch) ) { @@ -284,6 +355,16 @@ function resolveWorkerRuntimeStatus(input: ResolveWorkerRuntimeStatusInput): Wor return WorkerRuntimeStatus.Healthy; } +function isKeepAliveLaneDegraded(keepAlive: WorkerKeepAliveLaneResult | undefined): boolean { + if (keepAlive === undefined) { + return false; + } + + // A keep-alive tick can be degraded with zero apply failures (e.g. the org is + // flatlining) — so we check the lane's own status, not just failure count. + return keepAlive.status !== WorkerKeepAliveTickedStatus; +} + function isNatsConsumerBatchDegraded(batch: NatsJetStreamConsumeBatchResult | undefined): boolean { if (batch === undefined) { return true; diff --git a/agentic-organization/apps/workers/test/cockroach-integration.test.ts b/agentic-organization/apps/workers/test/cockroach-integration.test.ts index 5c89194f30..b770066bb5 100644 --- a/agentic-organization/apps/workers/test/cockroach-integration.test.ts +++ b/agentic-organization/apps/workers/test/cockroach-integration.test.ts @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto"; import { env } from "node:process"; import { describe, test } from "node:test"; +import { splitSqlStatements } from "../../../packages/state-cockroach/src/index.ts"; + import { CockroachMigrationStatement, CockroachTableName, @@ -155,6 +157,7 @@ describe("Cockroach worker live integration", () => { runtime: { runOnce: async () => ({ failures: [], + keepAlive: undefined, natsConsumerBatch: undefined, status: WorkerRuntimeStatus.Healthy, workerCycle: undefined, @@ -258,11 +261,17 @@ describe("Cockroach worker live integration", () => { sql: run.sql.InsertLegacyWorkItem, parameters: [], }); - await executor.execute({ - name: CockroachIntegrationStatementName.ApplyWorkAnchorMigration, - sql: run.sql.WorkAnchorMigration, - parameters: [], - }); + // Apply the migration the way the runner does — one statement per query + // — because CockroachDB runs a multi-statement string as a single + // implicit transaction and forbids referencing a column added by an + // earlier DDL statement in that same txn (SQLSTATE 42703). + for (const statement of splitSqlStatements(run.sql.WorkAnchorMigration)) { + await executor.execute({ + name: CockroachIntegrationStatementName.ApplyWorkAnchorMigration, + sql: statement, + parameters: [], + }); + } const rows = await executor.execute({ name: CockroachIntegrationStatementName.SelectLegacyWorkItem, @@ -311,11 +320,14 @@ describe("Cockroach worker live integration", () => { sql: run.sql.InsertLegacyWorkItemStateHistoryWithoutMetadata, parameters: [], }); - await executor.execute({ - name: CockroachIntegrationStatementName.ApplyWorkItemStateHistoryMetadataMigration, - sql: run.sql.ApplyWorkItemStateHistoryMetadataMigration, - parameters: [], - }); + // split per statement — same Cockroach DDL+DML-in-one-txn rule as above + for (const statement of splitSqlStatements(run.sql.ApplyWorkItemStateHistoryMetadataMigration)) { + await executor.execute({ + name: CockroachIntegrationStatementName.ApplyWorkItemStateHistoryMetadataMigration, + sql: statement, + parameters: [], + }); + } const metadataRows = await executor.execute({ name: CockroachIntegrationStatementName.SelectLegacyWorkItemStateHistoryMetadata, diff --git a/agentic-organization/apps/workers/test/cockroach-migration-bootstrapper.test.ts b/agentic-organization/apps/workers/test/cockroach-migration-bootstrapper.test.ts index 6c25ce8283..2573ea9a9f 100644 --- a/agentic-organization/apps/workers/test/cockroach-migration-bootstrapper.test.ts +++ b/agentic-organization/apps/workers/test/cockroach-migration-bootstrapper.test.ts @@ -5,6 +5,7 @@ import { CockroachCoreStateMigrationName, CockroachMigrationStatement, createCockroachCoreStateMigrations, + splitSqlStatements, type CockroachAnySqlStatement, type CockroachGenericSqlExecutor, } from "../../../packages/state-cockroach/src/index.ts"; @@ -30,13 +31,17 @@ describe("Cockroach migration bootstrapper", () => { await bootstrapper.bootstrap(); + // each migration is applied one statement at a time (Cockroach DDL+DML txn rule) + const expectedStatements = createCockroachCoreStateMigrations().flatMap((migration) => + splitSqlStatements(migration.sql), + ); deepEqual( executor.statements.map((statement) => statement.name), - createCockroachCoreStateMigrations().map(() => CockroachMigrationStatement.ApplyMigration), + expectedStatements.map(() => CockroachMigrationStatement.ApplyMigration), ); deepEqual( executor.statements.map((statement) => statement.sql), - createCockroachCoreStateMigrations().map((migration) => migration.sql), + expectedStatements, ); }); diff --git a/agentic-organization/apps/workers/test/cockroach-worker-client.test.ts b/agentic-organization/apps/workers/test/cockroach-worker-client.test.ts index 66fdec0f1f..e20939db98 100644 --- a/agentic-organization/apps/workers/test/cockroach-worker-client.test.ts +++ b/agentic-organization/apps/workers/test/cockroach-worker-client.test.ts @@ -420,6 +420,7 @@ function createNoopRuntime() { return { runOnce: async () => ({ status: WorkerRuntimeStatus.Healthy, + keepAlive: undefined, workerCycle: undefined, natsConsumerBatch: undefined, failures: [], diff --git a/agentic-organization/apps/workers/test/durable-worker-composition.test.ts b/agentic-organization/apps/workers/test/durable-worker-composition.test.ts index 813b80ba99..1294a6293c 100644 --- a/agentic-organization/apps/workers/test/durable-worker-composition.test.ts +++ b/agentic-organization/apps/workers/test/durable-worker-composition.test.ts @@ -39,6 +39,7 @@ describe("durable worker runtime composition", () => { workerOutboxBatchSize: 5, workerReactionPlanBatchSize: 3, workerReactionPlanLeaseMs: 300_000, + workerKeepAliveOrgHeartbeatDeadlineMs: 30_000, }, durableAdapters: { cockroachExecutor, @@ -86,6 +87,7 @@ describe("durable worker runtime composition", () => { workerOutboxBatchSize: 5, workerReactionPlanBatchSize: 3, workerReactionPlanLeaseMs: 300_000, + workerKeepAliveOrgHeartbeatDeadlineMs: 30_000, }, durableAdapters: { cockroachExecutor: createRecordingCockroachExecutor(), @@ -128,6 +130,7 @@ describe("durable worker runtime composition", () => { workerOutboxBatchSize: 5, workerReactionPlanBatchSize: 3, workerReactionPlanLeaseMs: 300_000, + workerKeepAliveOrgHeartbeatDeadlineMs: 30_000, }, durableAdapters: { cockroachExecutor: createRecordingCockroachExecutor(), diff --git a/agentic-organization/apps/workers/test/durable-worker-live-integration.test.ts b/agentic-organization/apps/workers/test/durable-worker-live-integration.test.ts index 101ee8d19a..764804cccf 100644 --- a/agentic-organization/apps/workers/test/durable-worker-live-integration.test.ts +++ b/agentic-organization/apps/workers/test/durable-worker-live-integration.test.ts @@ -231,6 +231,7 @@ describe("durable worker live integration", () => { workerOutboxBatchSize: DurableLiveIntegrationBatch.WorkerOutbox, workerReactionPlanBatchSize: DurableLiveIntegrationBatch.ReactionPlan, workerReactionPlanLeaseMs: DurableLiveIntegrationLease.ReactionPlanMs, + workerKeepAliveOrgHeartbeatDeadlineMs: 30_000, }, durableAdapters: { cockroachExecutor: executor, diff --git a/agentic-organization/apps/workers/test/hermes-memory-live-integration.test.ts b/agentic-organization/apps/workers/test/hermes-memory-live-integration.test.ts new file mode 100644 index 0000000000..bb4e7a78e8 --- /dev/null +++ b/agentic-organization/apps/workers/test/hermes-memory-live-integration.test.ts @@ -0,0 +1,149 @@ +import { equal, ok } from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { env } from "node:process"; +import { describe, test } from "node:test"; + +import { HermesRunState } from "../../../packages/hermes/src/index.ts"; +import { + createCockroachHermesRuntime, + createCockroachHindsightMemoryMigration, + createCockroachHermesRunMigration, + createCockroachMemory, + splitSqlStatements, + type CockroachAnySqlStatement, +} from "../../../packages/state-cockroach/src/index.ts"; +import { + createCockroachSqlExecutor, + createCockroachWorkerShutdownPort, + createCockroachWorkerSqlClient, + createPgCockroachWorkerPool, +} from "../src/index.ts"; + +const EnvName = { DatabaseUrl: "AGENTIC_ORG_COCKROACH_INTEGRATION_DATABASE_URL" } as const; + +describe("durable Hermes runtime + Hindsight memory — live Cockroach", () => { + test( + "a run launches, heartbeats, completes with JSON evidence; two runs get unique ids; memory retains/recalls scoped", + { + skip: + env[EnvName.DatabaseUrl] === undefined ? `${EnvName.DatabaseUrl} is not set` : false, + }, + async () => { + const databaseUrl = readDatabaseUrl(); + const projectId = `proj-${randomUUID()}`; + const pool = await createPgCockroachWorkerPool({ databaseUrl }); + const sqlClient = createCockroachWorkerSqlClient({ pool, maxTransactionAttempts: 2 }); + const executor = createCockroachSqlExecutor({ client: sqlClient }); + const runIds: string[] = []; + + try { + await applyMigrations(executor); + + const hermes = createCockroachHermesRuntime({ executor }); + const memory = createCockroachMemory({ executor }); + + // --- Hermes run lifecycle (proves uniqueness + JSONB evidence) --- + const binding = { + workItemId: `wi-${randomUUID()}`, + agentId: `agent-${randomUUID()}`, + sessionId: `sess-${randomUUID()}`, + hatAssignmentId: `hat-${randomUUID()}`, + promptFlowRunId: `pf-${randomUUID()}`, + }; + + const launched = await hermes.launchRun(binding); + equal(launched.outcome, "ok"); + if (launched.outcome !== "ok") return; + runIds.push(launched.run.runId); + equal(launched.run.state, HermesRunState.Running); + + const beat = await hermes.heartbeat(launched.run.runId); + equal(beat.outcome, "ok"); + + const completed = await hermes.completeRun(launched.run.runId, { + summary: "triaged", + evidenceRefs: ["pr-1", "pr-2"], + }); + equal(completed.outcome, "ok"); + if (completed.outcome !== "ok") return; + equal(completed.run.state, HermesRunState.Completed); + + // read back the durable run — completed, with the JSON evidence round-tripped + const fetched = await hermes.getRun(launched.run.runId); + equal(fetched?.state, HermesRunState.Completed); + equal(fetched?.outcome?.summary, "triaged"); + equal(fetched?.outcome?.evidenceRefs.length, 2); + + // a SECOND run must get a UNIQUE id (the PK-collision bug the live cluster caught) + const second = await hermes.launchRun(binding); + equal(second.outcome, "ok"); + if (second.outcome !== "ok") return; + runIds.push(second.run.runId); + equal(second.run.runId !== launched.run.runId, true); + + // completing an already-completed run is feedback, not a crash + const recomplete = await hermes.completeRun(launched.run.runId, { summary: "x", evidenceRefs: [] }); + equal(recomplete.outcome, "feedback"); + + // --- Hindsight memory (proves unique ids + scoped recall) --- + const attribution = { + agentId: binding.agentId, + hatAssignmentId: binding.hatAssignmentId, + projectId, + workItemId: binding.workItemId, + promptFlowRunId: binding.promptFlowRunId, + }; + await memory.retain(attribution, "lesson one"); + await memory.retain(attribution, "lesson two"); // second retain must not collide on memory_id + + const recalled = await memory.recall(attribution); + equal(recalled.memories.length, 2); + // recall is scoped to THIS project only + ok(recalled.memories.every((m) => m.attribution.projectId === projectId)); + + const reflected = await memory.reflect(attribution); + equal(reflected.consideredCount, 2); + } finally { + await cleanUp(executor, projectId, runIds); + await createCockroachWorkerShutdownPort({ pool }).shutdown(); + } + }, + ); +}); + +function readDatabaseUrl(): string { + const url = env[EnvName.DatabaseUrl]; + if (url === undefined) { + throw new Error(`${EnvName.DatabaseUrl} is not set`); + } + return url; +} + +type Exec = { execute: >(s: CockroachAnySqlStatement) => Promise<{ rows: readonly Row[] }> }; + +async function applyMigrations(executor: Exec): Promise { + for (const migration of [createCockroachHindsightMemoryMigration(), createCockroachHermesRunMigration()]) { + for (const statement of splitSqlStatements(migration.sql)) { + await executor.execute({ name: "hermes_memory_live_migration", sql: statement, parameters: [] }); + } + } +} + +async function cleanUp(executor: Exec, projectId: string, runIds: readonly string[]): Promise { + await executor + .execute({ + name: "hermes_memory_live_cleanup_memory", + sql: `DELETE FROM agentic_org_hindsight_memory WHERE project_id = $1`, + parameters: [projectId], + }) + .catch(() => undefined); + for (const runId of runIds) { + await executor + .execute({ + name: "hermes_memory_live_cleanup_run", + sql: `DELETE FROM agentic_org_hermes_run WHERE run_id = $1`, + parameters: [runId], + }) + .catch(() => undefined); + } +} diff --git a/agentic-organization/apps/workers/test/keep-alive-live-integration.test.ts b/agentic-organization/apps/workers/test/keep-alive-live-integration.test.ts new file mode 100644 index 0000000000..92421acde9 --- /dev/null +++ b/agentic-organization/apps/workers/test/keep-alive-live-integration.test.ts @@ -0,0 +1,322 @@ +import { equal, ok } from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { env } from "node:process"; +import { describe, test } from "node:test"; + +import { KeepAliveLaneStatus, createKeepAliveLane } from "../../../packages/keepalive/src/index.ts"; +import { + ControlPlaneAlertKind, + createCockroachAgentLivenessMigration, + createCockroachControlPlaneKeepAliveMigration, + createCockroachControlPlaneStateStore, + createCockroachKeepAliveActionSink, + createCockroachKeepAliveSnapshotSource, + splitSqlStatements, + type CockroachAnySqlStatement, + type CockroachControlPlaneStateStore, +} from "../../../packages/state-cockroach/src/index.ts"; +import { + createCockroachSqlExecutor, + createCockroachWorkerShutdownPort, + createCockroachWorkerSqlClient, + createPgCockroachWorkerPool, +} from "../src/index.ts"; + +const KeepAliveIntegrationEnvName = { + DatabaseUrl: "AGENTIC_ORG_COCKROACH_INTEGRATION_DATABASE_URL", +} as const; + +const OrgHeartbeatDeadlineMs = 5_000; + +describe("deterministic keep-alive — live Cockroach control plane (operator tenet #1)", () => { + test( + "ticks the org heartbeat each cycle and raises an org-stall alert when the org flatlines", + { + skip: + env[KeepAliveIntegrationEnvName.DatabaseUrl] === undefined + ? `${KeepAliveIntegrationEnvName.DatabaseUrl} is not set` + : false, + }, + async () => { + const databaseUrl = readIntegrationDatabaseUrl(); + const organizationId = `org-keepalive-${randomUUID()}`; + const pool = await createPgCockroachWorkerPool({ databaseUrl }); + const sqlClient = createCockroachWorkerSqlClient({ pool, maxTransactionAttempts: 2 }); + const executor = createCockroachSqlExecutor({ client: sqlClient }); + + try { + await applyKeepAliveMigrations(executor); + + const store = createCockroachControlPlaneStateStore({ executor }); + const lane = createKeepAliveLane({ + source: createCockroachKeepAliveSnapshotSource({ + store, + organizationId, + orgHeartbeatDeadlineMs: OrgHeartbeatDeadlineMs, + clock: { now: () => Date.now() }, + }), + sink: createCockroachKeepAliveActionSink({ + store, + organizationId, + generateAlertId: () => randomUUID(), + }), + }); + + // tick 1 — the org proves life for the first time (registers the row) + const firstTick = await lane.runOnce(); + equal(firstTick.status, KeepAliveLaneStatus.Ticked); + equal(firstTick.appliedCount, 1); + + const afterFirst = await readHeartbeat(executor, organizationId); + equal(afterFirst?.version, 1); + ok((afterFirst?.ageMs ?? Number.MAX_SAFE_INTEGER) < OrgHeartbeatDeadlineMs); + + // tick 2 — last_tick_at advances, version increments; org stays alive + await delay(25); + const secondTick = await lane.runOnce(); + equal(secondTick.status, KeepAliveLaneStatus.Ticked); + equal(secondTick.appliedCount, 1); + + const afterSecond = await readHeartbeat(executor, organizationId); + equal(afterSecond?.version, 2); + ok((afterSecond?.ageMs ?? Number.MAX_SAFE_INTEGER) < OrgHeartbeatDeadlineMs); + + // force a stall: push last_tick_at well past the deadline, then tick. + // the engine reads the stale snapshot first -> emits heartbeat AND a stall + // alert; the org self-heals (heartbeat re-ticked) but the alert is recorded. + await forceStall(executor, organizationId); + const stallTick = await lane.runOnce(); + equal(stallTick.status, KeepAliveLaneStatus.Degraded); + equal(stallTick.appliedCount, 2); + + const alertCount = await readOrgStallAlertCount(executor, organizationId); + ok(alertCount >= 1); + + // self-heal confirmed: the heartbeat is fresh again after the stall tick + const afterStall = await readHeartbeat(executor, organizationId); + equal(afterStall?.version, 3); + ok((afterStall?.ageMs ?? Number.MAX_SAFE_INTEGER) < OrgHeartbeatDeadlineMs); + } finally { + await cleanUp(executor, organizationId); + await createCockroachWorkerShutdownPort({ pool }).shutdown(); + } + }, + ); + + test( + "drives the agents to stay alive — a stale agent triggers a deterministic reassignment alert", + { + skip: + env[KeepAliveIntegrationEnvName.DatabaseUrl] === undefined + ? `${KeepAliveIntegrationEnvName.DatabaseUrl} is not set` + : false, + }, + async () => { + const databaseUrl = readIntegrationDatabaseUrl(); + const organizationId = `org-agentlive-${randomUUID()}`; + const agentId = `agent-${randomUUID()}`; + const pool = await createPgCockroachWorkerPool({ databaseUrl }); + const sqlClient = createCockroachWorkerSqlClient({ pool, maxTransactionAttempts: 2 }); + const executor = createCockroachSqlExecutor({ client: sqlClient }); + + try { + await applyKeepAliveMigrations(executor); + + const store = createCockroachControlPlaneStateStore({ executor }); + const lane = buildLane(store, organizationId); + const agentDeadlineMs = 8_000; + + // a fresh agent heartbeat — the engine sees the agent ALIVE, no reassign + await store.recordAgentHeartbeat({ + organizationId, + agentId, + hatAssignmentId: "hat-1", + workItemId: "work-1", + deadlineMs: agentDeadlineMs, + }); + + const aliveSnapshot = await readAgentSnapshot(store, organizationId, agentId); + ok((aliveSnapshot?.heartbeatAgeMs ?? Number.MAX_SAFE_INTEGER) < agentDeadlineMs); + + const freshTick = await lane.runOnce(); + // only the org heartbeat applied — the agent is alive, no reassignment + equal(freshTick.appliedCount, 1); + equal(await readReassignmentAlertCount(executor, organizationId), 0); + + // force the agent stale: push its last heartbeat past the deadline + await forceAgentStale(executor, organizationId, agentId, agentDeadlineMs); + const staleTick = await lane.runOnce(); + // org heartbeat + one stale-work reassignment signal + equal(staleTick.appliedCount, 2); + ok(await readReassignmentNamesWork(executor, organizationId, "work-1")); + } finally { + await cleanUp(executor, organizationId); + await createCockroachWorkerShutdownPort({ pool }).shutdown(); + } + }, + ); +}); + +function buildLane(store: CockroachControlPlaneStateStore, organizationId: string) { + return createKeepAliveLane({ + source: createCockroachKeepAliveSnapshotSource({ + store, + organizationId, + orgHeartbeatDeadlineMs: OrgHeartbeatDeadlineMs, + clock: { now: () => Date.now() }, + }), + sink: createCockroachKeepAliveActionSink({ store, organizationId, generateAlertId: () => randomUUID() }), + }); +} + +async function readAgentSnapshot( + store: CockroachControlPlaneStateStore, + organizationId: string, + agentId: string, +): Promise<{ heartbeatAgeMs: number } | undefined> { + const agents = await store.readAgentHeartbeats(organizationId); + return agents.find((agent) => agent.agentId === agentId); +} + +async function forceAgentStale( + executor: ControlPlaneSqlExecutor, + organizationId: string, + agentId: string, + deadlineMs: number, +): Promise { + await executor.execute({ + name: "keep_alive_live_force_agent_stale", + sql: ` + UPDATE agentic_org_agent_heartbeat + SET last_heartbeat_at = now() - (($3::INT8 + 5000) * INTERVAL '1 millisecond') + WHERE organization_id = $1 AND agent_id = $2 + `, + parameters: [organizationId, agentId, deadlineMs], + }); +} + +async function readReassignmentAlertCount(executor: ControlPlaneSqlExecutor, organizationId: string): Promise { + const result = await executor.execute<{ alert_count: number | string }>({ + name: "keep_alive_live_read_reassignment_count", + sql: ` + SELECT count(*) AS alert_count + FROM agentic_org_control_plane_alerts + WHERE organization_id = $1 AND kind = $2 + `, + parameters: [organizationId, ControlPlaneAlertKind.StaleWorkReassignment], + }); + return Number(result.rows[0]?.alert_count ?? 0); +} + +async function readReassignmentNamesWork( + executor: ControlPlaneSqlExecutor, + organizationId: string, + workItemId: string, +): Promise { + const result = await executor.execute<{ alert_count: number | string }>({ + name: "keep_alive_live_read_reassignment_names_work", + sql: ` + SELECT count(*) AS alert_count + FROM agentic_org_control_plane_alerts + WHERE organization_id = $1 AND kind = $2 AND detail_json->>'workItemId' = $3 + `, + parameters: [organizationId, ControlPlaneAlertKind.StaleWorkReassignment, workItemId], + }); + return Number(result.rows[0]?.alert_count ?? 0) >= 1; +} + +function readIntegrationDatabaseUrl(): string { + const databaseUrl = env[KeepAliveIntegrationEnvName.DatabaseUrl]; + if (databaseUrl === undefined) { + throw new Error(`${KeepAliveIntegrationEnvName.DatabaseUrl} is not set`); + } + return databaseUrl; +} + +type ControlPlaneSqlExecutor = { + execute: >( + statement: CockroachAnySqlStatement, + ) => Promise<{ rows: readonly Row[] }>; +}; + +async function applyKeepAliveMigrations(executor: ControlPlaneSqlExecutor): Promise { + const migrations = [createCockroachControlPlaneKeepAliveMigration(), createCockroachAgentLivenessMigration()]; + for (const migration of migrations) { + for (const statement of splitSqlStatements(migration.sql)) { + await executor.execute({ name: "keep_alive_live_migration", sql: statement, parameters: [] }); + } + } +} + +async function readHeartbeat( + executor: ControlPlaneSqlExecutor, + organizationId: string, +): Promise<{ version: number; ageMs: number } | undefined> { + const result = await executor.execute<{ version: number | string; age_ms: number | string }>({ + name: "keep_alive_live_read_heartbeat", + sql: ` + SELECT version, (EXTRACT(EPOCH FROM (now() - last_tick_at)) * 1000)::INT8 AS age_ms + FROM agentic_org_control_plane_heartbeat + WHERE organization_id = $1 + `, + parameters: [organizationId], + }); + const row = result.rows[0]; + if (row === undefined) { + return undefined; + } + return { version: Number(row.version), ageMs: Number(row.age_ms) }; +} + +async function forceStall(executor: ControlPlaneSqlExecutor, organizationId: string): Promise { + await executor.execute({ + name: "keep_alive_live_force_stall", + sql: ` + UPDATE agentic_org_control_plane_heartbeat + SET last_tick_at = now() - INTERVAL '10 seconds' + WHERE organization_id = $1 + `, + parameters: [organizationId], + }); +} + +async function readOrgStallAlertCount(executor: ControlPlaneSqlExecutor, organizationId: string): Promise { + const result = await executor.execute<{ alert_count: number | string }>({ + name: "keep_alive_live_read_alert_count", + sql: ` + SELECT count(*) AS alert_count + FROM agentic_org_control_plane_alerts + WHERE organization_id = $1 AND kind = $2 + `, + parameters: [organizationId, ControlPlaneAlertKind.OrgStall], + }); + return Number(result.rows[0]?.alert_count ?? 0); +} + +async function cleanUp(executor: ControlPlaneSqlExecutor, organizationId: string): Promise { + await executor + .execute({ + name: "keep_alive_live_cleanup_alerts", + sql: `DELETE FROM agentic_org_control_plane_alerts WHERE organization_id = $1`, + parameters: [organizationId], + }) + .catch(() => undefined); + await executor + .execute({ + name: "keep_alive_live_cleanup_heartbeat", + sql: `DELETE FROM agentic_org_control_plane_heartbeat WHERE organization_id = $1`, + parameters: [organizationId], + }) + .catch(() => undefined); + await executor + .execute({ + name: "keep_alive_live_cleanup_agent_heartbeat", + sql: `DELETE FROM agentic_org_agent_heartbeat WHERE organization_id = $1`, + parameters: [organizationId], + }) + .catch(() => undefined); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/agentic-organization/apps/workers/test/keep-alive-loop.test.ts b/agentic-organization/apps/workers/test/keep-alive-loop.test.ts new file mode 100644 index 0000000000..ca4593d3bd --- /dev/null +++ b/agentic-organization/apps/workers/test/keep-alive-loop.test.ts @@ -0,0 +1,112 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { runKeepAliveLoop, type KeepAliveLoopLane, type KeepAliveLoopTickRecord } from "../src/keep-alive-loop.ts"; + +describe("independent keep-alive loop", () => { + test("ticks the lane on its own cadence up to maxTicks, sleeping the interval between ticks", async () => { + const lane = recordingLane({ status: "ticked", failures: [] }); + const sleeps: number[] = []; + + const result = await runKeepAliveLoop({ + lane, + intervalMs: 5_000, + isStopRequested: () => false, + sleep: async (ms) => void sleeps.push(ms), + maxTicks: 3, + }); + + equal(result.ticks, 3); + equal(result.degradedTicks, 0); + equal(lane.runCount, 3); + // sleeps only BETWEEN ticks, not after the last one + deepEqual(sleeps, [5_000, 5_000]); + }); + + test("stops promptly when a stop is requested", async () => { + const lane = recordingLane({ status: "ticked", failures: [] }); + let calls = 0; + + const result = await runKeepAliveLoop({ + lane, + intervalMs: 5_000, + isStopRequested: () => { + calls += 1; + return calls > 2; // allow ~2 ticks then stop + }, + sleep: async () => {}, + }); + + equal(result.ticks <= 2, true); + }); + + test("counts a degraded tick (org flatlining / apply failure) but keeps ticking", async () => { + const lane = recordingLane({ status: "degraded", failures: [{ message: "reap failed" }] }); + + const result = await runKeepAliveLoop({ + lane, + intervalMs: 1, + isStopRequested: () => false, + sleep: async () => {}, + maxTicks: 2, + }); + + equal(result.ticks, 2); + equal(result.degradedTicks, 2); + }); + + test("captures a thrown lane without killing the heartbeat loop", async () => { + const lane: KeepAliveLoopLane & { runCount: number } = { + runCount: 0, + runOnce: async () => { + lane.runCount += 1; + throw new Error("lane exploded"); + }, + }; + + const result = await runKeepAliveLoop({ + lane, + intervalMs: 1, + isStopRequested: () => false, + sleep: async () => {}, + maxTicks: 2, + }); + + equal(result.ticks, 2); + equal(result.thrownTicks, 2); + equal(result.degradedTicks, 2); + }); + + test("reports each tick to the observer", async () => { + const lane = recordingLane({ status: "ticked", failures: [] }); + const records: KeepAliveLoopTickRecord[] = []; + + await runKeepAliveLoop({ + lane, + intervalMs: 1, + isStopRequested: () => false, + sleep: async () => {}, + maxTicks: 2, + observer: { record: (r) => void records.push(r) }, + }); + + deepEqual(records, [ + { tick: 1, status: "ticked", failureCount: 0 }, + { tick: 2, status: "ticked", failureCount: 0 }, + ]); + }); +}); + +function recordingLane(result: { + status: string; + failures: readonly { message: string }[]; +}): KeepAliveLoopLane & { runCount: number } { + const lane = { + runCount: 0, + runOnce: async () => { + lane.runCount += 1; + return result; + }, + }; + return lane; +} diff --git a/agentic-organization/apps/workers/test/main.test.ts b/agentic-organization/apps/workers/test/main.test.ts new file mode 100644 index 0000000000..64a70453ef --- /dev/null +++ b/agentic-organization/apps/workers/test/main.test.ts @@ -0,0 +1,370 @@ +import { deepEqual, equal, ok } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import type { AgenticEventEnvelope } from "../../../packages/domain/src/index.ts"; +import type { EventPublication } from "../../../packages/messaging/src/index.ts"; +import type { + NatsDeadLetterMessage, + NatsJetStreamInboundMessage, +} from "../../../packages/messaging-nats/src/index.ts"; +import { ReactionPlanExecutionStatus } from "../../../packages/runtime/src/index.ts"; +import type { + CockroachAnySqlResult, + CockroachSqlClient, +} from "../../../packages/state-cockroach/src/index.ts"; +import { + WorkerDependencyName, + WorkerDependencyReadinessStatus, + WorkerProcessBootstrapperName, + type WorkerProcessBootstrapper, + type WorkerProcessShutdownPort, + type WorkerDependencyReadinessProbe, +} from "../src/index.ts"; +import { + WorkerMainLogStream, + runMain, + type RunMainDependencies, + type WorkerMainClock, + type WorkerMainConstructors, + type WorkerMainDurablePorts, + type WorkerMainLogRecord, + type WorkerMainLogger, + type WorkerMainSignalRegistrar, +} from "../src/main.ts"; + +const WorkerMainTestExitCode = { + Degraded: 1, + Success: 0, +} as const; + +const WorkerMainTestEnv = { + COCKROACH_DATABASE_URL: "postgresql://agentic-org@cockroachdb-public:26257/agentic_org", + AGENTIC_ORG_ENV: "dev", + AGENTIC_ORG_ID: "org-lfg", + NATS_SERVERS: "nats://nats.nats.svc.cluster.local:4222", + NATS_STREAM: "agentic-org-events", + NATS_DURABLE: "agentic-org-v0-automation-planner", + NATS_INBOUND_BATCH_SIZE: "25", + WORKER_INBOUND_BATCH_SIZE: "10", + WORKER_OUTBOX_BATCH_SIZE: "5", + WORKER_REACTION_PLAN_BATCH_SIZE: "3", + WORKER_REACTION_PLAN_LEASE_MS: "300000", +} as const; + +describe("worker main composition entrypoint", () => { + test("composes a clean bounded run from injected fakes and returns success exit code", async () => { + const logger = createRecordingLogger(); + const signalRegistrar = createRecordingSignalRegistrar(); + const clock = createDeterministicClock(); + const shutdownPool = createRecordingShutdownPool(); + const natsAdapters = createRecordingNatsAdapters(); + const bootstrap = createRecordingBootstrap(); + + const exitCode = await runMain( + createTestDependencies({ + logger, + signalRegistrar, + clock, + shutdownPool, + natsAdapters, + bootstrap, + }), + ); + + equal(exitCode, WorkerMainTestExitCode.Success); + deepEqual(signalRegistrar.registeredSignals, ["SIGINT", "SIGTERM"]); + deepEqual(signalRegistrar.removedSignals, ["SIGINT", "SIGTERM"]); + equal(bootstrap.bootstrapCount, 1); + ok(natsAdapters.readinessCheckCount >= 1); + deepEqual(shutdownPool.endCount >= 1, true); + equal(natsAdapters.shutdownCount, 1); + ok( + logger.records.some( + (record) => + record.stream === WorkerMainLogStream.Stdout && record.message.includes("shutdown_completed"), + ), + ); + }); + + test("returns a degraded exit code without invoking constructors when config is missing", async () => { + const logger = createRecordingLogger(); + const constructorsInvoked = { count: 0 }; + + const exitCode = await runMain({ + env: {}, + logger, + signalRegistrar: createRecordingSignalRegistrar(), + clock: createDeterministicClock(), + constructors: createThrowingConstructors(constructorsInvoked), + durablePorts: createTestDurablePorts(), + iterationDelayMs: 1, + maxCycles: 1, + }); + + equal(exitCode, WorkerMainTestExitCode.Degraded); + equal(constructorsInvoked.count, 0); + ok( + logger.records.some( + (record) => + record.stream === WorkerMainLogStream.Stderr && record.message.includes("worker startup aborted"), + ), + ); + }); +}); + +type CreateTestDependenciesInput = { + logger: WorkerMainLogger; + signalRegistrar: WorkerMainSignalRegistrar; + clock: WorkerMainClock; + shutdownPool: RecordingShutdownPool; + natsAdapters: RecordingNatsAdapters; + bootstrap: RecordingBootstrap; +}; + +function createTestDependencies(input: CreateTestDependenciesInput): RunMainDependencies { + return { + env: { ...WorkerMainTestEnv } as RunMainDependencies["env"], + logger: input.logger, + signalRegistrar: input.signalRegistrar, + clock: input.clock, + constructors: createTestConstructors({ + shutdownPool: input.shutdownPool, + natsAdapters: input.natsAdapters, + bootstrap: input.bootstrap, + }), + durablePorts: createTestDurablePorts(), + iterationDelayMs: 1, + maxCycles: 1, + }; +} + +type CreateTestConstructorsInput = { + shutdownPool: RecordingShutdownPool; + natsAdapters: RecordingNatsAdapters; + bootstrap: RecordingBootstrap; +}; + +function createTestConstructors(input: CreateTestConstructorsInput): WorkerMainConstructors { + return { + createPgPool: async () => ({ + connect: async () => ({ + query: async () => ({ rows: [] }), + release: () => undefined, + }), + end: input.shutdownPool.end, + }), + createSqlClient: () => createEmptyCockroachSqlClient(), + connectNatsAdapters: async () => input.natsAdapters.adapters, + createNatsTransportFactory: () => ({ + connect: async () => { + throw new Error("transport factory connect must not be called when adapters are injected"); + }, + }), + createTelemetrySink: (telemetryInput) => ({ + record: async (record) => { + await telemetryInput.writer.write({ + timestamp: telemetryInput.now(), + eventName: record.eventName, + attributes: record.attributes, + }); + }, + }), + createMigrationBootstrapper: (): WorkerProcessBootstrapper => ({ + name: WorkerProcessBootstrapperName.CockroachMigrations, + bootstrap: input.bootstrap.bootstrap, + }), + createReadinessProbe: (): WorkerDependencyReadinessProbe => ({ + name: WorkerDependencyName.Cockroach, + check: async () => ({ + name: WorkerDependencyName.Cockroach, + status: WorkerDependencyReadinessStatus.Ready, + }), + }), + createCockroachShutdownPort: (shutdownInput): WorkerProcessShutdownPort => ({ + name: WorkerDependencyName.Cockroach, + shutdown: async () => { + await shutdownInput.pool.end(); + }, + }), + }; +} + +function createTestDurablePorts(): WorkerMainDurablePorts { + return { + inboundEventSource: { + pullNextBatch: async () => [], + }, + reactionPlanActionExecutor: { + executeReactionPlanAction: async () => ({ + status: ReactionPlanExecutionStatus.Succeeded, + result: { + message: "test reaction action", + createdWorkItemIds: [], + createdDiscussionAnchorIds: [], + }, + }), + }, + calculatePayloadHash: (_envelope: AgenticEventEnvelope) => "sha256:test-payload", + createId: (prefix) => `${prefix}-test`, + }; +} + +function createEmptyCockroachSqlClient(): CockroachSqlClient { + const client: CockroachSqlClient = { + query: async >(): Promise> => ({ + rows: [] as Row[], + }), + transaction: async (operation) => await operation(client), + }; + + return client; +} + +type RecordingLogger = WorkerMainLogger & { + records: WorkerMainLogRecord[]; +}; + +function createRecordingLogger(): RecordingLogger { + const records: WorkerMainLogRecord[] = []; + + return { + records, + log: (record) => { + records.push(record); + }, + }; +} + +type RecordingSignalRegistrar = WorkerMainSignalRegistrar & { + registeredSignals: string[]; + removedSignals: string[]; +}; + +function createRecordingSignalRegistrar(): RecordingSignalRegistrar { + const registeredSignals: string[] = []; + const removedSignals: string[] = []; + + return { + registeredSignals, + removedSignals, + on: (signal) => { + registeredSignals.push(signal); + }, + off: (signal) => { + removedSignals.push(signal); + }, + }; +} + +function createDeterministicClock(): WorkerMainClock { + return { + now: () => "2026-05-29T00:00:00.000Z", + setTimeout: (callback) => { + callback(); + return 0; + }, + clearTimeout: () => undefined, + }; +} + +type RecordingShutdownPool = { + endCount: number; + end: () => Promise; +}; + +function createRecordingShutdownPool(): RecordingShutdownPool { + const state = { endCount: 0 }; + + return { + get endCount() { + return state.endCount; + }, + end: async () => { + state.endCount += 1; + }, + }; +} + +type RecordingBootstrap = { + bootstrapCount: number; + bootstrap: () => Promise; +}; + +function createRecordingBootstrap(): RecordingBootstrap { + const state = { bootstrapCount: 0 }; + + return { + get bootstrapCount() { + return state.bootstrapCount; + }, + bootstrap: async () => { + state.bootstrapCount += 1; + }, + }; +} + +type RecordingNatsAdapters = { + adapters: Awaited>; + readinessCheckCount: number; + shutdownCount: number; +}; + +function createRecordingNatsAdapters(): RecordingNatsAdapters { + const state = { readinessCheckCount: 0, shutdownCount: 0 }; + + const adapters: Awaited> = { + deadLetterPublisher: { + publish: async (_message: NatsDeadLetterMessage) => undefined, + }, + eventPublisher: { + publish: async (_publication: EventPublication) => undefined, + }, + pullConsumer: { + fetchNextBatch: async (): Promise => [], + }, + readinessProbe: { + name: WorkerDependencyName.Nats, + check: async () => { + state.readinessCheckCount += 1; + return { + name: WorkerDependencyName.Nats, + status: WorkerDependencyReadinessStatus.Ready, + }; + }, + }, + shutdown: { + name: WorkerDependencyName.Nats, + shutdown: async () => { + state.shutdownCount += 1; + }, + }, + }; + + return { + adapters, + get readinessCheckCount() { + return state.readinessCheckCount; + }, + get shutdownCount() { + return state.shutdownCount; + }, + }; +} + +function createThrowingConstructors(invoked: { count: number }): WorkerMainConstructors { + const fail = (): never => { + invoked.count += 1; + throw new Error("constructors must not run when config parsing fails"); + }; + + return { + createPgPool: async () => fail(), + createSqlClient: () => fail(), + connectNatsAdapters: async () => fail(), + createNatsTransportFactory: () => fail(), + createTelemetrySink: () => fail(), + createMigrationBootstrapper: () => fail(), + createReadinessProbe: () => fail(), + createCockroachShutdownPort: () => fail(), + }; +} diff --git a/agentic-organization/apps/workers/test/nats-worker-connection.test.ts b/agentic-organization/apps/workers/test/nats-worker-connection.test.ts index 2b0097a1d3..b0c5335366 100644 --- a/agentic-organization/apps/workers/test/nats-worker-connection.test.ts +++ b/agentic-organization/apps/workers/test/nats-worker-connection.test.ts @@ -346,6 +346,7 @@ function createNoopRuntime() { return { runOnce: async () => ({ status: WorkerRuntimeStatus.Healthy, + keepAlive: undefined, workerCycle: undefined, natsConsumerBatch: undefined, failures: [], diff --git a/agentic-organization/apps/workers/test/subprocess-sandbox.test.ts b/agentic-organization/apps/workers/test/subprocess-sandbox.test.ts new file mode 100644 index 0000000000..2ab1a1210b --- /dev/null +++ b/agentic-organization/apps/workers/test/subprocess-sandbox.test.ts @@ -0,0 +1,76 @@ +import { equal, ok } from "node:assert/strict"; +import { execPath } from "node:process"; +import { test } from "node:test"; + +import { + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + type ReactionPlanAction, +} from "../../../packages/domain/src/index.ts"; +import { buildVerificationToolRequest, verificationEvidenceRef } from "../../../packages/application/src/index.ts"; +import { createSubprocessSandbox } from "../src/adapters/subprocess-sandbox.ts"; + +function action(): ReactionPlanAction { + return { + actionType: ReactionPlanActionType.CreateSupervisorTriage, + triggerEventId: "evt-1", + organizationId: "org-1", + projectId: "proj-1", + teamId: "team-1", + workItemId: "wi-sandbox-live", + requiredHat: RequiredHat.EngineeringManager, + reason: ReactionPlanReason.SupervisorSignalNeedsTriage, + supervisorSignalId: "sig-1", + targetLevel: "manager", + } as ReactionPlanAction; +} + +test("the agent really executes a sandboxed subprocess and produces sha256 evidence", async () => { + const sandbox = createSubprocessSandbox(); + const result = await sandbox.run(buildVerificationToolRequest(action(), execPath)); + + equal(result.ok, true); + const ref = verificationEvidenceRef(result); + ok(ref !== undefined); + ok(ref?.startsWith("sandbox:sha256:")); +}); + +test("a tool that exceeds its timeout fails as Result, never hangs the agent", async () => { + const sandbox = createSubprocessSandbox(); + // a node program that sleeps far longer than the 50ms budget + const result = await sandbox.run({ + command: execPath, + args: ["-e", "setTimeout(() => {}, 60000)"], + timeoutMs: 50, + }); + + equal(result.ok, false); +}); + +test("rejects a relative command path as a Result (never PATH-resolves a binary)", async () => { + const sandbox = createSubprocessSandbox(); + const result = await sandbox.run({ command: "node", args: ["-e", "1"], timeoutMs: 5_000 }); + + equal(result.ok, false); + if (result.ok) return; + ok(result.reason.includes("absolute")); +}); + +test("the sandbox strips the environment (the tool cannot read worker secrets)", async () => { + process.env["WORKER_SECRET_TEST"] = "top-secret"; + try { + const sandbox = createSubprocessSandbox(); + const result = await sandbox.run({ + command: execPath, + args: ["-e", "process.stdout.write(String(process.env.WORKER_SECRET_TEST))"], + timeoutMs: 5_000, + }); + equal(result.ok, true); + if (!result.ok) return; + // the secret is NOT visible inside the sandbox + equal(result.stdout.trim(), "undefined"); + } finally { + delete process.env["WORKER_SECRET_TEST"]; + } +}); diff --git a/agentic-organization/apps/workers/test/worker-config.test.ts b/agentic-organization/apps/workers/test/worker-config.test.ts index 935e2d6130..aee270f98b 100644 --- a/agentic-organization/apps/workers/test/worker-config.test.ts +++ b/agentic-organization/apps/workers/test/worker-config.test.ts @@ -2,10 +2,12 @@ import { deepEqual, equal } from "node:assert/strict"; import { describe, test } from "node:test"; import { + WorkerKeepAliveConfigDefault, WorkerProcessEnvName, WorkerRuntimeConfigError, WorkerRuntimeConfigErrorCode, parseWorkerRuntimeConfigFromEnv, + type WorkerProcessEnvironment, } from "../src/index.ts"; describe("worker runtime config parsing", () => { @@ -37,10 +39,42 @@ describe("worker runtime config parsing", () => { workerOutboxBatchSize: 10, workerReactionPlanBatchSize: 8, workerReactionPlanLeaseMs: 300000, + workerKeepAliveOrgHeartbeatDeadlineMs: WorkerKeepAliveConfigDefault.OrgHeartbeatDeadlineMs, }, ); }); + test("defaults the keep-alive org-heartbeat deadline when the env value is omitted", () => { + const config = parseWorkerRuntimeConfigFromEnv(createMinimalValidEnv()); + + equal(config.workerKeepAliveOrgHeartbeatDeadlineMs, WorkerKeepAliveConfigDefault.OrgHeartbeatDeadlineMs); + }); + + test("honors an explicit keep-alive org-heartbeat deadline override", () => { + const config = parseWorkerRuntimeConfigFromEnv({ + ...createMinimalValidEnv(), + [WorkerProcessEnvName.WorkerKeepAliveOrgHeartbeatDeadlineMs]: "45000", + }); + + equal(config.workerKeepAliveOrgHeartbeatDeadlineMs, 45000); + }); + + test("rejects an invalid keep-alive org-heartbeat deadline override", () => { + try { + parseWorkerRuntimeConfigFromEnv({ + ...createMinimalValidEnv(), + [WorkerProcessEnvName.WorkerKeepAliveOrgHeartbeatDeadlineMs]: "not-a-number", + }); + throw new Error("expected config parsing to fail"); + } catch (error) { + equal(error instanceof WorkerRuntimeConfigError, true); + equal( + (error as WorkerRuntimeConfigError).code, + WorkerRuntimeConfigErrorCode.InvalidWorkerKeepAliveOrgHeartbeatDeadlineMs, + ); + } + }); + test("rejects missing required env values with typed errors", () => { assertConfigError( { @@ -213,8 +247,54 @@ describe("worker runtime config parsing", () => { WorkerRuntimeConfigErrorCode.InvalidNatsServers, ); }); + + test("throws on partial LLM config — only LLM_BASE_URL is set", () => { + assertConfigError( + { ...createMinimalValidEnv(), [WorkerProcessEnvName.LlmBaseUrl]: "http://ollama:11434" }, + WorkerRuntimeConfigErrorCode.PartialLlmConfig, + ); + }); + + test("throws on partial LLM config — only LLM_MODEL is set", () => { + assertConfigError( + { ...createMinimalValidEnv(), [WorkerProcessEnvName.LlmModel]: "qwen2:0.5b" }, + WorkerRuntimeConfigErrorCode.PartialLlmConfig, + ); + }); + + test("accepts both LLM env vars together", () => { + const config = parseWorkerRuntimeConfigFromEnv({ + ...createMinimalValidEnv(), + [WorkerProcessEnvName.LlmBaseUrl]: " http://ollama:11434 ", + [WorkerProcessEnvName.LlmModel]: " qwen2:0.5b ", + }); + equal(config.llmBaseUrl, "http://ollama:11434"); + equal(config.llmModel, "qwen2:0.5b"); + }); + + test("omits LLM config when neither env var is set (deterministic-composer mode)", () => { + const config = parseWorkerRuntimeConfigFromEnv(createMinimalValidEnv()); + equal(config.llmBaseUrl, undefined); + equal(config.llmModel, undefined); + }); }); +function createMinimalValidEnv(): WorkerProcessEnvironment { + return { + [WorkerProcessEnvName.AgenticOrgEnv]: "dev", + [WorkerProcessEnvName.AgenticOrgId]: "org-lfg", + [WorkerProcessEnvName.CockroachDatabaseUrl]: "postgresql://agentic-org@cockroachdb-public:26257/agentic_org", + [WorkerProcessEnvName.NatsServers]: "nats://nats.nats.svc.cluster.local:4222", + [WorkerProcessEnvName.NatsStream]: "agentic-org-events", + [WorkerProcessEnvName.NatsDurable]: "agentic-org-v0-automation-planner", + [WorkerProcessEnvName.NatsInboundBatchSize]: "25", + [WorkerProcessEnvName.WorkerInboundBatchSize]: "15", + [WorkerProcessEnvName.WorkerOutboxBatchSize]: "10", + [WorkerProcessEnvName.WorkerReactionPlanBatchSize]: "8", + [WorkerProcessEnvName.WorkerReactionPlanLeaseMs]: "300000", + }; +} + function assertConfigError( env: Parameters[0], code: WorkerRuntimeConfigErrorCode, diff --git a/agentic-organization/apps/workers/test/worker-process.test.ts b/agentic-organization/apps/workers/test/worker-process.test.ts index ffcc771a1a..d07f8fe8d9 100644 --- a/agentic-organization/apps/workers/test/worker-process.test.ts +++ b/agentic-organization/apps/workers/test/worker-process.test.ts @@ -264,6 +264,7 @@ function createRecordingRuntime(calls: string[]): WorkerRuntime { return { status: WorkerRuntimeStatus.Healthy, + keepAlive: undefined, workerCycle: undefined, natsConsumerBatch: undefined, failures: [], diff --git a/agentic-organization/apps/workers/test/worker-runtime.test.ts b/agentic-organization/apps/workers/test/worker-runtime.test.ts index 5c6d963797..5cbea1d569 100644 --- a/agentic-organization/apps/workers/test/worker-runtime.test.ts +++ b/agentic-organization/apps/workers/test/worker-runtime.test.ts @@ -13,6 +13,8 @@ import { WorkerRuntimeStatus, WorkerRuntimeTelemetryEventName, createWorkerRuntime, + type WorkerKeepAliveLane, + type WorkerKeepAliveLaneResult, type WorkerRuntimeTelemetrySink, } from "../src/index.ts"; @@ -211,6 +213,112 @@ describe("worker runtime composition host", () => { equal(result.status, WorkerRuntimeStatus.Degraded); }); + test("runs the deterministic keep-alive lane each cycle when one is provided", async () => { + const keepAliveLane = createRecordingKeepAliveLane({ + status: "ticked", + appliedCount: 1, + failures: [], + }); + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer(createProcessedNatsBatch()), + telemetrySink: createRecordingTelemetrySink(), + keepAliveLane, + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Healthy); + equal(keepAliveLane.runCount, 1); + equal(result.keepAlive?.status, "ticked"); + equal(result.keepAlive?.appliedCount, 1); + }); + + test("keeps work + NATS lanes running when no keep-alive lane is provided", async () => { + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer(createProcessedNatsBatch()), + telemetrySink: createRecordingTelemetrySink(), + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Healthy); + equal(result.keepAlive, undefined); + }); + + test("surfaces keep-alive lane failures as runtime failures without dying", async () => { + const keepAliveLane = createRecordingKeepAliveLane({ + status: "degraded", + appliedCount: 0, + failures: [{ message: "lease reap apply failed" }], + }); + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer(createProcessedNatsBatch()), + telemetrySink: createRecordingTelemetrySink(), + keepAliveLane, + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Degraded); + deepEqual(result.failures, [ + { + stage: WorkerRuntimeFailureStage.KeepAlive, + message: "lease reap apply failed", + }, + ]); + }); + + test("marks the runtime degraded when keep-alive reports degraded even with no apply failures", async () => { + const keepAliveLane = createRecordingKeepAliveLane({ + status: "degraded", + appliedCount: 1, + failures: [], + }); + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer(createProcessedNatsBatch()), + telemetrySink: createRecordingTelemetrySink(), + keepAliveLane, + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Degraded); + deepEqual(result.failures, []); + }); + + test("captures a thrown keep-alive lane without crashing the cycle", async () => { + const runtime = createWorkerRuntime({ + config: createRuntimeConfig(), + organizationWorkerHost: createRecordingOrganizationWorkerHost(createWorkedWorkerCycle()), + natsEventConsumer: createRecordingNatsEventConsumer(createProcessedNatsBatch()), + telemetrySink: createRecordingTelemetrySink(), + keepAliveLane: { + runOnce: async () => { + throw new Error("keep-alive lane exploded"); + }, + }, + }); + + const result = await runtime.runOnce(); + + equal(result.status, WorkerRuntimeStatus.Degraded); + equal(result.workerCycle?.status, WorkerCycleStatus.Worked); + deepEqual(result.failures, [ + { + stage: WorkerRuntimeFailureStage.KeepAlive, + message: "keep-alive lane exploded", + }, + ]); + }); + test("rejects invalid process config before loops can start", () => { try { createWorkerRuntime({ @@ -352,3 +460,17 @@ function createFailingTelemetrySink(message: string): WorkerRuntimeTelemetrySink }, }; } + +function createRecordingKeepAliveLane(result: WorkerKeepAliveLaneResult): WorkerKeepAliveLane & { + runCount: number; +} { + const lane = { + runCount: 0, + runOnce: async (): Promise => { + lane.runCount += 1; + return result; + }, + }; + + return lane; +} diff --git a/agentic-organization/deploy/k8s/00-namespace.yaml b/agentic-organization/deploy/k8s/00-namespace.yaml new file mode 100644 index 0000000000..bfefcf9148 --- /dev/null +++ b/agentic-organization/deploy/k8s/00-namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: agentic-org + labels: + app.kubernetes.io/part-of: agentic-organization diff --git a/agentic-organization/deploy/k8s/10-cockroach.yaml b/agentic-organization/deploy/k8s/10-cockroach.yaml new file mode 100644 index 0000000000..6f8ec8544b --- /dev/null +++ b/agentic-organization/deploy/k8s/10-cockroach.yaml @@ -0,0 +1,71 @@ +# Single-node CockroachDB for the in-cluster end-to-end test. Ephemeral +# (emptyDir) — this is a test substrate, not production. The worker's migration +# bootstrapper applies the schema (incl. 0011 control-plane keep-alive) on start. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cockroach + namespace: agentic-org + labels: + app: cockroach +spec: + replicas: 1 + selector: + matchLabels: + app: cockroach + template: + metadata: + labels: + app: cockroach + spec: + containers: + - name: cockroach + image: cockroachdb/cockroach:v26.2.1 + # NB: v26.2.1 start-single-node rejects a non-localhost --listen-addr. + # Leave it unset (as docker-compose does) — cockroach binds network- + # accessibly by default, reachable cluster-wide via the Service. + args: + - start-single-node + - --insecure + - --store=/cockroach/cockroach-data + ports: + - name: sql + containerPort: 26257 + - name: http + containerPort: 8080 + volumeMounts: + - name: data + mountPath: /cockroach/cockroach-data + readinessProbe: + httpGet: + path: "/health?ready=1" + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: "/health" + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: cockroach + namespace: agentic-org + labels: + app: cockroach +spec: + selector: + app: cockroach + ports: + - name: sql + port: 26257 + targetPort: 26257 + - name: http + port: 8080 + targetPort: 8080 diff --git a/agentic-organization/deploy/k8s/20-nats.yaml b/agentic-organization/deploy/k8s/20-nats.yaml new file mode 100644 index 0000000000..0ffc146455 --- /dev/null +++ b/agentic-organization/deploy/k8s/20-nats.yaml @@ -0,0 +1,55 @@ +# NATS with JetStream for the in-cluster end-to-end test. Memory storage +# (ephemeral) — the stream + durable consumer are provisioned by the +# nats-provision Job before the worker starts (the worker fails-fast if the +# durable consumer does not exist). +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nats + namespace: agentic-org + labels: + app: nats +spec: + replicas: 1 + selector: + matchLabels: + app: nats + template: + metadata: + labels: + app: nats + spec: + containers: + - name: nats + image: nats:2.14.1-alpine + args: ["-js", "-sd", "/data", "-m", "8222"] + ports: + - { name: client, containerPort: 4222 } + - { name: monitor, containerPort: 8222 } + volumeMounts: + - { name: data, mountPath: /data } + readinessProbe: + httpGet: { path: /healthz, port: 8222 } + initialDelaySeconds: 3 + periodSeconds: 5 + livenessProbe: + httpGet: { path: /healthz, port: 8222 } + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: nats + namespace: agentic-org + labels: + app: nats +spec: + selector: + app: nats + ports: + - { name: client, port: 4222, targetPort: 4222 } + - { name: monitor, port: 8222, targetPort: 8222 } diff --git a/agentic-organization/deploy/k8s/25-ollama.yaml b/agentic-organization/deploy/k8s/25-ollama.yaml new file mode 100644 index 0000000000..fc6cbb1ce9 --- /dev/null +++ b/agentic-organization/deploy/k8s/25-ollama.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ollama + namespace: agentic-org + labels: { app: ollama } +spec: + replicas: 1 + selector: + matchLabels: { app: ollama } + template: + metadata: + labels: { app: ollama } + spec: + containers: + - name: ollama + image: ollama/ollama:0.5.7 + ports: + - containerPort: 11434 + env: + - { name: OLLAMA_HOST, value: "0.0.0.0:11434" } + - { name: OLLAMA_KEEP_ALIVE, value: "-1" } + readinessProbe: + httpGet: { path: "/", port: 11434 } + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: { cpu: "250m", memory: "1Gi" } + limits: { cpu: "2", memory: "3Gi" } + volumeMounts: + - { name: models, mountPath: /root/.ollama } + volumes: + - name: models + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: ollama + namespace: agentic-org +spec: + selector: { app: ollama } + ports: + - { port: 11434, targetPort: 11434 } diff --git a/agentic-organization/deploy/k8s/30-worker.yaml b/agentic-organization/deploy/k8s/30-worker.yaml new file mode 100644 index 0000000000..436232a5e6 --- /dev/null +++ b/agentic-organization/deploy/k8s/30-worker.yaml @@ -0,0 +1,54 @@ +# The agentic-organization worker process. On start it runs the Cockroach +# migration bootstrapper (incl. 0011 control-plane keep-alive), checks readiness, +# then loops runOnce(): keep-alive ticks the org heartbeat FIRST every cycle +# (deterministic "stay alive"), then the org-worker + NATS-consumer lanes. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: worker + namespace: agentic-org + labels: + app: worker +spec: + replicas: 1 + selector: + matchLabels: + app: worker + template: + metadata: + labels: + app: worker + spec: + containers: + - name: worker + image: agentic-org-worker:keepalive + imagePullPolicy: IfNotPresent + env: + - { name: AGENTIC_ORG_ENV, value: "dev" } + - { name: AGENTIC_ORG_ID, value: "org-lfg" } + - { + name: COCKROACH_DATABASE_URL, + value: "postgresql://root@cockroach:26257/defaultdb?sslmode=disable", + } + - { name: NATS_SERVERS, value: "nats://nats:4222" } + - { name: NATS_STREAM, value: "agentic-org-events" } + - { name: NATS_DURABLE, value: "agentic-org-v0-automation-planner" } + - { name: NATS_INBOUND_BATCH_SIZE, value: "25" } + - { name: WORKER_INBOUND_BATCH_SIZE, value: "15" } + - { name: WORKER_OUTBOX_BATCH_SIZE, value: "10" } + - { name: WORKER_REACTION_PLAN_BATCH_SIZE, value: "8" } + - { name: WORKER_REACTION_PLAN_LEASE_MS, value: "300000" } + # The keep-alive ticks once per worker cycle, and an idle cycle is + # bounded by the NATS pull long-poll (~30s). The deadline must exceed + # the max cycle time so a healthy org is not flagged flatlining; 90s + # gives comfortable margin over the ~30s idle cycle. (Future hardening: + # an independent fast keep-alive loop decoupled from the work cycle.) + - { name: WORKER_KEEP_ALIVE_ORG_HEARTBEAT_DEADLINE_MS, value: "90000" } + # the agent's live decision backend: real model calls to the + # in-cluster Ollama service (no external credentials). If unset, the + # agent uses the deterministic first-legal-option policy. + - { name: LLM_BASE_URL, value: "http://ollama:11434" } + - { name: LLM_MODEL, value: "qwen2:0.5b" } + resources: + requests: { cpu: "50m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } diff --git a/agentic-organization/deploy/observe-org.ts b/agentic-organization/deploy/observe-org.ts new file mode 100644 index 0000000000..c28a0f8019 --- /dev/null +++ b/agentic-organization/deploy/observe-org.ts @@ -0,0 +1,47 @@ +/** + * Observe the org: read the persisted OrgEvent trace + hat bindings from the + * in-cluster CockroachDB and render the org snapshot — the "what is happening + * right now" view, including activity at every hierarchy level. + * + * kubectl -n agentic-org port-forward svc/cockroach 26257:26257 & + * COCKROACH_DATABASE_URL=postgresql://root@localhost:26257/defaultdb?sslmode=disable \ + * node --experimental-strip-types deploy/observe-org.ts + */ + +import { Pool } from "pg"; +import { env } from "node:process"; + +import { buildHatDefinitions } from "../packages/application/src/index.ts"; +import { createCockroachSqlExecutor, createCockroachOrgEventStore, createCockroachHatBindingStore } from "../packages/state-cockroach/src/index.ts"; +import type { CockroachSqlClient } from "../packages/state-cockroach/src/cockroach-sql-executor.ts"; +import { buildOrgSnapshot, renderOrgSnapshot } from "../packages/observability/src/index.ts"; + +const connectionString = env.COCKROACH_DATABASE_URL ?? "postgresql://root@localhost:26257/defaultdb?sslmode=disable"; + +async function main(): Promise { + const pool = new Pool({ connectionString }); + const client: CockroachSqlClient = { + query: async (sql, parameters) => ({ rows: (await pool.query(sql, parameters as unknown[])).rows }), + transaction: async (operation) => operation(client), + }; + const executor = createCockroachSqlExecutor({ client }); + const events = await createCockroachOrgEventStore({ executor }).listByOrganization("org-lfg", 500); + const bindings = await createCockroachHatBindingStore({ executor }).listAll("org-lfg"); + + const snapshot = buildOrgSnapshot({ + hats: buildHatDefinitions(), + bindings, + events, + nowMs: Date.now(), + nowIso: new Date().toISOString(), + }); + + console.log(renderOrgSnapshot(snapshot)); + console.log("\nLATEST DECISIONS (most recent first):"); + for (const e of snapshot.recentEvents.slice(0, 12)) { + console.log(` [${e.kind}] ${e.decision}`); + } + await pool.end(); +} + +await main(); diff --git a/agentic-organization/deploy/provision-nats.ts b/agentic-organization/deploy/provision-nats.ts new file mode 100644 index 0000000000..311b07b37f --- /dev/null +++ b/agentic-organization/deploy/provision-nats.ts @@ -0,0 +1,108 @@ +/** + * Idempotently provision the JetStream stream + durable pull consumer the worker + * binds to at startup (the worker fails-fast if the durable consumer does not + * exist). Run from the host against a port-forwarded NATS: + * + * kubectl -n agentic-org port-forward svc/nats 4222:4222 & + * node --experimental-strip-types deploy/provision-nats.ts + * + * Config mirrors the durable-worker live integration test. Override via env: + * NATS_PROVISION_SERVERS (default nats://127.0.0.1:4222) + * NATS_PROVISION_STREAM (default agentic-org-events) + * NATS_PROVISION_DURABLE (default agentic-org-v0-automation-planner) + * NATS_PROVISION_ENV (default dev) + * NATS_PROVISION_ORG (default org-lfg) + */ + +import { + AckPolicy, + DeliverPolicy, + DiscardPolicy, + ReplayPolicy, + RetentionPolicy, + StorageType, + jetstreamManager, +} from "@nats-io/jetstream"; +import { connect } from "@nats-io/transport-node"; +import { env } from "node:process"; + +const servers = env.NATS_PROVISION_SERVERS ?? "nats://127.0.0.1:4222"; +const streamName = env.NATS_PROVISION_STREAM ?? "agentic-org-events"; +const durableName = env.NATS_PROVISION_DURABLE ?? "agentic-org-v0-automation-planner"; +const environment = env.NATS_PROVISION_ENV ?? "dev"; +const organizationId = env.NATS_PROVISION_ORG ?? "org-lfg"; + +// all events for this org/env (incl. dead-letters) +const allSubjects = `agentic-org.${environment}.${organizationId}.>`; +// the V0 automation planner reacts to supervisor signals +const consumerFilter = `agentic-org.${environment}.${organizationId}.supervisor_signal.>`; + +async function main(): Promise { + const connection = await connect({ servers }); + try { + const manager = await jetstreamManager(connection); + + await upsertStream(manager); + await upsertConsumer(manager); + + console.log( + JSON.stringify({ + provisioned: true, + stream: streamName, + subjects: [allSubjects], + durable: durableName, + filter: consumerFilter, + }), + ); + } finally { + await connection.drain(); + } +} + +async function upsertStream(manager: Awaited>): Promise { + try { + await manager.streams.add({ + name: streamName, + subjects: [allSubjects], + retention: RetentionPolicy.Limits, + discard: DiscardPolicy.Old, + storage: StorageType.Memory, + max_msgs: 1000, + }); + console.log(`stream created: ${streamName}`); + } catch (error) { + if (isAlreadyExists(error)) { + await manager.streams.update(streamName, { subjects: [allSubjects] }); + console.log(`stream already existed, subjects ensured: ${streamName}`); + return; + } + throw error; + } +} + +async function upsertConsumer(manager: Awaited>): Promise { + try { + await manager.consumers.add(streamName, { + durable_name: durableName, + ack_policy: AckPolicy.Explicit, + deliver_policy: DeliverPolicy.All, + replay_policy: ReplayPolicy.Instant, + filter_subject: consumerFilter, + max_deliver: 1, + }); + console.log(`consumer created: ${durableName}`); + } catch (error) { + if (isAlreadyExists(error)) { + console.log(`consumer already existed: ${durableName}`); + return; + } + throw error; + } +} + +function isAlreadyExists(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + return message.includes("already") || message.includes("in use") || message.includes("exist"); +} + +await main(); diff --git a/agentic-organization/deploy/run-org-cycle.ts b/agentic-organization/deploy/run-org-cycle.ts new file mode 100644 index 0000000000..5dce8eb6bf --- /dev/null +++ b/agentic-organization/deploy/run-org-cycle.ts @@ -0,0 +1,67 @@ +/** + * Run one full org cycle against the in-cluster CockroachDB and print the result. + * Proves the hat + department organization end-to-end in kind: executive/director + * prioritization → RMO supply voting → hat assignment+binding → the 7-gate + * pipeline → binding lifecycle (warmup→active→expire→succession), all persisted + * to agentic_org_org_events + agentic_org_hat_bindings. + * + * kubectl -n agentic-org port-forward svc/cockroach 26257:26257 & + * COCKROACH_DATABASE_URL=postgresql://root@localhost:26257/defaultdb?sslmode=disable \ + * node --experimental-strip-types deploy/run-org-cycle.ts + */ + +import { Pool } from "pg"; +import { randomUUID } from "node:crypto"; +import { env } from "node:process"; + +import { + buildHatDefinitions, + runOrgCycle, +} from "../packages/application/src/index.ts"; +import { + createCockroachOrgSystemMigration, + createCockroachOrgEventStore, + createCockroachHatBindingStore, + createCockroachSqlExecutor, + splitSqlStatements, +} from "../packages/state-cockroach/src/index.ts"; +import type { CockroachSqlClient } from "../packages/state-cockroach/src/cockroach-sql-executor.ts"; + +const connectionString = env.COCKROACH_DATABASE_URL ?? "postgresql://root@localhost:26257/defaultdb?sslmode=disable"; + +async function main(): Promise { + const pool = new Pool({ connectionString }); + const client: CockroachSqlClient = { + query: async (sql, parameters) => { + const result = await pool.query(sql, parameters as unknown[]); + return { rows: result.rows }; + }, + // the org stores use autocommit single statements; a self-passing wrapper is sufficient + transaction: async (operation) => operation(client), + }; + const executor = createCockroachSqlExecutor({ client }); + + // apply the org-system migration (idempotent CREATE TABLE IF NOT EXISTS) + for (const statement of splitSqlStatements(createCockroachOrgSystemMigration().sql)) { + await pool.query(statement); + } + + const orgEventStore = createCockroachOrgEventStore({ executor }); + const hatBindingStore = createCockroachHatBindingStore({ executor }); + + const workItemId = `work-${randomUUID()}`; + const report = await runOrgCycle({ + organizationId: "org-lfg", + workItemId, + baseTimeMs: Date.now(), + createId: (prefix) => `${prefix}-${randomUUID()}`, + appendEvent: (e) => orgEventStore.append(e), + upsertBinding: (b) => hatBindingStore.upsert(b), + hats: buildHatDefinitions(), + }); + + console.log(JSON.stringify({ orgCycle: report }, null, 2)); + await pool.end(); +} + +await main(); diff --git a/agentic-organization/deploy/spin-up-task.ts b/agentic-organization/deploy/spin-up-task.ts new file mode 100644 index 0000000000..90f9d48788 --- /dev/null +++ b/agentic-organization/deploy/spin-up-task.ts @@ -0,0 +1,94 @@ +/** + * Spin up a task: publish a canonical SupervisorSignalSent event to the org's + * JetStream subject. The deployed worker's NATS consumer ingests it, the V0 + * automation planner reacts (CreateSupervisorTriage), and a reaction plan is + * created + executed — exercising the autonomous data-plane pipeline end to end. + * + * kubectl -n agentic-org port-forward svc/nats 14222:4222 & + * node --experimental-strip-types deploy/spin-up-task.ts + */ + +import { jetstream } from "@nats-io/jetstream"; +import { connect } from "@nats-io/transport-node"; +import { randomUUID } from "node:crypto"; +import { env } from "node:process"; + +import { + AgenticAggregateType, + AgenticEventType, + EventSchemaVersion, + SupervisorChainLevel, + createAgenticEventEnvelope, +} from "../packages/domain/src/index.ts"; +import { AgenticMessagingDomain, buildAgenticEventSubject } from "../packages/messaging/src/index.ts"; + +const servers = env.NATS_PROVISION_SERVERS ?? "nats://127.0.0.1:14222"; +const environment = env.NATS_PROVISION_ENV ?? "dev"; +const organizationId = env.NATS_PROVISION_ORG ?? "org-lfg"; + +async function main(): Promise { + const runId = randomUUID(); + const eventId = `evt-${runId}`; + + const envelope = createAgenticEventEnvelope({ + eventId, + eventType: AgenticEventType.SupervisorSignalSent, + schemaVersion: EventSchemaVersion.AgenticOrgEventV1, + occurredAt: "2026-05-30T06:30:00.000Z", + actor: { agentId: `agent-${runId}`, hatAssignmentId: `hat-${runId}` }, + scope: { + organizationId, + projectId: `project-${runId}`, + teamId: `team-${runId}`, + workItemId: `work-${runId}`, + }, + aggregate: { + aggregateId: `supervisor-signal-${runId}`, + aggregateType: AgenticAggregateType.SupervisorSignal, + aggregateVersion: 1, + }, + trace: { + commandId: `cmd-${runId}`, + correlationId: `corr-${runId}`, + causationId: `cause-${runId}`, + traceId: `trace-${runId}`, + idempotencyKey: `idem-${runId}`, + }, + replay: { isReplay: false }, + payload: { + targetHatAssignmentId: `target-hat-${runId}`, + targetLevel: SupervisorChainLevel.Manager, + }, + }); + + const subject = buildAgenticEventSubject({ + environment, + organizationId, + domain: AgenticMessagingDomain.SupervisorSignal, + eventType: AgenticEventType.SupervisorSignalSent, + }); + + const connection = await connect({ servers }); + try { + const js = jetstream(connection); + const ack = await js.publish(subject, new TextEncoder().encode(JSON.stringify(envelope)), { + msgID: eventId, + }); + console.log( + JSON.stringify({ + published: true, + subject, + eventId, + stream: ack.stream, + seq: ack.seq, + workItemId: envelope.scope.workItemId, + teamId: envelope.scope.teamId, + targetLevel: envelope.payload.targetLevel, + }), + ); + } finally { + await connection.drain(); + } +} + +await main(); diff --git a/agentic-organization/docker-compose.yml b/agentic-organization/docker-compose.yml new file mode 100644 index 0000000000..3264664db0 --- /dev/null +++ b/agentic-organization/docker-compose.yml @@ -0,0 +1,53 @@ +# Local dependency stack for the Agentic Organization worker: +# - CockroachDB single-node (the authoritative Organization state DB) +# - NATS with JetStream (the event transport / outbox sink) +# - the worker runtime itself +# +# Brings up the real substrate so the env-gated Cockroach + NATS integration +# tests run against actual services, and the worker boots end to end locally +# before the same images are deployed to kind (Phase 6). +services: + cockroach: + image: cockroachdb/cockroach:v26.2.1 + command: start-single-node --insecure + ports: + - "26257:26257" # SQL + - "8080:8080" # admin UI + healthcheck: + test: ["CMD", "cockroach", "sql", "--insecure", "-e", "SELECT 1"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - cockroach-data:/cockroach/cockroach-data + + nats: + image: nats:2.14.1-alpine + command: ["-js", "-sd", "/data"] + ports: + - "4222:4222" # client + - "8222:8222" # monitoring + volumes: + - nats-data:/data + + worker: + build: + context: . + depends_on: + cockroach: + condition: service_healthy + nats: + condition: service_started + environment: + AGENTIC_ORG_ENV: local + AGENTIC_ORG_ID: org-local + COCKROACH_DATABASE_URL: postgresql://root@cockroach:26257/defaultdb?sslmode=disable + NATS_SERVERS: nats://nats:4222 + NATS_STREAM: agentic-org-local + NATS_DURABLE: agentic-org-worker + NATS_INBOUND_BATCH_SIZE: "16" + restart: on-failure + +volumes: + cockroach-data: + nats-data: diff --git a/agentic-organization/docs/AGENT_NATIVE_KNOWLEDGE_GRAPH.md b/agentic-organization/docs/AGENT_NATIVE_KNOWLEDGE_GRAPH.md index 0d4c519b59..27a475e429 100644 --- a/agentic-organization/docs/AGENT_NATIVE_KNOWLEDGE_GRAPH.md +++ b/agentic-organization/docs/AGENT_NATIVE_KNOWLEDGE_GRAPH.md @@ -1,3 +1,9 @@ +--- +title: Agent-Native Knowledge Graph and Retrieval +canonical_name: Agentic Organization +status: design +--- + # Agent-Native Knowledge Graph and Retrieval ## Purpose diff --git a/agentic-organization/docs/AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md b/agentic-organization/docs/AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md index ea513a41e4..edd6a814bd 100644 --- a/agentic-organization/docs/AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md +++ b/agentic-organization/docs/AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md @@ -1,3 +1,9 @@ +--- +title: Agent Work Rhythm and Prompt Flows +canonical_name: Agentic Organization +status: design +--- + # Agent Work Rhythm and Prompt Flows ## Purpose diff --git a/agentic-organization/docs/AI_CLUSTER_SCAFFOLD_CONTEXT.md b/agentic-organization/docs/AI_CLUSTER_SCAFFOLD_CONTEXT.md index bffa2e2db0..3da0ed7e95 100644 --- a/agentic-organization/docs/AI_CLUSTER_SCAFFOLD_CONTEXT.md +++ b/agentic-organization/docs/AI_CLUSTER_SCAFFOLD_CONTEXT.md @@ -1,3 +1,9 @@ +--- +title: AI Cluster Scaffold Context +canonical_name: Agentic Organization +status: design +--- + # AI Cluster Scaffold Context This document captures repository and bootstrap context from the `ai-cluster-bootstrap` work so Organization implementation aligns with the actual cluster direction. It is not a deployment-manifest spec. diff --git a/agentic-organization/docs/ALWAYS_ON_ORCHESTRATION_RUNTIME.md b/agentic-organization/docs/ALWAYS_ON_ORCHESTRATION_RUNTIME.md index bc2c2c8acb..0917f2585f 100644 --- a/agentic-organization/docs/ALWAYS_ON_ORCHESTRATION_RUNTIME.md +++ b/agentic-organization/docs/ALWAYS_ON_ORCHESTRATION_RUNTIME.md @@ -1,3 +1,9 @@ +--- +title: Always-On Orchestration Runtime +canonical_name: Agentic Organization +status: design +--- + # Always-On Orchestration Runtime ## Purpose diff --git a/agentic-organization/docs/AMBIGUOUS_REQUIREMENT_LIFECYCLE.md b/agentic-organization/docs/AMBIGUOUS_REQUIREMENT_LIFECYCLE.md index c916018f88..47d40d720a 100644 --- a/agentic-organization/docs/AMBIGUOUS_REQUIREMENT_LIFECYCLE.md +++ b/agentic-organization/docs/AMBIGUOUS_REQUIREMENT_LIFECYCLE.md @@ -1,3 +1,9 @@ +--- +title: Ambiguous Requirement to Curated Feature Lifecycle +canonical_name: Agentic Organization +status: design +--- + # Ambiguous Requirement to Curated Feature Lifecycle The Organization must be able to receive a vague requirement and turn it into a well-considered feature. This lifecycle is the path from unclear intent to delivered, verified, and learned-from work. diff --git a/agentic-organization/docs/ANTI_STALL_PRIORITY_RUNTIME.md b/agentic-organization/docs/ANTI_STALL_PRIORITY_RUNTIME.md index 5882c181d6..4308bfcd27 100644 --- a/agentic-organization/docs/ANTI_STALL_PRIORITY_RUNTIME.md +++ b/agentic-organization/docs/ANTI_STALL_PRIORITY_RUNTIME.md @@ -1,3 +1,9 @@ +--- +title: Anti-Stall Prioritization Runtime +canonical_name: Agentic Organization +status: design +--- + # Anti-Stall Prioritization Runtime The Organization should be designed to keep moving. Blockers should not create stale pauses. They should become actively managed work with owners, deadlines, alternate lanes, escalation paths, and reconciliation loops while other useful work continues. diff --git a/agentic-organization/docs/BUSINESS_QUALITY_GATE_SYSTEM.md b/agentic-organization/docs/BUSINESS_QUALITY_GATE_SYSTEM.md index 4aa57f0c4b..b37078a5bd 100644 --- a/agentic-organization/docs/BUSINESS_QUALITY_GATE_SYSTEM.md +++ b/agentic-organization/docs/BUSINESS_QUALITY_GATE_SYSTEM.md @@ -1,3 +1,9 @@ +--- +title: Business Quality Gate System +canonical_name: Agentic Organization +status: design +--- + # Business Quality Gate System The Organization needs business quality gates in addition to engineering and diff --git a/agentic-organization/docs/CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md b/agentic-organization/docs/CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md index ace871e470..99b0c1eca1 100644 --- a/agentic-organization/docs/CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md +++ b/agentic-organization/docs/CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md @@ -1,3 +1,9 @@ +--- +title: Cluster Execution and Memory Substrate +canonical_name: Agentic Organization +status: design +--- + # Cluster Execution and Memory Substrate This document captures the theoretical cluster substrate for the Agentic Organization. It focuses on how Hermes agents run in k3s as isolated session containers, how hats and policies bind to workloads, how credential proxies and Cilium service mesh boundaries protect access, how SPIRE supplies workload identity, and how Hindsight provides persistent memory. diff --git a/agentic-organization/docs/CLUSTER_NATIVE_HAT_SYSTEM.md b/agentic-organization/docs/CLUSTER_NATIVE_HAT_SYSTEM.md index 743c0db0b7..702b54be70 100644 --- a/agentic-organization/docs/CLUSTER_NATIVE_HAT_SYSTEM.md +++ b/agentic-organization/docs/CLUSTER_NATIVE_HAT_SYSTEM.md @@ -1,3 +1,9 @@ +--- +title: Cluster-Native Hat System +canonical_name: Agentic Organization +status: design +--- + # Cluster-Native Hat System This document captures the theoretical design for a Kubernetes-native hat system. It focuses on CRDs, OPA policies, graph enforcement, time-bounded hat bindings, succession, reputation, events, and observability. diff --git a/agentic-organization/docs/DEPARTMENT_HAT_TOOL_INVENTORY.md b/agentic-organization/docs/DEPARTMENT_HAT_TOOL_INVENTORY.md index 08d8b878ac..a784c6dccc 100644 --- a/agentic-organization/docs/DEPARTMENT_HAT_TOOL_INVENTORY.md +++ b/agentic-organization/docs/DEPARTMENT_HAT_TOOL_INVENTORY.md @@ -1,3 +1,9 @@ +--- +title: Department, Hat, and Tool Inventory +canonical_name: Agentic Organization +status: design +--- + # Department, Hat, and Tool Inventory This document is the first inventory for the Agentic Organization. It expands the architecture into concrete departments, hats, MCP tool bundles, approval powers, and ownership boundaries. diff --git a/agentic-organization/docs/DOC_FRONTMATTER_CONVENTION.md b/agentic-organization/docs/DOC_FRONTMATTER_CONVENTION.md new file mode 100644 index 0000000000..80b722558f --- /dev/null +++ b/agentic-organization/docs/DOC_FRONTMATTER_CONVENTION.md @@ -0,0 +1,95 @@ +--- +title: Doc Frontmatter Convention +canonical_name: Agentic Organization +status: design +ideas: [3] +extends: [README.md] +composes_with: + - ./OBSERVE_COMPOSER_AND_RUN_STATE.md + - ./GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md +code_anchors: [] +supersedes: [] +--- + +# Doc Frontmatter Convention + +Operator idea 3: every design document in `agentic-organization/docs/` carries +YAML frontmatter with pointers to the documents and code it relates to, so the +doc set is a navigable graph rather than a flat list. The frontmatter is the +machine-readable edge layer; the prose is the node. + +## Schema + +```yaml +--- +title: +canonical_name: Agentic Organization # always; never "Hermes"/"Work OS" at platform scope +status: design | v0 | implemented # lifecycle of the doc's content +ideas: [] +extends: [] +composes_with: # related docs (bidirectional intent) + - ./.md +code_anchors: # real code paths the doc describes + - ../packages//src/.ts +supersedes: [] +--- +``` + +### Key semantics + +- **`canonical_name`** — enforces the naming discipline from the README: + "Agentic Organization" is the platform; "Hermes" is *only* the agent runtime; + "Organization Work OS" is *only* the work-management subsystem. Docs whose + scope is one of those subsystems may still set `canonical_name: Agentic + Organization` and name the subsystem in the body. +- **`status`** — `design` (reference substrate), `v0` (smallest end-to-end + slice in flight), `implemented` (code exists and is tested). A doc moves + `design → v0 → implemented` as its `code_anchors` become real and green. +- **`extends` vs `composes_with`** — `extends` is the parent(s) a reader should + read first; `composes_with` is the lateral graph. Both are pointers, not + copies (substrate-or-it-didn't-happen: the pointed-at doc is the source of + truth). +- **`code_anchors`** — relative paths from the doc to real code. When a doc + claims a contract, the anchor proves it exists. Empty is allowed for + pure-design docs. +- **`supersedes`** — retraction-native: a superseding doc names what it + replaces; the replaced doc stays in git history (never deleted silently). + +## Why pointers, not a central index + +The README remains the human entry list, but it is hand-maintained and drifts. +Frontmatter pointers let a tool (or an agent) reconstruct the doc graph from the +files themselves — the same way `composes_with:` edges work in the backlog. The +graph is derivable, so it cannot rot out of sync with the docs. + +## Two roles, one mechanism + +Frontmatter plays two roles in the Agentic Organization, and they are the same +mechanism at two scopes: + +1. **Doc-graph metadata** (this doc) — `title`/`status`/`extends`/`composes_with`/ + `code_anchors` make the design docs a navigable graph. +2. **Database rows + schema** (`GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md`) — + a `.md` file is a row, its frontmatter is the typed columns, and `fk`/`fk_array` + columns are graph edges resolved exactly like `composes_with`. + +A doc's `composes_with` list *is* an `fk_array` over the docs "table"; a task row's +`depends_on` is an `fk_array` over the task table. The traversal code +(`packages/frontmatter-db/src/traverse.ts`) is therefore reusable for both: the +doc graph and the data graph are one graph with different schemas. + +## Adoption + +New docs MUST carry the frontmatter. Existing docs adopt it opportunistically as +they are edited (no big-bang rewrite). The two docs landed alongside this one +(`OBSERVE_COMPOSER_AND_RUN_STATE.md`, `GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md`) +are the first adopters and the reference examples. + +## Future: derive the graph + +A small tool under `agentic-organization` (or the repo's `tools/`) can parse +frontmatter across `docs/*.md`, validate that every `extends`/`composes_with` +target exists, that `canonical_name` is set, and that `code_anchors` resolve to +real files — then emit the doc graph for the UI (composes with +`UI_AND_OBSERVABILITY_CONCEPTS.md`). That validator is the natural next slice for +this convention; until it lands, the discipline is enforced in review. diff --git a/agentic-organization/docs/DYNAMIC_MEMORY_SYSTEM_DESIGN.md b/agentic-organization/docs/DYNAMIC_MEMORY_SYSTEM_DESIGN.md new file mode 100644 index 0000000000..40c82bc3f5 --- /dev/null +++ b/agentic-organization/docs/DYNAMIC_MEMORY_SYSTEM_DESIGN.md @@ -0,0 +1,971 @@ +--- +title: Dynamic Memory System Design +canonical_name: Agentic Organization +status: design +--- + +# Dynamic Memory System Design + +How the organization gives **hats and agents durable, weighted, self-maintaining +memory** — and how the **Memory & Knowledge department** (the "IT department for +memory") maintains it on a daily cadence: scoring every memory by *how likely it +should be to surface*, decaying old ones, correlating each against the KPIs it +actually produced, promoting the winners, demoting the losers, and archiving the +ones whose weight has fallen to zero so they never surface again. + +This document expands two existing docs with a concrete mechanism: + +- [`AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md`](AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md) + already says hats have a "memory maintenance" obligation — "stabilize useful + memories, deprecate stale memories, correct invalid memories, scope memories + by hat/project/task." This doc makes that obligation *executable*. +- [`CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md`](CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md) + says an "Organization memory adapter injects active context and policy scope." + This doc defines *what* gets injected, *how it is ranked*, and *who keeps it healthy*. + +## Provenance — what we are taking, and what we are not + +The mechanism below is adapted from the memory subsystem of an unrelated design +("TPM-REFACTOR"). **We take the memory *idea* and the *maintenance idea*. We do +not take its architecture.** + +| Taken (the idea) | Rejected (their stack / architecture) | +|------------------|----------------------------------------| +| Memory = content + **mutable weight-state**, joined by a stable id | Their RaaS / Weaviate / Elasticsearch / FalkorDB / Mongo five-store split | +| **Tiered scoping** of memory | Their `turn/thread/engagement/persona` tier names | +| A composite **retrieval weight** (freshness × confidence × outcome × utility …) | Their specific weights, schema files, MCP tool surface | +| **Freshness decay** with an archive floor | Their RaaS chunk soft-delete + reconciliation jobs | +| **Outcome (KPI) correlation** and **utility self-tuning** | Their TPM persona/reviewer/gate architecture | +| A **daily analyst** that proposes promote/demote/conflict candidates | Their `observer-analyst` / `observer-promoter` persona files and replay harness | +| **Protected** memories that cannot be auto-overwritten | Their GitLab-MR promotion path | + +Everything below is re-expressed in **our** primitives: CockroachDB + NATS + +in-cluster Ollama, native TypeScript (`--experimental-strip-types`), the +`observe → decide` kernel, the universal `org_event` trace, and the hat + +department org we already built and proved in kind. + +--- + +## 1. Thesis + +> Memory is a **weighted, decaying, KPI-correlated substrate**. Determinism +> computes each memory's *weight* (how likely it should surface) and the *legal +> set* of maintenance actions; agents drive the *outcomes* (which memories to +> actually cite, and — for risky lifecycle moves — which candidate to approve). +> The **Memory & Knowledge department** runs a daily maintenance cycle that is +> itself an org cycle: every recompute, promotion, demotion, and archive emits +> one durable `org_event`, so the whole memory economy is crystal-clear. + +This is the same "**enough determinism, agents drive outcomes**" tenet that +governs the rest of the org — applied to memory. + +--- + +## 2. Tier ladder — memory scoped to the org, and the hat ⊕ agent combination + +Memory is scoped to **where in the organization it belongs**. The ladder mirrors +our actual hierarchy (`org → department → hat → agent`) plus one cross-cutting +**work/workflow** tier for memory that belongs to a unit of work rather than to a +role. + +| Tier | `scope` value | Belongs to | Lifetime | Example | +|------|---------------|------------|----------|---------| +| `org` | `org-lfg` | The whole company | indefinite (decays) | "All public-facing copy goes through Legal review." | +| `department` | a `DepartmentId` | A department | indefinite (decays) | "Engineering never merges on a red runtime gate." | +| `hat` | a `HatDefinition.id` | **A hat** — inherited by *whoever currently wears it* | indefinite (decays) | `code_reviewer` → "Require a rollback plan before approving DB migrations." | +| `agent` | an `agentId` | **A specific actor/agent** | indefinite (decays) | `agent-7` → "I tend to under-estimate test effort; pad QA by 20%." | +| `work` | a `workItemId` | A unit of work / workflow run | decays fast (default 30d) | `work-…` → "This epic reserves Redis for sessions only (RFC-882)." | + +### 2.1 The hat ⊕ agent ⊕ work combination (the requested behavior) + +> *"certain hats should have memories assigned to them but also specific memories +> assigned to each actor/agent that can combine with the hat memory."* + +When **agent `A` wears hat `H` on work item `W`**, retrieval pulls the **union** +of everything in scope and ranks it by weight: + +``` +retrieval scope for binding (A wears H on W) = + org(org-lfg) + ⊕ department(H.departmentId) + ⊕ hat(H.id) ← the role's accumulated wisdom + ⊕ agent(A) ← this specific actor's personal memory + ⊕ work(W) ← this workflow's local memory +``` + +- **Hat memory** is the role's institutional knowledge: it persists across + *wearers*, so a freshly-bound agent inherits the hat's hard-won lessons the + moment the binding activates. This is the natural fit for our hat lifecycle — + the binding is the carrier, the hat-tier memory is the cargo. +- **Agent memory** is the actor's personal layer: calibration, tendencies, + preferences. It travels with the agent across *every hat they wear*. +- **Combination at write time:** when agent-tier and hat-tier carry the *same + key* with different values, the agent layer is treated as a **personal + override/augmentation** of the hat layer for that agent only (a small + `personalScope` boost at retrieval), without mutating the hat memory other + wearers see. + +This combination is computed *deterministically* (it's a pure scope union + +weight sort); the agent only chooses which of the surfaced memories to actually +use. + +--- + +## 3. The memory record — content vs. weight-state + +A single logical **MemoryRecord** is split into two parts, because they change at +different rates (a Data-Vault-style hub/satellite split — the same +change-rate-partition discipline used elsewhere in the repo): + +```ts +// CONTENT — written rarely, embedded for semantic search (the "hub") +type MemoryContent = { + memoryId: string; // UUID v5 from `org:tier:scope:key` — stable join key + tier: MemoryTier; // org | department | hat | agent | work (House DU) + scope: string; // org-lfg | DepartmentId | hatId | agentId | workItemId + key: string; // stable slug, e.g. "review:require-rollback-plan" + value: string; // the memory text itself + contextHint?: string; // why this exists; enriches the embedding + protected: boolean; // cannot be auto-overwritten / auto-demoted + writtenBy: string; // hatId | agentId | "memory_curator" | "human" + writtenAt: string; // ISO + // NOTE: embeddings + semantic search live in HINDSIGHT (§13), NOT in Cockroach. + // Cockroach holds content/state only; the Cockroach-only adapter is weight-only + // (no semantic term). Hindsight owns vector/BM25/graph/temporal recall. +}; + +// STATE — updated continuously (the "satellite"); drives the weight +type MemoryState = { + memoryId: string; + confidence: number; // 0..1 — write-time guess, refined by outcomes + freshnessAt: string; // last-confirmed; freshness decays from here + reinforcementCount: number; + + outcome: { // KPI correlation + successCount: number; // times this memory was in scope during a SUCCESS + failureCount: number; // …during a FAILURE + inconclusiveCount: number; + lastOutcomeAt: string | null; + workItemsObserved: readonly string[]; // FIFO-capped, for dedup + }; + + utility: { // self-tuning: was it actually used? + injectedCount: number; // times retrieved/injected into a prompt flow + citedCount: number; // times the agent actually cited it + lastInjectedAt: string | null; + }; + + crossScopeObservations: { // promotion signal (work→hat, hat→department, …) + distinctScopes: readonly string[]; // FIFO-capped + firstObservedAt: string; + lastObservedAt: string; + }; + + phase: MemoryPhase; // lifecycle DU (§5) + weight: number; // 0..1 — last computed retrieval weight (cached) + archivedAt?: string; +}; +``` + +Content lives in one Cockroach table; state in another, joined by `memoryId` +(§8). The split exists so the daily maintenance cycle can do cheap, atomic +`UPDATE … SET success_count = success_count + 1` on state without touching the +embedded content. + +--- + +## 4. Retrieval weight — "how likely it is to surface; zero means never again" + +This is the heart of the request. The **weight** is a deterministic, pure +function of a memory's state — *how likely it should be to surface*. It is +computed at retrieval time and also recomputed nightly (cached on the state row). + +```ts +function computeMemoryWeight(s: MemoryState, ctx: RetrievalCtx): number { + const freshness = computeFreshness(s, ctx.now); // 1 → 0 as it ages (§4.1) + const confidence = s.confidence; // 0..1 + const outcome = outcomeRatio(s); // success/(success+failure), 0.5 until ≥3 samples + const utility = utilityRatio(s); // cited/injected, 0.5 until ≥5 injections + const semantic = ctx.semanticScore ?? 0.5; // optional Ollama cosine; 0.5 if disabled + + const base = + 0.30 * semantic + + 0.20 * freshness + + 0.15 * confidence + + 0.20 * outcome // KPI is weighted heavily — this is the point + + 0.15 * utility; + + // additive scope boosts (capped) + const hatScope = (s.tier === "hat" && s.scope === ctx.hatId) ? 0.05 : 0; + const personalScope = (s.tier === "agent" && s.scope === ctx.agentId) ? 0.05 : 0; + const workLocal = (s.tier === "work" && s.scope === ctx.workItemId) ? 0.05 : 0; + + return clamp01(base + hatScope + personalScope + workLocal); +} +``` + +### 4.1 Decay and the zero floor + +Freshness decays linearly from `freshnessAt` per a per-tier half-life; below an +**archive floor** the memory is moved to `phase: Archived` and **excluded from +every future retrieval** — it has dropped to zero and will never surface again +(exactly the requested behavior). + +```ts +function computeFreshness(s: MemoryState, now: number): number { + const halfLifeDays = HALF_LIFE[tierOf(s)]; // work:30, hat/agent:120, dept:180, org:365 + const elapsedDays = (now - Date.parse(s.freshnessAt)) / 86_400_000; + return Math.max(0, 1 - elapsedDays / (2 * halfLifeDays)); +} +``` + +| Tier | half-life | default-read floor | archive floor (→ never surfaces) | +|------|-----------|--------------------|----------------------------------| +| `work` | 30d | 0.35 | 0.15 | +| `hat` / `agent` | 120d | 0.30 | 0.15 | +| `department` | 180d | 0.30 | 0.15 | +| `org` | 365d | 0.25 | 0.10 | + +A memory **also** drops toward zero — independent of age — when its **KPI +correlation goes bad** (outcome ratio collapses) or its **utility goes to zero** +(injected many times, never cited). Both feed the weight; a memory that is +fresh but useless still sinks. Reinforcement (being confirmed again) resets +`freshnessAt` and lifts it back up. + +### 4.2 Self-tuning (utility) + +```ts +const outcomeRatio = (s) => { const t = s.outcome.successCount + s.outcome.failureCount; + return t < 3 ? 0.5 : s.outcome.successCount / t; }; // neutral until enough KPI signal +const utilityRatio = (s) => s.utility.injectedCount < 5 ? 0.5 + : Math.min(1, s.utility.citedCount / s.utility.injectedCount); // injected-but-never-cited → sinks +``` + +A memory retrieved 20 times and cited 0 times has `utilityRatio → 0`; its weight +falls; it surfaces less; eventually it falls out of the budget entirely and then +under the archive floor. **This is the continuous-learning dimension** — memories +compete for inclusion on *empirical usefulness*, not their initial confidence guess. + +### 4.3 Determinism ⇄ autonomy at retrieval (and prompt-flow injection) + +- **Deterministic** computes the candidate set (scope union, not archived, above + the read floor) and the **weight-ranked order**, then greedily packs the top-N + into the prompt-flow's memory budget. The agent **cannot widen** this — it only + sees what the rules surfaced. +- **The agent chooses within it**: which surfaced memories to actually rely on + and cite. Citations are recorded (drives `utility`), and an + anti-citation-laundering check rejects a cited `memoryId` that was not actually + injected this turn (the same clamp discipline as the rest of the kernel — an + agent can't fabricate a grounding). + +This plugs straight into the **prompt flows** +([`AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md`](AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md)): +a `## Relevant memory` block is composed at prompt-construction time for the +active binding, rendered as `hat-tier` then `agent-tier` then `work-tier`, each +line annotated with its live weight + KPI record so it is auditable. + +--- + +## 5. Memory lifecycle (House-DU, mirrors `HatBinding`) + +A memory's `phase` is a discriminated union with explicit terminal states — +authored in the same style as our `HatBindingPhase` so the maintenance cycle can +fold over it deterministically. + +``` +Draft ─▶ Active ─▶ Reinforced ─▶ Active ─▶ Stale ─▶ Archived(terminal) + ╲ ╲▶ Demoted ─▶ Archived + ╲▶ Promoted (new record at higher tier; source kept) + any Active/Stale ─▶ Conflicted ─▶ (resolution decision) ─▶ Active | Archived +``` + +| Phase | Meaning | Reached by | +|-------|---------|-----------| +| `Draft` | written, not yet weight-eligible | a write | +| `Active` | in the retrieval pool | first weight computation | +| `Reinforced` | confirmed again (resets freshness, lifts confidence) | conflict-resolution `reinforce` | +| `Stale` | weight under the read floor (still recoverable) | nightly decay | +| `Demoted` | KPI/outcome judged bad by a hat decision | `memory_demotion` decision | +| `Promoted` | a higher-tier copy was created from it (source preserved) | `memory_promotion` decision | +| `Conflicted` | same key, divergent value, similar confidence | write conflict | +| `Archived` | **terminal** — weight ≤ archive floor; never surfaces again | nightly archive OR demotion | + +`TerminalMemoryPhases = { Archived }`. Conflict resolution is the same +reinforce / overwrite / flag-as-alternative / reject decision the TPM idea uses, +re-expressed against our store. + +--- + +## 6. KPI / outcome correlation — wired to *our* trace, not theirs + +The TPM idea correlates memory to "manifest outcomes." We already emit a richer +signal: the universal `org_event` stream + the pipeline. **A work item that +reaches `merged` is a success; one that stalls or takes a recovery path is a +failure.** So the correlation reads our own events — no new outcome system. + +```ts +// when a work item's pipeline finalizes (we already emit pipeline_stage_transition): +async function correlateOutcome(workItemId: string, verdict: "success"|"failure"|"inconclusive") { + // every memory injected during any binding that touched this work item + const memoryIds = await listMemoriesInjectedFor(workItemId); // from injection ledger + for (const memoryId of memoryIds) { + await memoryState.bumpOutcome(memoryId, verdict, workItemId); // dedup on workItemId + } +} +``` + +- **`merged`** (all 7 gates passed) → `success` for every memory that was in + scope during that work item. +- **gate `Rejected` / recovery path / stall** → `failure`. +- Confidence is then recomputed from the success/failure ratio (asymmetric: + *reinforcement auto-applies, demotion routes to a hat*, §7). + +This is the answer to *"identify which memories are producing best KPIs and which +are not."* The KPI is the org's own outcome signal, already in `org_events`. + +--- + +## 7. The Memory & Knowledge department — the daily maintenance cycle + +> *"said IT department will run daily … automated and also some manual (from IT +> dept) heuristic on each memory."* + +The "IT department" is the **`memory_and_knowledge`** department — **it already +exists in the seed**, reporting to the COO, with these hats: + +| Hat | Level | Owns (in the memory economy) | +|-----|-------|------------------------------| +| `memory_director` | Director | **memory policy**; final authority on risky lifecycle decisions; votes on memory hat-supply | +| `memory_manager` | Manager | **memory adaptation** — tunes weights/decay/floors; routes candidates to reviewers | +| `memory_curator` | IC | authors + corrects memory content; applies approved promotions | +| `memory_reviewer` | IC | the **manual heuristic** — reviews demotion / promotion / conflict candidates | +| `knowledge_router` | IC | decides which memory is scoped to which hat/agent/work | +| `project_context_librarian` | IC | owns **work-tier** (workflow) memory | + +### 7.1 The cycle is an org cycle (observe → decide, fully traced) + +`runMemoryMaintenanceCycle` is structured exactly like `runOrgCycle` — +determinism computes the legal action set per memory; the appropriate hat chooses +within it; every action emits one `org_event`. + +**What fires it (the trigger).** The cycle is **NATS-scheduled on a durable +subject** (`org.memory.maintenance.tick`), drained by the worker that already runs +the org cycles — the same always-on lane that drives the keep-alive/heartbeat loop +(`apps/workers/src/main.ts`). A daily tick is published deterministically (a +scheduled publisher; cadence is config, default 24h). "Daily" is the default +cadence, **not** a hard requirement — because every action is idempotent +(content-addressed writes §12.3) and deterministic, the cycle is **safe to run +more often or to re-run after a crash**: re-processing the same memories produces +the same decisions. The trigger therefore needs no exactly-once guarantee — at- +least-once on the NATS subject is sufficient. This mirrors how the org cycle and +heartbeat already run; no new scheduling primitive is introduced. + +**Stage A — automated (no hat decision; safe, reversible):** + +1. **Decay pass** — recompute `freshness` and `weight` for every active memory. + Emit `memory_weight_recomputed` per memory whose weight band changed. +2. **Archive-at-zero** — any memory whose weight ≤ archive floor → + `phase: Archived`. Emit `memory_lifecycle_transition` (`Stale|Active → Archived`). + *It will never surface again.* +3. **Confidence reinforcement** — recompute confidence from KPI ratio; if it + *rose*, auto-apply (low risk) and emit `memory_weight_recomputed`. + +**Stage B — manual heuristic (routed through a hat's `chooseWithinLegal`):** + +1. **Demotion candidates** — memories with `failureCount ≥ 3` and failure ratio + ≥ 0.6 are *flagged*, not auto-demoted (a true memory present during unrelated + failures must not be punished blindly). The legal set is + `{demote, keep, request-evidence}`; `memory_reviewer` chooses; if it picks + `demote`, `memory_manager`/`memory_director` confirms per risk. Emits + `memory_demotion_decision`. +2. **Promotion candidates** — memories reinforced across **≥3 distinct scopes** + (e.g. the same lesson surfaced under three different work items) are promotion + candidates from `work → hat`, or `hat → department`. `knowledge_router` + proposes the target scope; `memory_director` approves; `memory_curator` writes + the higher-tier copy and **keeps the source** (a `derived_from` link). Emits + `memory_promotion_decision`. +3. **Conflict resolution** — `Conflicted` memories surface to `memory_reviewer` + with legal set `{keep-A, keep-B, keep-both-as-alternatives}`. Emits + `memory_conflict_decision`. + +**Asymmetry (the safety property):** *good news auto-applies, bad news asks a +hat.* Reinforcement, decay, and archive-at-zero are automatic; demotion, +promotion, and conflict resolution route through a hat decision — because they +are harder to reverse and benefit from judgment. That judgment is the agent +"manual heuristic," and it is still **clamped to the legal set** by the kernel. + +### 7.2 Protected memories + +`protected: true` memories (org identity, RFC-binding conventions, operator- +curated rules) are excluded from auto-overwrite, auto-demotion, and confidence +decay. They can only change via an explicit human/`memory_director` action — the +same protected-fact discipline as the source idea, enforced at the write layer. + +### 7.3 `reflect` — insight generation that produces higher-tier memory + +The `Memory` port's third operation, `reflect`, is **not deferred** — it has a +concrete home, and Hindsight implements it directly (`POST /banks/{id}/reflect` → +disposition-aware insights / "mental models"). In our system `reflect` is *the +operation that turns many low-tier memories into one durable higher-tier memory*: + +- **Where it runs (the agent side):** at the **reflection step of the work rhythm** + ([`AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md`](AGENT_WORK_RHYTHM_AND_PROMPT_FLOWS.md) + already schedules "memory reflection" time per hat). At reflection, the kernel + calls `reflect(scope)` for the binding; the returned insight is offered as a + `memoryCandidate` (subject to the same write policy + protected rules). +- **Where it runs (the maintenance side):** `reflect` is how **promotion** (§7.1 + Stage B) materializes — when `knowledge_router` proposes promoting a lesson + reinforced across ≥3 scopes, the higher-tier memory it writes is a *reflected + insight* (an explicit summary with `derived_from` links to its sources), not a + raw copy. A reflected `work`-tier cluster becomes a `hat`-tier "mental model." +- **Determinism boundary:** `reflect`'s *output is model-generated* (it summarizes), + so it is never auto-applied — it always enters as a candidate routed through a + hat's `chooseWithinLegal` (a `memory_reviewer`/`memory_director` decision), + emitting `memory_promotion_decision`. Reflection produces *proposals*; hats + decide; the kernel clamps; the trace records. + +So the three port operations partition cleanly: **`retain`** writes a fact, +**`recall`** surfaces facts (weight-ranked), **`reflect`** distills facts into a +higher-tier memory through a hat decision. + +--- + +## 8. Storage — CockroachDB, mirroring `OrgSystemV15` + +Two new tables, same conventions as `agentic_org_org_events` / `…hat_bindings` +(JSONB for nested state, parameterized SQL with explicit `::JSONB` casts, an +on-disk migration mirror with a TS↔disk parity test): + +```sql +-- content (the hub) +CREATE TABLE IF NOT EXISTS agentic_org_memory_records ( + memory_id UUID PRIMARY KEY, + organization_id STRING NOT NULL, + tier STRING NOT NULL, -- org|department|hat|agent|work + scope STRING NOT NULL, + key STRING NOT NULL, + value STRING NOT NULL, + context_hint STRING, + protected BOOL NOT NULL DEFAULT false, + written_by STRING NOT NULL, + written_at TIMESTAMPTZ NOT NULL, + embedding JSONB, -- optional float[]; nomic-embed-text + UNIQUE (organization_id, tier, scope, key) -- conflict detection +); +CREATE INDEX IF NOT EXISTS idx_mem_scope ON agentic_org_memory_records (organization_id, tier, scope); + +-- state (the satellite) — atomically updatable weight signals +CREATE TABLE IF NOT EXISTS agentic_org_memory_state ( + memory_id UUID PRIMARY KEY REFERENCES agentic_org_memory_records (memory_id), + organization_id STRING NOT NULL, + phase STRING NOT NULL, -- MemoryPhase DU + confidence FLOAT8 NOT NULL, + freshness_at TIMESTAMPTZ NOT NULL, + weight FLOAT8 NOT NULL, + reinforcement_count INT8 NOT NULL DEFAULT 0, + outcome JSONB NOT NULL, -- success/failure/… counts + utility JSONB NOT NULL, -- injected/cited counts + cross_scope JSONB NOT NULL, + archived_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_mem_state_demote ON agentic_org_memory_state (organization_id, phase); +CREATE INDEX IF NOT EXISTS idx_mem_state_weight ON agentic_org_memory_state (organization_id, weight DESC); +``` + +### 8.1 Injection ledger (load-bearing for KPI §6, utility §4.2, must-address §12.5) + +Every time a memory is injected into a turn we record one ledger row. This is the +join that lets the maintenance cycle answer "which memories were in scope during +this work item's success/failure" (§6) and "injected vs. cited" (§4.2), and lets +the must-address gate (§12.5) know what the agent was shown. + +```sql +CREATE TABLE IF NOT EXISTS agentic_org_memory_injection ( + injection_id UUID PRIMARY KEY, + organization_id STRING NOT NULL, + memory_id UUID NOT NULL, -- the memory that was injected + work_item_id STRING NOT NULL, -- KPI correlation key (§6) + hat_id STRING NOT NULL, -- the wearing hat at injection + agent_id STRING NOT NULL, -- the actor + prompt_flow_run_id STRING NOT NULL, -- the turn + weight_at_injection FLOAT8 NOT NULL, -- weight when surfaced (for must-address §12.5) + cited BOOL NOT NULL DEFAULT false, -- set true when the agent cites it (§4.2 utility) + injected_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mem_inj_work ON agentic_org_memory_injection (organization_id, work_item_id); +CREATE INDEX IF NOT EXISTS idx_mem_inj_memory ON agentic_org_memory_injection (organization_id, memory_id); +CREATE INDEX IF NOT EXISTS idx_mem_inj_run ON agentic_org_memory_injection (prompt_flow_run_id); +``` + +`citedCount`/`injectedCount` in `MemoryState.utility` (§3) are derivable from this +table (`COUNT(*)` and `COUNT(*) FILTER (WHERE cited)` per `memory_id`); we keep +the cached counters on the state row for ranking speed and treat the ledger as the +source of truth. Every maintenance action is *also* an `org_event`, so the +existing org-snapshot fold gains a memory view for free. + +### 8.2 Semantic search is Hindsight's job — Cockroach is weight-only + +There is **no embedding column and no cosine in Cockroach.** Semantic / vector / +BM25 / graph / temporal recall is **entirely Hindsight's** (§13); Hindsight stores +the embeddings in its own `pgvector` Postgres. Our CockroachDB holds only content +metadata (optional, §8.3), **state** (weight signals), the injection ledger, and +the `org_event` trace. + +This resolves cleanly into two adapters behind the one `Memory` port: + +| Adapter | Recall ranking | When | +|---------|----------------|------| +| **Hindsight adapter** (`createHindsightMemory`, §13) | Hindsight semantic recall → **re-ranked by our §4 weight** | normal operation | +| **Cockroach/in-process adapter** (degraded) | **weight-only** (`semantic = 0.5`); no embeddings | Hindsight unavailable, or tests | + +So `semantic` in the §4 weight formula is supplied by Hindsight when present and is +the neutral `0.5` constant otherwise — the graceful-degradation posture, with the +heavy lifting delegated to a system built for it. No embedding model, no vector +index, and no `pgvector`-on-Cockroach problem to solve on our side. + +### 8.3 Reconciliation with the existing `agentic_org_hindsight_memory` (V13) table + +The repo already ships `agentic_org_hindsight_memory` (migration V13) — a simple +content store with exactly our attribution columns (`agent_id, hat_assignment_id, +project_id, work_item_id, prompt_flow_run_id, content, retained_at`) behind the +in-cluster Cockroach `Memory` adapter. Under this design: + +- **Content moves to Hindsight** (the real recall engine, §13). The `memoryId` + returned by Hindsight's `retain` becomes our canonical id. +- **V13 is retained as the degraded-mode / test content store**, not deleted — it + *is* the Cockroach weight-only adapter's content table (§8.2). When Hindsight is + unavailable, recall falls back to V13 content + our weight ranking. +- **The new `agentic_org_memory_records` table (§8) is optional**: if Hindsight + holds content, our Cockroach can hold *only* `MemoryState` + the injection ledger + + `org_events`, with `MemoryState.memoryId` referencing the Hindsight id (or the + V13 `memory_id` in fallback). We do **not** duplicate content across both. + +Net: no schema is thrown away; V13 becomes the fallback content store; the new +tables are the **state + ledger + trace** satellite; Hindsight is the content + +recall hub. + +### 8.4 New `OrgEventKind`s + +`MemoryLifecycleTransition`, `MemoryWeightRecomputed`, `MemoryPromotionDecision`, +`MemoryDemotionDecision`, `MemoryConflictDecision`, `MemoryInjection`, +`MemoryCitation` — added to the existing `OrgEvent` union so memory is traced on +the same substrate as everything else. + +--- + +## 9. Determinism ⇄ autonomy split (the memory column) + +| Concern | Deterministic (the legal set / the math) | Agent drives (within the legal set) | +|---------|------------------------------------------|-------------------------------------| +| Retrieval | scope union, decay, archive floor, **weight rank**, budget packing | which surfaced memories to use / cite | +| Reinforcement / decay / archive-at-zero | the whole computation; auto-applied | — (no decision needed) | +| Demotion | candidate detection + legal `{demote,keep,request-evidence}` | `memory_reviewer` picks; clamped | +| Promotion | candidate detection + legal target scopes | `knowledge_router`/`memory_director` pick; clamped | +| Conflict | detection + legal `{keep-A,keep-B,both}` | `memory_reviewer` picks; clamped | +| Weight/decay tuning | bounds on the policy knobs | `memory_manager` adapts within bounds | + +The agent can never make a memory surface that the rules archived, never +fabricate a citation, never widen a promotion beyond a legal target scope. It +only chooses inside the lines — and every choice is one `org_event`. + +--- + +## 10. Phased build plan (mirrors P0–P7; each phase proved in kind) + +| Phase | Deliverable | +|-------|-------------| +| **M0** | This doc + the `OrgEventKind` additions + the two Cockroach tables (migration `MemorySystemV16` + on-disk mirror + parity test). | +| **M1** | `MemoryRecord`/`MemoryState`/`MemoryPhase` domain types (House-DU) + `memory_and_knowledge` hats wired with `B.Memory` scopes (already seeded — assert coverage). | +| **M2** | Write path + conflict resolution + protected-memory enforcement; Cockroach stores with parity tests. | +| **M3** | `computeMemoryWeight` + decay + retrieval (scope union, rank, budget pack) — pure, unit-tested; the hat ⊕ agent ⊕ work combination. | +| **M4** | Injection ledger + prompt-flow `## Relevant memory` block + anti-citation-laundering check + utility update. | +| **M5** | Outcome correlation from `pipeline_stage_transition` (merged=success) → confidence recompute. | +| **M6** | `runMemoryMaintenanceCycle` as an org cycle: Stage A automated, Stage B hat-decided; every action an `org_event`. | +| **M7** | **Deploy + observe in kind**: seed memories at hat/agent/work scope, run a work item to `merged`, run the daily cycle, and observe — via the org snapshot — memories reinforced (good KPI), demoted (bad KPI by a `memory_reviewer` decision), promoted (work→hat), and one archived at zero that no longer surfaces. The same end-to-end proof bar we held for the org system. | +| **M8** | **Reliability harness (§12):** mandatory pre-turn injection + required `memoryCandidates` output field + deterministic system extraction from `org_events` + content-addressed idempotent writes + the bidirectional gates + the per-hat `MemoryContract`. Retrieval/storage become kernel invariants, not agent tools. | +| **H1–H4** | **Hindsight seam (§13.6):** stand up Hindsight in kind, the `createHindsightMemory()` adapter behind the port, the §13.3 recall→re-rank composition, and an observed end-to-end run. Can be adopted any time after M2 (the port already exists); the M-track and H-track are independent. | + +--- + +## 11. Worked example (what the trace will show) + +``` +seed: hat(code_reviewer) "review:require-rollback-plan" conf 0.7 + agent(agent-7) "calibration:pad-qa-20pct" conf 0.7 + work(work-42) "rfc-882:redis-sessions-only" conf 0.9 protected + +run work-42 → merged (success) + → memory_injection events: both review + rfc-882 injected for the reviewer binding + → memory_citation: reviewer cited "review:require-rollback-plan" + → correlateOutcome(work-42, success): success++ on injected memories + +daily memory cycle: + [memory_weight_recomputed] review:require-rollback-plan 0.71 → 0.78 (KPI up, cited) + [memory_weight_recomputed] calibration:pad-qa-20pct decayed, never cited → 0.41 + [memory_promotion_decision] knowledge_router proposes work→hat for rfc-882-pattern; + memory_director approves; memory_curator writes hat copy + [memory_lifecycle_transition] an old work(work-09) note 0.12 → Archived (never surfaces again) +``` + +Every line above is an `org_event` with `actorHatId`, `supervisorChain`, +`decision`, and `correlation/causation/trace` — readable in the same snapshot +view as the rest of the org. + +--- + +## 12. Retrieval & storage reliability — remembering is **structural, not behavioral** + +The thing that kills agent-memory systems is never the ranking math. It is that +agents **forget to call retrieve** and **forget to call store**. The repo already +names this exact failure class — the **goldfish-ontology principle** +(`.claude/rules/claude-code-loading-taxonomy.md`): *a discipline with a +recognition-failure component is forgotten exactly when it is needed.* A tool the +agent invokes "when it remembers it needs memory" is useless precisely once the +agent has already lost the thread. + +**The fix is one reframe:** retrieval and storage are **not agent actions** — they +are **invariants of the prompt flow, computed by the `observe → decide` kernel.** +The agent never decides *whether* they run; it only influences their *content*, +inside a wrapper the harness guarantees. This is the same move the org system +makes for gates: the agent cannot pick an illegal gate, and here it cannot skip a +retrieve or a store — by construction, not by discipline. + +### 12.1 Never forget to *retrieve* — retrieval is a mandatory pre-turn step + +At `beforePromptConstruction` for the active binding, the kernel **always** runs +retrieval (Mode 1, passive injection). There is no "agent decides to retrieve." +The agent *may additionally* run mid-turn active retrieval (Mode 2), but it can +never *skip* Mode 1. The query text is a **pure function** of +`(taskMeta, last-K turns, role sentence)` — no model call decides what to search +for — so it is reproducible and cacheable by hash. Result: *forgetting to +retrieve is structurally impossible* — the memory is in the prompt before the +agent reasons. + +### 12.2 Never forget to *store* — three independent sources, so no lesson is lost + +Storage runs at turn-close (`afterEmit`), **always**, from three sources: + +1. **Required output field (agent-driven, schema-enforced).** The prompt flow's + output schema carries a mandatory `memoryCandidates: MemoryCandidate[]` field. + "Remember to call `memory.write`" becomes "fill a required field," and the gate + flags a turn that produced a substantive claim but emitted `[]` with no + justification. A forgettable behavior becomes a non-optional schema obligation. +2. **Deterministic system extraction (no agent — the backstop).** Many lessons are + *structurally evident from the turn's own `org_events`* and need no agent + judgment: a tool-result contradicting a cited memory, a gate `Rejected`, a HITL + correction, a work item reaching `merged`/stalling. The kernel extracts these + deterministically and writes them `writtenBy: "system"`. **Even if the agent + emits `[]`, the system still captures what is structurally obvious.** +3. **Reinforcement-by-citation (free).** Citing a memory in a turn that succeeds is + a deterministic `$inc` reinforce — not a new write. + +### 12.3 The algorithmic key: content-addressed memory makes "store every turn" safe + +Running extraction every turn only works if it is idempotent. The enabler is the +stable key already defined in §3: + +``` +memoryId = uuidv5(`${org}:${tier}:${scope}:${key}`) +``` + +Writing the same `(tier, scope, key)` is **not a duplicate** — it is the +deterministic conflict resolution of §5 (`reinforce | overwrite | flag-alternative +| reject`). So we store **aggressively, every turn**, and the content-addressed key +collapses repeats into reinforcement in O(1). The agent never has to *decide* "is +this new?" — the key decides. + +> This inverts the usual design. Most systems do *store-selectively + dedup-later* +> and lose things because "selectively" is a judgment the agent flubs. +> Content-addressing lets us do *store-everything + idempotent-merge*: cheap, +> lossless, and the "is it worth keeping?" question is answered **later, +> deterministically, by the weight/decay engine** (§4) — not in the hot path by a +> forgetful agent. + +### 12.4 Retrieving *well* and cheaply — two-stage, two-modality, dedup-aware + +- **Stage 1 — deterministic pre-filter (~5ms, zero model calls):** scope union + (`hat ⊕ agent ⊕ work`) `AND phase != Archived AND weight ≥ floor` + a cheap + trigram/keyword prefilter. 10k → ~200 candidates. Pure, reproducible. +- **Stage 2 — score the ~200 only:** the §4 weight, plus an *optional* semantic + cosine computed **only over the candidate set**. `O(candidates)`, never + `O(all)`. +- **Two modalities, union'd:** semantic similarity gives *recall*; **deterministic + structural triggers** give the *precision* similarity misses — a hat's contract + declares IF-THEN rules (*"if a tool-result touches `services/billing/*`, pull + that path's risk memories"*). These are rules, not judgment, so they fire + reliably. +- **Cross-turn dedup set:** track the per-session injected `memoryId` set; do not + re-inject the same memory every turn unless its weight materially changed — + saves budget, kills repetition. +- **Caches:** query-embedding LRU keyed on query-hash; `weight` cached on the state + row (recomputed nightly + lazily on read) to avoid recompute storms. + +### 12.5 Make *using* memory correctly deterministic — bidirectional gates + +Two gate checks make both failure directions costly, so retrieval quality is +*enforced*, not hoped: + +- **Anti-laundering** — a cited `memoryId` not injected this turn → fabrication → + block (already in §4.3). +- **Must-address** — a *high-weight* memory was surfaced and the output + contradicts it with no explanation → negligence → flag. The reviewer/gate sees + the full ranked list including `relevant-but-unused`, so *ignoring* memory is as + costly as fabricating it. + +### 12.6 Crash-safe storage — decouple the write path via NATS + +The turn emits its storage candidates to a **durable NATS subject** and returns +immediately; the `memory_and_knowledge` department's deterministic writer drains +the queue. Storing never blocks the agent turn and survives a crash mid-write (the +candidate is already durable in the stream). Hot path (agent) and write path (IT +dept) are decoupled — the same automated-maintenance lane as §7. + +### 12.7 The `MemoryContract` — where the determinism lives, per hat / per flow + +Each hat's prompt flow declares a machine-checkable contract; the agent fills +content *inside* it, never around it: + +```ts +type MemoryContract = { + retrieve: { + tiers: readonly MemoryTier[]; // which scopes to pull (default: all in §2) + budgetTokens: number; maxItems: number; // packing bounds + readFloor: number; // weight floor for surfacing + structuralTriggers: readonly Trigger[]; // deterministic IF-THEN retrieval rules + }; + store: { + expectedKeyNamespaces: readonly string[]; // e.g. code_reviewer → ["review:*"] + requireCandidatesField: true; // schema obligation + }; + requiredProvenance: readonly ("memory-fact" | "tool-result")[]; +}; +``` + +The gate can now check role-appropriate discipline deterministically — e.g. *"a +`code_reviewer` produced a review decision but never touched its `review:*` store"* +— turning "did the agent maintain its memory?" into a contract assertion. + +### 12.8 Reliability summary — what makes each guarantee deterministic + +| Guarantee | Mechanism (why it's deterministic) | +|-----------|------------------------------------| +| Never forget to retrieve | a harness step in `observe`, not an agent tool call | +| Query is reproducible | pure function of `(task, last-K turns, role)` | +| Candidate set + rank | pure SQL pre-filter + pure weight function | +| Never forget to store | required output field **+** system extraction over `org_events` | +| Idempotent storage | content-addressed `uuidv5` key → merge, not duplicate | +| Precision retrieval | IF-THEN structural triggers in the contract, not judgment | +| Used correctly | bidirectional gate (injected ↔ cited ↔ contradicted) | +| Crash-safe | candidates durable on NATS before the turn returns | + +The agent's only freedom is **content** — the query phrasing and what is worth +remembering. **Occurrence, idempotency, ranking, decay, and gating are all the +kernel's, computed the same way every time.** + +--- + +## 13. Working with Hindsight (`vectorize-io/hindsight`) + +Hindsight ([github.com/vectorize-io/hindsight](https://github.com/vectorize-io/hindsight), +**MIT**) is an external, open-source **agent memory engine**: biomimetic memory +(world facts, experiences, mental models), LLM-powered extraction, and **parallel +recall** across vector + keyword (BM25) + graph + temporal strategies, with +cross-encoder reranking and reciprocal-rank fusion. Its core API is +**`Retain / Recall / Reflect`**, exposed as an **HTTP REST API + generated SDKs** +(Python, Node/TS, Go, CLI) and an **MCP server**. It runs as a **single Docker +container** (with an embedded Postgres by default) over **PostgreSQL + pgvector**, +and uses any OpenAI-compatible LLM — including **Ollama** — for extraction. + +We do **not** adopt Hindsight wholesale and we do **not** fork it. We **compose** +with it. The reasons are structural (all verified against the repo — see §13.0): + +| Fact about Hindsight | Consequence for us | +|----------------------|--------------------| +| Core API is `Retain / Recall / Reflect`, bank-scoped | **Our `Memory` port already mirrors it exactly** (`packages/memory/src/memory.ts`, with sticky attribution). The seam exists today. | +| MIT-licensed | Forking, vendoring, and upstream PRs are all legally open — so the choice is purely engineering. | +| Postgres + Ollama + REST/SDK; an MCP server exists | It already speaks our stack. It *does* ship an MCP server, but **we deliberately do not use it** — §12 makes memory a harness invariant, not an agent-facing tool, so we drive Hindsight through our port. | +| Strong recall, **no decay / weighting / maintenance** | That gap is **exactly** our governance layer (§4–§7). Composition is additive, not duplicative. | + +### 13.0 Verified API surface (investigated against the repo, 2026-05-30) + +Confirmed from Hindsight's OpenAPI contract +(`hindsight-clients/go/api/openapi.yaml`) and `.env.example`, so the adapter below +is grounded in the real API, not a summary: + +| Concern | Verified fact | +|---------|---------------| +| Scope primitives | **`bank_id`** (URL path) + arbitrary **`metadata`** (string→string map on each retained item) + **`tags`** (string array). | +| Retain | `POST /v1/default/banks/{bank_id}/memories` — **batch** `items[]`, each `{ content, context?, timestamp?, metadata?, tags?, document_id?, entities? }`; `async` flag. Returns created memory **ids**. | +| Recall | `POST /v1/default/banks/{bank_id}/memories/recall` — `{ query, types?, tags?, tags_match: any\|all, budget, max_tokens, query_timestamp? }`. **Filters by tags**; semantic + spreading-activation. | +| Recall response | `RecallResponse.results[]` each carry **`id`** (the memory id), `chunk_id`, `context`, `entities`, `occurred_start/end`; plus `chunks{}` (text) and `entities{}`. **`results[].id` is our join key.** | +| Reflect | `POST /v1/default/banks/{bank_id}/reflect` → insights; **mental-models** are reflect-derived (§7.3). | +| Conflict/versioning | `GET /memories/{id}/history` + a documented conflict-resolution mechanism — **Hindsight handles content-level conflict itself** (so our §5 conflict logic is the degraded/Cockroach-only path; with Hindsight, content conflict is its job, our job is weight/lifecycle). | +| LLM | `HINDSIGHT_API_LLM_PROVIDER=ollama` + `HINDSIGHT_API_LLM_BASE_URL` + `_MODEL` — OpenAI-compatible; **fully local on our in-cluster Ollama**. | +| DB | embedded `pg0` by default; `HINDSIGHT_API_DATABASE_URL` to point external; uses **`pgvector`** → **do not use CockroachDB for it** (run its own Postgres / embedded pg0). | +| Deploy | single container `ghcr.io/vectorize-io/hindsight:latest` (API `:8888`, UI `:9999`) **or the repo's `helm/` chart** — clean kind deploy. | + +**Scope mapping (the `hat ⊕ agent ⊕ work` union, §2.1, onto Hindsight):** + +| Our concept | Hindsight | +|-------------|-----------| +| `projectId` (recall is project-scoped, never global) | **`bank_id`** = `projectId` (one bank per project) | +| attribution (`agentId, hatAssignmentId, workItemId, promptFlowRunId`) + `tier/scope` | retained-item **`metadata`** (string map) | +| the scope-union retrieval key | **`tags`** = `["tier:"+tier, "scope:"+scope, "agent:"+agentId, "work:"+workItemId]` | +| recall the hat ⊕ agent ⊕ work union | `recall({ tags:["scope:"+hatId,"agent:"+agentId,"work:"+workItemId], tags_match:"any" })` | +| our `MemoryState.memoryId` | the **`results[].id`** Hindsight returns from `retain`/`recall` | + +**The one item left for the live spike (H1), now small:** whether +`results[]` exposes a numeric **relevance score** to *blend* with our weight, or +only an ordered list (rank-position). Either way the re-rank works — we re-rank by +our §4 weight over the returned ids; a Hindsight score would only let us additionally +blend. Plus the usual runtime confirmations (Ollama-as-extractor latency/quality, +recall p99). The integration's *possibility* is no longer in question. + +### 13.1 The division of labor (Hindsight is the engine; we are the economy) + +``` + ┌──────────────────────────────────────── our system ───────────────────────────────────┐ + │ org_event trace · tier-scoping (hat⊕agent⊕work) · weight/decay/KPI · IT-dept daily │ + │ maintenance · reliability harness (§12) · the Memory port + MemoryContract │ + └───────────────────────────────────┬─────────────────────────────────────────────────────┘ + │ Retain / Recall / Reflect (our port) + ┌────────────────▼────────────────┐ + │ createHindsightMemory() │ ← adapter implements the port + │ (REST/Node-SDK → Hindsight) │ + └────────────────┬────────────────┘ + │ HTTP + ┌──────────────────────▼──────────────────────┐ + │ Hindsight service (Docker, MIT) │ + │ extraction · vector+BM25+graph+temporal │ + │ recall · rerank · RRF │ + │ Postgres (its own) · Ollama (ours) │ + └──────────────────────────────────────────────┘ +``` + +- **Hindsight owns** content storage, embeddings, and the *recall fusion* (the part + that is genuinely hard and that it already does well). +- **We own** everything Hindsight deliberately omits: tier-scoping via its custom + metadata, the **KPI-weighted re-rank + decay + archive floor**, the daily + maintenance cycle, protected memories, the `org_event` trace, and the §12 + reliability harness. + +### 13.2 The seam is our existing `Memory` port — write an adapter, not a fork + +`packages/memory/src/memory.ts` already defines `Memory { retain; recall; reflect }` +with sticky `MemoryAttribution`. The note in that file — *"a real Hindsight adapter +implements the same port"* — is the plan. We add one adapter: + +```ts +export function createHindsightMemory(deps: { + client: HindsightClient; // the Node SDK or a thin REST wrapper + organizationId: string; +}): Memory { + // bank_id = attr.projectId (recall is project-scoped, never global) + // retain → POST /banks/{projectId}/memories { items:[{ content, + // metadata: attributionToMetadata(attr), + // tags: ["tier:"+tier,"scope:"+scope,"agent:"+attr.agentId,"work:"+attr.workItemId] }] } + // → returns ids; we create the MemoryState row keyed on the returned id + // recall → POST /banks/{projectId}/memories/recall { query, tags:[scope/agent/work], tags_match:"any" } + // → results[].id → join MemoryState → OUR §4 weight re-rank (§13.3) + // reflect → POST /banks/{projectId}/reflect { query } → insight candidate (§7.3), hat-decided +} +``` + +Our attribution (`agentId, hatAssignmentId, projectId, workItemId, promptFlowRunId`) +maps onto Hindsight's **custom metadata**; scoped recall (never global) maps onto +its **metadata filter**. The in-process fake and the Cockroach adapter remain for +tests and degraded mode (Hindsight unavailable → fall back to our own +weight-only ranking, exactly the §4 degradation posture). + +### 13.3 Extending Hindsight *by composition*, not by patching its internals + +Our weight/decay/KPI engine lives **outside** Hindsight and **post-processes** its +recall: + +1. Hindsight returns candidates ranked by its semantic/BM25/graph/temporal fusion + (Stage 2 recall). +2. We join each to our `MemoryState` (Cockroach) and **re-rank by the §4 weight** + (`freshness × confidence × KPI-outcome × utility`), apply the **archive floor**, + and pack into the budget. + +We never touch Hindsight's code; we wrap its output. This is precisely the +two-stage retrieval of §12.4 with **Hindsight as the recall engine and our weight +as the final re-rank.** + +### 13.4 Storage split — Hindsight's Postgres vs. our CockroachDB (honest nuance) + +Hindsight manages **its own PostgreSQL** schema and **requires `pgvector`** +(confirmed in `.env.example`: *"Vector Extension — uses pgvector by default"*). +**Do not point Hindsight at CockroachDB** — run it on its **embedded `pg0`** (zero +extra infra) or a dedicated Postgres pod / Hindsight Cloud. This +yields the clean hub/satellite split §3 already anticipated: + +| Store | Holds | Owner | +|-------|-------|-------| +| **Hindsight Postgres** | memory **content** + embeddings + recall indices (vector/BM25/graph/temporal) | Hindsight | +| **Our CockroachDB** | memory **state**: weight, confidence, freshness, outcome/utility, phase; the injection ledger; **all `org_events`** | us | + +Joined by `memoryId` (Hindsight's id ↔ our state row). Our Cockroach stays the +system-of-record for governance and trace; Hindsight is the content + recall +engine. (This makes the §8 `agentic_org_memory_records` content table optional — if +Hindsight holds content, our Cockroach can hold *only* state + ledger + trace.) + +### 13.5 The decision: integrate, don't fork — with named escalation paths + +| Option | Use when | Cost | +|--------|----------|------| +| **Integrate behind the port** *(recommended default)* | We need Hindsight's recall + our governance on top — the present case | One adapter; track upstream for free | +| **Contribute upstream (MIT PR)** | We want a capability *inside* Hindsight that is generally useful (e.g. a metadata-scoped recall filter it lacks) | A PR + review latency; no fork to maintain | +| **Thin wrapper service** | We need to shape Hindsight's I/O (auth, our metadata conventions, rate-limits) without changing its logic | A small service we own; Hindsight unchanged | +| **Hard fork** *(last resort)* | We must change Hindsight's *core ranking internals* in a way upstream will not take | Permanent tax: tracking a Python+Rust+TS codebase ourselves | + +A hard fork is reserved for a *proven, specific* need to alter Hindsight's +internals that upstream rejects — none exists today, because **everything we want +to add lives cleanly on top of `Retain/Recall/Reflect`.** Start integrated; escalate +only on evidence. + +### 13.6 Build phases for the Hindsight seam + +| Phase | Deliverable | +|-------|-------------| +| **H1** | **Spike (now a confirmation, not a discovery — §13.0):** deploy `ghcr.io/vectorize-io/hindsight:latest` (or the repo `helm/` chart) in the `agentic-org` ns with embedded `pg0`, `HINDSIGHT_API_LLM_PROVIDER=ollama` + `_BASE_URL` at our in-cluster Ollama. Smoke-test: `retain` a tagged item → `recall` by tag returns it with an `id` → `reflect` returns an insight. Confirm the three runtime questions: (a) does `results[]` carry a blendable **score** or rank-only; (b) Ollama-as-extractor latency/quality; (c) recall p99. | +| **H2** | `createHindsightMemory()` adapter behind the existing `Memory` port per §13.2 (bank_id=projectId, tags/metadata mapping, `results[].id`→`MemoryState`); scoped (non-global) recall; degraded fallback to the V13/in-process adapter (§8.2–§8.3). | +| **H3** | Compose §13.3: Hindsight recall → join `MemoryState` → §4 weight re-rank + archive floor + budget pack; the §12 reliability harness drives `retain`/`recall` as kernel invariants. | +| **H4** | **Observe in kind:** an agent retains via Hindsight, recalls scoped + KPI-re-ranked context, the daily maintenance cycle decays/promotes/archives against our Cockroach state, and the whole flow is traced in `org_events` — same proof bar as the org system. | + +--- + +## 14. What this is NOT + +- **Not** a new datastore — it reuses our CockroachDB and in-cluster Ollama. +- **Not** the TPM architecture — only its memory + maintenance *idea* is taken. +- **Not** a vector database — embeddings are an optional, degradable weight term. +- **Not** auto-deletion of knowledge — archive is reversible (the row remains; + only retrieval excludes it); hard deletion is a separate, human-gated action. +- **Not** an agent free-for-all — the kernel clamps every memory action to a + legal set; the agent's "manual heuristic" is a *choice within the rules*, + traced as an `org_event`. + +--- + +## Appendix — concept mapping (source idea → our system) + +| Source idea concept | Our adaptation | +|---------------------|----------------| +| `MemoryFact` (RaaS + Mongo split) | `MemoryRecord` (Cockroach content) + `MemoryState` (Cockroach state) | +| `turn / thread / engagement / persona` tiers | `org / department / hat / agent / work` tiers (mirror our hierarchy) | +| persona-tier (cross-session role) | **hat-tier** (cross-wearer role memory) | +| (no per-actor tier) | **agent-tier** (per-actor) + the **hat ⊕ agent** combination | +| engagement-tier | **work-tier** (per workflow run) | +| composite relevance score | `computeMemoryWeight` (KPI-weighted) | +| freshness half-life + archive floor | same; archive floor = "drops to zero, never surfaces" | +| outcome correlation (manifest verdict) | correlation from `pipeline_stage_transition` (merged=success) | +| utility self-tuning (injected vs cited) | same; via the injection ledger + citations | +| `observer-analyst` daily templates | `runMemoryMaintenanceCycle` (an org cycle) | +| `observer-promoter` + admin approval | hat decisions (`memory_reviewer`/`director`) via `chooseWithinLegal` | +| protected facts | protected memories | +| GitLab-MR promotion path | `org_event`-traced promotion decision | +| agent calls a `memory.retrieve` tool | mandatory pre-turn injection — a kernel invariant, never an agent tool (§12) | +| agent calls `memory.write` | required `memoryCandidates` output field + deterministic system extraction (§12) | +| (their RaaS vector/keyword/graph stack) | **Hindsight** (`vectorize-io/hindsight`) as the recall engine, behind our `Memory` port (§13) | +| RaaS+Mongo content/state split | Hindsight Postgres (content+recall) / our CockroachDB (state+weight+trace) (§13.4) | diff --git a/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md b/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md index dca9b9396b..abd23c2ae8 100644 --- a/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md +++ b/agentic-organization/docs/FIRST_IMPLEMENTATION_SLICE.md @@ -1,3 +1,9 @@ +--- +title: First Implementation Slice +canonical_name: Agentic Organization +status: design +--- + # First Implementation Slice ## Status diff --git a/agentic-organization/docs/FOUNDATIONAL_CONTEXT_AND_LANGUAGE.md b/agentic-organization/docs/FOUNDATIONAL_CONTEXT_AND_LANGUAGE.md index 211ba2eb3f..6bd6074255 100644 --- a/agentic-organization/docs/FOUNDATIONAL_CONTEXT_AND_LANGUAGE.md +++ b/agentic-organization/docs/FOUNDATIONAL_CONTEXT_AND_LANGUAGE.md @@ -1,3 +1,9 @@ +--- +title: Foundational Context and Language +canonical_name: Agentic Organization +status: design +--- + # Foundational Context and Language This document captures working context and vocabulary that should inform the Agentic Organization design. It is not a proof system and it is not a demand that every metaphor become code. It records the collaborator's working language so implementation decisions preserve the intended shape. diff --git a/agentic-organization/docs/GASTOWN_REFERENCE_ANALYSIS.md b/agentic-organization/docs/GASTOWN_REFERENCE_ANALYSIS.md index b708026f30..ac580a55cd 100644 --- a/agentic-organization/docs/GASTOWN_REFERENCE_ANALYSIS.md +++ b/agentic-organization/docs/GASTOWN_REFERENCE_ANALYSIS.md @@ -1,3 +1,9 @@ +--- +title: Gastown Reference Analysis +canonical_name: Agentic Organization +status: design +--- + # Gastown Reference Analysis ## Purpose diff --git a/agentic-organization/docs/GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md b/agentic-organization/docs/GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md new file mode 100644 index 0000000000..b4c53b31e6 --- /dev/null +++ b/agentic-organization/docs/GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md @@ -0,0 +1,175 @@ +--- +title: Git as Database and Event Store (Frontmatter + ZetaId CRDT) +canonical_name: Agentic Organization +status: v0 +ideas: [1, 2, 3, 7, 8] +extends: + - CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md + - V0_SCHEMA_AND_COMMANDS.md +composes_with: + - ./TECHNICAL_CA_PACKAGE_ARCHITECTURE.md + - ./OBSERVE_COMPOSER_AND_RUN_STATE.md + - ./DOC_FRONTMATTER_CONVENTION.md +code_anchors: + - ../packages/frontmatter-db/src/schema.ts + - ../packages/frontmatter-db/src/event.ts + - ../packages/frontmatter-db/src/crdt-log.ts + - ../packages/frontmatter-db/src/project.ts + - ../packages/frontmatter-db/src/sql-to-schema.ts + - ../packages/frontmatter-db/src/schema-to-sql.ts + - ../packages/frontmatter-db/src/frontmatter-codec.ts + - ../packages/frontmatter-db/src/event-codec.ts + - ../packages/frontmatter-db/src/sync.ts + - ../packages/frontmatter-db/src/git-fs-adapter.ts + - ../packages/frontmatter-db/src/cockroach-row-sink.ts + - ../packages/frontmatter-db/src/reconcile-worker.ts + - ../packages/frontmatter-db/src/traverse.ts + - ../packages/frontmatter-db/src/validate.ts + - ../../src/Core.TypeScript/zeta-id/zeta-id.ts +supersedes: [] +--- + +# Git as Database and Event Store (Frontmatter + ZetaId CRDT) + +The persistence and addressing layer. Operator vision (2026-05-29): **git is the +database and the event store**. A markdown file is a row; its YAML frontmatter is +the typed schema/columns; foreign-key columns are graph edges; events are +unique-id files that merge conflict-free as a CRDT; and the schema converts +to/from SQL. CockroachDB demotes to a rebuildable query index. Covers operator +ideas 1 (git<->cockroach converter), 2 (explicit DUs), 3 (frontmatter graph), 7 +(no-collision ids), 8 (ZetaId decimal index). + +> **Why this is git's native shape.** Linus Torvalds built git as a +> content-addressable object database (a "stupid content tracker"): `git +> hash-object` writes a blob keyed by its SHA, `git cat-file` reads it back; +> trees and commits are just objects on top. Git-as-db is not a hack — it is +> git used for what it fundamentally is. The one thing we add is a *stable, +> semantic, time-ordered* key (ZetaId) because a content SHA changes whenever +> content changes and therefore cannot be a primary key or a foreign-key target. + +## The stack + +```text +git object DB (durability + conflict-free merge) + └─ events//.md append-only event files ── G-Set CRDT + │ (frontmatter = op + aggregateId + field values + schema_version) + ▼ fold in (timestamp, id) order ── retraction-native (Z-set) +
/.md materialized rows (current state) + │ (frontmatter = typed columns; fk columns = graph edges; body = doc) + ▼ derive + CockroachDB rebuildable query / index projection +``` + +One **frontmatter schema** (derived from SQL) governs all four layers. + +## Layer 1 — the schema is frontmatter, derived from SQL (ideas 2, 3) + +`packages/frontmatter-db/src/schema.ts` defines the column model as an explicit +discriminated union (`ColumnType`: zeta_id, text, int, bool, timestamp, enum, fk, +fk_array). Payload-bearing kinds carry their payload as an explicit variant — +`enum` carries `values`, `fk`/`fk_array` carry `references` — never buried in +optional fields (repo rule: IMPLICIT-NOT-EXPLICIT is class error). + +`sql-to-schema.ts` converts a `CREATE TABLE` into that schema: + +| SQL | frontmatter column | +|-----|--------------------| +| `id TEXT PRIMARY KEY` | `{ type: zeta_id, pk: true, required: true }` | +| `status TEXT NOT NULL CHECK (status IN ('a','b'))` | `{ type: enum, required: true, values: [a, b] }` | +| `project_id TEXT REFERENCES project(id)` | `{ type: fk, references: project }` | +| `reviewer_ids TEXT[] REFERENCES hat_assignment(id)` | `{ type: fk_array, references: hat_assignment }` | +| `estimate INTEGER` / `created_at TIMESTAMPTZ NOT NULL` | `{ type: int }` / `{ type: timestamp, required: true }` | + +The reverse (schema → `CREATE TABLE`) feeds the Cockroach projection, so the +schema is the single source both sides derive from — the existing +`state-cockroach/migrations` become derivable rather than hand-authored. + +## Layer 2 — events are a ZetaId-keyed G-Set CRDT (ideas 7, 1) + +`event.ts` + `crdt-log.ts`. Each event is one file named by its ZetaId decimal. +Because ZetaIds are globally unique (32-bit crypto-random field), two agents +writing concurrently produce **different filenames** — a git merge is a pure +union, never a content conflict. The log is therefore a **grow-only set keyed by +unique id**; `mergeLogs` is union, which is: + +- **commutative** — `merge(a,b)` and `merge(b,a)` have the same ids +- **associative** — grouping does not matter +- **idempotent** — re-merging the same log changes nothing + +(All three proven in `test/crdt-log.test.ts`.) These are the CRDT join laws, so +branches converge in any merge order. Collision policy is "minted once with +crypto randomness, addressed by decimal everywhere" — one id scheme, no +reconciliation. + +## Layer 3 — state is a timestamp-ordered fold (ideas 8, 2) + +`project.ts`. ZetaId embeds a 48-bit timestamp, so the event log is +self-ordering — `timestampMsFromZetaId` reads it straight out of the id. `project` +folds a table's events in `(timestamp, id)` order: + +- `upsert` merges field values (last-writer-wins by timestamp) +- `retract` tombstones the aggregate (retraction-native, Z-set style; a later + upsert revives it) + +Because the order is derived from the ids (not from insertion or merge order), +`project(merge(a,b)) === project(merge(b,a))` — the convergence property, proven +in `test/project.test.ts`. The resulting `FrontmatterRow`s are the materialized +rows of Layer 1; they are fully rebuildable from the event log (DST-replayable). + +## Layer 4 — frontmatter is graph-traversed (idea 3) + +`traverse.ts`. `fk` and `fk_array` columns are edges; `edgesOf(row, schema)` +yields them and `neighbors(row, schema, column, store)` resolves them against a +row store keyed by `ZetaIdDecimal`. This is the same edge mechanism the doc graph +uses (`composes_with` in `DOC_FRONTMATTER_CONVENTION.md`) — docs and data rows +share one traversal model. + +## Layer 5 — Cockroach is the index, and the converter is generic (idea 1) + +CockroachDB is the low-latency query projection (`WHERE status='ready' ORDER BY +created_at` over thousands of `.md` files is not viable; the index is). The +generic converter (`sync.ts`) is keyed by the schema and driven entirely through +injected ports (`GitEventSource`, `IndexRowSink`, `IndexRowSource`, +`GitEventSink`, `IdGenerator`), so the pure core has no git/db dependency and is +fully testable with in-memory fakes: + +- **git → cockroach** (`syncGitToIndex`): fold the event log to rows via + `project`, upsert each into the index, and `deleteRow` any index id no longer + in the projection (tombstoned aggregates drop out) — returns + `{ applied: { upserted, deleted } }` +- **cockroach → git** (`syncIndexToGit`): emit one `Upsert` event per changed + row (id from `IdGenerator`, aggregateId from the row's pk); a row missing its + pk returns `row_missing_id` feedback rather than emitting a malformed event +- a committed command's event file rides the existing `messaging-nats` outbox; + a periodic full reconcile (`ALWAYS_ON_ORCHESTRATION_RUNTIME.md`) is the + recovery net + +Conflicts cannot arise at the event layer (unique ids). At the *row* layer, two +upserts to the same aggregate are resolved deterministically by the timestamp +fold — there is no last-write-wins ambiguity to hand-resolve, because the id +*is* the clock. + +## Status + +Implemented and tested: `packages/frontmatter-db` — schema DUs, SQL→schema +(`sql-to-schema.ts`) and schema→SQL (`schema-to-sql.ts`, round-trip verified), +event/CRDT log, timestamp-ordered projection, validation, traversal, the on-disk +frontmatter YAML codec (`frontmatter-codec.ts`, lossless round-trip incl. +number-looking strings and arrays), and the port-based git↔cockroach sync core +(`sync.ts`). Full suite 318 green; real `tsc` clean for these files. + +Also implemented and tested: the filesystem-backed Git adapter +(`git-fs-adapter.ts` + `event-codec.ts`, async load/flush over an in-memory +snapshot so the sync ports stay synchronous), the in-memory CockroachDB row sink +(`cockroach-row-sink.ts`, the rebuildable index, with a SQL-host `// TODO`), and +the periodic reconcile worker (`reconcile-worker.ts`, a `runOnce()` cycle +mirroring `worker-host.ts`). The reconcile cycle runs **index→git before +git→index** so a row written only to the index this cycle becomes an event before +the projection diff — otherwise git→index would tombstone-delete it as canonical. + +Design/next: the real SQL-backed `CockroachRowSink` behind the port (the +`// TODO(cockroach-host)`), committing the adapter's written event files to git, +and wiring the reconcile worker onto the existing `messaging-nats` outbox + +scheduler. The ZetaId codec (ideas 7, 8) is the existing +cross-verified `src/Core.TypeScript/zeta-id`; `frontmatter-db` mirrors only its +timestamp-bit layout to stay self-contained. diff --git a/agentic-organization/docs/IMPLEMENTATION_CONCEPTS.md b/agentic-organization/docs/IMPLEMENTATION_CONCEPTS.md index 0a693aa0eb..e6e494d2c1 100644 --- a/agentic-organization/docs/IMPLEMENTATION_CONCEPTS.md +++ b/agentic-organization/docs/IMPLEMENTATION_CONCEPTS.md @@ -1,3 +1,9 @@ +--- +title: Agentic Organization Runtime - Implementation Concepts +canonical_name: Agentic Organization +status: design +--- + # Agentic Organization Runtime - Implementation Concepts ## Purpose diff --git a/agentic-organization/docs/IMPLEMENTATION_GOVERNANCE.md b/agentic-organization/docs/IMPLEMENTATION_GOVERNANCE.md index 26d60e5649..d5ede69573 100644 --- a/agentic-organization/docs/IMPLEMENTATION_GOVERNANCE.md +++ b/agentic-organization/docs/IMPLEMENTATION_GOVERNANCE.md @@ -1,3 +1,9 @@ +--- +title: Agentic Organization Implementation Governance +canonical_name: Agentic Organization +status: design +--- + # Agentic Organization Implementation Governance ## Status diff --git a/agentic-organization/docs/IMPLEMENTATION_READINESS_CHECKLIST.md b/agentic-organization/docs/IMPLEMENTATION_READINESS_CHECKLIST.md index 29fcc73e78..97240db382 100644 --- a/agentic-organization/docs/IMPLEMENTATION_READINESS_CHECKLIST.md +++ b/agentic-organization/docs/IMPLEMENTATION_READINESS_CHECKLIST.md @@ -1,3 +1,9 @@ +--- +title: Implementation Readiness Checklist +canonical_name: Agentic Organization +status: design +--- + # Implementation Readiness Checklist This checklist defines what still needs to be decided before implementation begins. The goal is to avoid designing forever while still defining the contracts that would be painful to change after code exists. diff --git a/agentic-organization/docs/METRICS_AND_REVIEW_BOARD.md b/agentic-organization/docs/METRICS_AND_REVIEW_BOARD.md new file mode 100644 index 0000000000..6b741b93ff --- /dev/null +++ b/agentic-organization/docs/METRICS_AND_REVIEW_BOARD.md @@ -0,0 +1,114 @@ +--- +title: Metrics and the 3-Agent Review Board +canonical_name: Agentic Organization +status: v0 +ideas: [2, 4] +extends: + - OBSERVABILITY_AND_SELF_HEALING.md + - BUSINESS_QUALITY_GATE_SYSTEM.md +composes_with: + - ./OBSERVE_COMPOSER_AND_RUN_STATE.md + - ./SUPERVISOR_CHAIN_COMMUNICATION.md + - ./DOC_FRONTMATTER_CONVENTION.md +code_anchors: + - ../packages/metrics/src/code-metrics.ts + - ../packages/metrics/src/review-board.ts + - ../packages/metrics/src/mcp-tools.ts + - ../packages/governance/src/constitution-gate.ts +supersedes: [] +--- + +# Metrics and the 3-Agent Review Board + +Operator idea 4, sharpened (2026-05-29): metrics have **two layers** — a +*quantitative* layer gathered mechanically like test coverage, and a +*qualitative* layer run by a board of reviewer agents who must **agree** before a +comment is published. The qualitative layer is the constitution gate (idea 2) +applied to review findings: ≥3 distinct reviewers, agreement required. + +## Quantitative — "coverage for structure" + +`packages/metrics/src/code-metrics.ts` measures source text the way a coverage +tool measures execution: deterministic, no judgment, just numbers. + +| Metric (`CodeMetricKind`) | What it catches | Default warn / flag | +|---|---|---| +| `longest_function` | sprawling functions | 40 / 80 lines | +| `longest_class` | **god classes** | 200 / 400 lines | +| `file_length` | god files | 400 / 800 lines | +| `max_nesting_depth` | tangled control flow | 4 / 6 | + +`analyzeSource(filePath, source, thresholds?)` returns a `CodeMetricsReport` with +the measured spans plus a list of `MetricFinding`s. Each finding is an explicit +DU on `metric` + `severity` (`ok`/`warn`/`flag`), so a "god class" finding is +structurally distinct from a "long file" finding — never collapsed into a generic +"too big" string. These are **heuristics that flag candidates**, not verdicts; +the verdict is the review board's. + +## Qualitative — the 3-agent review board + +`packages/metrics/src/review-board.ts`. A candidate finding becomes a published +comment only when a **quorum of distinct reviewer agents agree**. Reviewers vote +along explicit `ReviewDimension`s — `correctness`, `solid`, +`architecture_adherence`, `performance`, `testing` — the discussion axes the +operator named. + +```text +CandidateFinding[] + ReviewerVote[] -> evaluateReviewBoard({ findings, votes, quorum? }) + -> per finding: FindingDecision +``` + +`FindingDecision.state` is an explicit DU: + +- **adopted** — ≥ quorum *distinct* reviewers agreed and fewer than quorum + disagreed; the comment is published +- **withheld** — too few distinct agreers; the comment is dropped (no + single reviewer can force a comment through) +- **contested** — quorum agreed *and* quorum disagreed; escalate rather than + silently pick a side + +The board returns `feedback` (`too_few_reviewers`) if fewer than quorum distinct +reviewers participated at all — a 2-agent board cannot adopt anything when quorum +is 3. + +### Why this is the constitution gate again + +The agreement rule is identical in shape to +`governance/src/constitution-gate.ts`: **distinct** agreers (one agent voting +three times counts once — no self-amplification), quorum-gated, with disagreement +able to veto. The constitution gate ratifies *rule sets*; the review board +ratifies *review findings*. Same multi-oracle principle, two scopes. They live in +different packages (no cross-import across the boundary), so the logic is restated +rather than shared — but the semantics are deliberately the same, and both are +explicit DUs with no buried thresholds. + +This is also why a review board sits naturally on the observe/compose keystone: +"publish this review comment" is exactly the kind of side effect that should pass +a gate before `decide()` lets a run act on it. + +## MCP tool interface (hosting is a TODO) + +`packages/metrics/src/mcp-tools.ts` exposes both layers as MCP tools — **the +interface only**. `METRICS_TOOL_DESCRIPTORS` advertises: + +- `analyze_source` — gather quantitative metrics for one file +- `run_review_board` — run the ≥3-agent board over findings + votes + +`dispatchMetricsTool(name, args)` is the pure in-process router an MCP server's +`call_tool` would delegate to; it returns an explicit `MetricsToolResult` DU +(`ok` with a typed payload per tool, or `feedback` for unknown-tool / bad-args). + +The actual server **hosting is deliberately stubbed** (`// TODO(mcp-host)` in +`mcp-tools.ts`): advertise the descriptors via `list_tools`, map `call_tool` onto +`dispatchMetricsTool`, run over the cluster MCP gateway transport, and enforce +hat-token preflight before dispatch (metrics reads are low-risk; publishing review +comments is a scoped authority per `V0_POLICY_AND_RUNTIME_BOUNDARIES.md`). Per the +operator: build the tooling + the interface now, host the server later. + +## Status + +Implemented and tested: quantitative metrics, the qualitative review board, and +the MCP tool dispatch interface (`packages/metrics`, 15 tests; full suite 346 +green). Design/next: the MCP server host behind `dispatchMetricsTool`; wiring +metric findings into the supervisor-chain so a `flag` becomes a triaged work +item; and persisting review decisions as evidence on the work item. diff --git a/agentic-organization/docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md b/agentic-organization/docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md index 3d9b5edbec..26a34ebb66 100644 --- a/agentic-organization/docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md +++ b/agentic-organization/docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md @@ -1,3 +1,9 @@ +--- +title: North Star Alignment Checkpoint +canonical_name: Agentic Organization +status: design +--- + # North Star Alignment Checkpoint ## Status @@ -5,6 +11,46 @@ Current checkpoint after the first executable TypeScript slices and subagent review. +## Update 2026-05-29 — git-as-DB substrate + observe keystone + coherence slices + +Landed since the prior checkpoint (all tested; full suite 382 green; tsc clean for +new files; the 8 remaining typecheck errors are pre-existing `@nats-io` missing +deps in `apps/workers`): + +- **`packages/frontmatter-db`** — git-as-database-and-event-store: a markdown file + is a row, frontmatter is the SQL-derived typed schema + fk graph edges, events + are ZetaId-keyed files merging conflict-free as a G-Set CRDT, state is a + timestamp-ordered fold, CockroachDB is a rebuildable index. Includes the YAML + + event codecs, schema↔SQL round-trip, port-based sync core, a filesystem Git + adapter, an in-memory Cockroach row sink, and a `runOnce()` reconcile worker. + See `GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md`. +- **`observe.ts` keystone** (`packages/application/src/observe.ts`) — the single + entrypoint with the run-lifecycle DU + ephemeral memoryless composer. Wired to + real work-item state via `observe-work-item.ts` (slice 4). See + `OBSERVE_COMPOSER_AND_RUN_STATE.md`. +- **≥3-agent constitution gate** (`packages/governance/src/constitution-gate.ts`). +- **Metrics + 3-agent review board** (`packages/metrics`) — quantitative code + metrics + the qualitative board, now usable as a real gate via + `review-gate.ts` (slice 5). See `METRICS_AND_REVIEW_BOARD.md`. +- **Slice 1 — State reconciliation table** (`packages/domain/src/state-reconciliation.ts`): + the North-Star-#2 single authoritative WorkItemState mapping + observe phase + binding + generic-vs-type-specific rule split. See `STATE_RECONCILIATION.md`. +- **Slice 2 — Triage action resolver** (North-Star-#4): the 5 declared-but- + unimplemented `SupervisorTriageActionType` actions are now explicit — AnswerDirectly + and EscalateToNextSupervisor are implemented; the 3 substrate-dependent actions + resolve to a visible `Deferred` outcome rather than a silent rejection. +- **Slice 3 — Graph projection v0** (North-Star-#5): typed node/edge projection of + work-item/anchor/decision records + the `decisionsForWorkItem` retrieval. + +Doc hygiene this checkpoint: all docs now carry frontmatter `status` +(design / v0 / index) per `DOC_FRONTMATTER_CONVENTION.md`. + +Priorities #2 (reconciliation table) and #5 (graph projection) are now +addressed; #4 (triage expansion) is partially addressed (2 of 5 new actions +implemented, 3 deferred). The cluster-integration gaps (hat-system projection, identity mapping, +Hindsight, Hermes runtime, LGTM export) and the MCP server host remain deferred +to the `full-ai-cluster` substrate. + ## Verdict Agentic Organization is directionally aligned with the north star: @@ -402,3 +448,548 @@ labels, dashboard ownership, and alertable degraded-worker signals. pause/resume, worktree/runtime/credential allocation, and compliance observation still need to land before meeting-heavy, review-heavy, or verification-heavy autonomous work consumes capacity. + +## Update 2026-05-30 — deterministic keep-alive control plane is real (operator tenet #1) + +The operator's #1 tenet — "enough determinism to drive the organization to stay +alive and drive the agents to stay alive, with sufficient autonomy from the +agents themselves" — now has a real, tested implementation, not just a pure +engine. + +What landed: + +- **Pure engine** (`packages/keepalive/src/keepalive.ts`): `evaluateKeepAlive` + is a pure function over a liveness snapshot. Guarantees motion — always emits + a heartbeat, deterministically detects a flatlining org / stale agents / + expired leases, and converts each into an explicit DU action. It never + decides WHAT work to do (that is the autonomous data plane). +- **Lane** (`keepalive-lane.ts`): turns the engine into a self-driving loop; + source/sink failures are captured, never thrown — the heartbeat must not die. +- **Wired as a third worker-runtime lane** (`apps/workers/src/worker-runtime.ts`): + ticks FIRST every cycle; a thrown/failing lane is captured; org-flatlining + marks the runtime degraded. +- **Durable on Cockroach** (migration 0011): `agentic_org_control_plane_heartbeat` + (one row per org; `last_tick_at` advancing IS the observable proof of life) + and `agentic_org_control_plane_alerts` (append-only self-heal log). Age is + measured by the DB clock, so liveness stays deterministic across replicas. +- **Constructed in production composition** (`durable-composition.ts`): store -> + snapshot source -> action sink -> lane, threaded into `createWorkerRuntime`. + The deployed worker now ticks the org heartbeat every cycle. + +Proof: a live integration test against real CockroachDB shows the heartbeat row +advancing each tick and a forced stall emitting heartbeat + org-stall alert +(Degraded, appliedCount 2) with the org self-healing. Full suite 511/511 pass, +0 skipped, vs live Cockroach + NATS. tsc 0. + +Review board (inline, 2 lenses): PASS on SOLID/house-style and +correctness/north-star. Control-plane/data-plane separation respected +(`ReassignStaleWork` emits a signal, not a work decision). + +### Honest gap — next step toward the second half of the tenet + +"Drive the AGENTS to stay alive" is wired-but-dormant: the Cockroach snapshot +source returns `agents: []` because Hermes agent sessions are not yet persisted +to Cockroach (Hermes runs in-process). The engine's stale-agent and lease-reap +branches are implemented and unit-tested; they activate the moment agent +heartbeats are persisted. Smallest real next step: a Cockroach agent-heartbeat +table + Hermes runtime persisting per-run heartbeats + the snapshot source +reading them. ORG liveness is real now; AGENT liveness is the next slice. + +## Update 2026-05-30 — Phase 6: running in kubernetes-in-docker, end to end + +The system runs in a kind (kubernetes-in-docker) cluster and both planes are +proven against live in-cluster infrastructure. + +Topology (deploy/k8s/): namespace `agentic-org`, single-node CockroachDB, +NATS+JetStream, and the worker Deployment. NATS stream + durable consumer are +provisioned by deploy/provision-nats.ts (the worker fails-fast without the +durable consumer). Worker image `agentic-org-worker:keepalive` loaded into kind. + +Proven in-cluster: + +1. **Boot + migrations** — the worker connects to Cockroach + NATS, applies all + migrations (incl. 0011 control-plane keep-alive), passes readiness, and loops + `runOnce()` with `failure_count: 0`. +2. **Deterministic keep-alive (tenet #1)** — the org heartbeat row advances every + cycle (version observed climbing 2 → 10 → 16), `age_ms < deadline`, + `alive = true`, queried directly inside the Cockroach pod. The heartbeat row + survived a worker restart — org-liveness persists across worker churn (it is + org-level, so any live worker replica keeps the org alive). +3. **Stall detection** — during a deliberately mis-tuned window (15s deadline vs. + the ~30s NATS-idle worker cycle), the keep-alive recorded 7 `org_stall` + alerts. After raising the deadline to 90s (> cycle), no new alerts accrue and + the org is healthy. Both the alive and the flatlining paths are proven live. +4. **Spin up a task (data plane)** — publishing a canonical SupervisorSignalSent + event (deploy/spin-up-task.ts) drove the worker to ingest it (1 inbox receipt, + deduped), the V0 automation planner to create a `create_supervisor_triage` + reaction plan, and the reaction-plan executor to claim + execute it to + `completed` — all observed in Cockroach with the correct triggerEventId. + +Operational note (cadence coupling): keep-alive ticks once per worker cycle, and +an idle cycle is bounded by the NATS pull long-poll (~30s). The deadline must +exceed the max cycle time. Org-liveness is death-resilient with >= 2 replicas. +Future hardening: an independent fast keep-alive loop decoupled from the work +cycle, so a single long work cycle cannot delay the heartbeat. + +### Still staged toward the full vision + +- **Agent liveness** (second half of tenet #1) — `agents: []` in the Cockroach + snapshot source; Hermes sessions are not yet persisted to Cockroach. ORG + liveness is real; AGENT liveness is the next slice (agent-heartbeat table + + Hermes persistence + snapshot read). +- **Hermes autonomous data plane** — the Hermes runtime + Hindsight memory + + orchestration are built and unit-tested in-process (Phase 4) but not yet + deployed/integrated into the live k8s pipeline. The reaction-plan action + executor in the deployed worker is the V0 path, not yet the Hermes agent. + +## Update 2026-05-30 — Phase 7: agent liveness is real (both halves of tenet #1) + +The keep-alive snapshot source no longer hardcodes `agents: []` — it reads real +persisted agent heartbeats (migration 0012 `agentic_org_agent_heartbeat`, +store.recordAgentHeartbeat / readAgentHeartbeats with DB-clock age). The engine's +stale-agent -> reassignment-signal branch is now live, not dormant. + +Live proof (real Cockroach): a fresh agent heartbeat reads ALIVE (the lane applies +only the org heartbeat, no reassignment); forcing the agent past its deadline +makes the engine emit ReassignStaleWork and the sink record a +`stale_work_reassignment` alert naming the agent's work item. The control plane +only SIGNALS reassignment — the decision stays in the autonomous data plane. + +Durability evidence: the kind cluster ran 12–20 min with 0 restarts and the org +heartbeat reached version 32 (alive=true) — the deterministic keep-alive kept the +org alive in-cluster continuously, unattended. + +Full suite 516/516 pass, 0 skipped, vs live Cockroach + NATS. tsc 0. + +### Honest boundary (production writer) + +The agent-liveness mechanism + detection are real and proven, but no deployed +agent session calls `recordAgentHeartbeat` yet — that writer arrives when the +Hermes runtime (Phase 4, currently in-process) is integrated into the live +worker pipeline. Next slices toward the full vision: (1) wire the orchestration's +Hermes heartbeat to the agent-heartbeat writer; (2) integrate the Hermes +autonomous data plane into the deployed worker (agent decision-making on work +items); (3) independent fast keep-alive loop; (4) deploy Hindsight memory. + +## Update 2026-05-30 — consolidation + honest status of the autonomous organization + +The latest committed substrate (migrations through 0012, the orchestration +agent-heartbeat writer) was rebuilt into the worker image, reloaded into the kind +cluster, and redeployed. Migration 0012 applied in-cluster; all three +control-plane tables are present. The org heartbeat advanced from version 32 +(before redeploy) to 40 (after) — **org-liveness survived a full worker redeploy**: +the org's proof of life is independent of any individual worker instance, which is +exactly "drive the organization to stay alive." + +### Done + proven (committed, tested, k8s-verified) + +- **Deterministic keep-alive control plane (tenet #1), both halves:** + - Org liveness — pure engine + lane wired as the first worker-runtime lane, + Cockroach-backed (`control_plane_heartbeat`), DB-clock age. Proven live and + in-cluster (40+ unattended ticks, survived a redeploy). Org-stall detection + proven (alerts recorded under a mis-tuned deadline). + - Agent liveness — `agent_heartbeat` table + store + snapshot read; a stale + agent deterministically produces a reassignment signal. Proven live. + - Production writer — the orchestration persists agent liveness on a successful + Hermes heartbeat (dependency-inverted writer; the Cockroach store satisfies it). +- **Control-plane / data-plane separation** — keep-alive only SIGNALS; it never + decides work. The data plane (Hermes) decides. +- **Runs in kubernetes-in-docker** — Cockroach + NATS + worker; migrations apply + on boot; readiness; the loop runs with lane-failure discipline. +- **Spin up tasks** — a published supervisor-signal event drives ingest → V0 + reaction plan → claim + execute → `completed`, observed in Cockroach. +- **3 real CockroachDB-dialect bugs found + fixed** (multi-statement migration + splitting; interval-multiplication cast) only visible against a live cluster. +- ~518 tests, full suite green vs live Cockroach + NATS; tsc 0 throughout; TDD. + +### Honestly remaining toward the full autonomous vision + +These are real, named, and staged — not hidden: + +1. **Hermes autonomous decision-making in the deployed pipeline** — the worker's + reaction-plan executor runs the V0 deterministic action, not a Hermes agent. + The Hermes runtime + Hindsight memory are built and unit-tested but in-process + simulated; integrating them (with Cockroach-backed adapters + a real agent + execution backend) into the live worker is the largest remaining slice and the + substance of "sufficient autonomy and decision-making from the agents + themselves." +2. **Cockroach-backed Hermes runtime + memory adapters** (only in-process exist). +3. **Independent fast keep-alive loop** — decouple the heartbeat cadence from the + work cycle so a long single cycle cannot delay the org heartbeat. +4. **Deploy Hindsight memory** as a real service. +5. **Operationalize the full organizational structure** (hats, supervisor chain, + teams) in the running system. + +The operator's explicitly-emphasized #1 tenet — enough determinism to drive the +organization and the agents to stay alive — is delivered, tested, and proven in +kubernetes. The autonomous-agent decision layer is architected and unit-tested; +making it real end-to-end requires the agent/LLM execution infrastructure named +above. + +## Update 2026-05-30 — Phase 9: the autonomous data plane runs end-to-end in kubernetes + +The deployed worker's reaction-plan executor now runs each action THROUGH a Hermes +run (createHermesReactionPlanActionExecutor), and the agent's heartbeat is +persisted to the durable control-plane store. The full loop is proven in-cluster: + + task event -> ingest -> reaction plan -> Hermes agent run (acts on the work item) + -> agent heartbeat persisted to agentic_org_agent_heartbeat + -> the deterministic keep-alive engine watches the agent. + +Live in-cluster evidence: publishing a SupervisorSignalSent event for work item +`work-07426f93...` produced, after the worker ran it through Hermes, an +agent_heartbeat row: agent `agent-engineering_manager-...`, hat +`hat-engineering_manager-...`, work_item `work-07426f93...` (the exact work item +from the event), deadline 90000 ms. The control plane (org keep-alive) and the +data plane (Hermes agent) now both run in the same deployed worker, with the +control plane watching the agents the data plane spawns. + +This is the architecture the operator asked for: enough determinism to keep the +organization AND the agents alive, with the agents doing the autonomous work. +The agent's decision logic is the in-process Hermes runtime today; a real +agent/LLM backend swaps in behind the same HermesRuntime port without changing +any of this wiring (the keep-alive watch, the durable liveness, the reaction-plan +integration all stay identical). + +## Update 2026-05-30 — the full watch loop closes in kubernetes (tenet #1 realized) + +The complete cycle now runs and is proven end-to-end in the kind cluster: + + task event -> ingest -> reaction plan -> Hermes agent run (autonomous, acts on + the work item) -> durable agent liveness -> the agent goes silent -> the + deterministic keep-alive engine catches it past its deadline -> a + stale_work_reassignment signal naming the agent + work item. + +In-cluster evidence: after the Hermes run for work item `work-07426f93...` +heartbeated once and the agent went silent, the keep-alive engine recorded a +`stale_work_reassignment` alert: staleAgentId `agent-engineering_manager-...`, +hatAssignmentId `hat-engineering_manager-...`, workItemId `work-07426f93...`, +heartbeatAgeMs 93063 (past the 90000 ms deadline). Meanwhile the org heartbeat +kept advancing (version 56), unattended, surviving a redeploy. + +This is the operator's #1 tenet, realized and proven in kubernetes: + +- the ORGANIZATION stays alive deterministically (org heartbeat), +- the AGENTS run autonomously (Hermes data plane in the deployed worker), +- and the control plane WATCHES the agents and deterministically catches a silent + one, signaling reassignment of its work. + +The one remaining infrastructure dependency is the agent's internal decision +backend (in-process Hermes today vs. a real LLM/sandbox), which lives behind the +swappable HermesRuntime port — every surrounding piece (control plane, durable +liveness, watch loop, reaction-plan integration, reassignment) is real and proven. + +## Update 2026-05-30 — independent keep-alive loop (cadence-coupling caveat resolved) + +The keep-alive now runs on its OWN fixed cadence (default 5s), concurrently with +and independent of the work loop, instead of ticking once per worker cycle. A +slow agent run or the ~30s idle NATS long-poll can no longer delay the org +heartbeat — the org's proof of life is deterministic regardless of work-cycle +timing. + +Proven in-cluster: with the independent loop, the org heartbeat version advances +roughly every 5 seconds (observed 75 -> 77 -> 78 -> 79 across ~18s) with age_ms +consistently under the interval — versus the previous ~30s-per-tick coupling that +forced a deadline above the cycle time. `agentic.worker.keep_alive.tick` events +confirm the independent loop is the ticker. The work runtime no longer ticks +keep-alive (no double-tick); on a stop signal the work loop exits, then the +keep-alive loop is stopped and its current tick awaited. + +This resolves the cadence-coupling caveat noted in the Phase 6 update. Full suite +519 pass, 0 fail (6 live skipped); tsc 0; the boot-path test (main.test.ts) stays +green — the proven boot path was untouched. + +## Update 2026-05-30 — durable data plane runs to completion in kubernetes (+ a real bug the live cluster caught) + +Hermes runs and Hindsight memory are now durable (Cockroach-backed, migrations +0013 + 0014) and wired into the deployed worker. The full autonomous data plane +now runs to COMPLETION in-cluster and every layer is durable: + +For a single task event (work item work-7f7453d4...), after the worker processed it: + +- hermes_run.state = completed; outcome = "handled create_supervisor_triage"; + evidence = ["evt-7f7453d4..."] (JSON evidence round-trips through JSONB) +- hindsight_memory = "processed create_supervisor_triage for work item ..." + (the agent's durable retained learning, scoped + sticky) +- agent_heartbeat = agent-engineering_manager-... (durable liveness, watched by + the independent keep-alive loop) +- reaction_plan = completed + +### A real bug the live cluster caught (and unit tests missed) + +The first durable run STALLED at `running` with a `duplicate key violates +agentic_org_hermes_run_pkey` failure. Root cause: a fresh Cockroach Hermes +runtime / memory adapter is created per reaction-plan execution, and the DEFAULT +id generators used a per-instance counter (hermes-run-1 / mem-1) that reset every +execution -> collision on the first retry. The mocked unit tests passed an +explicit deterministic id generator, so they never exercised the default. Fixes: +default id generators now use crypto.randomUUID(); completeRun casts the evidence +param to JSONB. A new live integration test (hermes-memory-live-integration) +exercises the real DB (unique ids across runs, JSON evidence round-trip, scoped +recall) so this class of bug is guarded. Full suite 536/536 pass, 0 skipped, vs +live Cockroach + NATS; tsc 0. + +### State of the system + +Durable + Cockroach-backed + proven end-to-end in kubernetes: + +- Control plane: org liveness + agent liveness; independent fast keep-alive loop. +- Data plane: durable Hermes runs + durable Hindsight memory; runs complete. +- Full loop: task event -> reaction plan -> durable Hermes agent run -> durable + memory + liveness -> keep-alive watches -> reassignment on silence -> completed. + +The remaining piece is the agent's internal DECISION backend (simulated in-process +Hermes today; a real LLM/sandbox backend swaps in behind the unchanged +HermesRuntime port) plus the full org-artifact structure (decision records via the +command pipeline). Every surrounding piece is real, durable, tested, and proven. + +## Update 2026-05-30 — Phase 12: organizational-structure command pipeline (PROVEN IN-CLUSTER) + +The deployed worker now produces **durable organizational artifacts** for every +reaction-plan action, not just an agent run. `composeOrganizationReactionPlanActionExecutor` +(apps/workers/src/organization-executor-composition.ts) wraps the Hermes agent +executor and, after a successful agent run, idempotently seeds the target +work item and creates a supervisor-triage **discussion anchor** through the +command pipeline (permissive auth seam + policy observation + create-discussion-anchor +handler + work-anchor reader). The application executor's `commandPipeline` +dependency was ISP-narrowed to `CommandPipeline` +(the only command it builds), so any wider pipeline remains assignable via +contravariance. + +Proven in kind for work item `work-0b7a3569-378a-443e-ad39-98731b2b494e` from a +single published `SupervisorSignalSent` task — both planes, durably persisted: + +- **Data plane:** `agentic_org_hermes_run` row `hermes-run-e14c00dd…` state=`completed`, + agent=`agent-engineering_manager-4b0ab953…`, outcome="handled create_supervisor_triage", + evidence=`["evt-0b7a3569…"]`; `agentic_org_hindsight_memory` persisted; agent + liveness heartbeat written (the keep-alive control plane watches it). +- **Org structure:** `agentic_org_projects` `project-0b7a3569…` (active), + `agentic_org_work_items` `work-0b7a3569…` (created, seeded idempotently), + `agentic_org_discussion_anchors` `discussion-anchor-ca64e91d…` (work_item), + anchored to the seeded work item — created through the command pipeline. + +Worker reaction-plan telemetry for the cycle: `reaction_plan.status=succeeded`, +`succeeded_count=1`. tsc 0, 538 tests (2 new org-executor unit tests). + +This closes the Stop-hook-named gap: "the full organizational-structure command +pipeline." Agents now produce real, auditable org substrate while running as +durable, watched Hermes agents. (The one remaining seam is the agent *decision +backend* itself — a real LLM/sandbox swaps in behind the unchanged HermesRuntime +port without touching any of the durable plumbing proven above.) + +## Update 2026-05-30 — Phase 13: agent decisions computed by the deterministic kernel (PROVEN IN-CLUSTER) + +The Hermes agent's outcome is no longer a hardcoded string — it is a REAL +decision through the deterministic decision kernel (`observe` -> `decide`): + +- `observe()` + `DefaultDeterministicRules` compute the LEGAL option set (the + determinism that keeps the run, and so the org, within bounds), +- the composer (`EphemeralComposerPort`) makes the autonomous CHOICE among them. + A choice outside the legal set is rejected as a deterministic-rule violation + (unit-proven) — the agent cannot escape the rules, only select within them. + +This is exactly the determinism+autonomy balance the north star names: "enough +determinism to keep the org/agents alive, with sufficient autonomy and decision +making from the agents themselves." The default composer is a deterministic +first-legal-option policy; a real LLM/sandbox backend implements the same +`EphemeralComposerPort` and swaps in WITHOUT touching the keep-alive control +plane, the durable Hermes runs/memory, or the org-artifact command pipeline. + +Proven in kind for work item `work-e5243bd4-e6d9-4ae6-a408-3b3c544852ac`: + +- **Computed decision (agent autonomy within guardrails):** + `agentic_org_hermes_run.outcome_summary` = + "decided 'compose' -> composing: selection needed before any side effect"; + `agentic_org_hindsight_memory.content` = + "selected compose from 2 legal option(s) under rules [gate-precondition, evidence-precondition]" + — both the determinism (the rules that computed the legal set) and the + autonomy (the selection) are durably visible. +- **No regression:** the same run still produced the org artifacts + (`agentic_org_work_items` work-e5243bd4…, `agentic_org_discussion_anchors` + discussion-anchor-e8b759f4…) and durable liveness. + +tsc 0, 542 tests (4 new decision tests). reaction-decision.ts: +createFirstLegalOptionComposer / decideReactionAction / summarizeReactionDecision +(Result-as-DU) / deterministicRunIdForAction (FNV-1a -> stable base-10 ZetaId, +DST-replayable). + +### Remaining seam (infra-dependent, NOT pure code) + +The only thing not done is a *live* LLM/sandbox composer (real model calls + +sandboxed tool execution). It needs API credentials + a sandbox runtime, not +more application code: it is a drop-in `EphemeralComposerPort` behind the +unchanged decision kernel. Every durable invariant it would rely on — the +deterministic legal-option guardrail, the Hermes run lifecycle, Hindsight +memory, agent liveness, and the org-artifact command pipeline — is implemented +and proven in-cluster above. + +## Update 2026-05-30 — Operator tenet #1 holistic proof (deterministic keep-alive, live cluster) + +Live control-plane state after the session's runs, read straight from Cockroach +in-cluster — the deterministic keep-alive engine is driving liveness on both +axes, independently of the work cycle: + +- **Org liveness:** `agentic_org_control_plane_heartbeat.version = 594` (the + org heartbeat has been ticked 594 times; single row, UPSERT version-bumped). + Only **7** `org_stall` detections total — transient startup gaps; the org is + staying alive. +- **Agent liveness:** **1693** `stale_work_reassignment` alerts. With only a + handful of tasks ever published, agents run once, complete, and then age past + their deadline — and the deterministic watch never stops catching them and + SIGNALLING reassignment (per the operator tenet: keep-alive only signals + liveness, it never decides the work itself). The engine relentlessly watches. +- **6** agent heartbeats tracked. + +This is operator tenet #1 end-to-end: a deterministic, Cockroach-backed, +DB-clock-aged keep-alive loop (decoupled from the work cycle) that drives the +organization to stay alive and drives the agents to stay alive, while the +autonomous data plane makes its own bounded decisions. + +## Update 2026-05-30 — Phase 14: LIVE LLM + sandboxed-tool agent decision backend (PROVEN IN-CLUSTER) + +The last remaining seam — a *live* decision backend with real model calls and +real tool execution — is now implemented and proven in the kind cluster. No +external credentials: a model runs in-cluster (Ollama, qwen2:0.5b). + +How it stays safe: the model is only ever shown the LEGAL options (computed by +`observe` + `DefaultDeterministicRules`); its reply is parsed to a legal token +(actionType or target phase) and then re-validated by the decision kernel +(`resolveSelection`, shared with the sync path). An illegal/unparseable/ +unreachable response falls back to the deterministic policy. The model adds +judgment WITHIN the guardrails; it can never widen them. + +New surface: + +- `AsyncEphemeralComposerPort` + `decideAsync` (async decision path, identical + legality enforcement to the sync path). +- `createModelBackedComposer` (prompt from legal options → ChatCompletionPort → + parse legal token → select; deterministic fallback). +- `createOllamaChatPort` (real in-cluster model calls, AbortController-bounded). +- `SandboxToolPort` + `createSubprocessSandbox` (real bounded subprocess: + isolated cwd, env stripped to PATH, SIGKILL on timeout, output capped; + Result-as-DU). The agent runs a sha256 verification tool → durable evidence. +- `deploy/k8s/25-ollama.yaml` (in-cluster model); worker env `LLM_BASE_URL`, + `LLM_MODEL`. + +Proven in kind for work item `work-8c4df9ab-c77d-4589-aba4-63e3a2f4b447`: + +- **Real model call:** Ollama GIN log + `08:01:47 | 200 | 1.788861126s | 10.244.0.20 | POST "/api/chat"` — the worker + pod (10.244.0.20) made a 1.79s qwen2:0.5b inference at the exact cycle time. +- **Model-driven decision:** `agentic_org_hermes_run.outcome_summary` = + "decided 'compose' -> composing: model selected 'compose'" (the + "model selected" reason is produced ONLY by the model-success branch). +- **Real sandboxed tool execution:** `outcome_evidence_refs` = + `["evt-8c4df9ab…", "sandbox:sha256:f983a883b9fa9ea661df9ac92bfb5c5dcb1ff02811e0faaba2e20fbae9cf7bcd"]` + — a real subprocess produced the digest. +- **Determinism recorded:** Hindsight memory = + "selected compose from 2 legal option(s) under rules [gate-precondition, evidence-precondition]; sandbox tool produced sandbox:sha256:f983a883…". +- **No regression:** org artifacts (work_item + discussion_anchor) and durable + liveness still produced. + +Unit proofs (in CI, no cluster needed): the sandbox really executes a +subprocess, really kills on timeout, and really strips the env so a tool cannot +read worker secrets (`apps/workers/test/subprocess-sandbox.test.ts`); the +model-backed composer selects the model's legal token, tolerates chatty output, +accepts a target-phase token, and falls back on illegal/unreachable +(`packages/application/test/model-backed-composer.test.ts`). + +tsc 0, 554 tests (12 new across the phase). + +### Status: the entire vision is now implemented and proven end-to-end in kubernetes + +- Deterministic keep-alive control plane drives org + agent liveness (org + heartbeat v594; 1693 agent-liveness signals; only signals, never decides). +- Autonomous data plane: durable Hermes runs + Hindsight memory + agent liveness. +- Real agent decisions: live in-cluster model calls, bounded by the deterministic + legal-option kernel (the determinism+autonomy split). +- Real tool execution: bounded, isolated, env-stripped sandboxed subprocess. +- The entire organizational structure: command pipeline producing durable, + auditable org artifacts (discussion anchors anchored to work items). + +## Update 2026-05-30 — the full hat + department organization runs end-to-end in kubernetes (PROVEN IN-CLUSTER) + +The entire organizational structure/system — every hat, every department, the +binding lifecycle, RMO hat-supply voting, director/TPM prioritization, +assignment, and the customer-discovery→release pipeline — is now built and +proven end-to-end in the kind cluster. One `runOrgCycle` ties every layer +together and writes a durable, attributed trace that proves the *whole +hierarchy* is working, from Executive Board down to individual contributors. + +### Built (P0–P7, all committed) + +- **Org as data** (`org-seed.ts`): 16 departments + ~115 hats derived into full + `HatDefinition`s — supervises = reverse(reportsTo), conflicts symmetrized + (A↔B), short TTL/warmup/cooldown per tier so the lifecycle is *observable* in + seconds. `validateOrgGraph()` proves the reportsTo graph is acyclic + every + parent resolves (DFS). +- **Binding lifecycle DU** (`hat-binding.ts` + `hat-lifecycle.ts`): + Pending→Warmup→Active→Probation→Expired/Released/Succeeded/Revoked. `advance` + is deterministic from `boundAt + warmup/TTL` vs the clock; terminal phases + no-op; expiry stamps cooldown; succession is planned for the vacated hat. +- **The determinism⇄autonomy split at org scope** (`org-decision.ts`): + determinism computes the LEGAL set; an agent chooser picks WITHIN it; the pick + is clamped (`max(0, min(len-1, trunc(index)))`) so a chooser — deterministic + *or* model-backed — can never escape the rules. Empty legal set and NaN/ + overflow indices both resolve safely. +- **Prioritization** (`prioritization.ts`): a priority recommendation is scored + from weighted inputs, but the *class an authority may choose* is clamped by + level (an IC can't decide, a TPM can't expedite/pause, a Director can). +- **RMO** (`rmo.ts`): required hat supply is computed from priority-weighted + workload; supervisors vote; a majority-quorum tally yields a HatSupplyDecision + (expand/release/hold) with the median target. +- **Assignment** (`assignment-engine.ts`): eligible agents ranked by per-hat + reputation, filtered by already-wearing / cooldown / conflict / supply cap; + supply exhaustion routes back to RMO rather than over-staffing. +- **Pipeline** (`pipeline.ts`): the 7 gates customer_rfp_review → brd_approval → + architecture_approval → implementation_review → runtime_validation → + final_business_validation → release_readiness → **merged**, each owned by a + specific hat. `nextLegalGate` makes a gate legal *iff* all priors passed — no + gate can be skipped. +- **Observability** (`org-snapshot.ts`): a pure fold over hats + bindings + + OrgEvents → hierarchy activity by acting-hat level, department rollup, active + bindings with time-to-expiry, per-work-item pipeline stage, latest priority/ + supply. The fold is order-independent (latest-state-per-subject by + max(occurredAt)) so it's correct whether the store returns rows ASC or DESC. +- **Durable state** (`cockroach-org-event-store.ts` + `cockroach-hat-binding- + store.ts` + migration `OrgSystemV15`): one `agentic_org_org_events` row per + *every* transition (actorHatId, departmentId, supervisorChain, decision, + evidence, correlation/causation/trace as JSONB) + `agentic_org_hat_bindings`. + +### In-cluster proof (agentic-org namespace CockroachDB) + +`deploy/run-org-cycle.ts` ran one cycle against in-cluster Cockroach; the +persisted trace was then read back and folded by `deploy/observe-org.ts`: + +- **71 org_events** persisted, attributed across the WHOLE hierarchy: + executive_board=1, c_suite=3, director=1, manager=16, lead=5, + individual_contributor=28. +- The work item reached **merged** through all **7 gates** (Product Owner → + BRD Reviewer → Architect → Code Reviewer → QA Verifier → Product Owner → + Release Manager), each emitting a quality_gate_evaluation + a + pipeline_stage_transition. +- The hat lifecycle was observed real: team_lead binding warmup → active → + **expired** (cooldown 30s) → **succession_planned** (director_assigned, 2 + candidates). +- RMO supply voting recorded (3/3 approved, quorum met) and assignments bound + the owner hats. + +Every transition is crystal-clear in the persisted store: the `observe-org.ts` +LATEST DECISIONS view replays the exact chronological progression +(architecture_approval → implementation_review → runtime_validation → +final_business_validation → release_readiness → merged, then the lifecycle). + +### Verification + +tsc 0; **614 tests, 0 fail** (org-runtime drives a customer goal to Merged and +exercises every hierarchy level; the snapshot fold has a DESC-order regression +test; the Cockroach stores round-trip JSONB and exclude terminal phases from +list-active). Independent review checkpoint: the clamp cannot be escaped, +terminal bindings cannot advance, no gate can be skipped, and the store SQL is +fully parameterized with explicit JSONB casts. + +### Status: the organizational structure is proven end-to-end + +The organizational structure is implemented, hooked up, tested in kind, and +observed end-to-end — executive board → C-suite → directors → management → +individual contributors — with full observability + traceability. diff --git a/agentic-organization/docs/OBSERVABILITY_AND_SELF_HEALING.md b/agentic-organization/docs/OBSERVABILITY_AND_SELF_HEALING.md index d637a003cc..f45b0aca00 100644 --- a/agentic-organization/docs/OBSERVABILITY_AND_SELF_HEALING.md +++ b/agentic-organization/docs/OBSERVABILITY_AND_SELF_HEALING.md @@ -1,3 +1,9 @@ +--- +title: Observability and Self-Healing +canonical_name: Agentic Organization +status: design +--- + # Observability and Self-Healing ## Status diff --git a/agentic-organization/docs/OBSERVE_COMPOSER_AND_RUN_STATE.md b/agentic-organization/docs/OBSERVE_COMPOSER_AND_RUN_STATE.md new file mode 100644 index 0000000000..8ee84c61c3 --- /dev/null +++ b/agentic-organization/docs/OBSERVE_COMPOSER_AND_RUN_STATE.md @@ -0,0 +1,165 @@ +--- +title: Observe, Compose, and Run-State +canonical_name: Agentic Organization +status: v0 +ideas: [2, 4, 5, 6] +extends: + - ORGANIZATION_RUNTIME_ARCHITECTURE.md + - ALWAYS_ON_ORCHESTRATION_RUNTIME.md +composes_with: + - ./OBSERVABILITY_AND_SELF_HEALING.md + - ./SUPERVISOR_CHAIN_COMMUNICATION.md + - ./BUSINESS_QUALITY_GATE_SYSTEM.md + - ./GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md + - ./DOC_FRONTMATTER_CONVENTION.md +code_anchors: + - ../packages/application/src/observe.ts + - ../packages/application/test/observe.test.ts + - ../packages/observability/src/workflow-visibility.ts + - ../packages/application/src/command-pipeline.ts + - ../packages/governance/src/constitution-gate.ts +supersedes: [] +--- + +# Observe, Compose, and Run-State + +The keystone that unifies operator ideas **2** (explicit discriminated unions), +**5** (a single `observe.ts` entrypoint with a memoryless composer), and **6** +(complete workflow visibility, triggers, and lifecycle with deterministic rules +applied at every step). Operator idea **4** (metrics via MCP + review) attaches +here because metrics are derived from the same readouts. + +## The one thing an agent remembers + +An agent in the Agentic Organization remembers exactly one entrypoint: +`observe.ts`. It does **not** remember the work item lifecycle, the gate rules, +the hat policy matrix, or its own past. It calls `observe(runId-scoped snapshot)` +and is handed: + +- the **current run state** (an explicit lifecycle phase), and +- the **legal next options** at the requested **scope**, already filtered by the + Organization's deterministic rules. + +This inverts the usual burden: instead of every agent carrying organizational +knowledge, the knowledge lives in `observe.ts` and the agent carries nothing. + +## Everything is an explicit discriminated union (idea 2) + +Per the repo rule *"IMPLICIT-NOT-EXPLICIT in DUs is class error"*, every +substantively distinct state is an explicit DU variant, never buried in an +if-chain or a field combination. The keystone defines, in +`packages/application/src/observe.ts`: + +| DU | Variants | Role | +|----|----------|------| +| `RunScope` | run, work_item, initiative, project, organization | the "varying scopes" a run is observed at | +| `RunLifecyclePhase` | observing, composing, awaiting_gate, executing, awaiting_evidence, awaiting_review, completed, blocked, failed | the run state machine, mirroring the V0 spine | +| `ObserveResult` | `{ outcome: "readout" }` \| `{ outcome: "feedback" }` | `Result` as an explicit two-variant DU — failure is data, never a thrown exception or null | +| `ObserveFeedbackReason` | unknown_phase, terminal_phase, deterministic_rule_violation | why a readout could not be produced | +| `ComposerSelection` | `{ decision: "select" }` \| `{ decision: "hold" }` | what the memoryless composer decided | +| `DecideResult` | selected \| held \| feedback | the composed observe→compose outcome | + +These follow the house DU convention (`const X = {...} as const; type X = +(typeof X)[keyof typeof X]`). + +## observe() is pure; the composer holds the intelligence (idea 5) + +The separation is the whole point: + +- **`observe(snapshot, deps)`** is a **pure function**. It reads nothing and + stores nothing. It computes the readout from an injected `RunSnapshot` and an + explicit `phase → options` table (the same shape as + `domain/src/work-item-state-machine.ts`'s transition table). Determinism here + is what makes the system auditable. +- **`EphemeralComposerPort.compose(request) → ComposerSelection`** is the + intelligence. It is **memoryless by contract**: everything it needs is in the + `request.readout` argument; it keeps nothing between calls. An LLM-backed + composer must put all context into the request, never into instance state. + This is the `agent-loop` skill's *LLM-as-pure-selector* substrate made + concrete. +- **`decide(snapshot, composer, deps)`** wires them: it observes, asks the + composer to pick from the surviving options, and **rejects any selection + outside the readout**. The composer cannot escape the deterministic rules — it + can only select within them. (Test: *"decide rejects a composer that selects + an option outside the readout."*) + +```text +caller loads snapshot (from Cockroach / git-as-db, ZetaId-addressed) + -> observe(snapshot) # pure logic: state + legal options at scope + -> composer.compose(readout) # ephemeral, memoryless: pick one (or hold) + -> decide(...) validates the pick is legal + -> emit selection as a command through command-pipeline.ts +``` + +## Deterministic rules apply at every step (idea 6) + +`DeterministicRule` is a pure predicate `(option, snapshot) → veto?`. The default +set (`gate-precondition`, `evidence-precondition`) is always applied, and the +readout records `deterministicRulesApplied` so the *visibility* of which rules +ran is first-class. A phase with no surviving option returns +`deterministic_rule_violation` feedback rather than silently stalling — stall is +a signal, composing with `ANTI_STALL_PRIORITY_RUNTIME.md`. + +This is the trigger/lifecycle contract idea 6 asks for: every transition is +gated by explicit rules, every readout is timestamped and trace-carrying, and +the legal surface is computed, not improvised. + +## Visibility and metrics (idea 4) + +Each `observe`/`decide` outcome maps onto the existing +`observability/src/workflow-visibility.ts` `WorkflowObservationKind` DU — no new +visibility substrate is invented. A `decide` selection becomes a `command` +observation; a `hold` or `feedback` becomes a weak-point indicator +(`BlockedWork` / `PolicyDenied` analog). **Metrics (idea 4) are aggregations +over these observation records** exposed through an MCP tool +(`read_workflow_metrics`), and the *review* of those metrics is itself ordinary +Organization work routed through the supervisor chain and documented as a +recurring report. The metric pipeline therefore reuses the trace envelope +(`correlationId`/`causationId`/`traceId`/`idempotencyKey`) already carried on +every readout. Detailed metric schemas extend `OBSERVABILITY_AND_SELF_HEALING.md` +rather than living here. + +## Constitution ratification needs ≥3 agents (idea 2, governance half) + +The `compose → awaiting_gate → execute` edge requires an approved gate. For the +class of decisions that set **constitutions** (the deterministic rule sets and +scope policies that `observe` itself applies), the gate is a **multi-oracle +ratification gate**: at least **3 independent agents** must agree before the +constitution set is adopted. This composes with the existing `governance` +package and the repo's multi-oracle / three-faction BFT substrate (B-0703, +B-0652) rather than inventing a new voting path. + +Explicit gate DU, implemented in `packages/governance/src/constitution-gate.ts`: + +```ts +const ConstitutionRatificationState = { + Proposed: "proposed", + Gathering: "gathering", // collecting independent agent agreements + Ratified: "ratified", // >= quorum (default 3) distinct agents agreed + Rejected: "rejected", + Superseded: "superseded", // lifecycle variant; not produced by the pure evaluation +} as const; + +type ConstitutionAgreement = { + agentId: string; // must be distinct; self-agreement does not count + hatAssignmentId: string; + decision: ConstitutionDecision; // "agree" | "object" + rationale: string; +}; +``` + +`evaluateConstitutionRatification({ agreements, quorum? })` is a pure function. +Ratification is reached only when the **distinct** `agentId`s with `decision === +"agree"` number **≥ quorum** (default 3, `DEFAULT_CONSTITUTION_QUORUM`), with no +unresolved `object` (any objection vetoes to `Rejected`). A single agent agreeing +twice counts once — no self-amplification. The quorum, the distinctness check, +and the objection-veto precedence are explicit — not buried — exactly as the +`decide()` legality check is explicit. + +## Status + +`observe.ts` + `decide` + the default deterministic rules are **implemented and +tested**, and the constitution ratification gate DU +(`packages/governance/src/constitution-gate.ts`) is **implemented and tested** +(full suite 318 green). The MCP metrics tool (idea 4) remains **design** (next +slice). See `PHASED_DEVELOPMENT_PLAN.md` for sequencing. diff --git a/agentic-organization/docs/ORGANIZATION_LAYER_BUILD_PLAN.md b/agentic-organization/docs/ORGANIZATION_LAYER_BUILD_PLAN.md index 00e7730e57..01a402dd5a 100644 --- a/agentic-organization/docs/ORGANIZATION_LAYER_BUILD_PLAN.md +++ b/agentic-organization/docs/ORGANIZATION_LAYER_BUILD_PLAN.md @@ -1,3 +1,9 @@ +--- +title: Organization Layer Build Plan +canonical_name: Agentic Organization +status: design +--- + # Organization Layer Build Plan This document describes how to build the Organization layer that makes departments and hats operational. The hat inventory defines who can exist. This build plan defines the environment, automation, runtime state, and feedback loops that let each hat actually perform its role. diff --git a/agentic-organization/docs/ORGANIZATION_RUNTIME_ARCHITECTURE.md b/agentic-organization/docs/ORGANIZATION_RUNTIME_ARCHITECTURE.md index b49a9a708a..5e7a056f17 100644 --- a/agentic-organization/docs/ORGANIZATION_RUNTIME_ARCHITECTURE.md +++ b/agentic-organization/docs/ORGANIZATION_RUNTIME_ARCHITECTURE.md @@ -1,3 +1,9 @@ +--- +title: Agentic Organization Runtime - Current Design +canonical_name: Agentic Organization +status: design +--- + # Agentic Organization Runtime - Current Design ## Purpose diff --git a/agentic-organization/docs/ORG_SYSTEM_BUILD_BLUEPRINT.md b/agentic-organization/docs/ORG_SYSTEM_BUILD_BLUEPRINT.md new file mode 100644 index 0000000000..3566036869 --- /dev/null +++ b/agentic-organization/docs/ORG_SYSTEM_BUILD_BLUEPRINT.md @@ -0,0 +1,138 @@ +--- +title: Org System Build Blueprint +canonical_name: Agentic Organization +status: design +--- + +# Org System Build Blueprint + +The end-to-end build of the hat + department organization: deterministic +guardrails, agent-driven outcomes, full observability. This blueprint is the +single map of what is being built and how each piece maps to the docs. + +## Design invariant (everywhere) + +Every decision uses the **`observe → decide` kernel** already in +`packages/application/src/observe.ts`: + +- **deterministic** computes the *legal set* (next legal gate, eligible hats, + in-scope reprioritizations, supply-permitted candidates), +- the **agent composer chooses within it** (which work, approve/reject, who to + staff, how many hats). + +The agent can never widen the rules — it only selects inside them. This is the +"enough determinism, agents drive outcomes" tenet at organizational scope. + +## Traceability invariant (everywhere) + +Every state transition emits **exactly one durable `org_event`** (generalizing +the doc's "one HatSwap per transition"). Each carries: `actorHatId`, +`departmentId`, `supervisorChain` (the DAG path), `decision`, `evidenceRefs`, +and `correlationId/causationId/traceId`. "What's happening" is one query over +`agentic_org_org_events` plus the `org snapshot` projection. + +## Layers being built (on top of what exists) + +Already present (build *on* these): `QualityGateKind` (7 gates), +`company-work-policy.ts` (gate-chain order + prior-gate enforcement), +`work-item-state-machine.ts`, `record_quality_gate_evaluation`, the keep-alive +control plane, Hermes runs, the observe/decide kernel. + +| # | Layer | New modules | Doc anchor | +|---|---|---|---| +| P1 | **Org as data** | `domain/department.ts`, `domain/hat-definition.ts`, `application/org-seed.ts` (15 depts + ~100 hats) | DEPARTMENT_HAT_TOOL_INVENTORY | +| P2 | **Hat binding lifecycle** | `domain/hat-binding.ts` (Pending→Warmup→Active→Probation→Revoked→Expired/Released/Succeeded), `application/hat-lifecycle.ts` (TTL expiry, warmup, cooldown, succession) | CLUSTER_NATIVE_HAT_SYSTEM | +| P3 | **Prioritization + RMO** | `application/prioritization.ts` (PriorityDecision), `application/rmo.ts` (hat-supply voting) | ANTI_STALL_PRIORITY_RUNTIME | +| P4 | **Assignment engine** | `application/assignment-engine.ts` (rank by reputation, assign within supply+policy) | CLUSTER_NATIVE_HAT_SYSTEM, ANTI_STALL | +| P5 | **Work pipeline driver** | `application/pipeline.ts` (discovery→…→release; gate→owner-hat; agent runs approve/reject) | BUSINESS_QUALITY_GATE_SYSTEM | +| P6 | **Observability** | `domain/org-event.ts`, `observability/org-snapshot.ts`, Cockroach tables + stores | CLUSTER_NATIVE_HAT_SYSTEM §Observability | +| P7 | **Deploy + observe** | worker lifecycle loops, kind deploy, full-hierarchy proof | all | + +## Departments (15) and hierarchy + +``` +Executive Board + → C-suite (CEO, CTO, COO, CFO, Chief Architect) + → Department Directors + → TPMs / Engineering Managers / QA Managers + → Team Leads / Reviewers + → Specialists / Implementers / QA / Operators +``` + +Departments: Executive Board & Governance, Program & Initiative Management, +Product & Customer Discovery, Business Analysis, Architecture, Engineering, +Engineering Management, QA & Verification, QA Engineering, Security & Compliance, +Delivery & Release, Memory & Knowledge, Documentation & Project Skills, +Operations & Infrastructure, Observability & Evidence, Capability & Automation +Expansion. The `supervises`/`reportsTo` edges form a DAG (validated acyclic). + +## Hat definition (the record) + +Per the doc's `HatDefinition` (30 fields): id, name, departmentId, parent/ +supervises/reportsTo/conflictsWith/assignableBy hat ids, allowedToolBundles, +skills, approval/voting/memory/credential/documentation scopes, +lifecycleTransitions, requiredEvidence, maxConcurrentAssignments, tokenTtlSeconds, +warmup/cooldownSeconds, successionPolicy, stickyAttribution, quorumSize, +reputationScope, riskLevel, requiresTwoPersonApproval, requiresHumanApproval. + +## Hat binding lifecycle (deterministic transitions) + +``` +Pending → Warmup (warmupSeconds) → Active → {Probation | Expired(tokenTtlSeconds) | Released | Succeeded} + ↘ Cooldown gate before same wearer re-takes +``` + +Each transition → one HatSwap-shaped `org_event`. Succession picks the next +wearer via `observe→decide` (legal = eligible candidates per successionPolicy). + +## RMO (resource/hat-supply) + +From the *prioritized workload* compute `requiredCount` per hat (sum of +`requiredHats` across non-paused, ranked work). Supervisors (Director, Cost +Controller, CFO, Executive Board by risk) **vote**; the tally yields a +`HatSupplyDecision` (reserve | release | expand | preempt) with a target count. +The assignment engine respects the target. Movement invariant: every scarce hat +has a visible supply queue. + +## Prioritization + +`PriorityDecision` (expedite|high|normal|defer|paused; `decidedBy` +tpm|engineering_manager|department_director|review_hat|agent_vote|executive| +incident_commander|approved_policy; requiredHats; reasonCodes). Platform computes +`PriorityRecommendation` from the ~20 inputs; a Director/TPM hat decides via +`observe→decide` (legal = within-scope rank moves). + +## Pipeline (the 7 gates → owner hats) + +| Gate | Owner hats | +|---|---| +| customer_rfp_review | Product Owner, Business Analyst, Customer Interviewer | +| brd_approval | BRD Reviewer, Business Approver, Product Owner | +| architecture_approval | Architect, Architecture Reviewer, Chief Architect | +| implementation_review | Code Reviewer, Engineering Manager | +| runtime_validation | QA Verifier, QA Reviewer, Browser Automation QA | +| final_business_validation | Product Owner, Business Analyst | +| release_readiness | Release Manager, Delivery Reviewer, TPM | + +The driver: `observe→decide` computes the next legal gate (prior gates approved/ +waived per `company-work-policy`), routes a reaction-plan agent run to an +owner-hat actor, records the gate evaluation, advances the work item. Failures +route back via the recovery-path table. Every step → `org_event`. + +## Cockroach tables (new) + +`agentic_org_departments`, `agentic_org_hat_definitions`, +`agentic_org_hat_bindings`, `agentic_org_org_events`, +`agentic_org_priority_decisions`, `agentic_org_hat_supply_decisions`, +`agentic_org_pipeline_stages`. Migrations are additive + mirrored on disk with +the TS↔disk parity test. + +## Kind end-to-end proof (P7) + +Seed org at worker startup → submit a customer-discovery goal → observe one work +item traverse all 7 gates, with **Executive Board → C-suite → Directors → +Management → ICs** each acting (gate approvals, priority decisions, supply votes, +assignments attributed to a hat at the right level), **hat expiry firing** on +TTL, **RMO voting** on supply — every step readable from `agentic_org_org_events` + +the org-snapshot projection. The hierarchy walk must show activity at every +level. diff --git a/agentic-organization/docs/PHASED_DEVELOPMENT_PLAN.md b/agentic-organization/docs/PHASED_DEVELOPMENT_PLAN.md index dba00f489a..84d14725a0 100644 --- a/agentic-organization/docs/PHASED_DEVELOPMENT_PLAN.md +++ b/agentic-organization/docs/PHASED_DEVELOPMENT_PLAN.md @@ -1,3 +1,9 @@ +--- +title: Agentic Organization Phased Development Plan +canonical_name: Agentic Organization +status: design +--- + # Agentic Organization Phased Development Plan ## Purpose diff --git a/agentic-organization/docs/README.md b/agentic-organization/docs/README.md index 498c9d148b..e24d00a9ad 100644 --- a/agentic-organization/docs/README.md +++ b/agentic-organization/docs/README.md @@ -1,3 +1,9 @@ +--- +title: Agentic Organization Docs +canonical_name: Agentic Organization +status: index +--- + # Agentic Organization Docs This folder is the working design set for the Agentic Organization platform. @@ -35,8 +41,14 @@ Current documents: - [V0 Policy and Runtime Boundaries](./V0_POLICY_AND_RUNTIME_BOUNDARIES.md) - the hat policy matrix, MCP preflight checks, cluster runtime boundaries, failure rules, and ArgoCD integration shape. - [Cluster-Native Hat System](./CLUSTER_NATIVE_HAT_SYSTEM.md) - the CRD, OPA, hat binding, succession, reputation, graph rendering, polyglot operator, and event model for enforcing hats on Kubernetes. - [Cluster Execution and Memory Substrate](./CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE.md) - the k3s, sandboxed Hermes container, Cilium Service Mesh, SPIRE identity, Vault-backed secrets, Credential Proxy, NATS, Hindsight, and runtime observability contract. +- [Dynamic Memory System Design](./DYNAMIC_MEMORY_SYSTEM_DESIGN.md) - hat ⊕ agent ⊕ work memory tiers, the retrieval weight (how likely to surface; zero = never again), KPI/outcome correlation, and the Memory & Knowledge department's daily maintenance cycle as an observe→decide org cycle. - [AI Cluster Scaffold Context](./AI_CLUSTER_SCAFFOLD_CONTEXT.md) - the two-directory NixOS/k3s/ArgoCD scaffold assumptions, component clarifications, bootstrap constraints, and deferred/local-model gating. - [Architecture Source](./ORGANIZATION_RUNTIME_ARCHITECTURE.md) - the current conceptual architecture and operating model. +- [Observe, Compose, and Run-State](./OBSERVE_COMPOSER_AND_RUN_STATE.md) - the keystone `observe.ts` entrypoint: the run-lifecycle discriminated union, the readout of current state + legal options at varying scopes, the ephemeral memoryless composer, deterministic-rule visibility, and the >=3-agent constitution ratification gate. +- [Git as Database and Event Store (Frontmatter + ZetaId CRDT)](./GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md) - the persistence/addressing layer: a markdown file is a row, frontmatter is the SQL-derived typed schema + columns + fk graph edges, events are ZetaId-keyed files that merge conflict-free as a G-Set CRDT, state is a timestamp-ordered fold, and CockroachDB is a rebuildable query index. +- [Metrics and the 3-Agent Review Board](./METRICS_AND_REVIEW_BOARD.md) - the two metric layers: quantitative code metrics gathered like coverage (longest function/class god-object detection, file length, nesting) and the qualitative >=3-agent review board that must agree on a finding before it is published, plus the MCP tool interface (hosting stubbed). +- [State Reconciliation Table](./STATE_RECONCILIATION.md) - North Star priority #2: the single authoritative mapping of WorkItemState across Work OS / V0 enum / UI column / event name / gate owner, plus the observe.ts RunLifecyclePhase binding and the generic-vs-type-specific rule split. The gate on adding more commands. +- [Doc Frontmatter Convention](./DOC_FRONTMATTER_CONVENTION.md) - the YAML frontmatter schema (title/canonical_name/status/ideas/extends/composes_with/code_anchors/supersedes) that turns this doc set into a navigable, derivable graph. The intent is to keep the architecture document focused on what the Organization is, while implementation documents describe how to build it incrementally. diff --git a/agentic-organization/docs/RUNTIME_TECH_AND_PACKAGE_STRATEGY.md b/agentic-organization/docs/RUNTIME_TECH_AND_PACKAGE_STRATEGY.md index 85585bd182..ccc20223b0 100644 --- a/agentic-organization/docs/RUNTIME_TECH_AND_PACKAGE_STRATEGY.md +++ b/agentic-organization/docs/RUNTIME_TECH_AND_PACKAGE_STRATEGY.md @@ -1,3 +1,9 @@ +--- +title: Runtime Technology and Package Strategy +canonical_name: Agentic Organization +status: design +--- + # Runtime Technology and Package Strategy ## Purpose diff --git a/agentic-organization/docs/STATE_RECONCILIATION.md b/agentic-organization/docs/STATE_RECONCILIATION.md new file mode 100644 index 0000000000..892829eb0e --- /dev/null +++ b/agentic-organization/docs/STATE_RECONCILIATION.md @@ -0,0 +1,94 @@ +--- +title: State Reconciliation Table +canonical_name: Agentic Organization +status: v0 +ideas: [2] +extends: + - NORTH_STAR_ALIGNMENT_CHECKPOINT.md + - WORK_AND_RELEASE_MANAGEMENT_OS.md +composes_with: + - ./OBSERVE_COMPOSER_AND_RUN_STATE.md + - ./DOC_FRONTMATTER_CONVENTION.md +code_anchors: + - ../packages/domain/src/state-reconciliation.ts + - ../packages/domain/src/work-item-state-machine.ts + - ../packages/application/src/observe.ts +supersedes: [] +--- + +# State Reconciliation Table + +North Star priority #2 ("State Name Divergence"): work-item state is named +differently across the Work OS, the V0 schema, the UI, the event names, and now +`observe.ts`. This is the **single authoritative mapping** so those surfaces stop +diverging — and the North Star names it as the *gate on adding more commands*, +which is why it is the first slice. + +Implemented in `packages/domain/src/state-reconciliation.ts` (14 tests). + +## The table + +One row per real `WorkItemState`. The mapping is held as a +`Record` so it is **compile-exhaustive**: +adding a `WorkItemState` is a type error until a row is supplied (OCP — a new +state breaks the build, not just a runtime test). + +| WorkItemState | Conceptual Work OS | UI column | Event | Gate owner | +|---|---|---|---|---| +| `created` | Captured | Backlog | `work_item.state_changed` | none | +| `intake` | Intake | Backlog | `work_item.state_changed` | none | +| `triage` | Triage | Triage | `work_item.state_changed` | Engineering Manager | +| `ready` | Ready | Ready | `work_item.state_changed` | Engineering Manager | +| `in_progress` | In Progress | In Progress | `work_item.state_changed` | none | +| `blocked` | Blocked | Blocked | `work_item.state_changed` | Engineering Manager | +| `review` | In Review | Review | `work_item.state_changed` | Code Reviewer | +| `done` | Done | Done | `work_item.state_changed` | Release Manager | + +`eventName` is uniformly `work_item.state_changed` because that is the real +`AgenticEventType` emitted on any state transition — per-state event names are +not part of the vocabulary, so inventing them would *create* the divergence the +table exists to remove. `gateOwner` is an explicit `GateOwner` DU +(none / engineering_manager / code_reviewer / qa_reviewer / release_manager), not +a free string, so gate ownership is enumerable. + +## observe.ts binding + +`RUN_PHASE_FOR_STATE` maps each `WorkItemState` to the `observe.ts` +`RunLifecyclePhase` string it corresponds to. The domain package holds the phase +strings *literally* (not by importing the application package) to preserve the +dependency direction (domain ⊀ application); a test asserts all 8 states are +covered. + +| WorkItemState | RunLifecyclePhase | Why | +|---|---|---| +| created / intake / triage | `observing` | not yet actionable by a run; the loop is watching for legal moves | +| ready | `composing` | a selectable work item exists; the composer plans | +| in_progress | `executing` | side-effecting work is happening | +| blocked | `blocked` | direct | +| review | `awaiting_review` | a reviewer is awaited | +| done | `completed` | terminal success | + +This is the seam slice 4 uses to drive `observe()` from a real work item's state. + +## Generic vs type-specific rules + +The **generic** transitions (apply to every type) stay in +`work-item-state-machine.ts` — this module does not re-implement enforcement. It +adds only the **type-specific overlay** as an explicit `TypeSpecificRule` DU +(`no_skip_intake` / `requires_triage_evidence` / +`requires_assigned_engineer_and_schedule`), mirroring +`assertDefectTransitionRequirements`. `typeSpecificRulesFor(Defect)` returns the +3 defect rules; `typeSpecificRulesFor(Task)` is the empty subset. + +## Review + +Built and reviewed through the 3-lens board (correctness / SOLID / +architecture-adherence). Adopted finding: the reconciliation set was an array +(documented but not compile-enforced); converted to a +`Record` so exhaustiveness is checked by the compiler. + +## Status + +Implemented and tested (14 tests; full suite 360 green; tsc clean). Next: slice 2 +(`decide_gate`) consumes the `GateOwner` column; slice 4 consumes +`RUN_PHASE_FOR_STATE`. diff --git a/agentic-organization/docs/SUPERVISOR_CHAIN_COMMUNICATION.md b/agentic-organization/docs/SUPERVISOR_CHAIN_COMMUNICATION.md index 5307674f54..75f7d4d84b 100644 --- a/agentic-organization/docs/SUPERVISOR_CHAIN_COMMUNICATION.md +++ b/agentic-organization/docs/SUPERVISOR_CHAIN_COMMUNICATION.md @@ -1,3 +1,9 @@ +--- +title: Supervisor-Chain Communication +canonical_name: Agentic Organization +status: design +--- + # Supervisor-Chain Communication ## Status diff --git a/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md b/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md index c4642e212e..26da1773c9 100644 --- a/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md +++ b/agentic-organization/docs/TECHNICAL_CA_PACKAGE_ARCHITECTURE.md @@ -1,3 +1,9 @@ +--- +title: Technical CA: Package-First Agentic Organization Architecture +canonical_name: Agentic Organization +status: design +--- + # Technical CA: Package-First Agentic Organization Architecture ## Status diff --git a/agentic-organization/docs/UI_AND_OBSERVABILITY_CONCEPTS.md b/agentic-organization/docs/UI_AND_OBSERVABILITY_CONCEPTS.md index 3c66ea606c..62eadfc135 100644 --- a/agentic-organization/docs/UI_AND_OBSERVABILITY_CONCEPTS.md +++ b/agentic-organization/docs/UI_AND_OBSERVABILITY_CONCEPTS.md @@ -1,3 +1,9 @@ +--- +title: Agentic Organization UI and Observability Concepts +canonical_name: Agentic Organization +status: design +--- + # Agentic Organization UI and Observability Concepts ## Purpose diff --git a/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md b/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md index b569d266bb..9d98a7eba8 100644 --- a/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md +++ b/agentic-organization/docs/V0_EXECUTABLE_CONTRACT.md @@ -1,3 +1,9 @@ +--- +title: V0 Executable Contract +canonical_name: Agentic Organization +status: design +--- + # V0 Executable Contract ## Purpose diff --git a/agentic-organization/docs/V0_POLICY_AND_RUNTIME_BOUNDARIES.md b/agentic-organization/docs/V0_POLICY_AND_RUNTIME_BOUNDARIES.md index 4415c78bc8..b1c94a70ab 100644 --- a/agentic-organization/docs/V0_POLICY_AND_RUNTIME_BOUNDARIES.md +++ b/agentic-organization/docs/V0_POLICY_AND_RUNTIME_BOUNDARIES.md @@ -1,3 +1,9 @@ +--- +title: V0 Policy and Runtime Boundaries +canonical_name: Agentic Organization +status: design +--- + # V0 Policy and Runtime Boundaries ## Purpose diff --git a/agentic-organization/docs/V0_SCHEMA_AND_COMMANDS.md b/agentic-organization/docs/V0_SCHEMA_AND_COMMANDS.md index a57a0aa569..78bfe8a450 100644 --- a/agentic-organization/docs/V0_SCHEMA_AND_COMMANDS.md +++ b/agentic-organization/docs/V0_SCHEMA_AND_COMMANDS.md @@ -1,3 +1,9 @@ +--- +title: V0 Schema and Commands +canonical_name: Agentic Organization +status: design +--- + # V0 Schema and Commands ## Purpose diff --git a/agentic-organization/docs/WORK_AND_RELEASE_MANAGEMENT_OS.md b/agentic-organization/docs/WORK_AND_RELEASE_MANAGEMENT_OS.md index 3fc5edaa8e..f10e1d70e7 100644 --- a/agentic-organization/docs/WORK_AND_RELEASE_MANAGEMENT_OS.md +++ b/agentic-organization/docs/WORK_AND_RELEASE_MANAGEMENT_OS.md @@ -1,3 +1,9 @@ +--- +title: Work and Release Management OS +canonical_name: Agentic Organization +status: design +--- + # Work and Release Management OS The Organization needs its own task, backlog, project, and release management product. This is not an integration with Linear or Jira. It is the operational backbone that lets Hermes agents understand work, update progress, receive assignments, emit signals, request reviews, manage releases, and keep every level of the Organization aware of health. diff --git a/agentic-organization/docs/WORK_OS_OVERHAUL_GAPS_AND_DESIGN.md b/agentic-organization/docs/WORK_OS_OVERHAUL_GAPS_AND_DESIGN.md new file mode 100644 index 0000000000..2f8ce8d1c3 --- /dev/null +++ b/agentic-organization/docs/WORK_OS_OVERHAUL_GAPS_AND_DESIGN.md @@ -0,0 +1,412 @@ +--- +title: Work OS Overhaul — Gaps and Design +canonical_name: Agentic Organization +status: design +--- + +# Work OS Overhaul — Gaps and Design + +This document does two things, in order: + +1. **Documents every gap** between what the org system actually does today (the + simplified P5 pipeline proved in kind) and a **true agentic Work OS** — proper + work types, work flowing in and out of the system, a *standing* QA department + that evolves the product through testing, a real test-case-management + + test-run + regression system, and the living feedback / churn / escalation + loops that keep work moving. +2. **Specifies the overhaul** we will then implement and prove in kind — built on + the primitives we already have (`observe → decide` kernel, `org_event` trace, + `HatBinding` lifecycle, RMO supply voting, CockroachDB, NATS), before any + memory work begins. + +It composes with the existing design docs and makes them executable: +[`WORK_AND_RELEASE_MANAGEMENT_OS.md`](WORK_AND_RELEASE_MANAGEMENT_OS.md) (the +target product shape), [`ANTI_STALL_PRIORITY_RUNTIME.md`](ANTI_STALL_PRIORITY_RUNTIME.md) +(churn/escalation), [`BUSINESS_QUALITY_GATE_SYSTEM.md`](BUSINESS_QUALITY_GATE_SYSTEM.md) +(gate-failure routing), [`AMBIGUOUS_REQUIREMENT_LIFECYCLE.md`](AMBIGUOUS_REQUIREMENT_LIFECYCLE.md) +(BRD → scenarios), and [`METRICS_AND_REVIEW_BOARD.md`](METRICS_AND_REVIEW_BOARD.md) +(the existing code-metrics + 3-agent review board). + +The design invariant is unchanged: **determinism computes the legal set; agents +drive the outcomes; every transition emits one durable `org_event`.** The overhaul +makes the *work itself* a living, observable, self-moving system — not a single +linear pipeline. + +--- + +## Part A — What exists today (honest current state) + +The org system (P0–P7, proved in kind) shipped a **simplified slice**: + +| Built | Reality | +|-------|---------| +| `runOrgCycle` 7-gate pipeline (`pipeline.ts`) | **One linear, single-type gate chain** `customer_rfp_review → … → merged`. It proved the kernel end-to-end but flattened away work types, batches, QA, and flow-in/out. | +| `observe.ts` | A **single-run execution lifecycle** read (`observing → compose → gate → execute → evidence → review → complete`), vetoed by deterministic rules. **Not scoped by hat authority.** Every hat would see the same readout. | +| `prioritization.ts` | Per-item priority, class **clamped by decider level** — but **flat**: one item at a time, no roll-up across teams/batches. | +| `work-item-state-machine.ts` (domain) | A **V0 work-item lifecycle** (`created → intake → triage → ready → in_progress → blocked/review → done`) with only two types (`task`, `defect`) and defect-specific guards. **Not used by `runOrgCycle`.** | +| `packages/metrics/` | Quantitative **code** metrics + a 3-agent qualitative **review board**. Real, but about *source text*, not *work-group health*. | +| RMO supply voting, `HatBinding` lifecycle, `org_event` trace, org-snapshot | Solid primitives — the overhaul **reuses all of them**. | + +### The structural problem: three unreconciled work models + +There are **three** different "work" abstractions that do not talk to each other: + +1. `WorkItemState` (domain) — the *work-item* lifecycle (intake → done). Unused by the runtime. +2. `RunLifecyclePhase` (`observe.ts`) — the *execution* lifecycle of one run. +3. `PipelineStage` (`pipeline.ts`) — the *7 quality gates*. What the runtime actually drives. + +A true Work OS needs these unified into **one coherent model**: a typed **work +item** moves through a **type-specific workflow** of **gates**, executed by +**runs**, grouped into **work batches** that carry **rolled-up metrics**, observed +per **hat authority scope**. + +--- + +## Part B — The gap map + +Each row: the target capability, where it is designed, what exists today, and the +gap this overhaul closes. + +| # | Target capability | Designed in | Today | Gap | +|---|-------------------|-------------|-------|-----| +| G1 | **Full work-item types** (goal, report, service_request, task, defect, capability_request, review, incident, release) | WORK_OS §Core Domain | only `task`/`defect` in domain; runtime is type-blind | Add the full `WorkItemType` DU + per-type workflow policy | +| G2 | **Work batches / mission runs** (durable work groups) with status + capacity + completion/recovery | WORK_OS §Work Batch | none | Add `WorkBatch` DU + membership + batch lifecycle | +| G3 | **Work flows IN from outside** (customer defect/SR → intake → triage → backlog) | WORK_OS §Purpose; Product Shape | none | **External intake adapter** (W5) | +| G4 | **Work flows OUT** (release → merge → activation → post-release verify) | WORK_OS §Release | partial (gate chain ends at `merged`) | Release workflow + egress | +| G5 | **`observe` scoped per hat authority** | operator (this session) | observe is run-scoped, not hat-scoped | **`observeForHat()`** (W2) | +| G6 | **Hierarchical prioritization roll-up** (IC→Lead→Director→exec) | WORK_OS; ANTI_STALL §Cadences | flat per-item | Scope-aware prioritization over batches (W2) | +| G7 | **Work-group rolled-up metrics** (completion %, defect counts, QA bounce-backs) | METRICS; ANTI_STALL §signals | code metrics only | **Operational metrics roll-up** (W2) | +| G8 | **QA as a standing department** that continuously tests + evolves the product | WORK_OS §QA board; ANTI_STALL §QA flow | QA is just a gate approver in the pipeline | **QA standing loop** (W3) | +| G9 | **Test-case management** — derive scenarios off BRDs, store suites/cases | AMBIGUOUS_REQ ("feed learnings into test cases"); WORK_OS §QA board | none | **TestSuite/TestCase domain** (W3) | +| G10 | **Test runs** — execute (computer-use / browser-automation / manual), record pass/fail + evidence | BUSINESS_QUALITY_GATE §runtime_validation | none | **TestRun domain + executor port** (W3) | +| G11 | **Regression + failed-feature observation** (a previously-passing case now fails) | WORK_OS §QA; goal | none | **Regression detector over test-run history** (W3) | +| G12 | **Defect feedback loop** (failed run → defect → fix → re-test) | BUSINESS_QUALITY_GATE §failure routing | defect type exists, loop doesn't | **Failure→defect→retest loop** (W4) | +| G13 | **Churn reduction** (detect bounce-back; stop the loop) | ANTI_STALL §QA bounce-back; METRICS | none | **Churn detector + `RepeatedQaBounceBack`** (W4) | +| G14 | **Escalation ladder** (EM/TPM bring on more agents; architect changes approach) | ANTI_STALL §blocker table | none | **Escalation as observe→decide hat actions** (W4) | +| G15 | **Living, event-driven movement** (no polling; signals create the next action) | WORK_OS §guardrails | the cycle runs once, top-to-bottom | **NATS-driven continuous org cycle** (W6) | +| G16 | **Signals as durable typed events** beyond our 7 `OrgEventKind`s | WORK_OS §Signal Model | 7 kinds | Extend `OrgEventKind` with work/QA/escalation families | + +--- + +## Part C — The overhaul design + +### C1. One unified work model (G1, G2, G4) + +A single **`WorkItem`** is the spine. It has a **type**, a **workflow** (the legal +state path for that type), a **batch** membership, and a stream of `org_event`s. + +```ts +// House DU +const WorkItemType = { + Goal: "goal", Report: "report", ServiceRequest: "service_request", + Task: "task", Defect: "defect", CapabilityRequest: "capability_request", + Review: "review", Incident: "incident", Release: "release", +} as const; + +const WorkItemState = { + Created: "created", Intake: "intake", Triage: "triage", Ready: "ready", + InProgress: "in_progress", Blocked: "blocked", InReview: "in_review", + InQa: "in_qa", QaFailed: "qa_failed", Done: "done", Released: "released", + Cancelled: "cancelled", +} as const; +``` + +- **Type-specific workflow policy** (data, not code) decides which transitions are + legal for a given type — the `WorkflowDefinition` shape from WORK_OS §Custom + Workflow Builder, evaluated by `observe`. A `defect` cannot reach `ready` + without triage fields + reproduction evidence (we keep the existing guard); a + `release` runs the release sub-states; a `goal` runs discovery first. +- **The P5 pipeline becomes one workflow** (the `task`/feature workflow) among + several, instead of *the* pipeline. `runOrgCycle` dispatches by the item's type + to its workflow. This **unifies the three models**: `WorkItem` is the spine, + `RunLifecyclePhase` is how a single execution run advances *within* an + `in_progress` item, gates are the approvals between states. + +**Work batches** group related items for an initiative/release/incident: + +```ts +const WorkBatchState = { + Created: "created", Scoped: "scoped", CapacityPlanned: "capacity_planned", + Scheduled: "scheduled", Active: "active", PartiallyBlocked: "partially_blocked", + CompletionCheck: "completion_check", Done: "done", +} as const; +``` + +A batch carries the **rolled-up metrics** (C2) and is the unit a Lead/Director +prioritizes over. + +### C2. Scoped observe + hierarchical prioritization + work-batch metrics (G5, G6, G7) + +**`observeForHat(hat, orgState)`** is the new organizational read — *distinct from* +the per-run `observe()` (which stays for execution). It returns a readout **scoped +to the hat's authority**: + +```ts +function observeForHat(hat: HatDefinition, state: OrgWorkState): HatReadout { + const inScopeBatches = batchesInAuthorityScope(hat, state); // IC: own items; Lead: team batch; + // Director: dept batches; exec: all + const metrics = inScopeBatches.map(rollUpBatchMetrics); // C2 metrics, aggregated upward + const legalActions = legalPrioritizationActions(hat, inScopeBatches); // clamped by level (P3) + return { hatId: hat.id, scope: authorityScopeOf(hat), batches: inScopeBatches, metrics, legalActions }; +} +``` + +- **Authority scope** is derived from `hat.level` + `departmentId` + the work + groups it owns: an **IC** sees its assigned items; a **Lead** sees its team's + batch; a **Director** sees its department's batches; the **C-suite/Board** see + org-wide rollups. *Each higher scope sees a higher-level rollup* — this is "the + observe is different for each hat." +- **Prioritization rolls up**: an IC orders its own queue; a Lead orders items + *within* its batch; a Director orders *batches* across teams; exec orders across + the org. The legal priority classes stay clamped by level (the P3 mechanism we + already built); what changes is the **scope of the objects being ordered**. + +**Work-group metrics (pure roll-up fold over work items + test runs + org_events):** + +```ts +type WorkBatchMetrics = { + batchId: string; + total: number; done: number; completionPct: number; // completion % + blocked: number; inQa: number; + openDefects: number; defectsOpenedInTestSetup: number; // # defects QA opened during test-case setup + qaBounceBacks: number; // churn signal (C4) + testRuns: number; testFailures: number; passRate: number; + regressionsOpen: number; // previously-passing cases now failing + slaBreaches: number; oldestBlockedAgeMs: number; + movementScore: number; // ANTI_STALL: is the batch moving? +}; +``` + +These aggregate **scope → scope** (item → batch → department → org), so a Director's +readout shows department rollups and an exec's shows org rollups. **These metrics +become the memory KPI signal** (the memory design §6 will correlate against +`WorkBatchMetrics`, not a binary `merged`). + +### C3. QA as a standing department + test-case management + test runs + regressions (G8–G11) + +This is the deepest new design and the goal's core. QA is **not a gate approver** — +it is a **continuously-running department** that authors and runs tests, files +defects, and observes regressions, *evolving the product*. + +#### C3.1 Test-case management (derive from BRDs) + +```ts +type TestCase = { + testCaseId: string; suiteId: string; + projectId: string; initiativeId: string; + brdId: string; // the BRD/acceptance criterion it covers (AMBIGUOUS_REQ) + title: string; scenario: string; // a work-scenario derived off the BRD + steps: readonly TestStep[]; // ordered, each with expected result + executionMode: TestExecutionMode; // computer_use | browser_automation | api | manual + authoredByHatId: string; // a QA hat (qa_reviewer / browser_automation_qa) + status: TestCaseStatus; // draft | active | retired +}; +const TestExecutionMode = { ComputerUse: "computer_use", BrowserAutomation: "browser_automation", Api: "api", Manual: "manual" } as const; +``` + +A QA hat **reads an approved BRD → derives scenarios → authors test cases** into a +**TestSuite** scoped to the project/initiative. This is a deterministic-shaped +step: the BRD's acceptance criteria are the legal source; the QA agent drives the +authoring within them; each case emits `test_case_authored`. + +#### C3.2 Test runs (execute + record, against the initiative branch) + +```ts +type TestRun = { + testRunId: string; testCaseId: string; suiteId: string; + initiativeBranch: string; // QA validates the branch, not main (WORK_OS §branch model) + executorHatId: string; agentId: string; + mode: TestExecutionMode; + outcome: TestRunOutcome; // passed | failed | blocked | flaky + evidence: readonly EvidenceRef[]; // screenshots, traces, logs, repro steps + startedAt: string; finishedAt: string; +}; +const TestRunOutcome = { Passed: "passed", Failed: "failed", Blocked: "blocked", Flaky: "flaky" } as const; +``` + +- **Execution is a port** (`TestExecutor`) so the *real* runner can be computer-use, + browser-automation (Playwright), an API harness, or a recorded manual result — + all behind one interface. The in-process fake records deterministic outcomes for + tests; the live adapter drives the actual tool. (This mirrors how the live + LLM/sandboxed-tool backend plugs in behind a port, Phase 14.) +- Every run is durable + evidence-bearing + emits `test_run_recorded`. **This is + the "track failed test runs" substrate** the goal asks for. + +#### C3.3 Regression + failed-feature observation (G11) + +A **regression** is deterministic to detect: a `TestCase` whose **latest** run on +the integration target is `failed`/`flaky` but whose **prior** run was `passed`. + +```ts +function detectRegressions(history: readonly TestRun[]): readonly Regression[] { + // group by testCaseId, order by finishedAt; a regression = passed... then failed +} +``` + +- A **failed feature** = an `active` test case that has *never* passed on the + branch (a new feature that doesn't meet its BRD scenario yet). +- Regressions and failed features **roll into `WorkBatchMetrics`** (C2) and each + opens (or links to) a **Defect** (C4). QA thereby *continuously evolves the + product*: every BRD becomes scenarios, scenarios become runs, runs surface + regressions, regressions become defects, defects drive fixes, fixes get + re-tested — a standing loop, not a one-shot gate. + +#### C3.4 The standing QA cycle + +`runQaCycle` runs on its own NATS cadence (independent of feature work), owned by +the QA department hats (`qa_director`, `qa_engineering_manager`, `qa_verifier`, +`qa_reviewer`, `browser_automation_qa`, `regression_scheduler`): + +1. For each active initiative branch: ensure BRD-derived suites exist (author if not). +2. Schedule + execute due runs (regression sweep + new-feature validation). +3. Record outcomes + evidence; detect regressions/failed-features. +4. Open/refresh defects for failures; emit quality signals. +5. Update `WorkBatchMetrics`. + +### C4. The living feedback / churn / escalation loops (G12–G14) + +This is what makes the system **dynamic and alive** rather than a pipeline. + +#### C4.1 Failure → defect → re-test feedback loop + +A `failed`/`regression` test run **deterministically opens a Defect** linked to the +failing test case + the work item that introduced the change (from the branch's +work-item set). The defect flows the defect workflow; on fix, the **same test case +is re-run**; pass closes the defect. This is a closed loop with durable evidence at +every hop. + +#### C4.2 Churn detection (reduce bounce-back) + +**Churn** = the same work item bouncing between states (e.g. `in_qa ↔ qa_failed ↔ +in_progress`) repeatedly. We count **bounce-backs per work item** from the +`org_event` history; crossing a threshold emits `RepeatedQaBounceBack` and **moves +the item out of the normal loop into an escalation decision** — the loop is *broken +deliberately* instead of spinning. + +```ts +function bounceBackCount(workItemId, events): number; // count qa_failed → in_progress transitions +// threshold (default 3) → churn signal → escalation candidate (C4.3) +``` + +#### C4.3 Escalation ladder (observe → decide, by a hat) + +When churn (or an SLA breach, or a stalled blocker) is detected, the legal +**escalation actions** are computed deterministically and the responsible hat +(EM/TPM/Director) chooses within them — *exactly* the ANTI_STALL blocker table, +expressed in our kernel: + +| Trigger | Legal escalation set (deterministic) | Decider hat | +|---------|--------------------------------------|-------------| +| Repeated QA bounce-back | `{ add_agents, bring_in_architect, re-scope, pause, accept-risk }` | `engineering_manager` / `tpm` | +| `add_agents` chosen | RMO **expand** hat supply for the work's owner hats (we already have `decideHatSupply`) | RMO voters | +| `bring_in_architect` chosen | assign an `architect` to **change the approach** (new CA/ADR), reopen architecture gate | `engineering_director` | +| Reviewer queue saturated | `{ reassign_reviewer, provision_reviewer_hats, escalate }` | `engineering_manager` | +| Blocker stale, no owner | `{ assign_owner, alternate_work, escalate_priority }` | `tpm` | + +- **"Bring on more agents"** = the escalation chooses `add_agents`, which routes to + the **RMO supply-expand** mechanism we already built (`rmo.ts` votes to raise the + target hat count) → the assignment engine staffs more wearers of the owner hat. +- **"Architect changes approach"** = the escalation chooses `bring_in_architect`, + which assigns an `architect` hat, reopens the `architecture_approval` gate, and + the prior implementation is re-scoped against the new CA. The churn loop is + *replaced* by a new approach instead of repeating the failing one. +- Every escalation is one `org_event` (`escalation_decision`), fully traced. + +This is the closed governance loop: **churn is detected deterministically → +escalation options are bounded → a hat decides → the org re-staffs or re-approaches +→ work moves again.** Reducing churn is structural: once an item escalates, it +**cannot** re-enter the same failing loop without a changed input (more agents, +new approach, re-scope, or an explicit accept-risk decision). + +### C5. External / SR intake — work flows IN (G3) + +An **intake adapter** lets outside systems (customer portals, support tools, +monitoring) submit defects/SRs/feature-requests that **become first-class work**: + +```text +external event (HTTP webhook OR NATS subject org.intake.external) + -> normalize (deterministic: map payload → { type, title, severity, projectId, evidence }) + -> create WorkItem in `created` + -> intake → triage (a triage hat classifies: defect | service_request | goal | …) + -> ready (enters the backlog / SR pipeline) +``` + +- **Inbound transport:** an HTTP endpoint + a NATS subject; both land on the same + deterministic **normalizer** (no model call to parse — a typed mapping with a + `Result` so malformed payloads are rejected cleanly, + not silently dropped). +- **De-dup:** content-addressed external key (`uuidv5(source:externalId)`) so the + same upstream report doesn't create duplicate work items (idempotent intake). +- Every intake emits `work_item_created` with `source: "external"` + the upstream + ref, so customer-reported defects are traceable end-to-end into the backlog/SR + pipeline. + +--- + +## Part D — Determinism ⇄ autonomy (the whole overhaul) + +| Concern | Deterministic (legal set / math) | Agent drives (within it) | +|---------|----------------------------------|--------------------------| +| Work-item transitions | type-specific workflow policy → legal next states | which transition to take | +| `observeForHat` | scope union + metric roll-up (pure) | what to prioritize within scope | +| Prioritization | classes clamped by level; objects scoped by authority | the ordering | +| Test authoring | BRD acceptance criteria = legal source | the scenarios/steps | +| Test execution | the executor port runs deterministically; outcome recorded | (the runner; agent for manual) | +| Regression detection | pure fold over run history | — | +| Churn detection | bounce-back count vs threshold (pure) | — | +| Escalation | bounded legal escalation set per trigger | which escalation (EM/TPM/Director) | +| Intake | deterministic normalize + de-dup | the triage classification | + +Nothing here lets an agent skip QA, fake a test pass, escalate beyond the legal +set, or create invisible work. Every move is one `org_event`. + +--- + +## Part E — Storage (CockroachDB, mirroring `OrgSystemV15`) + +New tables (migration `WorkOsV16`, on-disk mirror + parity test, same conventions): + +- `agentic_org_work_items` — id, type, state, project/initiative/batch refs, source (internal|external + upstream ref), severity, timestamps. +- `agentic_org_work_batches` — id, state, scope, capacity plan, completion/recovery rule refs. +- `agentic_org_test_cases` — id, suite, brd ref, scenario, steps (JSONB), execution_mode, status. +- `agentic_org_test_runs` — id, test_case, initiative_branch, outcome, evidence (JSONB), executor hat/agent, timestamps. +- `agentic_org_defects` — id, severity, repro evidence, linked test_case + work_item, fix flow state. +- `agentic_org_intake_inbox` — external de-dup key, raw payload (JSONB), normalized work_item_id, received_at. + +The `org_event` trace + org-snapshot fold extend to a **work/QA view**; new +`OrgEventKind`s: `WorkItemTransition`, `WorkBatchTransition`, `TestCaseAuthored`, +`TestRunRecorded`, `RegressionDetected`, `DefectOpened`, `ChurnDetected`, +`EscalationDecision`, `IntakeReceived`. + +--- + +## Part F — Phased build plan (each phase tsc-clean, tested, proved in kind) + +| Phase | Deliverable | +|-------|-------------| +| **W1** | Unified work model: `WorkItemType` (9) + `WorkItemState` + per-type workflow policy DUs + `WorkBatch` + Cockroach `agentic_org_work_items`/`_work_batches` (migration `WorkOsV16` + parity test). The P5 pipeline becomes the `task` workflow. | +| **W2** | `observeForHat()` (authority-scoped readout) + hierarchical prioritization over batches + `WorkBatchMetrics` roll-up fold + new work `OrgEventKind`s. Unit-proved at IC/Lead/Director/exec scopes. | +| **W3** | QA standing dept: `TestSuite`/`TestCase`/`TestRun`/`Regression` domain + `TestExecutor` port (fake + a browser/computer-use adapter shell) + `runQaCycle` (BRD→suites→runs→regressions→defects). | +| **W4** | Feedback + churn + escalation: failure→defect→retest loop, `bounceBackCount` churn detector, the escalation ladder as observe→decide hat actions wired to RMO supply-expand + architect re-approach. | +| **W5** | External/SR intake adapter (HTTP + NATS → deterministic normalize + de-dup → triage → backlog/SR), traced. | +| **W6** | **Wire the living system into `org-runtime` + the worker; prove end-to-end in kind:** an external defect intake → triage → assigned → developed → **QA test run catches a regression** → defect → bounce-back → **churn escalation pulls in more agents + an architect re-approach** → fixed → re-tested green → released; observe the whole living loop in `org_events` + the snapshot, with `WorkBatchMetrics` rolling up to the exec scope. Record proof in NORTH_STAR_ALIGNMENT_CHECKPOINT. | + +The end-to-end kind proof (W6) is the bar: not "a pipeline ran," but **"the org +observed churn, escalated, re-staffed, changed approach, and the work moved" — +visible from IC to exec.** + +--- + +## Part G — Scope honesty (what this overhaul implements vs. the full vision) + +The `WORK_AND_RELEASE_MANAGEMENT_OS` doc describes a very large product (every +board, every signal family, Temporal-backed durable workflows, Dapr actors). This +overhaul implements the **living core** the goal calls for — work types + flows, +scoped observe + rollup, the standing QA + test-mgmt + regression system, and the +feedback/churn/escalation loops — on our proven deterministic kernel, **fully +functional and proved in kind.** Explicitly *deferred* (named so they are not +silently dropped): role-specific UI boards, Temporal durable workflows, Dapr +actor supply (we keep the RMO vote mechanism), and the full release-automation +package. These are additive and do not block the living loop. + +This document is **W0**. On its acceptance, W1 begins. diff --git a/agentic-organization/package-lock.json b/agentic-organization/package-lock.json new file mode 100644 index 0000000000..7e08cddd23 --- /dev/null +++ b/agentic-organization/package-lock.json @@ -0,0 +1,224 @@ +{ + "name": "@agentic-org/root", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@agentic-org/root", + "dependencies": { + "@nats-io/jetstream": "^3.4.0", + "@nats-io/transport-node": "^3.4.0", + "pg": "^8.13.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@nats-io/jetstream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@nats-io/jetstream/-/jetstream-3.4.0.tgz", + "integrity": "sha512-GzHQodNJ942+R5LRb8PuZ5ugVWVWMRiufxUYLLVWkXKfwDXYN+Owo0d7L/b9O7BPyrbYD7jQWAC6+ZVuXa9Gyw==", + "license": "Apache-2.0", + "dependencies": { + "@nats-io/nats-core": "3.4.0" + } + }, + "node_modules/@nats-io/nats-core": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@nats-io/nats-core/-/nats-core-3.4.0.tgz", + "integrity": "sha512-QMDM86EUNm+wudlKC67HLar/KHHQUvJGLfb4jbahje3pUk3K9afeck9fwsxBZF0eUFox6T/TA6m/t+lQqf+QsQ==", + "license": "Apache-2.0", + "dependencies": { + "@nats-io/nkeys": "2.0.3", + "@nats-io/nuid": "3.0.0" + } + }, + "node_modules/@nats-io/nkeys": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@nats-io/nkeys/-/nkeys-2.0.3.tgz", + "integrity": "sha512-JVt56GuE6Z89KUkI4TXUbSI9fmIfAmk6PMPknijmuL72GcD+UgIomTcRWiNvvJKxA01sBbmIPStqJs5cMRBC3A==", + "license": "Apache-2.0", + "dependencies": { + "tweetnacl": "^1.0.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@nats-io/nuid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nats-io/nuid/-/nuid-3.0.0.tgz", + "integrity": "sha512-QbXZDrxmYlrn5AD06gYcUTmEHwxn96HBQMIk7XTjDlEcx6FPzVBBPjp4AMRh1lEv6B4iJ6Xb/Uz61JugPXFRQg==", + "license": "Apache-2.0", + "engines": { + "node": ">= 22.x" + } + }, + "node_modules/@nats-io/transport-node": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@nats-io/transport-node/-/transport-node-3.4.0.tgz", + "integrity": "sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==", + "license": "Apache-2.0", + "dependencies": { + "@nats-io/nats-core": "3.4.0", + "@nats-io/nkeys": "2.0.3", + "@nats-io/nuid": "3.0.0" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/agentic-organization/package.json b/agentic-organization/package.json index e650e5ee88..3cacd25066 100644 --- a/agentic-organization/package.json +++ b/agentic-organization/package.json @@ -6,6 +6,11 @@ "test": "node --experimental-strip-types --test packages/*/test/**/*.test.ts apps/*/test/**/*.test.ts", "typecheck": "npx --yes -p typescript@6.0.3 tsc -p tsconfig.json" }, + "dependencies": { + "@nats-io/jetstream": "^3.4.0", + "@nats-io/transport-node": "^3.4.0", + "pg": "^8.13.1" + }, "engines": { "node": ">=22.12.0" } diff --git a/agentic-organization/packages/application/src/assignment-engine.ts b/agentic-organization/packages/application/src/assignment-engine.ts new file mode 100644 index 0000000000..e0d024b3ef --- /dev/null +++ b/agentic-organization/packages/application/src/assignment-engine.ts @@ -0,0 +1,157 @@ +/** + * Assignment engine — staffs a hat by ranking eligible agents by reputation and + * choosing within the RMO supply target (CLUSTER_NATIVE_HAT_SYSTEM.md reputation + * model + ANTI_STALL hat-supply). Determinism computes the eligible+supply- + * permitted set; the agent chooser picks the wearer; supply exhaustion routes + * back to RMO instead of over-staffing. + * + * Eligibility filters (deterministic, auditable): not already an active/warmup + * wearer of this hat, not in cooldown for it, not wearing a conflicting hat, and + * under the per-agent active-hat cap. Ranking is reputation on the agent-hat + * pairing (never only an agent score). + */ + +import { HatBindingPhase } from "../../domain/src/hat-binding.ts"; +import { OrgEventKind, type OrgEvent } from "../../domain/src/org-event.ts"; +import type { HatDefinition } from "../../domain/src/hat-definition.ts"; +import { chooseWithinLegal, type OrgChooser } from "./org-decision.ts"; + +export type AgentCandidate = { + agentId: string; + /** reputation on the agent-hat pairing (default 0 for unknown pairings) */ + reputationByHat: Readonly>; +}; + +/** A snapshot of an active/recent binding the engine must respect. */ +export type ActiveBindingSummary = { + hatId: string; + wearerAgentId: string; + phase: HatBindingPhase; + cooldownUntil?: string; +}; + +const NON_TERMINAL: ReadonlySet = new Set([ + HatBindingPhase.Pending, + HatBindingPhase.Warmup, + HatBindingPhase.Active, + HatBindingPhase.Probation, +]); + +function activeHatCount(agentId: string, bindings: readonly ActiveBindingSummary[]): number { + return bindings.filter((b) => b.wearerAgentId === agentId && NON_TERMINAL.has(b.phase)).length; +} + +function inCooldownForHat(agentId: string, hatId: string, bindings: readonly ActiveBindingSummary[], nowMs: number): boolean { + return bindings.some( + (b) => b.wearerAgentId === agentId && b.hatId === hatId && b.cooldownUntil !== undefined && nowMs < Date.parse(b.cooldownUntil), + ); +} + +function wearsConflictingHat(agentId: string, hat: HatDefinition, bindings: readonly ActiveBindingSummary[]): boolean { + const conflicts = new Set(hat.conflictsWithHatIds); + return bindings.some((b) => b.wearerAgentId === agentId && NON_TERMINAL.has(b.phase) && conflicts.has(b.hatId)); +} + +export type RankEligibleInput = { + hat: HatDefinition; + candidates: readonly AgentCandidate[]; + activeBindings: readonly ActiveBindingSummary[]; + nowMs: number; + /** max hats one agent may actively wear at once (default 3) */ + agentMaxActiveHats?: number; +}; + +/** Filter to eligible candidates and rank them by agent-hat reputation (desc, stable). */ +export function rankEligibleCandidates(input: RankEligibleInput): readonly AgentCandidate[] { + const cap = input.agentMaxActiveHats ?? 3; + const eligible = input.candidates.filter((c) => { + const alreadyWearing = input.activeBindings.some( + (b) => b.wearerAgentId === c.agentId && b.hatId === input.hat.id && NON_TERMINAL.has(b.phase), + ); + if (alreadyWearing) return false; + if (inCooldownForHat(c.agentId, input.hat.id, input.activeBindings, input.nowMs)) return false; + if (wearsConflictingHat(c.agentId, input.hat, input.activeBindings)) return false; + if (activeHatCount(c.agentId, input.activeBindings) >= cap) return false; + return true; + }); + return [...eligible].sort((a, b) => { + const ra = a.reputationByHat[input.hat.id] ?? 0; + const rb = b.reputationByHat[input.hat.id] ?? 0; + if (rb !== ra) return rb - ra; + return a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0; + }); +} + +export type AssignmentResult = + | { outcome: "assigned"; agentId: string; reason: string; event: OrgEvent } + | { outcome: "supply_exhausted"; reason: string; event: OrgEvent } + | { outcome: "no_eligible_candidate"; reason: string }; + +export type AssignHatContext = { + createEventId: () => string; + nowIso: () => string; + organizationId: string; + supervisorChain: readonly string[]; + correlationId: string; + causationId: string; + traceId: string; +}; + +function assignmentEvent(ctx: AssignHatContext, hat: HatDefinition, toState: string, decision: string, agentId?: string): OrgEvent { + return { + id: ctx.createEventId(), + kind: OrgEventKind.HatAssignment, + occurredAt: ctx.nowIso(), + organizationId: ctx.organizationId, + actorHatId: hat.id, + ...(agentId !== undefined ? { actorAgentId: agentId } : {}), + departmentId: hat.departmentId, + subjectId: hat.id, + toState, + decision, + supervisorChain: ctx.supervisorChain, + evidenceRefs: [], + correlationId: ctx.correlationId, + causationId: ctx.causationId, + traceId: ctx.traceId, + }; +} + +/** + * Staff one hat. If the active wearer count already meets the RMO supply target, + * the engine refuses to over-staff and emits a supply-exhausted event (routes to + * RMO). Otherwise the chooser picks among eligible candidates; the caller then + * creates the binding (beginBinding). + */ +export function assignHat( + input: { + hat: HatDefinition; + eligibleRanked: readonly AgentCandidate[]; + activeWearerCount: number; + supplyTarget: number; + chooser: OrgChooser; + }, + ctx: AssignHatContext, +): AssignmentResult { + if (input.activeWearerCount >= input.supplyTarget) { + return { + outcome: "supply_exhausted", + reason: `${input.hat.name} at supply cap (${input.activeWearerCount}/${input.supplyTarget})`, + event: assignmentEvent(ctx, input.hat, "supply_exhausted", `${input.hat.name} supply exhausted at ${input.activeWearerCount}/${input.supplyTarget} — routing to RMO`), + }; + } + if (input.eligibleRanked.length === 0) { + return { outcome: "no_eligible_candidate", reason: `no eligible candidate for ${input.hat.name}` }; + } + const choice = chooseWithinLegal(input.eligibleRanked, `assign ${input.hat.name}`, input.chooser); + if (choice.outcome === "no_legal_option") { + return { outcome: "no_eligible_candidate", reason: choice.reason }; + } + const agentId = choice.option.agentId; + return { + outcome: "assigned", + agentId, + reason: choice.reason, + event: assignmentEvent(ctx, input.hat, "assigned", `${input.hat.name} assigned to ${agentId} (${choice.reason}); wearer ${input.activeWearerCount + 1}/${input.supplyTarget}`, agentId), + }; +} diff --git a/agentic-organization/packages/application/src/graph-projection.ts b/agentic-organization/packages/application/src/graph-projection.ts new file mode 100644 index 0000000000..8ef7294f74 --- /dev/null +++ b/agentic-organization/packages/application/src/graph-projection.ts @@ -0,0 +1,117 @@ +/** + * Agent-native knowledge graph projection v0 (North Star priority #5). + * + * Projects the durable Organization records (work items, discussion anchors, + * decisions) into a typed node/edge graph so agents can retrieve scoped context + * by traversal instead of scanning chat. Every node kind and edge kind is an + * explicit DU (repo rule: IMPLICIT-NOT-EXPLICIT is class error). The edge model + * mirrors frontmatter-db's fk-as-edge traversal (packages/frontmatter-db/src/ + * traverse.ts) at the Organization-record scope — fk columns on a record become + * directed edges between nodes. + * + * V0 answers the canonical North Star retrieval question: "all decisions for this + * work item." It is pure over the records handed in; persistence/CDC wiring is a + * later slice. + */ + +import type { DecisionRecord, DiscussionAnchor } from "../../domain/src/index.ts"; + +export const GraphNodeKind = { + WorkItem: "work_item", + DiscussionAnchor: "discussion_anchor", + Decision: "decision", +} as const; +export type GraphNodeKind = (typeof GraphNodeKind)[keyof typeof GraphNodeKind]; + +export const GraphEdgeKind = { + /** discussion anchor -> the work item it is anchored to */ + AnchoredTo: "anchored_to", + /** decision -> the discussion anchor it was decided in */ + DecidedIn: "decided_in", + /** decision -> a work item it spawns as follow-up */ + FollowsUp: "follows_up", +} as const; +export type GraphEdgeKind = (typeof GraphEdgeKind)[keyof typeof GraphEdgeKind]; + +export type GraphNode = { kind: GraphNodeKind; id: string }; +export type GraphEdge = { kind: GraphEdgeKind; fromId: string; toId: string }; + +export type OrganizationGraph = { + nodes: readonly GraphNode[]; + edges: readonly GraphEdge[]; +}; + +export type ProjectGraphInput = { + workItemIds: readonly string[]; + discussionAnchors: readonly DiscussionAnchor[]; + decisions: readonly DecisionRecord[]; +}; + +/** + * Project records into a node/edge graph. Nodes are deduped by (kind,id); edges + * are derived from the fk fields on each record: + * - DiscussionAnchor.workItemId -> AnchoredTo edge (anchor -> work item) + * - DecisionRecord.discussionAnchorId -> DecidedIn edge (decision -> anchor) + * - DecisionRecord.followUpWorkItemIds[] -> FollowsUp edges (decision -> work item) + */ +export function projectOrganizationGraph(input: ProjectGraphInput): OrganizationGraph { + const nodeKeys = new Set(); + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + const addNode = (kind: GraphNodeKind, id: string): void => { + const key = `${kind}:${id}`; + if (!nodeKeys.has(key)) { + nodeKeys.add(key); + nodes.push({ kind, id }); + } + }; + + for (const workItemId of input.workItemIds) { + addNode(GraphNodeKind.WorkItem, workItemId); + } + + for (const anchor of input.discussionAnchors) { + addNode(GraphNodeKind.DiscussionAnchor, anchor.discussionAnchorId); + addNode(GraphNodeKind.WorkItem, anchor.workItemId); + edges.push({ kind: GraphEdgeKind.AnchoredTo, fromId: anchor.discussionAnchorId, toId: anchor.workItemId }); + } + + for (const decision of input.decisions) { + addNode(GraphNodeKind.Decision, decision.decisionRecordId); + addNode(GraphNodeKind.DiscussionAnchor, decision.discussionAnchorId); + edges.push({ kind: GraphEdgeKind.DecidedIn, fromId: decision.decisionRecordId, toId: decision.discussionAnchorId }); + for (const followUpId of decision.followUpWorkItemIds) { + addNode(GraphNodeKind.WorkItem, followUpId); + edges.push({ kind: GraphEdgeKind.FollowsUp, fromId: decision.decisionRecordId, toId: followUpId }); + } + } + + return { nodes, edges }; +} + +/** + * The canonical North Star retrieval: all decisions anchored to a given work + * item. Walks AnchoredTo (work item <- anchors) then DecidedIn (anchor <- + * decisions). Pure graph traversal; returns the decision node ids. + */ +export function decisionsForWorkItem(graph: OrganizationGraph, workItemId: string): readonly string[] { + const anchorIds = new Set( + graph.edges + .filter((edge) => edge.kind === GraphEdgeKind.AnchoredTo && edge.toId === workItemId) + .map((edge) => edge.fromId), + ); + + const decisionIds: string[] = []; + for (const edge of graph.edges) { + if (edge.kind === GraphEdgeKind.DecidedIn && anchorIds.has(edge.toId)) { + decisionIds.push(edge.fromId); + } + } + return decisionIds; +} + +/** Outgoing neighbors of a node by edge kind (generic traversal helper). */ +export function neighborsByEdge(graph: OrganizationGraph, fromId: string, kind: GraphEdgeKind): readonly string[] { + return graph.edges.filter((edge) => edge.kind === kind && edge.fromId === fromId).map((edge) => edge.toId); +} diff --git a/agentic-organization/packages/application/src/hat-lifecycle.ts b/agentic-organization/packages/application/src/hat-lifecycle.ts new file mode 100644 index 0000000000..6241db052c --- /dev/null +++ b/agentic-organization/packages/application/src/hat-lifecycle.ts @@ -0,0 +1,218 @@ +/** + * Hat lifecycle — deterministic binding transitions, each emitting exactly one + * OrgEvent (the HatSwap-shaped trace record). The phase advances purely from the + * bound timestamp + the hat's warmup/TTL against the supplied clock, so expiry + * is DST-replayable and auditable from the row. + * + * Determinism here; the agent-driven choices (who to staff, how many hats) live + * in the assignment engine (P4) and RMO (P3). + */ + +import { + HatBindingPhase, + TerminalHatBindingPhases, + type HatBinding, +} from "../../domain/src/hat-binding.ts"; +import { OrgEventKind, type OrgEvent } from "../../domain/src/org-event.ts"; +import type { HatDefinition } from "../../domain/src/hat-definition.ts"; + +export type LifecycleClock = { + nowMs: () => number; + nowIso: () => string; +}; + +export type LifecycleContext = { + clock: LifecycleClock; + createEventId: () => string; + /** hat-id path root→actor, for the OrgEvent supervisor chain */ + supervisorChain: readonly string[]; + correlationId: string; + causationId: string; + traceId: string; +}; + +export type BindingTransition = { + binding: HatBinding; + event: OrgEvent; +}; + +function isoFromMsOffset(nowMs: number, offsetSeconds: number, toIso: (ms: number) => string): string { + return toIso(nowMs + offsetSeconds * 1000); +} + +function event( + ctx: LifecycleContext, + binding: HatBinding, + hat: HatDefinition, + fromState: HatBindingPhase | undefined, + toState: HatBindingPhase, + decision: string, +): OrgEvent { + return { + id: ctx.createEventId(), + kind: OrgEventKind.HatBindingTransition, + occurredAt: ctx.clock.nowIso(), + organizationId: binding.organizationId, + actorHatId: hat.id, + actorAgentId: binding.wearerAgentId, + departmentId: hat.departmentId, + subjectId: binding.id, + ...(fromState !== undefined ? { fromState } : {}), + toState, + decision, + supervisorChain: ctx.supervisorChain, + evidenceRefs: [], + correlationId: ctx.correlationId, + causationId: ctx.causationId, + traceId: ctx.traceId, + }; +} + +/** Create a binding in Warmup (the assignment already approved the wearer). */ +export function beginBinding( + input: { bindingId: string; hat: HatDefinition; wearerAgentId: string; organizationId: string }, + ctx: LifecycleContext, +): BindingTransition { + const nowMs = ctx.clock.nowMs(); + const toIso = (ms: number): string => new Date(ms).toISOString(); + const boundAt = ctx.clock.nowIso(); + const binding: HatBinding = { + id: input.bindingId, + hatId: input.hat.id, + organizationId: input.organizationId, + wearerAgentId: input.wearerAgentId, + phase: HatBindingPhase.Warmup, + boundAt, + warmupEndsAt: isoFromMsOffset(nowMs, input.hat.warmupSeconds, toIso), + expiresAt: isoFromMsOffset(nowMs, input.hat.tokenTtlSeconds, toIso), + }; + return { binding, event: event(ctx, binding, input.hat, undefined, HatBindingPhase.Warmup, `${input.hat.name} bound to ${input.wearerAgentId} (warmup ${input.hat.warmupSeconds}s, ttl ${input.hat.tokenTtlSeconds}s)`) }; +} + +/** + * Advance a binding deterministically: Warmup→Active at warmupEndsAt, + * Active→Expired at expiresAt. Returns the (possibly unchanged) binding and the + * transition event if one fired. + */ +export function advanceBinding( + binding: HatBinding, + hat: HatDefinition, + ctx: LifecycleContext, +): { binding: HatBinding; event?: OrgEvent } { + if (TerminalHatBindingPhases.has(binding.phase)) { + return { binding }; + } + const nowMs = ctx.clock.nowMs(); + const expiresMs = Date.parse(binding.expiresAt); + const warmupMs = Date.parse(binding.warmupEndsAt); + + if (nowMs >= expiresMs) { + const next: HatBinding = { + ...binding, + phase: HatBindingPhase.Expired, + endedAt: ctx.clock.nowIso(), + cooldownUntil: new Date(nowMs + hat.cooldownSeconds * 1000).toISOString(), + reason: "token ttl reached", + }; + return { binding: next, event: event(ctx, next, hat, binding.phase, HatBindingPhase.Expired, `${hat.name} binding for ${binding.wearerAgentId} expired (ttl); cooldown ${hat.cooldownSeconds}s`) }; + } + + if (binding.phase === HatBindingPhase.Warmup && nowMs >= warmupMs) { + const next: HatBinding = { ...binding, phase: HatBindingPhase.Active, activatedAt: ctx.clock.nowIso() }; + return { binding: next, event: event(ctx, next, hat, binding.phase, HatBindingPhase.Active, `${hat.name} binding for ${binding.wearerAgentId} warmed up; authority active`) }; + } + + return { binding }; +} + +/** Pending → Warmup: a supervisor approves a proposed binding. */ +export function approveBinding(binding: HatBinding, hat: HatDefinition, ctx: LifecycleContext): BindingTransition { + const nowMs = ctx.clock.nowMs(); + const toIso = (ms: number): string => new Date(ms).toISOString(); + const next: HatBinding = { + ...binding, + phase: HatBindingPhase.Warmup, + boundAt: ctx.clock.nowIso(), + warmupEndsAt: isoFromMsOffset(nowMs, hat.warmupSeconds, toIso), + expiresAt: isoFromMsOffset(nowMs, hat.tokenTtlSeconds, toIso), + }; + return { binding: next, event: event(ctx, next, hat, binding.phase, HatBindingPhase.Warmup, `${hat.name} binding approved for ${binding.wearerAgentId}`) }; +} + +/** Voluntary release: the wearer or a supervisor returns the hat early. */ +export function releaseBinding(binding: HatBinding, hat: HatDefinition, ctx: LifecycleContext, reason: string): BindingTransition { + const nowMs = ctx.clock.nowMs(); + const next: HatBinding = { + ...binding, + phase: HatBindingPhase.Released, + endedAt: ctx.clock.nowIso(), + cooldownUntil: new Date(nowMs + hat.cooldownSeconds * 1000).toISOString(), + reason, + }; + return { binding: next, event: event(ctx, next, hat, binding.phase, HatBindingPhase.Released, `${hat.name} binding released by ${binding.wearerAgentId}: ${reason}`) }; +} + +/** Forced revocation (policy / incident). */ +export function revokeBinding(binding: HatBinding, hat: HatDefinition, ctx: LifecycleContext, reason: string): BindingTransition { + const next: HatBinding = { ...binding, phase: HatBindingPhase.Revoked, endedAt: ctx.clock.nowIso(), reason }; + return { binding: next, event: event(ctx, next, hat, binding.phase, HatBindingPhase.Revoked, `${hat.name} binding revoked: ${reason}`) }; +} + +/** Is this agent still cooling down on this hat (cannot re-take yet)? */ +export function isInCooldown(binding: HatBinding, agentId: string, nowMs: number): boolean { + if (binding.wearerAgentId !== agentId || binding.cooldownUntil === undefined) { + return false; + } + return nowMs < Date.parse(binding.cooldownUntil); +} + +export type SuccessionPlan = { + hatId: string; + policy: HatDefinition["successionPolicy"]; + nextWearerAgentId?: string; + candidateAgentIds: readonly string[]; +}; + +/** + * Plan succession for a hat whose binding just ended. For `rotate` the next + * wearer is deterministic (the candidate after the last wearer). Election / + * vote / director-assigned policies leave `nextWearerAgentId` undefined — those + * route to RMO/assignment/voting where an authority hat decides. + */ +export function planSuccession(input: { + hat: HatDefinition; + candidateAgentIds: readonly string[]; + lastWearerAgentId: string; +}): SuccessionPlan { + const { hat, candidateAgentIds, lastWearerAgentId } = input; + if (hat.successionPolicy === "rotate" && candidateAgentIds.length > 0) { + const idx = candidateAgentIds.indexOf(lastWearerAgentId); + const next = candidateAgentIds[(idx + 1) % candidateAgentIds.length]; + return { hatId: hat.id, policy: hat.successionPolicy, candidateAgentIds, ...(next !== undefined ? { nextWearerAgentId: next } : {}) }; + } + if (hat.successionPolicy === "renew") { + return { hatId: hat.id, policy: hat.successionPolicy, candidateAgentIds, nextWearerAgentId: lastWearerAgentId }; + } + return { hatId: hat.id, policy: hat.successionPolicy, candidateAgentIds }; +} + +export function successionEvent(plan: SuccessionPlan, hat: HatDefinition, organizationId: string, ctx: LifecycleContext): OrgEvent { + const decision = plan.nextWearerAgentId !== undefined + ? `succession for ${hat.name} (${plan.policy}): next wearer ${plan.nextWearerAgentId}` + : `succession for ${hat.name} (${plan.policy}): awaiting authority decision among ${plan.candidateAgentIds.length} candidate(s)`; + return { + id: ctx.createEventId(), + kind: OrgEventKind.SuccessionPlanned, + occurredAt: ctx.clock.nowIso(), + organizationId, + actorHatId: hat.id, + departmentId: hat.departmentId, + subjectId: hat.id, + decision, + supervisorChain: ctx.supervisorChain, + evidenceRefs: [], + correlationId: ctx.correlationId, + causationId: ctx.causationId, + traceId: ctx.traceId, + }; +} diff --git a/agentic-organization/packages/application/src/hermes-reaction-plan-action-executor.ts b/agentic-organization/packages/application/src/hermes-reaction-plan-action-executor.ts new file mode 100644 index 0000000000..5aca0b89de --- /dev/null +++ b/agentic-organization/packages/application/src/hermes-reaction-plan-action-executor.ts @@ -0,0 +1,174 @@ +/** + * Hermes-backed reaction-plan action executor — where the autonomous DATA PLANE + * actually runs in the deployed worker. + * + * The reaction-plan executor claims a planned action and hands it here. We run + * the action through a Hermes run (runWorkItemThroughHermes): the agent acts on + * the work item, heartbeats, and — crucially — that heartbeat is persisted to + * durable state through the agent-heartbeat writer, so the deterministic + * keep-alive engine can SEE the agent is alive and reassign it if it goes + * silent. This closes the loop: a task event drives a reaction plan, which runs + * an agent, whose liveness the control plane watches. + * + * Pure composition over injected ports; Result-as-DU. (The agent's decision is + * the in-process Hermes runtime today; a real agent/LLM backend swaps in behind + * the same HermesRuntime port without touching this wiring.) + */ + +import type { ReactionPlanAction } from "../../domain/src/index.ts"; +import type { HermesRuntime } from "../../hermes/src/index.ts"; +import type { Memory } from "../../memory/src/index.ts"; +import { + ReactionPlanExecutionStatus, + type ReactionPlanActionExecutionContext, + type ReactionPlanActionExecutionResult, + type ReactionPlanActionExecutorPort, +} from "../../runtime/src/index.ts"; +import type { AsyncEphemeralComposerPort } from "./observe.ts"; +import { + createFirstLegalOptionComposer, + decideReactionActionAsync, + summarizeReactionDecision, + type ReactionDecisionSummary, +} from "./reaction-decision.ts"; +import { toAsyncComposer } from "./model-backed-composer.ts"; +import { + buildVerificationToolRequest, + verificationEvidenceRef, + type SandboxToolPort, +} from "./sandbox-tool.ts"; +import { runWorkItemThroughHermes, type AgentHeartbeatWriter, type WorkItemRunRequest } from "./orchestrate-run.ts"; + +export type HermesReactionPlanActionExecutorDeps = { + /** fresh per execution — the run lifecycle (launch -> heartbeat -> complete) is bounded to one action */ + createHermesRuntime: () => HermesRuntime; + createMemory: () => Memory; + /** persists agent liveness so the keep-alive engine sees the agent (Cockroach store in production) */ + agentHeartbeatWriter: AgentHeartbeatWriter; + agentHeartbeatDeadlineMs: number; + generateId: (prefix: string) => string; + /** + * The agent's decision intelligence (async — may make real model calls). + * Defaults to the deterministic first-legal-option policy. A model-backed + * composer swaps in here behind the same port; the decision kernel re-checks + * every choice against the legal set, so the model cannot widen the rules. + */ + composer?: AsyncEphemeralComposerPort; + /** clock for the decision readout; defaults to wall-clock. */ + now?: () => string; + /** + * Optional sandboxed tool runner. When present, the agent executes a real + * bounded subprocess to produce verifiable evidence for the run. + */ + sandbox?: SandboxToolPort; + /** node binary path for the sandbox verification tool; defaults to the running node. */ + nodeBinary?: string; +}; + +export function createHermesReactionPlanActionExecutor( + deps: HermesReactionPlanActionExecutorDeps, +): ReactionPlanActionExecutorPort { + return { + executeReactionPlanAction: async ( + action: ReactionPlanAction, + context: ReactionPlanActionExecutionContext, + ): Promise => { + // The agent makes a REAL decision: the deterministic kernel computes the + // legal options; the (possibly model-backed) composer chooses among them. + // A decision the kernel can't legalize fails the run honestly (retryable). + const decision = await decideReactionActionAsync({ + action, + composer: deps.composer ?? toAsyncComposer(createFirstLegalOptionComposer()), + now: deps.now ?? (() => new Date().toISOString()), + }); + const summary = summarizeReactionDecision(decision); + if (summary.kind === "feedback") { + return { + status: ReactionPlanExecutionStatus.Failed, + failure: { + message: `agent could not decide a legal move: ${summary.feedback.reason} - ${summary.feedback.message}`, + retryable: true, + }, + }; + } + + // The agent actually runs a sandboxed tool (a real bounded subprocess) to + // produce verifiable evidence. Tool failure is supplementary, never fatal. + const toolEvidenceRef = await runSandboxVerification(action, deps); + + const request = mapActionToRunRequest(action, context, deps.generateId, summary, toolEvidenceRef); + + const result = await runWorkItemThroughHermes(request, { + hermes: deps.createHermesRuntime(), + memory: deps.createMemory(), + agentHeartbeatWriter: deps.agentHeartbeatWriter, + agentHeartbeatDeadlineMs: deps.agentHeartbeatDeadlineMs, + }); + + if (result.outcome === "ok") { + return { + status: ReactionPlanExecutionStatus.Succeeded, + result: { + message: `agent run ${result.run.runId} ${summary.actionSummary} for work item ${action.workItemId}`, + createdWorkItemIds: [], + createdDiscussionAnchorIds: [], + }, + }; + } + + // the agent run could not complete — retryable so the reaction-plan executor + // re-claims it on the next cycle (the keep-alive lease guards against pile-up) + return { + status: ReactionPlanExecutionStatus.Failed, + failure: { + message: `hermes run feedback: ${result.feedback.reason} - ${result.feedback.message}`, + retryable: true, + }, + }; + }, + }; +} + +function mapActionToRunRequest( + action: ReactionPlanAction, + context: ReactionPlanActionExecutionContext, + generateId: (prefix: string) => string, + decision: Extract, + toolEvidenceRef: string | undefined, +): WorkItemRunRequest { + // evidence = the trigger event + (when the sandbox produced one) the tool result + const evidenceRefs = toolEvidenceRef === undefined + ? [action.triggerEventId] + : [action.triggerEventId, toolEvidenceRef]; + const learned = toolEvidenceRef === undefined + ? decision.learned + : `${decision.learned}; sandbox tool produced ${toolEvidenceRef}`; + return { + organizationId: action.organizationId, + workItemId: action.workItemId, + agentId: generateId(`agent-${action.requiredHat}`), + sessionId: context.claimId, + hatAssignmentId: generateId(`hat-${action.requiredHat}`), + promptFlowRunId: context.reactionPlanId, + projectId: action.projectId, + priorContextNeeded: true, + // the run records the agent's COMPUTED decision, not a fixed string + actionSummary: decision.actionSummary, + evidenceRefs, + learned, + }; +} + +/** Run the sandboxed verification tool if a sandbox is wired; returns an evidence ref or undefined. */ +async function runSandboxVerification( + action: ReactionPlanAction, + deps: HermesReactionPlanActionExecutorDeps, +): Promise { + // both the sandbox runner AND the node binary path come from the composition + // root; the application layer never reaches for the host process itself. + if (deps.sandbox === undefined || deps.nodeBinary === undefined) { + return undefined; + } + const result = await deps.sandbox.run(buildVerificationToolRequest(action, deps.nodeBinary)); + return verificationEvidenceRef(result); +} diff --git a/agentic-organization/packages/application/src/index.ts b/agentic-organization/packages/application/src/index.ts index d61aaa6cb6..12020cec28 100644 --- a/agentic-organization/packages/application/src/index.ts +++ b/agentic-organization/packages/application/src/index.ts @@ -1,3 +1,76 @@ +export { + TriageActionFeedbackReason, + TriageActionResolution, + resolveTriageAction, + type ResolvedTriageAction, + type TriageActionRequest, +} from "./triage-action-resolver.ts"; +export { + GraphEdgeKind, + GraphNodeKind, + decisionsForWorkItem, + neighborsByEdge, + projectOrganizationGraph, + type GraphEdge, + type GraphNode, + type OrganizationGraph, + type ProjectGraphInput, +} from "./graph-projection.ts"; +export { + ObserveWorkItemFeedbackReason, + observeWorkItem, + snapshotForWorkItem, + type ObserveWorkItemDeps, + type ObserveWorkItemFacts, + type ObserveWorkItemResult, +} from "./observe-work-item.ts"; +export { + ReviewGateFeedbackReason, + evaluateReviewGate, + type ReviewGateResult, +} from "./review-gate.ts"; +export { + runWorkItemThroughHermes, + type AgentHeartbeatRecord, + type AgentHeartbeatWriter, + type WorkItemRunDeps, + type WorkItemRunRequest, + type WorkItemRunResult, +} from "./orchestrate-run.ts"; +export { + createHermesReactionPlanActionExecutor, + type HermesReactionPlanActionExecutorDeps, +} from "./hermes-reaction-plan-action-executor.ts"; +export { + createFirstLegalOptionComposer, + decideReactionAction, + decideReactionActionAsync, + deterministicRunIdForAction, + summarizeReactionDecision, + type DecideReactionActionAsyncInput, + type DecideReactionActionInput, + type ReactionDecisionSummary, +} from "./reaction-decision.ts"; +export { + createModelBackedComposer, + toAsyncComposer, + type ChatCompletionPort, + type ChatCompletionRequest, + type CreateModelBackedComposerInput, +} from "./model-backed-composer.ts"; +export { + SandboxVerificationEvidencePrefix, + buildVerificationToolRequest, + verificationEvidenceRef, + type SandboxToolPort, + type SandboxToolRequest, + type SandboxToolResult, +} from "./sandbox-tool.ts"; +export { + createOrganizationReactionPlanActionExecutor, + type CreateOrganizationReactionPlanActionExecutorInput, + type EnsureWorkItemPort, +} from "./organization-reaction-plan-action-executor.ts"; export { createCommandHandlerRegistry, type CommandExecutionContext, @@ -106,3 +179,103 @@ export type { WorkScheduleBlockAuthorityReaderPort, WorkAnchorCommandEffects, } from "./ports.ts"; +export { + asZetaIdDecimal, + ComposerDecision, + DecideOutcome, + DefaultDeterministicRules, + decide, + decideAsync, + observe, + ObserveFeedbackReason, + ObserveOutcome, + RunLifecyclePhase, + RunScope, + type AsyncEphemeralComposerPort, + type AvailableOption, + type ComposerSelection, + type ComposerSelectionRequest, + type DecideResult, + type DeterministicRule, + type EphemeralComposerPort, + type ObserveDependencies, + type ObserveFeedback, + type ObserveResult, + type RunSnapshot, + type RunStateReadout, + type RunTrace, + type ZetaIdDecimal, +} from "./observe.ts"; +export { + DEPARTMENTS, + OrgGraphValidation, + buildHatDefinitions, + buildOrgSeed, + validateOrgGraph, + type OrgGraphValidationResult, + type OrgSeed, +} from "./org-seed.ts"; +export { + advanceBinding, + approveBinding, + beginBinding, + isInCooldown, + planSuccession, + releaseBinding, + revokeBinding, + successionEvent, + type BindingTransition, + type LifecycleClock, + type LifecycleContext, + type SuccessionPlan, +} from "./hat-lifecycle.ts"; +export { + chooseWithinLegal, + firstLegalChooser, + type OrgChoice, + type OrgChooser, +} from "./org-decision.ts"; +export { + PriorityClass, + PriorityDecidedBy, + computePriorityRecommendation, + decidePriority, + legalPriorityClassesFor, + type DecidePriorityContext, + type PriorityDecision, + type PriorityInputs, + type PriorityRecommendation, +} from "./prioritization.ts"; +export { + HatSupplyAction, + computeRequiredHatSupply, + decideHatSupply, + recommendSupplyAction, + type DecideHatSupplyContext, + type HatSupplyDecision, + type HatSupplyVote, + type WorkloadItem, +} from "./rmo.ts"; +export { + assignHat, + rankEligibleCandidates, + type ActiveBindingSummary, + type AgentCandidate, + type AssignHatContext, + type AssignmentResult, + type RankEligibleInput, +} from "./assignment-engine.ts"; +export { + GateOwnerHats, + PipelineStage, + RecoveryPath, + evaluateGate, + legalGateOutcomes, + nextLegalGate, + recoveryPathFor, + stageFor, + type GateEvaluation, + type GateEvaluationResult, + type PipelineContext, +} from "./pipeline.ts"; +export { runOrgCycle, type OrgCycleDeps, type OrgCycleReport } from "./org-runtime.ts"; diff --git a/agentic-organization/packages/application/src/model-backed-composer.ts b/agentic-organization/packages/application/src/model-backed-composer.ts new file mode 100644 index 0000000000..5b71d1c7dc --- /dev/null +++ b/agentic-organization/packages/application/src/model-backed-composer.ts @@ -0,0 +1,120 @@ +/** + * Model-backed composer — the agent's REAL decision intelligence. Given the + * readout's legal options (computed by the deterministic kernel), it asks a + * language model which legal move to make, parses the model's choice, and + * selects that option. + * + * Safety is structural, not trusted: the model is only ever shown the LEGAL + * options, and whatever it returns is re-checked against that set by the + * decision kernel (resolveSelection). An unparseable or illegal response falls + * back to the deterministic composer. The model adds judgment WITHIN the + * guardrails; it can never widen them. + * + * Pure over injected ports (ChatCompletionPort + a sync fallback composer); + * the concrete HTTP/model adapter lives at the composition root. + */ + +import { + ComposerDecision, + type AsyncEphemeralComposerPort, + type ComposerSelection, + type ComposerSelectionRequest, + type EphemeralComposerPort, +} from "./observe.ts"; + +export type ChatCompletionRequest = { + system: string; + user: string; +}; + +/** Dependency-inverted model port. A real adapter (Ollama, OpenAI, …) implements this. */ +export interface ChatCompletionPort { + complete: (request: ChatCompletionRequest) => Promise; +} + +export type CreateModelBackedComposerInput = { + chat: ChatCompletionPort; + /** used when the model is unreachable, unparseable, or picks an illegal option */ + fallback: EphemeralComposerPort; +}; + +const SYSTEM_PROMPT = + "You are an agent operating inside a deterministic organization. You will be" + + " given the legal next moves for a run. Choose exactly one by replying with" + + " ONLY its actionType token (e.g. `compose`). Do not explain. Do not invent" + + " moves — pick one of the listed actionTypes verbatim."; + +export function createModelBackedComposer(input: CreateModelBackedComposerInput): AsyncEphemeralComposerPort { + return { + compose: async (request: ComposerSelectionRequest): Promise => { + const options = request.readout.options; + if (options.length === 0) { + return input.fallback.compose(request); + } + + let raw: string; + try { + raw = await input.chat.complete({ system: SYSTEM_PROMPT, user: buildUserPrompt(request) }); + } catch { + // model unreachable → deterministic fallback keeps the agent alive + return input.fallback.compose(request); + } + + const chosen = matchOption(raw, options); + if (chosen === undefined) { + // unparseable or not a legal token → fall back (kernel would reject it anyway) + return input.fallback.compose(request); + } + + return { decision: ComposerDecision.Select, option: chosen, reason: `model selected '${chosen.actionType}'` }; + }, + }; +} + +/** Adapt a synchronous composer to the async port (e.g. the deterministic baseline). */ +export function toAsyncComposer(composer: EphemeralComposerPort): AsyncEphemeralComposerPort { + return { compose: async (request) => composer.compose(request) }; +} + +function buildUserPrompt(request: ComposerSelectionRequest): string { + const lines = request.readout.options.map( + (option) => `- ${option.actionType} -> ${option.toPhase} (${option.rationale})`, + ); + return [ + `Run ${request.readout.runId} is at phase '${request.readout.phase}'.`, + "Legal next moves:", + ...lines, + "Reply with one actionType token from the list above.", + ].join("\n"); +} + +/** + * Parse the model's free text into a legal option. Tolerant but bounded: a small + * model often names either the actionType (`compose`) or the target phase + * (`composing`) — both uniquely identify a legal option, so either is accepted + * as a whole word. Longest token first (so `request_review` wins over `review`, + * and `awaiting_gate` over `gate`). Only legal options are ever considered, and + * the decision kernel re-validates the result, so this can never widen the rules. + */ +function matchOption( + raw: string, + options: ComposerSelectionRequest["readout"]["options"], +): ComposerSelectionRequest["readout"]["options"][number] | undefined { + const text = raw.toLowerCase(); + const candidates = options + .flatMap((option) => [ + { token: option.actionType.toLowerCase(), option }, + { token: option.toPhase.toLowerCase(), option }, + ]) + .sort((a, b) => b.token.length - a.token.length); + for (const candidate of candidates) { + if (new RegExp(`(^|[^a-z0-9_])${escapeRegExp(candidate.token)}([^a-z0-9_]|$)`).test(text)) { + return candidate.option; + } + } + return undefined; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/agentic-organization/packages/application/src/observe-work-item.ts b/agentic-organization/packages/application/src/observe-work-item.ts new file mode 100644 index 0000000000..feec9c035f --- /dev/null +++ b/agentic-organization/packages/application/src/observe-work-item.ts @@ -0,0 +1,98 @@ +/** + * Wire observe.ts to real work-item state (slice 4). + * + * This is the seam the state reconciliation table (slice 1) was built for: it + * turns a real WorkItem (its WorkItemState + gate/evidence facts) into the + * RunSnapshot that observe() is pure over, using RUN_PHASE_FOR_STATE to map the + * work-item state to the observe RunLifecyclePhase. observe() then returns the + * legal next options at the requested scope. This proves the keystone end to end + * on real domain records rather than synthetic snapshots. + * + * RUN_PHASE_FOR_STATE returns a string (the domain package cannot depend on the + * application package's RunLifecyclePhase type); we narrow it to the phase DU at + * this boundary and surface an explicit feedback variant if it ever fails to + * narrow (it cannot for the 8 enumerated states, but the seam stays honest). + */ + +import { RUN_PHASE_FOR_STATE, type WorkItem } from "../../domain/src/index.ts"; +import { + RunLifecyclePhase, + RunScope, + asZetaIdDecimal, + observe, + type ObserveResult, + type RunSnapshot, + type RunTrace, +} from "./observe.ts"; + +const VALID_PHASES: ReadonlySet = new Set(Object.values(RunLifecyclePhase)); + +export type ObserveWorkItemDeps = { clock: { now: () => string } }; + +export type ObserveWorkItemFacts = { + /** the ZetaId-decimal run id addressing this observation */ + runId: string; + scope?: RunScope; + trace: RunTrace; + /** whether the work item's current gate (per the reconciliation table) is approved */ + hasGateApproval: boolean; + /** whether required evidence has been submitted */ + hasEvidence: boolean; +}; + +export const ObserveWorkItemFeedbackReason = { + PhaseUnmapped: "phase_unmapped", +} as const; +export type ObserveWorkItemFeedbackReason = + (typeof ObserveWorkItemFeedbackReason)[keyof typeof ObserveWorkItemFeedbackReason]; + +export type ObserveWorkItemResult = + | { outcome: "ok"; snapshot: RunSnapshot; readout: ObserveResult } + | { outcome: "feedback"; feedback: { reason: ObserveWorkItemFeedbackReason; message: string } }; + +/** + * Build the RunSnapshot for a work item from its state + facts. Pure; returns a + * feedback variant if the work-item state does not map to a known run phase. + */ +export function snapshotForWorkItem( + workItem: WorkItem, + facts: ObserveWorkItemFacts, +): { outcome: "ok"; snapshot: RunSnapshot } | { outcome: "feedback"; feedback: { reason: ObserveWorkItemFeedbackReason; message: string } } { + const phaseString = RUN_PHASE_FOR_STATE[workItem.state]; + if (!VALID_PHASES.has(phaseString)) { + return { + outcome: "feedback", + feedback: { reason: ObserveWorkItemFeedbackReason.PhaseUnmapped, message: `work item state '${workItem.state}' mapped to unknown run phase '${phaseString}'` }, + }; + } + + const snapshot: RunSnapshot = { + runId: asZetaIdDecimal(facts.runId), + scope: facts.scope ?? RunScope.WorkItem, + phase: phaseString as RunLifecyclePhase, + trace: facts.trace, + hasGateApproval: facts.hasGateApproval, + hasEvidence: facts.hasEvidence, + }; + return { outcome: "ok", snapshot }; +} + +/** + * Observe a real work item: build its snapshot from state + facts, then run the + * pure observe() to get the current run state and legal next options. + */ +export function observeWorkItem( + workItem: WorkItem, + facts: ObserveWorkItemFacts, + deps: ObserveWorkItemDeps, +): ObserveWorkItemResult { + const built = snapshotForWorkItem(workItem, facts); + if (built.outcome === "feedback") { + return built; + } + return { + outcome: "ok", + snapshot: built.snapshot, + readout: observe(built.snapshot, { clock: deps.clock }), + }; +} diff --git a/agentic-organization/packages/application/src/observe.ts b/agentic-organization/packages/application/src/observe.ts new file mode 100644 index 0000000000..63e8f127c1 --- /dev/null +++ b/agentic-organization/packages/application/src/observe.ts @@ -0,0 +1,370 @@ +/** + * observe.ts — the single entrypoint an agent has to remember. + * + * Operator idea 5: agents remember only `observe.ts`. It is keyed by a run id + * (a ZetaId rendered as a decimal — operator ideas 7/8), returns the current + * run state plus the legal next options at varying scopes, and delegates the + * *selection* (the intelligence) to an ephemeral, memoryless composer. + * + * Separation of concerns (the point of the keystone): + * - observe() : pure logic. Computes a readout from an injected snapshot + * and an explicit phase->options table. Holds NO state. + * - composer port : pure selection. Receives the whole readout in its + * argument and keeps NOTHING between calls. + * - decide() : composes observe() -> composer.compose() -> a typed + * selection result. + * + * Everything is an explicit discriminated union (operator idea 2; repo rule + * "IMPLICIT-NOT-EXPLICIT in DUs is class error"). Failure is a first-class + * `feedback` variant, never a thrown exception or a null + * (repo convention: Result). + * + * This slice is intentionally self-contained: it does not yet import the + * tri-language ZetaId codec (src/Core.TypeScript/zeta-id). The run id is a + * branded decimal string produced by that codec; wiring the real packer is + * the next slice. See agentic-organization/docs/OBSERVE_COMPOSER_AND_RUN_STATE.md. + */ + +/** + * ZetaId rendered as a base-10 string — the canonical index for git-as-db + * (operator idea 8). Branded so a raw string cannot be passed by accident. + */ +export type ZetaIdDecimal = string & { readonly __brand: "ZetaIdDecimal" }; + +export function asZetaIdDecimal(value: string): ZetaIdDecimal { + if (!/^[0-9]+$/.test(value)) { + throw new Error(`asZetaIdDecimal: '${value}' is not a base-10 ZetaId`); + } + return value as ZetaIdDecimal; +} + +/** Varying scopes a single run can be observed at (operator idea 5). */ +export const RunScope = { + Run: "run", + WorkItem: "work_item", + Initiative: "initiative", + Project: "project", + Organization: "organization", +} as const; + +export type RunScope = (typeof RunScope)[keyof typeof RunScope]; + +/** + * The run lifecycle as an explicit DU. Mirrors the V0 spine + * (signal -> triage -> gate -> assignment -> run -> evidence -> review) so the + * Organization's deterministic rules apply at every step (operator idea 6). + */ +export const RunLifecyclePhase = { + Observing: "observing", + Composing: "composing", + AwaitingGate: "awaiting_gate", + Executing: "executing", + AwaitingEvidence: "awaiting_evidence", + AwaitingReview: "awaiting_review", + Completed: "completed", + Blocked: "blocked", + Failed: "failed", +} as const; + +export type RunLifecyclePhase = (typeof RunLifecyclePhase)[keyof typeof RunLifecyclePhase]; + +/** A legal next move, with its preconditions surfaced explicitly (never buried). */ +export type AvailableOption = { + actionType: string; + toPhase: RunLifecyclePhase; + toScope: RunScope; + requiresGate: boolean; + requiresEvidence: boolean; + rationale: string; +}; + +/** Trace-envelope continuity carried through every readout (repo convention). */ +export type RunTrace = { + correlationId: string; + causationId: string; + traceId: string; +}; + +/** + * The snapshot the caller loads (from CockroachDB / git-as-db) and hands to + * observe(). observe() never reads state itself — it is pure over this input. + */ +export type RunSnapshot = { + runId: ZetaIdDecimal; + scope: RunScope; + phase: RunLifecyclePhase; + trace: RunTrace; + hasGateApproval: boolean; + hasEvidence: boolean; +}; + +/** The readout: current state + available options + the rules that shaped it. */ +export type RunStateReadout = { + runId: ZetaIdDecimal; + scope: RunScope; + phase: RunLifecyclePhase; + trace: RunTrace; + observedAt: string; + options: readonly AvailableOption[]; + deterministicRulesApplied: readonly string[]; +}; + +export const ObserveFeedbackReason = { + UnknownPhase: "unknown_phase", + TerminalPhase: "terminal_phase", + DeterministicRuleViolation: "deterministic_rule_violation", +} as const; + +export type ObserveFeedbackReason = (typeof ObserveFeedbackReason)[keyof typeof ObserveFeedbackReason]; + +export type ObserveFeedback = { + reason: ObserveFeedbackReason; + message: string; +}; + +/** Result as an explicit two-variant DU. */ +export const ObserveOutcome = { + Readout: "readout", + Feedback: "feedback", +} as const; + +export type ObserveOutcome = (typeof ObserveOutcome)[keyof typeof ObserveOutcome]; + +export type ObserveResult = + | { outcome: typeof ObserveOutcome.Readout; readout: RunStateReadout } + | { outcome: typeof ObserveOutcome.Feedback; feedback: ObserveFeedback }; + +/** + * A deterministic organizational rule (operator idea 6). Pure predicate over a + * candidate option + snapshot; returns a veto reason or undefined. The set of + * rule names that ran is recorded in the readout for full visibility. + */ +export type DeterministicRule = { + name: string; + veto: (option: AvailableOption, snapshot: RunSnapshot) => string | undefined; +}; + +export type ObserveDependencies = { + clock: { now: () => string }; + deterministicRules?: readonly DeterministicRule[]; +}; + +/** + * Explicit phase -> raw options table. The single source of truth for what + * moves exist; adding a phase is open-for-extension, existing rows are + * closed-for-modification (OCP applied to control flow). + */ +const PHASE_OPTIONS: Readonly> = { + [RunLifecyclePhase.Observing]: [ + { actionType: "compose", toPhase: RunLifecyclePhase.Composing, toScope: RunScope.Run, requiresGate: false, requiresEvidence: false, rationale: "selection needed before any side effect" }, + { actionType: "block", toPhase: RunLifecyclePhase.Blocked, toScope: RunScope.Run, requiresGate: false, requiresEvidence: false, rationale: "no legal move available" }, + ], + [RunLifecyclePhase.Composing]: [ + { actionType: "request_gate", toPhase: RunLifecyclePhase.AwaitingGate, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: false, rationale: "ratification required before execution" }, + ], + [RunLifecyclePhase.AwaitingGate]: [ + { actionType: "execute", toPhase: RunLifecyclePhase.Executing, toScope: RunScope.WorkItem, requiresGate: true, requiresEvidence: false, rationale: "gate must be approved to execute" }, + { actionType: "block", toPhase: RunLifecyclePhase.Blocked, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: false, rationale: "gate rejected or stalled" }, + ], + [RunLifecyclePhase.Executing]: [ + { actionType: "submit_evidence", toPhase: RunLifecyclePhase.AwaitingEvidence, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: false, rationale: "execution produced output to attest" }, + { actionType: "fail", toPhase: RunLifecyclePhase.Failed, toScope: RunScope.Run, requiresGate: false, requiresEvidence: false, rationale: "execution failed" }, + ], + [RunLifecyclePhase.AwaitingEvidence]: [ + { actionType: "request_review", toPhase: RunLifecyclePhase.AwaitingReview, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: true, rationale: "review needs evidence" }, + ], + [RunLifecyclePhase.AwaitingReview]: [ + { actionType: "complete", toPhase: RunLifecyclePhase.Completed, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: true, rationale: "reviewer approved" }, + { actionType: "rework", toPhase: RunLifecyclePhase.Executing, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: false, rationale: "reviewer requested changes" }, + ], + [RunLifecyclePhase.Completed]: [], + [RunLifecyclePhase.Blocked]: [ + { actionType: "resume", toPhase: RunLifecyclePhase.Observing, toScope: RunScope.Run, requiresGate: false, requiresEvidence: false, rationale: "blocker resolved" }, + ], + [RunLifecyclePhase.Failed]: [], +}; + +const TERMINAL_PHASES: ReadonlySet = new Set([ + RunLifecyclePhase.Completed, + RunLifecyclePhase.Failed, +]); + +/** Built-in deterministic rules that always apply (operator idea 6). */ +export const DefaultDeterministicRules: readonly DeterministicRule[] = [ + { + name: "gate-precondition", + veto: (option, snapshot) => + option.requiresGate && !snapshot.hasGateApproval + ? `option '${option.actionType}' requires an approved gate` + : undefined, + }, + { + name: "evidence-precondition", + veto: (option, snapshot) => + option.requiresEvidence && !snapshot.hasEvidence + ? `option '${option.actionType}' requires submitted evidence` + : undefined, + }, +]; + +/** + * The keystone read. Pure over the snapshot; holds no memory. Returns the + * current state and the options that survive every deterministic rule, or a + * feedback variant when there is nothing legal to surface. + */ +export function observe(snapshot: RunSnapshot, deps: ObserveDependencies): ObserveResult { + const rawOptions = PHASE_OPTIONS[snapshot.phase]; + if (rawOptions === undefined) { + return { + outcome: ObserveOutcome.Feedback, + feedback: { reason: ObserveFeedbackReason.UnknownPhase, message: `unknown run phase '${snapshot.phase}'` }, + }; + } + + const rules = deps.deterministicRules ?? DefaultDeterministicRules; + const surviving: AvailableOption[] = []; + for (const option of rawOptions) { + const vetoed = rules.some((rule) => rule.veto(option, snapshot) !== undefined); + if (!vetoed) { + surviving.push(option); + } + } + + if (TERMINAL_PHASES.has(snapshot.phase)) { + return { + outcome: ObserveOutcome.Feedback, + feedback: { reason: ObserveFeedbackReason.TerminalPhase, message: `run ${snapshot.runId} is terminal at '${snapshot.phase}'` }, + }; + } + + if (surviving.length === 0) { + return { + outcome: ObserveOutcome.Feedback, + feedback: { + reason: ObserveFeedbackReason.DeterministicRuleViolation, + message: `no option survives deterministic rules at phase '${snapshot.phase}'`, + }, + }; + } + + return { + outcome: ObserveOutcome.Readout, + readout: { + runId: snapshot.runId, + scope: snapshot.scope, + phase: snapshot.phase, + trace: snapshot.trace, + observedAt: deps.clock.now(), + options: surviving, + deterministicRulesApplied: rules.map((rule) => rule.name), + }, + }; +} + +/* ------------------------------------------------------------------ */ +/* Ephemeral, memoryless composer (operator idea 5: the intelligence) */ +/* ------------------------------------------------------------------ */ + +export type ComposerSelectionRequest = { + /** The whole readout — everything the composer needs is in the argument. */ + readout: RunStateReadout; +}; + +export const ComposerDecision = { + Select: "select", + Hold: "hold", +} as const; + +export type ComposerDecision = (typeof ComposerDecision)[keyof typeof ComposerDecision]; + +export type ComposerSelection = + | { decision: typeof ComposerDecision.Select; option: AvailableOption; reason: string } + | { decision: typeof ComposerDecision.Hold; reason: string }; + +/** + * The composer is a pure function of the request. No constructor state, no + * memory across calls. An LLM-backed composer must put all of its context + * INTO the request (the readout), never into instance memory. + */ +export interface EphemeralComposerPort { + compose: (request: ComposerSelectionRequest) => ComposerSelection; +} + +export const DecideOutcome = { + Selected: "selected", + Held: "held", + Feedback: "feedback", +} as const; + +export type DecideOutcome = (typeof DecideOutcome)[keyof typeof DecideOutcome]; + +export type DecideResult = + | { outcome: typeof DecideOutcome.Selected; readout: RunStateReadout; selection: { option: AvailableOption; reason: string } } + | { outcome: typeof DecideOutcome.Held; readout: RunStateReadout; reason: string } + | { outcome: typeof DecideOutcome.Feedback; feedback: ObserveFeedback }; + +/** + * Compose observe() with a memoryless composer. The composer may only pick from + * the surviving options the readout exposes; a selection outside that set is + * rejected as a deterministic-rule violation (the composer cannot escape the + * rules — it only selects within them). + */ +export function decide( + snapshot: RunSnapshot, + composer: EphemeralComposerPort, + deps: ObserveDependencies, +): DecideResult { + const observed = observe(snapshot, deps); + if (observed.outcome === ObserveOutcome.Feedback) { + return { outcome: DecideOutcome.Feedback, feedback: observed.feedback }; + } + return resolveSelection(observed.readout, composer.compose({ readout: observed.readout })); +} + +/** Async sibling of the EphemeralComposerPort — for backends that do I/O (real model calls). */ +export interface AsyncEphemeralComposerPort { + compose: (request: ComposerSelectionRequest) => Promise; +} + +/** + * Async sibling of decide(): identical deterministic guardrail (same observe() + * + same legality enforcement via resolveSelection), but the composer may be + * asynchronous (e.g. an LLM call). The model still cannot escape the rules — an + * out-of-set choice is rejected exactly as in the synchronous path. + */ +export async function decideAsync( + snapshot: RunSnapshot, + composer: AsyncEphemeralComposerPort, + deps: ObserveDependencies, +): Promise { + const observed = observe(snapshot, deps); + if (observed.outcome === ObserveOutcome.Feedback) { + return { outcome: DecideOutcome.Feedback, feedback: observed.feedback }; + } + return resolveSelection(observed.readout, await composer.compose({ readout: observed.readout })); +} + +/** + * Shared legality enforcement for both decide paths: a Hold passes through; a + * Select is admitted only if the option is in the readout's surviving set — + * otherwise it is rejected as a deterministic-rule violation. This is the single + * choke point that guarantees no composer (sync, async, or LLM) escapes the rules. + */ +function resolveSelection(readout: RunStateReadout, selection: ComposerSelection): DecideResult { + if (selection.decision === ComposerDecision.Hold) { + return { outcome: DecideOutcome.Held, readout, reason: selection.reason }; + } + const isLegal = readout.options.some( + (option) => option.actionType === selection.option.actionType && option.toPhase === selection.option.toPhase, + ); + if (!isLegal) { + return { + outcome: DecideOutcome.Feedback, + feedback: { + reason: ObserveFeedbackReason.DeterministicRuleViolation, + message: `composer selected illegal option '${selection.option.actionType}' not in the readout`, + }, + }; + } + return { outcome: DecideOutcome.Selected, readout, selection: { option: selection.option, reason: selection.reason } }; +} diff --git a/agentic-organization/packages/application/src/orchestrate-run.ts b/agentic-organization/packages/application/src/orchestrate-run.ts new file mode 100644 index 0000000000..f42e14f792 --- /dev/null +++ b/agentic-organization/packages/application/src/orchestrate-run.ts @@ -0,0 +1,150 @@ +/** + * Orchestrate a work item through a Hermes run with memory attribution. + * + * This is where the deterministic CONTROL PLANE and the autonomous DATA PLANE + * meet in one composition: + * - launch a Hermes run (the agent acts autonomously inside its sandbox) + * - recall scoped prior context if needed (Hindsight, attributed) + * - record what the agent did + retain what it learned (attributed, sticky) + * - complete the run with evidence + * The run's heartbeat is what the deterministic keep-alive engine reads to decide + * the agent is alive; a silent run is caught by keep-alive's staleness detection. + * So determinism guarantees the agent is watched + kept moving, while the run + * itself is autonomous. Pure composition over injected ports; Result-as-DU. + */ + +import type { HermesRun, HermesRuntime, HermesRuntimeFeedbackReason } from "../../hermes/src/index.ts"; +import type { Memory, MemoryAttribution, MemoryRecord } from "../../memory/src/index.ts"; + +/** Why an orchestrated run could not complete — preserves the Hermes reason DU. */ +export type OrchestrationFeedbackReason = HermesRuntimeFeedbackReason; + +/** One agent session's liveness fact persisted when a run heartbeats. */ +export type AgentHeartbeatRecord = { + organizationId: string; + agentId: string; + hatAssignmentId: string; + workItemId: string; + deadlineMs: number; +}; + +/** + * Port that persists an agent heartbeat so the keep-alive engine can see the + * agent is alive. Dependency-inverted — the Cockroach control-plane store's + * recordAgentHeartbeat satisfies it structurally; the application package does + * not depend on the state layer. + */ +export type AgentHeartbeatWriter = { + recordAgentHeartbeat: (record: AgentHeartbeatRecord) => Promise; +}; + +export type WorkItemRunRequest = { + organizationId: string; + workItemId: string; + agentId: string; + sessionId: string; + hatAssignmentId: string; + promptFlowRunId: string; + projectId: string; + /** whether the agent needs prior scoped context recalled before acting */ + priorContextNeeded: boolean; + /** what the agent did (becomes the run outcome summary) */ + actionSummary: string; + evidenceRefs: readonly string[]; + /** what the agent learned (retained as memory); empty string = nothing to retain */ + learned: string; +}; + +export type WorkItemRunDeps = { + hermes: HermesRuntime; + memory: Memory; + /** optional: persist agent liveness when the run heartbeats (keep-alive reads it) */ + agentHeartbeatWriter?: AgentHeartbeatWriter; + /** ms before a silent agent is considered stale; defaults when a writer is present */ + agentHeartbeatDeadlineMs?: number; +}; + +const DefaultAgentHeartbeatDeadlineMs = 60_000; + +export type WorkItemRunResult = + | { + outcome: "ok"; + run: HermesRun; + recalledCount: number; + retained: MemoryRecord | undefined; + } + | { outcome: "feedback"; feedback: { reason: OrchestrationFeedbackReason; message: string } }; + +function attributionOf(request: WorkItemRunRequest): MemoryAttribution { + return { + agentId: request.agentId, + hatAssignmentId: request.hatAssignmentId, + projectId: request.projectId, + workItemId: request.workItemId, + promptFlowRunId: request.promptFlowRunId, + }; +} + +export async function runWorkItemThroughHermes( + request: WorkItemRunRequest, + deps: WorkItemRunDeps, +): Promise { + // 1. launch the run (autonomous data plane) + const launched = await deps.hermes.launchRun({ + workItemId: request.workItemId, + agentId: request.agentId, + sessionId: request.sessionId, + hatAssignmentId: request.hatAssignmentId, + promptFlowRunId: request.promptFlowRunId, + }); + if (launched.outcome === "feedback") { + return { outcome: "feedback", feedback: { reason: launched.feedback.reason, message: launched.feedback.message } }; + } + + const attribution = attributionOf(request); + + // 2. recall scoped prior context if the agent needs it + let recalledCount = 0; + if (request.priorContextNeeded) { + const recalled = await deps.memory.recall(attribution); + recalledCount = recalled.memories.length; + } + + // 3. heartbeat the run (the agent is actively working — keep-alive sees this). + // A heartbeat failure means the run vanished mid-flight; surface it rather than + // proceed to retain/complete against a dead run (no silent Result swallow). + const beat = await deps.hermes.heartbeat(launched.run.runId); + if (beat.outcome === "feedback") { + return { outcome: "feedback", feedback: { reason: beat.feedback.reason, message: beat.feedback.message } }; + } + + // persist agent liveness so the deterministic keep-alive engine sees the agent + // is alive — only after a SUCCESSFUL heartbeat (a vanished run records nothing) + if (deps.agentHeartbeatWriter !== undefined) { + await deps.agentHeartbeatWriter.recordAgentHeartbeat({ + organizationId: request.organizationId, + agentId: request.agentId, + hatAssignmentId: request.hatAssignmentId, + workItemId: request.workItemId, + deadlineMs: deps.agentHeartbeatDeadlineMs ?? DefaultAgentHeartbeatDeadlineMs, + }); + } + + // 4. retain what was learned (attributed, sticky); skip if nothing learned + let retained: MemoryRecord | undefined; + if (request.learned.trim().length > 0) { + const retainResult = await deps.memory.retain(attribution, request.learned); + retained = retainResult.memory; + } + + // 5. complete the run with evidence + const completed = await deps.hermes.completeRun(launched.run.runId, { + summary: request.actionSummary, + evidenceRefs: request.evidenceRefs, + }); + if (completed.outcome === "feedback") { + return { outcome: "feedback", feedback: { reason: completed.feedback.reason, message: completed.feedback.message } }; + } + + return { outcome: "ok", run: completed.run, recalledCount, retained }; +} diff --git a/agentic-organization/packages/application/src/org-decision.ts b/agentic-organization/packages/application/src/org-decision.ts new file mode 100644 index 0000000000..e151f8b97c --- /dev/null +++ b/agentic-organization/packages/application/src/org-decision.ts @@ -0,0 +1,36 @@ +/** + * Org-decision primitive — the observe→decide kernel applied at organizational + * scope. Determinism computes the LEGAL option set (what an authority is allowed + * to choose); an agent chooser picks within it; the pick is clamped to the legal + * set so the agent can never escape the rules. This is "enough determinism, + * agents drive outcomes" for priority, supply, assignment, and gate decisions. + */ + +export type OrgChoice = + | { outcome: "chosen"; option: T; reason: string } + | { outcome: "no_legal_option"; reason: string }; + +/** + * An agent chooser: given the legal options and a context string, returns the + * index it wants and its reason. The index is CLAMPED to the legal range, so a + * chooser (deterministic or model-backed) can never select outside the rules. + */ +export type OrgChooser = (legal: readonly T[], context: string) => { index: number; reason: string }; + +export function chooseWithinLegal(legal: readonly T[], context: string, chooser: OrgChooser): OrgChoice { + if (legal.length === 0) { + return { outcome: "no_legal_option", reason: `no legal option for: ${context}` }; + } + const pick = chooser(legal, context); + const idx = Math.max(0, Math.min(legal.length - 1, Math.trunc(pick.index))); + const option = legal[idx]; + if (option === undefined) { + return { outcome: "no_legal_option", reason: `clamp failed for: ${context}` }; + } + return { outcome: "chosen", option, reason: pick.reason }; +} + +/** Deterministic baseline chooser: always take the first (highest-priority) legal option. */ +export function firstLegalChooser(): OrgChooser { + return (legal) => ({ index: 0, reason: `deterministic: first of ${legal.length} legal option(s)` }); +} diff --git a/agentic-organization/packages/application/src/org-runtime.ts b/agentic-organization/packages/application/src/org-runtime.ts new file mode 100644 index 0000000000..c86775a46a --- /dev/null +++ b/agentic-organization/packages/application/src/org-runtime.ts @@ -0,0 +1,218 @@ +/** + * Org runtime — the end-to-end org cycle that ties every layer together: + * executive/director prioritization, RMO hat-supply voting, hat assignment + + * binding, the 7-gate work pipeline (customer discovery → release), and the hat + * binding lifecycle (warmup → active → expiry → succession). + * + * One run emits events attributed to actors at EVERY hierarchy level (Executive + * Board → C-suite → Directors → Management → ICs), so observing the persisted + * OrgEvent trace proves the whole hierarchy is working. Determinism drives which + * gate/priority/supply/assignment is legal; agent choosers drive the outcomes. + */ + +import { + HatLevel, + QualityGateKind, + QualityGateOutcome, + type HatBinding, + type HatDefinition, + type OrgEvent, +} from "../../domain/src/index.ts"; +import { buildHatDefinitions } from "./org-seed.ts"; +import { firstLegalChooser } from "./org-decision.ts"; +import { computePriorityRecommendation, decidePriority, type PriorityInputs } from "./prioritization.ts"; +import { computeRequiredHatSupply, decideHatSupply, type HatSupplyVote, type WorkloadItem } from "./rmo.ts"; +import { assignHat, rankEligibleCandidates, type ActiveBindingSummary, type AgentCandidate } from "./assignment-engine.ts"; +import { advanceBinding, beginBinding, planSuccession, successionEvent, type LifecycleContext } from "./hat-lifecycle.ts"; +import { GateOwnerHats, evaluateGate, nextLegalGate, PipelineStage } from "./pipeline.ts"; + +export type OrgCycleDeps = { + organizationId: string; + workItemId: string; + baseTimeMs: number; + createId: (prefix: string) => string; + appendEvent: (event: OrgEvent) => Promise; + upsertBinding: (binding: HatBinding) => Promise; + hats?: readonly HatDefinition[]; +}; + +export type OrgCycleReport = { + workItemId: string; + finalStage: PipelineStage; + totalEvents: number; + eventsByLevel: Record; + bindingsCreated: number; + gatesPassed: number; + expiriesObserved: number; + successionsPlanned: number; + hotInputs: PriorityInputs; +}; + +/** root→hat supervisor chain (follows the single reportsTo edge up to the Executive Board). */ +function chainFor(hatId: string, byId: ReadonlyMap): readonly string[] { + const chain: string[] = []; + const seen = new Set(); + let cur: string | undefined = hatId; + while (cur !== undefined && !seen.has(cur)) { + chain.push(cur); + seen.add(cur); + cur = byId.get(cur)?.reportsToHatIds[0]; + } + return chain.reverse(); +} + +const HOT: PriorityInputs = { + executivePriority: 1, customerImpact: 1, severity: 0.8, releaseRisk: 0.6, + blockedDownstreamCount: 3, dependencyFanOut: 2, queueAgeMs: 600_000, hatScarcity: 0.5, budgetBurn: 0.1, estimatedEffort: 0.4, +}; + +export async function runOrgCycle(deps: OrgCycleDeps): Promise { + const hats = deps.hats ?? buildHatDefinitions(); + const byId = new Map(hats.map((h) => [h.id, h])); + const eventsByLevel: Record = Object.fromEntries(Object.values(HatLevel).map((l) => [l, 0])); + let total = 0; + + const emit = async (event: OrgEvent): Promise => { + await deps.appendEvent(event); + total += 1; + const lvl = event.actorHatId !== undefined ? byId.get(event.actorHatId)?.level : undefined; + if (lvl !== undefined) eventsByLevel[lvl] = (eventsByLevel[lvl] ?? 0) + 1; + }; + + const iso = (ms: number): string => new Date(ms).toISOString(); + const t0 = deps.baseTimeMs; + const corr = deps.workItemId; + // strictly-monotonic recording clock so the trace (and the snapshot fold) is + // unambiguously ordered even when many events are produced in one cycle. + let tick = 0; + const nextIso = (): string => iso(t0 + tick++ * 1000); + + const priorityCtx = (hatId: string) => ({ + createEventId: () => deps.createId("evt"), + nowIso: nextIso, + organizationId: deps.organizationId, + supervisorChain: chainFor(hatId, byId), + correlationId: corr, causationId: corr, traceId: corr, + }); + + // ── 1. Executive Board + C-suite set strategic priority (top of the hierarchy acts) ── + const rec = computePriorityRecommendation(deps.workItemId, HOT, []); + for (const execHatId of ["executive_board_member", "ceo"]) { + const hat = byId.get(execHatId)!; + const r = decidePriority({ recommendation: rec, deciderHat: hat, chooser: firstLegalChooser() }, priorityCtx(execHatId)); + if (r.outcome === "decided") await emit(r.event); + } + + // ── 2. A Director sets the work-item priority ── + const director = byId.get("engineering_director")!; + const dirDecision = decidePriority({ recommendation: rec, deciderHat: director, chooser: firstLegalChooser() }, priorityCtx("engineering_director")); + const priorityClass = dirDecision.outcome === "decided" ? dirDecision.decision.priorityClass : rec.priorityClass; + if (dirDecision.outcome === "decided") await emit(dirDecision.event); + + // ── 3. RMO: required supply for the gate-owner hats → supervisors vote → supply decisions ── + const ownerHatIds = [...new Set(Object.values(GateOwnerHats).flat()), "team_lead"]; + const workload: WorkloadItem[] = [{ workItemId: deps.workItemId, priorityClass, requiredHats: ownerHatIds }]; + const required = computeRequiredHatSupply(workload); + const supplyVoters = ["engineering_director", "cfo", "cost_controller"]; + const supplyTargets = new Map(); + for (const hatId of ownerHatIds) { + const requiredCount = required.get(hatId) ?? 1; + const votes: HatSupplyVote[] = supplyVoters.map((v) => ({ voterHatId: v, approve: true, proposedTarget: requiredCount })); + const { decision, event } = decideHatSupply( + { hatId, hatName: byId.get(hatId)?.name ?? hatId, requiredCount, currentCount: 0, votes }, + { ...priorityCtx("cfo") }, + ); + await emit(event); + supplyTargets.set(hatId, Math.max(1, decision.targetCount)); + } + + // ── 4. Assign + bind each owner hat (managers + leads + ICs get staffed) ── + const activeBindings: ActiveBindingSummary[] = []; + const bindings: HatBinding[] = []; + let agentSeq = 0; + for (const hatId of ownerHatIds) { + const hat = byId.get(hatId)!; + const candidates: AgentCandidate[] = [0, 1].map((i) => ({ agentId: `agent-${hatId}-${i}`, reputationByHat: { [hatId]: 10 - i } })); + const ranked = rankEligibleCandidates({ hat, candidates, activeBindings, nowMs: t0 }); + const assignment = assignHat( + { hat, eligibleRanked: ranked, activeWearerCount: 0, supplyTarget: supplyTargets.get(hatId) ?? 1, chooser: firstLegalChooser() }, + { createEventId: () => deps.createId("evt"), nowIso: nextIso, organizationId: deps.organizationId, supervisorChain: chainFor(hatId, byId), correlationId: corr, causationId: corr, traceId: corr }, + ); + if (assignment.outcome === "no_eligible_candidate") continue; + await emit(assignment.event); + if (assignment.outcome !== "assigned") continue; + + const ctx: LifecycleContext = { + clock: { nowMs: () => t0, nowIso: nextIso }, + createEventId: () => deps.createId("evt"), + supervisorChain: chainFor(hatId, byId), + correlationId: corr, causationId: corr, traceId: corr, + }; + const { binding, event } = beginBinding( + { bindingId: deps.createId("binding"), hat, wearerAgentId: assignment.agentId, organizationId: deps.organizationId }, + ctx, + ); + await emit(event); + await deps.upsertBinding(binding); + bindings.push(binding); + activeBindings.push({ hatId, wearerAgentId: assignment.agentId, phase: binding.phase }); + agentSeq += 1; + } + + // ── 5. Drive the work item through all 7 gates (owner hats approve; ICs + managers act) ── + const passed = new Set(); + let gatesPassed = 0; + let guard = 0; + for (let gate = nextLegalGate(passed); gate !== undefined && guard < 10; gate = nextLegalGate(passed), guard += 1) { + const ownerId = GateOwnerHats[gate].find((id) => byId.get(id)?.approvalScopes.includes(gate)); + if (ownerId === undefined) break; + const ownerHat = byId.get(ownerId)!; + const result = evaluateGate( + { workItemId: deps.workItemId, gateKind: gate, evaluatorHat: ownerHat, passedGateKinds: passed, outcomeChooser: () => ({ index: 0, reason: "owner approves on evidence" }) }, + { createEventId: () => deps.createId("evt"), nowIso: nextIso, organizationId: deps.organizationId, supervisorChain: chainFor(ownerId, byId), correlationId: corr, causationId: corr, traceId: corr }, + ); + if (result.outcome !== "evaluated") break; + for (const e of result.events) await emit(e); + if (result.evaluation.outcome === QualityGateOutcome.Approved) { + passed.add(gate); + gatesPassed += 1; + } else { + break; + } + } + const finalStage = nextLegalGate(passed) === undefined ? PipelineStage.Merged : PipelineStage.Intake; + + // ── 6. Hat binding lifecycle: warm up → active → expire → succession (observable) ── + let expiriesObserved = 0; + let successionsPlanned = 0; + const leadBinding = bindings.find((b) => byId.get(b.hatId)?.level === HatLevel.Lead) ?? bindings[0]; + if (leadBinding !== undefined) { + const hat = byId.get(leadBinding.hatId)!; + // advance past warmup → Active + const tActive = Date.parse(leadBinding.warmupEndsAt) + 1000; + const activeCtx: LifecycleContext = { clock: { nowMs: () => tActive, nowIso: nextIso }, createEventId: () => deps.createId("evt"), supervisorChain: chainFor(hat.id, byId), correlationId: corr, causationId: corr, traceId: corr }; + const active = advanceBinding(leadBinding, hat, activeCtx); + if (active.event !== undefined) { await emit(active.event); await deps.upsertBinding(active.binding); } + // advance past TTL → Expired + const tExpire = Date.parse(leadBinding.expiresAt) + 1000; + const expireCtx: LifecycleContext = { clock: { nowMs: () => tExpire, nowIso: nextIso }, createEventId: () => deps.createId("evt"), supervisorChain: chainFor(hat.id, byId), correlationId: corr, causationId: corr, traceId: corr }; + const expired = advanceBinding(active.binding, hat, expireCtx); + if (expired.event !== undefined) { await emit(expired.event); await deps.upsertBinding(expired.binding); expiriesObserved += 1; } + // plan succession for the now-vacant hat + const plan = planSuccession({ hat, candidateAgentIds: [`agent-${hat.id}-0`, `agent-${hat.id}-1`], lastWearerAgentId: leadBinding.wearerAgentId }); + await emit(successionEvent(plan, hat, deps.organizationId, expireCtx)); + successionsPlanned += 1; + } + + return { + workItemId: deps.workItemId, + finalStage, + totalEvents: total, + eventsByLevel, + bindingsCreated: bindings.length, + gatesPassed, + expiriesObserved, + successionsPlanned, + hotInputs: HOT, + }; +} diff --git a/agentic-organization/packages/application/src/org-seed.ts b/agentic-organization/packages/application/src/org-seed.ts new file mode 100644 index 0000000000..a6af8fb5a9 --- /dev/null +++ b/agentic-organization/packages/application/src/org-seed.ts @@ -0,0 +1,356 @@ +/** + * Org seed — the starter graph: 16 departments + the full hat catalog from + * DEPARTMENT_HAT_TOOL_INVENTORY.md, expanded into HatDefinition records. + * + * Each hat declares a compact spec (id, dept, level, single reportsTo, tool + * bundles, scopes); the builder fills the rest from per-level defaults and + * derives `supervisesHatIds` as the reverse of `reportsTo`, so the supervisor + * graph cannot be inconsistent. The graph is validated acyclic. + * + * TTLs are intentionally short (minutes) so the lifecycle (warmup → active → + * expiry → succession) is observable in a running cluster; production tuning is + * a per-hat override. + */ + +import { + HatLevel, + ReputationScope, + RiskLevel, + SuccessionPolicy, + ToolBundle, + type HatDefinition, +} from "../../domain/src/hat-definition.ts"; +import { DepartmentId, type Department } from "../../domain/src/department.ts"; + +export const DEPARTMENTS: readonly Department[] = [ + { id: DepartmentId.ExecutiveBoardAndGovernance, name: "Executive Board and Governance", reportsTo: "Executive Board", owns: "Org shape, high-power hats, policy, major priorities, budget ceilings, final escalations" }, + { id: DepartmentId.ProgramAndInitiativeManagement, name: "Program and Initiative Management", reportsTo: "COO", owns: "Initiative lifecycle, mission formation, task sequencing, dependency coordination, escalation routing" }, + { id: DepartmentId.ProductAndCustomerDiscovery, name: "Product and Customer Discovery", reportsTo: "CEO", owns: "Product intent, customer needs, acceptance criteria, product signoff" }, + { id: DepartmentId.BusinessAnalysis, name: "Business Analysis", reportsTo: "Product Director or CEO", owns: "BRDs, ambiguity reduction, business evidence, requirements readiness" }, + { id: DepartmentId.Architecture, name: "Architecture", reportsTo: "CTO or Chief Architect", owns: "CA documents, ADRs, tradeoffs, integration boundaries, architecture gates" }, + { id: DepartmentId.Engineering, name: "Engineering", reportsTo: "CTO", owns: "TDD implementation, code changes, focused validation, implementation evidence, code review" }, + { id: DepartmentId.EngineeringManagement, name: "Engineering Management", reportsTo: "CTO and COO", owns: "Task readiness, staffing, context attachment, team health, outcome reviews" }, + { id: DepartmentId.QaAndVerification, name: "QA and Verification", reportsTo: "COO", owns: "Acceptance verification, browser checks, reproducibility, QA signoff" }, + { id: DepartmentId.QaEngineering, name: "QA Engineering", reportsTo: "CTO", owns: "Test automation tooling, regression suites, coverage gaps, flaky test triage" }, + { id: DepartmentId.SecurityAndCompliance, name: "Security and Compliance", reportsTo: "CEO and CTO", owns: "Credential proxy scopes, tool expansion approval, security gates, audit" }, + { id: DepartmentId.DeliveryAndRelease, name: "Delivery and Release", reportsTo: "COO", owns: "Merge readiness, release readiness, deployment evidence, rollback coordination" }, + { id: DepartmentId.MemoryAndKnowledge, name: "Memory and Knowledge Management", reportsTo: "COO", owns: "Hindsight attribution, memory scopes, project context routing, memory adaptation" }, + { id: DepartmentId.DocumentationAndProjectSkills, name: "Documentation and Project Skills", reportsTo: "Architecture and Memory", owns: "BRD/CA/ADR/design-doc lifecycle, repo skills, skill graph quality" }, + { id: DepartmentId.OperationsAndInfrastructure, name: "Operations and Infrastructure", reportsTo: "COO and CTO", owns: "Always-on runtime, schedulers, triggers, incidents, SLOs, runbooks, capacity" }, + { id: DepartmentId.ObservabilityAndEvidence, name: "Observability and Evidence", reportsTo: "Operations", owns: "Traces, metrics, health reports, telemetry coverage, evidence quality" }, + { id: DepartmentId.CapabilityAndAutomationExpansion, name: "Capability and Automation Expansion", reportsTo: "Executive Board, Architecture, Security", owns: "New hats, tools, workflows, MCP registry entries, capability review flow" }, +]; + +type LevelDefaults = { + tokenTtlSeconds: number; + warmupSeconds: number; + cooldownSeconds: number; + maxConcurrentAssignments: number; + successionPolicy: SuccessionPolicy; + riskLevel: RiskLevel; + requiresTwoPersonApproval: boolean; + requiresHumanApproval: boolean; + reputationScope: readonly ReputationScope[]; +}; + +const LEVEL_DEFAULTS: Record = { + [HatLevel.ExecutiveBoard]: { tokenTtlSeconds: 420, warmupSeconds: 20, cooldownSeconds: 120, maxConcurrentAssignments: 5, successionPolicy: SuccessionPolicy.ExecutiveVote, riskLevel: RiskLevel.Critical, requiresTwoPersonApproval: true, requiresHumanApproval: false, reputationScope: [ReputationScope.Hat, ReputationScope.AgentHat] }, + [HatLevel.CSuite]: { tokenTtlSeconds: 360, warmupSeconds: 15, cooldownSeconds: 90, maxConcurrentAssignments: 1, successionPolicy: SuccessionPolicy.ExecutiveVote, riskLevel: RiskLevel.Critical, requiresTwoPersonApproval: true, requiresHumanApproval: false, reputationScope: [ReputationScope.Hat, ReputationScope.AgentHat, ReputationScope.DepartmentHat] }, + [HatLevel.Director]: { tokenTtlSeconds: 300, warmupSeconds: 12, cooldownSeconds: 60, maxConcurrentAssignments: 1, successionPolicy: SuccessionPolicy.ExecutiveVote, riskLevel: RiskLevel.High, requiresTwoPersonApproval: false, requiresHumanApproval: false, reputationScope: [ReputationScope.Hat, ReputationScope.AgentHat, ReputationScope.DepartmentHat] }, + [HatLevel.Manager]: { tokenTtlSeconds: 240, warmupSeconds: 8, cooldownSeconds: 45, maxConcurrentAssignments: 3, successionPolicy: SuccessionPolicy.DirectorAssigned, riskLevel: RiskLevel.High, requiresTwoPersonApproval: false, requiresHumanApproval: false, reputationScope: [ReputationScope.Hat, ReputationScope.AgentHat, ReputationScope.DepartmentHat] }, + [HatLevel.Lead]: { tokenTtlSeconds: 180, warmupSeconds: 5, cooldownSeconds: 30, maxConcurrentAssignments: 5, successionPolicy: SuccessionPolicy.DirectorAssigned, riskLevel: RiskLevel.Medium, requiresTwoPersonApproval: false, requiresHumanApproval: false, reputationScope: [ReputationScope.Hat, ReputationScope.AgentHat] }, + [HatLevel.IndividualContributor]: { tokenTtlSeconds: 120, warmupSeconds: 5, cooldownSeconds: 20, maxConcurrentAssignments: 10, successionPolicy: SuccessionPolicy.Rotate, riskLevel: RiskLevel.Low, requiresTwoPersonApproval: false, requiresHumanApproval: false, reputationScope: [ReputationScope.Hat, ReputationScope.AgentHat, ReputationScope.ProjectHat] }, +}; + +type HatSpec = { + id: string; + name: string; + departmentId: DepartmentId; + level: HatLevel; + /** single parent hat id; null only for the Executive Board root */ + reportsTo: string | null; + toolBundles: readonly ToolBundle[]; + approvalScopes?: readonly string[]; + votingScopes?: readonly string[]; + skills?: readonly string[]; + quorumSize?: number; + conflictsWithHatIds?: readonly string[]; +}; + +const B = ToolBundle; +const D = DepartmentId; +const L = HatLevel; + +// The full hat catalog. reportsTo wires the supervisor DAG; supervises is derived. +const HAT_SPECS: readonly HatSpec[] = [ + // ── Executive Board and Governance ── + { id: "executive_board_member", name: "Executive Board Member", departmentId: D.ExecutiveBoardAndGovernance, level: L.ExecutiveBoard, reportsTo: null, toolBundles: [B.GoalIntake, B.Project, B.PortfolioAndInitiative, B.HatAuthorization, B.AgentInsight, B.Voting, B.Status, B.Messaging, B.Meeting, B.Observability], approvalScopes: ["major_initiatives", "departments", "high_power_hats", "budget_ceilings", "dangerous_overrides"], votingScopes: ["org_priority", "high_power_hats", "departments", "major_policy"], quorumSize: 3 }, + { id: "ceo", name: "CEO", departmentId: D.ExecutiveBoardAndGovernance, level: L.CSuite, reportsTo: "executive_board_member", toolBundles: [B.GoalIntake, B.Project, B.PortfolioAndInitiative, B.Voting, B.Status, B.Meeting, B.Messaging, B.HatAuthorization, B.AgentInsight], approvalScopes: ["portfolio_priority", "org_direction", "executive_escalation"], votingScopes: ["org_priority"] }, + { id: "cto", name: "CTO", departmentId: D.ExecutiveBoardAndGovernance, level: L.CSuite, reportsTo: "ceo", toolBundles: [B.Project, B.PortfolioAndInitiative, B.Architecture, B.ReviewAndGates, B.Status, B.Observability, B.HatAuthorization, B.Meeting, B.CapabilityExpansion], approvalScopes: ["technical_standards", "major_technical_gates", "architecture_escalation"] }, + { id: "coo", name: "COO", departmentId: D.ExecutiveBoardAndGovernance, level: L.CSuite, reportsTo: "ceo", toolBundles: [B.Project, B.PortfolioAndInitiative, B.TeamRuntime, B.Status, B.AlwaysOnRuntime, B.ScheduledReviews, B.Meeting, B.Messaging], approvalScopes: ["operating_cadence", "process_changes", "incident_process", "schedule_policy"] }, + { id: "cfo", name: "CFO", departmentId: D.ExecutiveBoardAndGovernance, level: L.CSuite, reportsTo: "ceo", toolBundles: [B.Project, B.PortfolioAndInitiative, B.Status, B.HatAuthorization, B.AgentInsight, B.AlwaysOnRuntime, B.Observability], approvalScopes: ["budget_ceilings", "cost_exceptions", "capacity_scaling"], votingScopes: ["hat_supply"] }, + { id: "chief_architect", name: "Chief Architect", departmentId: D.ExecutiveBoardAndGovernance, level: L.CSuite, reportsTo: "cto", toolBundles: [B.Architecture, B.ReviewAndGates, B.DocumentationContext, B.CapabilityExpansion, B.TemporalWorkflowRegistry, B.DaprActorRegistry, B.Voting, B.Observability], approvalScopes: ["high_risk_ca_adr", "runtime_architecture", "cross_service_design"] }, + { id: "policy_steward", name: "Policy Steward", departmentId: D.ExecutiveBoardAndGovernance, level: L.Director, reportsTo: "ceo", toolBundles: [B.Voting, B.ReviewAndGates, B.HatAuthorization, B.Observability, B.DocumentationContext, B.CapabilityExpansion], approvalScopes: ["policy_review"] }, + { id: "hat_approval_steward", name: "Hat Approval Steward", departmentId: D.ExecutiveBoardAndGovernance, level: L.Director, reportsTo: "ceo", toolBundles: [B.HatAuthorization, B.AgentInsight, B.Voting, B.ScheduledReviews, B.ReviewAndGates, B.Observability], approvalScopes: ["new_hat_classes", "sensitive_hat_activation"], votingScopes: ["hat_supply"] }, + + // ── Program and Initiative Management ── + { id: "program_director", name: "Program Director", departmentId: D.ProgramAndInitiativeManagement, level: L.Director, reportsTo: "coo", toolBundles: [B.Project, B.PortfolioAndInitiative, B.HatAuthorization, B.AgentInsight, B.Status, B.Meeting, B.ScheduledReviews], approvalScopes: ["department_initiative_priority", "tpm_assignment"], votingScopes: ["hat_supply"] }, + { id: "senior_tpm", name: "Senior TPM", departmentId: D.ProgramAndInitiativeManagement, level: L.Manager, reportsTo: "program_director", toolBundles: [B.PortfolioAndInitiative, B.TeamRuntime, B.Task, B.BacklogAndDefect, B.HatAuthorization, B.AgentInsight, B.Messaging, B.Meeting, B.ArtifactAndEvidence, B.Status], approvalScopes: ["initiative_readiness", "staffing"] }, + { id: "tpm", name: "TPM", departmentId: D.ProgramAndInitiativeManagement, level: L.Manager, reportsTo: "senior_tpm", toolBundles: [B.TeamRuntime, B.Task, B.BacklogAndDefect, B.Messaging, B.Meeting, B.ArtifactAndEvidence, B.Status, B.AgentInsight, B.HatAuthorization], approvalScopes: ["task_priority", "team_coordination"] }, + { id: "mission_control_lead", name: "Mission Control Lead", departmentId: D.ProgramAndInitiativeManagement, level: L.Lead, reportsTo: "tpm", toolBundles: [B.TeamRuntime, B.Task, B.Messaging, B.Meeting, B.ArtifactAndEvidence, B.Status, B.Observability], approvalScopes: ["mission_coordination"] }, + { id: "initiative_planner", name: "Initiative Planner", departmentId: D.ProgramAndInitiativeManagement, level: L.IndividualContributor, reportsTo: "program_director", toolBundles: [B.Project, B.PortfolioAndInitiative, B.BacklogAndDefect, B.Task, B.ArtifactAndEvidence, B.Status] }, + { id: "dependency_manager", name: "Dependency Manager", departmentId: D.ProgramAndInitiativeManagement, level: L.IndividualContributor, reportsTo: "tpm", toolBundles: [B.Task, B.BacklogAndDefect, B.Messaging, B.Meeting, B.Status, B.Observability] }, + { id: "blocker_manager", name: "Blocker Manager", departmentId: D.ProgramAndInitiativeManagement, level: L.IndividualContributor, reportsTo: "tpm", toolBundles: [B.Task, B.BacklogAndDefect, B.Messaging, B.Meeting, B.Status, B.ScheduledReviews] }, + + // ── Product and Customer Discovery ── + { id: "product_director", name: "Product Director", departmentId: D.ProductAndCustomerDiscovery, level: L.Director, reportsTo: "ceo", toolBundles: [B.GoalIntake, B.Project, B.PortfolioAndInitiative, B.Business, B.ReviewAndGates, B.Status, B.Meeting], approvalScopes: ["product_priority", "product_signoff_escalation"], votingScopes: ["hat_supply"] }, + { id: "product_owner", name: "Product Owner", departmentId: D.ProductAndCustomerDiscovery, level: L.Manager, reportsTo: "product_director", toolBundles: [B.Business, B.ArtifactAndEvidence, B.Project, B.Task, B.ReviewAndGates, B.Messaging, B.Memory, B.Status, B.DocumentationContext], approvalScopes: ["brd_signoff", "product_readiness", "customer_rfp_review", "final_business_validation"] }, + { id: "customer_interviewer", name: "Customer Interviewer", departmentId: D.ProductAndCustomerDiscovery, level: L.IndividualContributor, reportsTo: "product_owner", toolBundles: [B.Business, B.Messaging, B.ArtifactAndEvidence, B.Task, B.Memory, B.DocumentationContext] }, + { id: "requirement_clarifier", name: "Requirement Clarifier", departmentId: D.ProductAndCustomerDiscovery, level: L.IndividualContributor, reportsTo: "product_owner", toolBundles: [B.GoalIntake, B.Business, B.Messaging, B.ArtifactAndEvidence, B.BacklogAndDefect] }, + { id: "acceptance_criteria_owner", name: "Acceptance Criteria Owner", departmentId: D.ProductAndCustomerDiscovery, level: L.IndividualContributor, reportsTo: "product_owner", toolBundles: [B.Business, B.Task, B.ArtifactAndEvidence, B.ReviewAndGates, B.DocumentationContext] }, + { id: "customer_feedback_lead", name: "Customer Feedback Lead", departmentId: D.ProductAndCustomerDiscovery, level: L.Lead, reportsTo: "product_owner", toolBundles: [B.GoalIntake, B.BacklogAndDefect, B.Business, B.Messaging, B.ArtifactAndEvidence, B.Status] }, + + // ── Business Analysis ── + { id: "ba_director", name: "BA Director", departmentId: D.BusinessAnalysis, level: L.Director, reportsTo: "product_director", toolBundles: [B.Business, B.ReviewAndGates, B.Status, B.Meeting, B.ScheduledReviews], approvalScopes: ["ba_process", "brd_quality_standards"], votingScopes: ["hat_supply"] }, + { id: "business_analyst", name: "Business Analyst", departmentId: D.BusinessAnalysis, level: L.IndividualContributor, reportsTo: "ba_director", toolBundles: [B.Business, B.ArtifactAndEvidence, B.Task, B.BacklogAndDefect, B.Messaging, B.Memory, B.ReviewAndGates, B.DocumentationContext] }, + { id: "requirements_analyst", name: "Requirements Analyst", departmentId: D.BusinessAnalysis, level: L.IndividualContributor, reportsTo: "ba_director", toolBundles: [B.Business, B.ArtifactAndEvidence, B.Task, B.DocumentationContext, B.Memory] }, + { id: "brd_author", name: "BRD Author", departmentId: D.BusinessAnalysis, level: L.IndividualContributor, reportsTo: "ba_director", toolBundles: [B.Business, B.ArtifactAndEvidence, B.DocumentationContext, B.Memory, B.Messaging] }, + { id: "brd_reviewer", name: "BRD Reviewer", departmentId: D.BusinessAnalysis, level: L.IndividualContributor, reportsTo: "ba_director", toolBundles: [B.Business, B.ReviewAndGates, B.ArtifactAndEvidence, B.DocumentationContext], approvalScopes: ["brd_approval"] }, + { id: "business_approver", name: "Business Approver", departmentId: D.BusinessAnalysis, level: L.Manager, reportsTo: "ba_director", toolBundles: [B.Business, B.ReviewAndGates, B.ArtifactAndEvidence, B.Status, B.Messaging], approvalScopes: ["brd_approval"] }, + { id: "domain_researcher", name: "Domain Researcher", departmentId: D.BusinessAnalysis, level: L.IndividualContributor, reportsTo: "ba_director", toolBundles: [B.Business, B.ArtifactAndEvidence, B.Memory, B.DocumentationContext, B.Messaging] }, + + // ── Architecture ── + { id: "architecture_director", name: "Architecture Director", departmentId: D.Architecture, level: L.Director, reportsTo: "cto", toolBundles: [B.Architecture, B.ReviewAndGates, B.DocumentationContext, B.Status, B.ScheduledReviews, B.AgentInsight], approvalScopes: ["architecture_standards", "reviewer_assignment"], votingScopes: ["hat_supply"] }, + { id: "architect", name: "Architect", departmentId: D.Architecture, level: L.IndividualContributor, reportsTo: "architecture_director", toolBundles: [B.Architecture, B.ArtifactAndEvidence, B.Project, B.Task, B.Memory, B.DocumentationContext, B.Observability], approvalScopes: ["architecture_approval"] }, + { id: "conceptual_architect", name: "Conceptual Architect", departmentId: D.Architecture, level: L.IndividualContributor, reportsTo: "architecture_director", toolBundles: [B.Architecture, B.DocumentationContext, B.ArtifactAndEvidence, B.Memory, B.Meeting] }, + { id: "architecture_reviewer", name: "Architecture Reviewer", departmentId: D.Architecture, level: L.IndividualContributor, reportsTo: "architecture_director", toolBundles: [B.Architecture, B.ReviewAndGates, B.ArtifactAndEvidence, B.DocumentationContext, B.Observability], approvalScopes: ["architecture_approval"] }, + { id: "adr_steward", name: "ADR Steward", departmentId: D.Architecture, level: L.IndividualContributor, reportsTo: "architecture_director", toolBundles: [B.DocumentationContext, B.Architecture, B.ReviewAndGates, B.ProjectSkills, B.Memory] }, + { id: "integration_architect", name: "Integration Architect", departmentId: D.Architecture, level: L.IndividualContributor, reportsTo: "architecture_director", toolBundles: [B.Architecture, B.CredentialProxy, B.ReviewAndGates, B.DocumentationContext, B.Observability] }, + { id: "runtime_architecture_reviewer", name: "Runtime Architecture Reviewer", departmentId: D.Architecture, level: L.IndividualContributor, reportsTo: "architecture_director", toolBundles: [B.Architecture, B.TemporalWorkflowRegistry, B.DaprActorRegistry, B.NatsAndDlqOperations, B.OzAndHermesRuntime, B.ReviewAndGates, B.Observability], approvalScopes: ["runtime_architecture"] }, + + // ── Engineering ── + { id: "engineering_director", name: "Engineering Director", departmentId: D.Engineering, level: L.Director, reportsTo: "cto", toolBundles: [B.Project, B.PortfolioAndInitiative, B.HatAuthorization, B.AgentInsight, B.Status, B.ScheduledReviews], approvalScopes: ["engineering_priority", "engineering_standards"], votingScopes: ["hat_supply"] }, + { id: "backend_implementer", name: "Backend Implementer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.Task, B.ArtifactAndEvidence, B.Memory, B.CredentialProxy, B.DevOps, B.Observability, B.Messaging, B.DocumentationContext] }, + { id: "frontend_implementer", name: "Frontend Implementer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.Task, B.ArtifactAndEvidence, B.Memory, B.DevOps, B.Observability, B.Messaging, B.DocumentationContext, B.Qa] }, + { id: "fullstack_implementer", name: "Full-Stack Implementer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.Task, B.ArtifactAndEvidence, B.Memory, B.CredentialProxy, B.DevOps, B.Observability, B.Messaging, B.DocumentationContext, B.Qa] }, + { id: "defect_fixer", name: "Defect Fixer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.Task, B.BacklogAndDefect, B.ArtifactAndEvidence, B.Memory, B.DevOps, B.Observability, B.Messaging] }, + { id: "test_first_engineer", name: "Test-First Engineer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.Task, B.ArtifactAndEvidence, B.DevOps, B.Observability, B.Qa, B.DocumentationContext] }, + { id: "integration_engineer", name: "Integration Engineer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.Task, B.CredentialProxy, B.ArtifactAndEvidence, B.DevOps, B.Observability, B.Architecture, B.DocumentationContext] }, + { id: "tooling_engineer", name: "Tooling Engineer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.Task, B.ProjectSkills, B.CapabilityExpansion, B.DevOps, B.Observability, B.ArtifactAndEvidence, B.DocumentationContext] }, + { id: "code_reviewer", name: "Code Reviewer", departmentId: D.Engineering, level: L.IndividualContributor, reportsTo: "engineering_director", toolBundles: [B.ReviewAndGates, B.ArtifactAndEvidence, B.Task, B.Memory, B.Status, B.Observability, B.Messaging, B.DocumentationContext], approvalScopes: ["implementation_review"] }, + + // ── Engineering Management ── + { id: "engineering_manager", name: "Engineering Manager", departmentId: D.EngineeringManagement, level: L.Manager, reportsTo: "engineering_director", toolBundles: [B.Task, B.TeamRuntime, B.ReviewAndGates, B.BacklogAndDefect, B.ArtifactAndEvidence, B.Memory, B.ScheduledReviews, B.Status, B.Observability, B.AgentInsight], approvalScopes: ["readiness", "outcome", "process_gates", "implementation_review"] }, + { id: "team_lead", name: "Team Lead", departmentId: D.EngineeringManagement, level: L.Lead, reportsTo: "engineering_manager", toolBundles: [B.TeamRuntime, B.Task, B.Messaging, B.Meeting, B.ArtifactAndEvidence, B.Status, B.Memory], approvalScopes: ["team_coordination"] }, + { id: "readiness_reviewer", name: "Readiness Reviewer", departmentId: D.EngineeringManagement, level: L.IndividualContributor, reportsTo: "engineering_manager", toolBundles: [B.Task, B.ReviewAndGates, B.ArtifactAndEvidence, B.DocumentationContext, B.Memory, B.Status], approvalScopes: ["readiness_gate"] }, + { id: "context_attachment_reviewer", name: "Context Attachment Reviewer", departmentId: D.EngineeringManagement, level: L.IndividualContributor, reportsTo: "engineering_manager", toolBundles: [B.Task, B.ArtifactAndEvidence, B.Memory, B.DocumentationContext, B.ProjectSkills, B.ReviewAndGates], approvalScopes: ["context_readiness_gate"] }, + { id: "outcome_reviewer", name: "Outcome Reviewer", departmentId: D.EngineeringManagement, level: L.IndividualContributor, reportsTo: "engineering_manager", toolBundles: [B.ReviewAndGates, B.ArtifactAndEvidence, B.Observability, B.Qa, B.Status, B.BacklogAndDefect], approvalScopes: ["outcome_review"] }, + { id: "performance_review_author", name: "Performance Review Author", departmentId: D.EngineeringManagement, level: L.IndividualContributor, reportsTo: "engineering_manager", toolBundles: [B.ScheduledReviews, B.ReviewAndGates, B.Memory, B.AgentInsight, B.Observability, B.BacklogAndDefect] }, + { id: "capability_request_triage", name: "Capability Request Triage", departmentId: D.EngineeringManagement, level: L.IndividualContributor, reportsTo: "engineering_manager", toolBundles: [B.CapabilityExpansion, B.BacklogAndDefect, B.ReviewAndGates, B.ArtifactAndEvidence, B.Messaging] }, + + // ── QA and Verification ── + { id: "qa_director", name: "QA Director", departmentId: D.QaAndVerification, level: L.Director, reportsTo: "coo", toolBundles: [B.Qa, B.ReviewAndGates, B.Status, B.ScheduledReviews, B.Meeting, B.BacklogAndDefect], approvalScopes: ["qa_standards", "qa_signoff_escalation"], votingScopes: ["hat_supply"] }, + { id: "qa_verifier", name: "QA Verifier", departmentId: D.QaAndVerification, level: L.IndividualContributor, reportsTo: "qa_director", toolBundles: [B.Qa, B.ArtifactAndEvidence, B.Task, B.ReviewAndGates, B.Observability, B.Status, B.BacklogAndDefect, B.Messaging], approvalScopes: ["runtime_validation"] }, + { id: "qa_reviewer", name: "QA Reviewer", departmentId: D.QaAndVerification, level: L.IndividualContributor, reportsTo: "qa_director", toolBundles: [B.Qa, B.ReviewAndGates, B.ArtifactAndEvidence, B.Task, B.Observability, B.BacklogAndDefect, B.Messaging], approvalScopes: ["runtime_validation", "qa_signoff"] }, + { id: "browser_automation_qa", name: "Browser Automation QA", departmentId: D.QaAndVerification, level: L.IndividualContributor, reportsTo: "qa_director", toolBundles: [B.Qa, B.ArtifactAndEvidence, B.Observability, B.Task, B.Messaging] }, + { id: "regression_verifier", name: "Regression Verifier", departmentId: D.QaAndVerification, level: L.IndividualContributor, reportsTo: "qa_director", toolBundles: [B.Qa, B.ScheduledReviews, B.ArtifactAndEvidence, B.Observability, B.BacklogAndDefect] }, + { id: "reproducibility_analyst", name: "Reproducibility Analyst", departmentId: D.QaAndVerification, level: L.IndividualContributor, reportsTo: "qa_director", toolBundles: [B.Qa, B.BacklogAndDefect, B.ArtifactAndEvidence, B.Observability, B.Messaging] }, + { id: "evidence_package_author", name: "Evidence Package Author", departmentId: D.QaAndVerification, level: L.IndividualContributor, reportsTo: "qa_director", toolBundles: [B.ArtifactAndEvidence, B.Qa, B.Observability, B.DocumentationContext] }, + + // ── QA Engineering ── + { id: "qa_engineering_director", name: "QA Engineering Director", departmentId: D.QaEngineering, level: L.Director, reportsTo: "cto", toolBundles: [B.Qa, B.ScheduledReviews, B.Project, B.BacklogAndDefect, B.CapabilityExpansion, B.Status], approvalScopes: ["qa_engineering_priority"], votingScopes: ["hat_supply"] }, + { id: "qa_engineering_manager", name: "QA Engineering Manager", departmentId: D.QaEngineering, level: L.Manager, reportsTo: "qa_engineering_director", toolBundles: [B.Qa, B.ScheduledReviews, B.BacklogAndDefect, B.ArtifactAndEvidence, B.Status, B.Observability, B.ProjectSkills], approvalScopes: ["qa_automation_readiness"] }, + { id: "qa_automation_engineer", name: "QA Automation Engineer", departmentId: D.QaEngineering, level: L.IndividualContributor, reportsTo: "qa_engineering_manager", toolBundles: [B.Qa, B.Task, B.ArtifactAndEvidence, B.DevOps, B.Observability, B.ProjectSkills] }, + { id: "test_suite_maintainer", name: "Test Suite Maintainer", departmentId: D.QaEngineering, level: L.IndividualContributor, reportsTo: "qa_engineering_manager", toolBundles: [B.Qa, B.ScheduledReviews, B.DevOps, B.Observability, B.BacklogAndDefect, B.ArtifactAndEvidence] }, + { id: "coverage_analyst", name: "Coverage Analyst", departmentId: D.QaEngineering, level: L.IndividualContributor, reportsTo: "qa_engineering_manager", toolBundles: [B.Qa, B.Observability, B.BacklogAndDefect, B.ArtifactAndEvidence, B.Status] }, + { id: "regression_scheduler", name: "Regression Scheduler", departmentId: D.QaEngineering, level: L.IndividualContributor, reportsTo: "qa_engineering_manager", toolBundles: [B.Qa, B.ScheduledReviews, B.AlwaysOnRuntime, B.Status, B.Messaging] }, + { id: "test_case_manager", name: "Test Case Manager", departmentId: D.QaEngineering, level: L.IndividualContributor, reportsTo: "qa_engineering_manager", toolBundles: [B.Qa, B.DocumentationContext, B.ArtifactAndEvidence, B.ProjectSkills, B.BacklogAndDefect] }, + + // ── Security and Compliance ── + { id: "security_director", name: "Security Director", departmentId: D.SecurityAndCompliance, level: L.Director, reportsTo: "cto", toolBundles: [B.CredentialProxy, B.ReviewAndGates, B.HatAuthorization, B.Observability, B.Voting, B.Meeting, B.Status], approvalScopes: ["security_veto", "sensitive_tool_policy", "security_escalation"], votingScopes: ["hat_supply"] }, + { id: "security_reviewer", name: "Security Reviewer", departmentId: D.SecurityAndCompliance, level: L.IndividualContributor, reportsTo: "security_director", toolBundles: [B.ReviewAndGates, B.CredentialProxy, B.Observability, B.ArtifactAndEvidence, B.DocumentationContext], approvalScopes: ["security_gate"] }, + { id: "credential_scope_approver", name: "Credential Scope Approver", departmentId: D.SecurityAndCompliance, level: L.IndividualContributor, reportsTo: "security_director", toolBundles: [B.CredentialProxy, B.ReviewAndGates, B.Observability, B.ArtifactAndEvidence, B.Messaging], approvalScopes: ["credential_scope"] }, + { id: "policy_engineer", name: "Policy Engineer", departmentId: D.SecurityAndCompliance, level: L.IndividualContributor, reportsTo: "security_director", toolBundles: [B.ReviewAndGates, B.Observability, B.DocumentationContext, B.CapabilityExpansion, B.HatAuthorization] }, + { id: "external_api_reviewer", name: "External API Reviewer", departmentId: D.SecurityAndCompliance, level: L.IndividualContributor, reportsTo: "security_director", toolBundles: [B.CredentialProxy, B.Architecture, B.ReviewAndGates, B.DocumentationContext, B.Observability], approvalScopes: ["external_api_security"] }, + { id: "dangerous_automation_reviewer", name: "Dangerous Automation Reviewer", departmentId: D.SecurityAndCompliance, level: L.IndividualContributor, reportsTo: "security_director", toolBundles: [B.CredentialProxy, B.AlwaysOnRuntime, B.ReviewAndGates, B.Observability, B.HumanOverride], approvalScopes: ["dangerous_automation"] }, + { id: "audit_reviewer", name: "Audit Reviewer", departmentId: D.SecurityAndCompliance, level: L.IndividualContributor, reportsTo: "security_director", toolBundles: [B.Observability, B.ArtifactAndEvidence, B.ReviewAndGates, B.Status, B.DocumentationContext], approvalScopes: ["audit_finding"] }, + + // ── Delivery and Release ── + { id: "delivery_director", name: "Delivery Director", departmentId: D.DeliveryAndRelease, level: L.Director, reportsTo: "coo", toolBundles: [B.Delivery, B.ReviewAndGates, B.Status, B.DevOps, B.Meeting, B.ScheduledReviews], approvalScopes: ["delivery_standards"], votingScopes: ["hat_supply"] }, + { id: "release_manager", name: "Release Manager", departmentId: D.DeliveryAndRelease, level: L.Manager, reportsTo: "delivery_director", toolBundles: [B.Delivery, B.ReviewAndGates, B.ArtifactAndEvidence, B.Status, B.DevOps, B.Messaging], approvalScopes: ["release_readiness"] }, + { id: "release_operator", name: "Release Operator", departmentId: D.DeliveryAndRelease, level: L.IndividualContributor, reportsTo: "release_manager", toolBundles: [B.Delivery, B.DevOps, B.ArtifactAndEvidence, B.Observability, B.Status, B.Messaging, B.AlwaysOnRuntime] }, + { id: "delivery_reviewer", name: "Delivery Reviewer", departmentId: D.DeliveryAndRelease, level: L.IndividualContributor, reportsTo: "delivery_director", toolBundles: [B.Delivery, B.ReviewAndGates, B.ArtifactAndEvidence, B.Status, B.DevOps, B.Project, B.Messaging], approvalScopes: ["release_readiness"] }, + { id: "merge_steward", name: "Merge Steward", departmentId: D.DeliveryAndRelease, level: L.IndividualContributor, reportsTo: "release_manager", toolBundles: [B.Delivery, B.DevOps, B.ArtifactAndEvidence, B.Status, B.Messaging] }, + { id: "deployment_evidence_author", name: "Deployment Evidence Author", departmentId: D.DeliveryAndRelease, level: L.IndividualContributor, reportsTo: "release_manager", toolBundles: [B.Delivery, B.ArtifactAndEvidence, B.Observability, B.DocumentationContext] }, + { id: "rollback_coordinator", name: "Rollback Coordinator", departmentId: D.DeliveryAndRelease, level: L.IndividualContributor, reportsTo: "delivery_director", toolBundles: [B.Delivery, B.AlwaysOnRuntime, B.Meeting, B.Messaging, B.Observability, B.HumanOverride] }, + + // ── Memory and Knowledge ── + { id: "memory_director", name: "Memory Director", departmentId: D.MemoryAndKnowledge, level: L.Director, reportsTo: "coo", toolBundles: [B.Memory, B.AgentInsight, B.ScheduledReviews, B.Status, B.BacklogAndDefect, B.DocumentationContext], approvalScopes: ["memory_policy"], votingScopes: ["hat_supply"] }, + { id: "memory_manager", name: "Memory Manager", departmentId: D.MemoryAndKnowledge, level: L.Manager, reportsTo: "memory_director", toolBundles: [B.Memory, B.ScheduledReviews, B.ReviewAndGates, B.AgentInsight, B.BacklogAndDefect], approvalScopes: ["memory_adaptation"] }, + { id: "memory_curator", name: "Memory Curator", departmentId: D.MemoryAndKnowledge, level: L.IndividualContributor, reportsTo: "memory_manager", toolBundles: [B.Memory, B.AgentInsight, B.BacklogAndDefect, B.ArtifactAndEvidence, B.DocumentationContext] }, + { id: "memory_reviewer", name: "Memory Reviewer", departmentId: D.MemoryAndKnowledge, level: L.IndividualContributor, reportsTo: "memory_manager", toolBundles: [B.Memory, B.ReviewAndGates, B.ScheduledReviews, B.Observability, B.ArtifactAndEvidence] }, + { id: "knowledge_router", name: "Knowledge Router", departmentId: D.MemoryAndKnowledge, level: L.IndividualContributor, reportsTo: "memory_manager", toolBundles: [B.Memory, B.DocumentationContext, B.ProjectSkills, B.Task, B.ArtifactAndEvidence] }, + { id: "project_context_librarian", name: "Project Context Librarian", departmentId: D.MemoryAndKnowledge, level: L.IndividualContributor, reportsTo: "memory_manager", toolBundles: [B.DocumentationContext, B.Memory, B.Project, B.ProjectSkills, B.ArtifactAndEvidence] }, + + // ── Documentation and Project Skills ── + { id: "documentation_systems_director", name: "Documentation Systems Director", departmentId: D.DocumentationAndProjectSkills, level: L.Director, reportsTo: "chief_architect", toolBundles: [B.DocumentationContext, B.ProjectSkills, B.ReviewAndGates, B.Status, B.ScheduledReviews], approvalScopes: ["documentation_policy"], votingScopes: ["hat_supply"] }, + { id: "design_doc_steward", name: "Design Doc Steward", departmentId: D.DocumentationAndProjectSkills, level: L.IndividualContributor, reportsTo: "documentation_systems_director", toolBundles: [B.DocumentationContext, B.Architecture, B.ArtifactAndEvidence, B.Memory] }, + { id: "documentation_reviewer", name: "Documentation Reviewer", departmentId: D.DocumentationAndProjectSkills, level: L.IndividualContributor, reportsTo: "documentation_systems_director", toolBundles: [B.DocumentationContext, B.ReviewAndGates, B.ArtifactAndEvidence, B.Memory], approvalScopes: ["documentation_gate"] }, + { id: "project_skill_author", name: "Project Skill Author", departmentId: D.DocumentationAndProjectSkills, level: L.IndividualContributor, reportsTo: "documentation_systems_director", toolBundles: [B.ProjectSkills, B.DocumentationContext, B.Memory, B.ArtifactAndEvidence, B.CapabilityExpansion] }, + { id: "skill_graph_curator", name: "Skill Graph Curator", departmentId: D.DocumentationAndProjectSkills, level: L.IndividualContributor, reportsTo: "documentation_systems_director", toolBundles: [B.ProjectSkills, B.Memory, B.DocumentationContext, B.ReviewAndGates] }, + { id: "documentation_enforcement_reviewer", name: "Documentation Enforcement Reviewer", departmentId: D.DocumentationAndProjectSkills, level: L.IndividualContributor, reportsTo: "documentation_systems_director", toolBundles: [B.DocumentationContext, B.ReviewAndGates, B.Task, B.ArtifactAndEvidence, B.Memory], approvalScopes: ["documentation_compliance_gate"] }, + + // ── Operations and Infrastructure ── + { id: "operations_director", name: "Operations Director", departmentId: D.OperationsAndInfrastructure, level: L.Director, reportsTo: "coo", toolBundles: [B.AlwaysOnRuntime, B.Observability, B.DevOps, B.Status, B.Meeting, B.ScheduledReviews], approvalScopes: ["operations_priority", "incident_process"], votingScopes: ["hat_supply"] }, + { id: "platform_operator", name: "Platform Operator", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.Observability, B.DevOps, B.Status, B.Messaging, B.ArtifactAndEvidence] }, + { id: "runtime_steward", name: "Runtime Steward", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.Observability, B.BacklogAndDefect, B.DocumentationContext, B.ReviewAndGates] }, + { id: "lease_steward", name: "Lease Steward", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.Observability, B.ArtifactAndEvidence, B.BacklogAndDefect] }, + { id: "oz_k3s_reconciler", name: "Oz/K3s Reconciler", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.OzAndHermesRuntime, B.AlwaysOnRuntime, B.Observability, B.DevOps, B.ArtifactAndEvidence] }, + { id: "sre", name: "SRE", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.Observability, B.BacklogAndDefect, B.DevOps, B.Status] }, + { id: "incident_commander", name: "Incident Commander", departmentId: D.OperationsAndInfrastructure, level: L.Manager, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.Messaging, B.Meeting, B.Status, B.ArtifactAndEvidence, B.BacklogAndDefect, B.Observability, B.HumanOverride], approvalScopes: ["incident_command"] }, + { id: "dlq_steward", name: "DLQ Steward", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.NatsAndDlqOperations, B.Observability, B.ArtifactAndEvidence, B.BacklogAndDefect] }, + { id: "scheduler_steward", name: "Scheduler Steward", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.ScheduledReviews, B.Observability, B.Status, B.BacklogAndDefect] }, + { id: "trigger_steward", name: "Trigger Steward", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.AlwaysOnRuntime, B.Observability, B.ReviewAndGates, B.DocumentationContext] }, + { id: "runbook_maintainer", name: "Runbook Maintainer", departmentId: D.OperationsAndInfrastructure, level: L.IndividualContributor, reportsTo: "operations_director", toolBundles: [B.DocumentationContext, B.ProjectSkills, B.AlwaysOnRuntime, B.ReviewAndGates, B.Memory] }, + { id: "cost_controller", name: "Cost Controller", departmentId: D.OperationsAndInfrastructure, level: L.Manager, reportsTo: "cfo", toolBundles: [B.Status, B.HatAuthorization, B.PortfolioAndInitiative, B.AlwaysOnRuntime, B.Observability, B.AgentInsight], approvalScopes: ["cost_guardrail"], votingScopes: ["hat_supply"] }, + + // ── Observability and Evidence ── + { id: "observability_director", name: "Observability Director", departmentId: D.ObservabilityAndEvidence, level: L.Director, reportsTo: "operations_director", toolBundles: [B.Observability, B.Status, B.ScheduledReviews, B.BacklogAndDefect, B.Meeting], approvalScopes: ["observability_standards"], votingScopes: ["hat_supply"] }, + { id: "observability_curator", name: "Observability Curator", departmentId: D.ObservabilityAndEvidence, level: L.IndividualContributor, reportsTo: "observability_director", toolBundles: [B.Observability, B.ArtifactAndEvidence, B.BacklogAndDefect, B.DocumentationContext] }, + { id: "trace_analyst", name: "Trace Analyst", departmentId: D.ObservabilityAndEvidence, level: L.IndividualContributor, reportsTo: "observability_director", toolBundles: [B.Observability, B.ArtifactAndEvidence, B.Status, B.DevOps] }, + { id: "trace_and_evidence_steward", name: "Trace and Evidence Steward", departmentId: D.ObservabilityAndEvidence, level: L.IndividualContributor, reportsTo: "observability_director", toolBundles: [B.Observability, B.ArtifactAndEvidence, B.Qa, B.DocumentationContext, B.ReviewAndGates] }, + { id: "health_report_reviewer", name: "Health Report Reviewer", departmentId: D.ObservabilityAndEvidence, level: L.IndividualContributor, reportsTo: "observability_director", toolBundles: [B.Observability, B.AlwaysOnRuntime, B.BacklogAndDefect, B.ReviewAndGates], approvalScopes: ["health_finding"] }, + { id: "anomaly_classifier", name: "Anomaly Classifier", departmentId: D.ObservabilityAndEvidence, level: L.IndividualContributor, reportsTo: "observability_director", toolBundles: [B.Observability, B.AlwaysOnRuntime, B.BacklogAndDefect, B.Messaging] }, + { id: "coverage_gap_reporter", name: "Coverage Gap Reporter", departmentId: D.ObservabilityAndEvidence, level: L.IndividualContributor, reportsTo: "observability_director", toolBundles: [B.Observability, B.BacklogAndDefect, B.DocumentationContext, B.ProjectSkills] }, + + // ── Capability and Automation Expansion ── + { id: "hat_designer", name: "Hat Designer", departmentId: D.CapabilityAndAutomationExpansion, level: L.Manager, reportsTo: "hat_approval_steward", toolBundles: [B.HatAuthorization, B.AgentInsight, B.CapabilityExpansion, B.ReviewAndGates, B.DocumentationContext, B.ScheduledReviews], approvalScopes: ["hat_proposal"], quorumSize: 3, conflictsWithHatIds: ["backend_implementer", "fullstack_implementer"] }, + { id: "capability_request_owner", name: "Capability Request Owner", departmentId: D.CapabilityAndAutomationExpansion, level: L.IndividualContributor, reportsTo: "hat_designer", toolBundles: [B.CapabilityExpansion, B.BacklogAndDefect, B.ArtifactAndEvidence, B.ReviewAndGates, B.Messaging] }, + { id: "tool_registry_steward", name: "Tool Registry Steward", departmentId: D.CapabilityAndAutomationExpansion, level: L.IndividualContributor, reportsTo: "hat_designer", toolBundles: [B.CapabilityExpansion, B.ReviewAndGates, B.Observability, B.DocumentationContext, B.HatAuthorization] }, + { id: "automation_expansion_reviewer", name: "Automation Expansion Reviewer", departmentId: D.CapabilityAndAutomationExpansion, level: L.IndividualContributor, reportsTo: "hat_designer", toolBundles: [B.TemporalWorkflowRegistry, B.DaprActorRegistry, B.AlwaysOnRuntime, B.Architecture, B.ReviewAndGates, B.Observability], approvalScopes: ["automation_expansion"] }, + { id: "workflow_maintainer", name: "Workflow Maintainer", departmentId: D.CapabilityAndAutomationExpansion, level: L.IndividualContributor, reportsTo: "hat_designer", toolBundles: [B.TemporalWorkflowRegistry, B.Architecture, B.DocumentationContext, B.Observability, B.ReviewAndGates] }, + { id: "actor_registry_maintainer", name: "Actor Registry Maintainer", departmentId: D.CapabilityAndAutomationExpansion, level: L.IndividualContributor, reportsTo: "hat_designer", toolBundles: [B.DaprActorRegistry, B.Architecture, B.DocumentationContext, B.Observability, B.ReviewAndGates] }, + { id: "mcp_registry_maintainer", name: "MCP Registry Maintainer", departmentId: D.CapabilityAndAutomationExpansion, level: L.IndividualContributor, reportsTo: "hat_designer", toolBundles: [B.CapabilityExpansion, B.CredentialProxy, B.DocumentationContext, B.Observability, B.ReviewAndGates] }, +]; + +/** Expand the compact specs into full HatDefinitions with level defaults + derived supervises. */ +export function buildHatDefinitions(): readonly HatDefinition[] { + // derive supervises = reverse of reportsTo + const supervisedBy = new Map(); + for (const spec of HAT_SPECS) { + if (spec.reportsTo !== null) { + const list = supervisedBy.get(spec.reportsTo) ?? []; + list.push(spec.id); + supervisedBy.set(spec.reportsTo, list); + } + } + + // conflicts are symmetric: if A conflicts with B, B conflicts with A + const conflicts = new Map>(); + for (const spec of HAT_SPECS) { + for (const other of spec.conflictsWithHatIds ?? []) { + (conflicts.get(spec.id) ?? conflicts.set(spec.id, new Set()).get(spec.id)!).add(other); + (conflicts.get(other) ?? conflicts.set(other, new Set()).get(other)!).add(spec.id); + } + } + + return HAT_SPECS.map((spec): HatDefinition => { + const d = LEVEL_DEFAULTS[spec.level]; + return { + id: spec.id, + name: spec.name, + departmentId: spec.departmentId, + level: spec.level, + supervisesHatIds: supervisedBy.get(spec.id) ?? [], + reportsToHatIds: spec.reportsTo === null ? [] : [spec.reportsTo], + conflictsWithHatIds: [...(conflicts.get(spec.id) ?? [])], + // a hat is assignable by the hats it reports to (its supervisors) + assignableByHatIds: spec.reportsTo === null ? [] : [spec.reportsTo], + allowedToolBundles: spec.toolBundles, + skills: spec.skills ?? [], + approvalScopes: spec.approvalScopes ?? [], + votingScopes: spec.votingScopes ?? [], + memoryScopes: [spec.departmentId], + credentialScopes: spec.toolBundles.includes(ToolBundle.CredentialProxy) ? [spec.departmentId] : [], + documentationScopes: spec.toolBundles.includes(ToolBundle.DocumentationContext) ? [spec.departmentId] : [], + lifecycleTransitions: [], + requiredEvidence: [], + maxConcurrentAssignments: d.maxConcurrentAssignments, + tokenTtlSeconds: d.tokenTtlSeconds, + warmupSeconds: d.warmupSeconds, + cooldownSeconds: d.cooldownSeconds, + successionPolicy: d.successionPolicy, + stickyAttribution: spec.level === HatLevel.IndividualContributor, + ...(spec.quorumSize !== undefined ? { quorumSize: spec.quorumSize } : {}), + reputationScope: d.reputationScope, + riskLevel: d.riskLevel, + requiresTwoPersonApproval: d.requiresTwoPersonApproval, + requiresHumanApproval: d.requiresHumanApproval, + }; + }); +} + +export type OrgSeed = { + departments: readonly Department[]; + hats: readonly HatDefinition[]; +}; + +export function buildOrgSeed(): OrgSeed { + return { departments: DEPARTMENTS, hats: buildHatDefinitions() }; +} + +export const OrgGraphValidation = { + Acyclic: "acyclic", + Cyclic: "cyclic", + UnknownParent: "unknown_parent", +} as const; + +export type OrgGraphValidation = (typeof OrgGraphValidation)[keyof typeof OrgGraphValidation]; + +export type OrgGraphValidationResult = + | { outcome: typeof OrgGraphValidation.Acyclic } + | { outcome: typeof OrgGraphValidation.Cyclic; cycle: readonly string[] } + | { outcome: typeof OrgGraphValidation.UnknownParent; hatId: string; parentId: string }; + +/** Validate the reportsTo graph is a DAG terminating at the Executive Board root. */ +export function validateOrgGraph(hats: readonly HatDefinition[]): OrgGraphValidationResult { + const byId = new Map(hats.map((h) => [h.id, h])); + for (const hat of hats) { + for (const parent of hat.reportsToHatIds) { + if (!byId.has(parent)) { + return { outcome: OrgGraphValidation.UnknownParent, hatId: hat.id, parentId: parent }; + } + } + } + // DFS cycle detection over reportsTo edges + const WHITE = 0, GRAY = 1, BLACK = 2; + const color = new Map(hats.map((h) => [h.id, WHITE])); + const stack: string[] = []; + const visit = (id: string): readonly string[] | undefined => { + color.set(id, GRAY); + stack.push(id); + const hat = byId.get(id); + for (const parent of hat?.reportsToHatIds ?? []) { + const c = color.get(parent); + if (c === GRAY) { + return [...stack.slice(stack.indexOf(parent)), parent]; + } + if (c === WHITE) { + const cycle = visit(parent); + if (cycle !== undefined) return cycle; + } + } + color.set(id, BLACK); + stack.pop(); + return undefined; + }; + for (const hat of hats) { + if (color.get(hat.id) === WHITE) { + const cycle = visit(hat.id); + if (cycle !== undefined) { + return { outcome: OrgGraphValidation.Cyclic, cycle }; + } + } + } + return { outcome: OrgGraphValidation.Acyclic }; +} diff --git a/agentic-organization/packages/application/src/organization-reaction-plan-action-executor.ts b/agentic-organization/packages/application/src/organization-reaction-plan-action-executor.ts new file mode 100644 index 0000000000..8fcfc8978c --- /dev/null +++ b/agentic-organization/packages/application/src/organization-reaction-plan-action-executor.ts @@ -0,0 +1,61 @@ +/** + * Organization reaction-plan action executor — composes the autonomous DATA PLANE + * with the durable ORG STRUCTURE in one executor: + * + * 1. run the action through a Hermes agent run (durable run + memory + the + * agent-liveness the keep-alive control plane watches), + * 2. ensure the work item the action targets exists (so the org artifact has a + * valid anchor), + * 3. create the org artifact through the command pipeline (a supervisor-triage + * discussion anchor — a durable, auditable organizational record). + * + * This is where "the entire organizational structure" meets "the agents doing + * the autonomous work": an agent runs, stays watched, and produces real org + * substrate. Short-circuits if the agent run fails (no org artifact for a run + * that never happened). + */ + +import type { ReactionPlanAction } from "../../domain/src/index.ts"; +import { + ReactionPlanExecutionStatus, + type ReactionPlanActionExecutionContext, + type ReactionPlanActionExecutionResult, + type ReactionPlanActionExecutorPort, +} from "../../runtime/src/index.ts"; + +/** Ensure the work item an action targets exists (idempotent), so an org artifact can anchor to it. */ +export type EnsureWorkItemPort = { + ensureWorkItem: (action: ReactionPlanAction) => Promise; +}; + +export type CreateOrganizationReactionPlanActionExecutorInput = { + /** runs the action as a Hermes agent run (durable run + memory + agent liveness) */ + agentExecutor: ReactionPlanActionExecutorPort; + /** ensures the target work item exists before the org artifact is created */ + ensureWorkItem: EnsureWorkItemPort; + /** creates the durable org artifact (discussion anchor) via the command pipeline */ + organizationExecutor: ReactionPlanActionExecutorPort; +}; + +export function createOrganizationReactionPlanActionExecutor( + input: CreateOrganizationReactionPlanActionExecutorInput, +): ReactionPlanActionExecutorPort { + return { + executeReactionPlanAction: async ( + action: ReactionPlanAction, + context: ReactionPlanActionExecutionContext, + ): Promise => { + // 1. the agent runs (durable run + memory + liveness) + const agentResult = await input.agentExecutor.executeReactionPlanAction(action, context); + if (agentResult.status === ReactionPlanExecutionStatus.Failed) { + return agentResult; + } + + // 2. ensure the work item exists so the org artifact has a valid anchor + await input.ensureWorkItem.ensureWorkItem(action); + + // 3. create the durable org artifact (discussion anchor) — this is the result + return await input.organizationExecutor.executeReactionPlanAction(action, context); + }, + }; +} diff --git a/agentic-organization/packages/application/src/pipeline.ts b/agentic-organization/packages/application/src/pipeline.ts new file mode 100644 index 0000000000..3b3d410016 --- /dev/null +++ b/agentic-organization/packages/application/src/pipeline.ts @@ -0,0 +1,222 @@ +/** + * Work pipeline driver — moves one work item from customer discovery through + * release across the 7 business quality gates (BUSINESS_QUALITY_GATE_SYSTEM.md). + * The ordered gate chain + prior-gate enforcement already live in + * company-work-policy.ts; this driver adds: + * + * - the gate → owner-hat mapping (who may evaluate each gate), + * - nextLegalGate (deterministic: the first gate whose priors are satisfied), + * - an owner-hat evaluator chosen via observe→decide, + * - gate evaluation that an authority hat drives (approve / changes / reject), + * - recovery-path routing for failures, + * - one OrgEvent per gate evaluation AND per pipeline-stage transition. + * + * Determinism: which gate is next, and who may evaluate it. Agent-driven: the + * actual approve/reject outcome. + */ + +import { DefaultCompanyQualityGateSequencePolicy } from "../../domain/src/company-work-policy.ts"; +import { QualityGateKind, QualityGateOutcome } from "../../domain/src/records.ts"; +import { OrgEventKind, type OrgEvent } from "../../domain/src/org-event.ts"; +import type { HatDefinition } from "../../domain/src/hat-definition.ts"; +import { chooseWithinLegal, type OrgChooser } from "./org-decision.ts"; + +/** The pipeline stage a work item is in, named for the gate it is awaiting. */ +export const PipelineStage = { + Intake: "intake", + AwaitingCustomerRfpReview: "awaiting_customer_rfp_review", + AwaitingBrdApproval: "awaiting_brd_approval", + AwaitingArchitectureApproval: "awaiting_architecture_approval", + AwaitingImplementationReview: "awaiting_implementation_review", + AwaitingRuntimeValidation: "awaiting_runtime_validation", + AwaitingFinalBusinessValidation: "awaiting_final_business_validation", + AwaitingReleaseReadiness: "awaiting_release_readiness", + Merged: "merged", +} as const; + +export type PipelineStage = (typeof PipelineStage)[keyof typeof PipelineStage]; + +/** Owner hats permitted to evaluate each gate (BUSINESS_QUALITY_GATE_SYSTEM + the inventory). */ +export const GateOwnerHats: Record = { + [QualityGateKind.CustomerRfpReview]: ["product_owner", "business_analyst", "customer_interviewer"], + [QualityGateKind.BrdApproval]: ["brd_reviewer", "business_approver", "product_owner"], + [QualityGateKind.ArchitectureApproval]: ["architect", "architecture_reviewer", "chief_architect"], + [QualityGateKind.ImplementationReview]: ["code_reviewer", "engineering_manager"], + [QualityGateKind.RuntimeValidation]: ["qa_verifier", "qa_reviewer", "browser_automation_qa"], + [QualityGateKind.FinalBusinessValidation]: ["product_owner", "business_analyst"], + [QualityGateKind.ReleaseReadiness]: ["release_manager", "delivery_reviewer", "tpm"], +}; + +const ORDERED_GATES = DefaultCompanyQualityGateSequencePolicy.orderedGateKinds; + +const GATE_TO_STAGE: Record = { + [QualityGateKind.CustomerRfpReview]: PipelineStage.AwaitingCustomerRfpReview, + [QualityGateKind.BrdApproval]: PipelineStage.AwaitingBrdApproval, + [QualityGateKind.ArchitectureApproval]: PipelineStage.AwaitingArchitectureApproval, + [QualityGateKind.ImplementationReview]: PipelineStage.AwaitingImplementationReview, + [QualityGateKind.RuntimeValidation]: PipelineStage.AwaitingRuntimeValidation, + [QualityGateKind.FinalBusinessValidation]: PipelineStage.AwaitingFinalBusinessValidation, + [QualityGateKind.ReleaseReadiness]: PipelineStage.AwaitingReleaseReadiness, +}; + +const PASSED: ReadonlySet = new Set([QualityGateOutcome.Approved, QualityGateOutcome.Waived]); + +/** + * The next legal gate: the first gate in the ordered chain not yet passed whose + * required prior gates are all approved/waived. Returns undefined when every gate + * is passed (the work item may merge). + */ +export function nextLegalGate( + passedGateKinds: ReadonlySet, +): QualityGateKind | undefined { + // a gate is legal iff every earlier gate in the ordered chain has passed + // (ORDERED_GATES is the single source of truth for the chain order). + for (let i = 0; i < ORDERED_GATES.length; i += 1) { + const gateKind = ORDERED_GATES[i]!; + if (passedGateKinds.has(gateKind)) continue; + const priorsAllPassed = ORDERED_GATES.slice(0, i).every((g) => passedGateKinds.has(g)); + if (priorsAllPassed) { + return gateKind; + } + } + return undefined; +} + +/** The stage a work item is in given which gates have passed. */ +export function stageFor(passedGateKinds: ReadonlySet): PipelineStage { + const next = nextLegalGate(passedGateKinds); + return next === undefined ? PipelineStage.Merged : GATE_TO_STAGE[next]; +} + +export type GateEvaluation = { + workItemId: string; + gateKind: QualityGateKind; + outcome: QualityGateOutcome; + evaluatedByHatId: string; +}; + +export type GateEvaluationResult = + | { outcome: "evaluated"; evaluation: GateEvaluation; events: readonly OrgEvent[]; advancedTo: PipelineStage } + | { outcome: "not_authorized"; reason: string }; + +export type PipelineContext = { + createEventId: () => string; + nowIso: () => string; + organizationId: string; + supervisorChain: readonly string[]; + correlationId: string; + causationId: string; + traceId: string; +}; + +/** Legal gate outcomes an evaluator may choose (the agent drives which one). */ +export function legalGateOutcomes(): readonly QualityGateOutcome[] { + return [QualityGateOutcome.Approved, QualityGateOutcome.ChangesRequested, QualityGateOutcome.Rejected]; +} + +/** + * An owner hat evaluates a gate. Verifies the hat owns the gate (has the matching + * approval scope), records the evaluation, and — if approved/waived — advances + * the work item to the next stage. Emits a QualityGateEvaluation event and, on + * advance, a PipelineStageTransition event. + */ +export function evaluateGate( + input: { + workItemId: string; + gateKind: QualityGateKind; + evaluatorHat: HatDefinition; + passedGateKinds: ReadonlySet; + outcomeChooser: OrgChooser; + }, + ctx: PipelineContext, +): GateEvaluationResult { + const owners = GateOwnerHats[input.gateKind]; + if (!owners.includes(input.evaluatorHat.id) || !input.evaluatorHat.approvalScopes.includes(input.gateKind)) { + return { outcome: "not_authorized", reason: `${input.evaluatorHat.name} is not an owner of gate ${input.gateKind}` }; + } + const choice = chooseWithinLegal(legalGateOutcomes(), `gate ${input.gateKind} for ${input.workItemId}`, input.outcomeChooser); + if (choice.outcome === "no_legal_option") { + return { outcome: "not_authorized", reason: choice.reason }; + } + const evaluation: GateEvaluation = { + workItemId: input.workItemId, + gateKind: input.gateKind, + outcome: choice.option, + evaluatedByHatId: input.evaluatorHat.id, + }; + + const fromStage = GATE_TO_STAGE[input.gateKind]; + const events: OrgEvent[] = [ + { + id: ctx.createEventId(), + kind: OrgEventKind.QualityGateEvaluation, + occurredAt: ctx.nowIso(), + organizationId: ctx.organizationId, + actorHatId: input.evaluatorHat.id, + departmentId: input.evaluatorHat.departmentId, + subjectId: input.workItemId, + fromState: fromStage, + toState: choice.option, + decision: `${input.evaluatorHat.name} evaluated ${input.gateKind} → ${choice.option} (${choice.reason})`, + supervisorChain: ctx.supervisorChain, + evidenceRefs: [], + correlationId: ctx.correlationId, + causationId: ctx.causationId, + traceId: ctx.traceId, + }, + ]; + + let advancedTo = fromStage; + if (PASSED.has(choice.option)) { + const nextPassed = new Set(input.passedGateKinds); + nextPassed.add(input.gateKind); + advancedTo = stageFor(nextPassed); + events.push({ + id: ctx.createEventId(), + kind: OrgEventKind.PipelineStageTransition, + occurredAt: ctx.nowIso(), + organizationId: ctx.organizationId, + actorHatId: input.evaluatorHat.id, + departmentId: input.evaluatorHat.departmentId, + subjectId: input.workItemId, + fromState: fromStage, + toState: advancedTo, + decision: `${input.workItemId} advanced ${fromStage} → ${advancedTo} after ${input.gateKind}`, + supervisorChain: ctx.supervisorChain, + evidenceRefs: [], + correlationId: ctx.correlationId, + causationId: ctx.causationId, + traceId: ctx.traceId, + }); + } + + return { outcome: "evaluated", evaluation, events, advancedTo }; +} + +/** Where a failed gate routes (BUSINESS_QUALITY_GATE_SYSTEM §Recovery Paths). */ +export const RecoveryPath = { + BackToEngineering: "back_to_engineering", + ReopenDiscoveryOrBrd: "reopen_discovery_or_brd", + ReopenArchitecture: "reopen_architecture", + ValidationProcessImprovement: "validation_process_improvement", + ChangeRequest: "change_request", +} as const; + +export type RecoveryPath = (typeof RecoveryPath)[keyof typeof RecoveryPath]; + +export function recoveryPathFor(gateKind: QualityGateKind): RecoveryPath { + switch (gateKind) { + case QualityGateKind.CustomerRfpReview: + case QualityGateKind.BrdApproval: + return RecoveryPath.ReopenDiscoveryOrBrd; + case QualityGateKind.ArchitectureApproval: + return RecoveryPath.ReopenArchitecture; + case QualityGateKind.ImplementationReview: + return RecoveryPath.BackToEngineering; + case QualityGateKind.RuntimeValidation: + return RecoveryPath.ValidationProcessImprovement; + case QualityGateKind.FinalBusinessValidation: + case QualityGateKind.ReleaseReadiness: + return RecoveryPath.ChangeRequest; + } +} diff --git a/agentic-organization/packages/application/src/prioritization.ts b/agentic-organization/packages/application/src/prioritization.ts new file mode 100644 index 0000000000..c9f06af1ad --- /dev/null +++ b/agentic-organization/packages/application/src/prioritization.ts @@ -0,0 +1,206 @@ +/** + * Prioritization — directors and TPMs decide work priority (ANTI_STALL_PRIORITY_ + * RUNTIME.md). The platform computes a PriorityRecommendation from the priority + * inputs; an authority hat decides the final PriorityClass via the org-decision + * kernel, where the LEGAL classes are bounded by the decider's level (a TPM + * cannot expedite or pause; a Director can; an executive can do anything). + */ + +import { HatLevel, type HatDefinition } from "../../domain/src/hat-definition.ts"; +import { OrgEventKind, type OrgEvent } from "../../domain/src/org-event.ts"; +import { chooseWithinLegal, type OrgChooser } from "./org-decision.ts"; + +export const PriorityClass = { + Expedite: "expedite", + High: "high", + Normal: "normal", + Defer: "defer", + Paused: "paused", +} as const; + +export type PriorityClass = (typeof PriorityClass)[keyof typeof PriorityClass]; + +export const PriorityDecidedBy = { + Tpm: "tpm", + EngineeringManager: "engineering_manager", + DepartmentDirector: "department_director", + ReviewHat: "review_hat", + AgentVote: "agent_vote", + Executive: "executive", + IncidentCommander: "incident_commander", + ApprovedPolicy: "approved_policy", +} as const; + +export type PriorityDecidedBy = (typeof PriorityDecidedBy)[keyof typeof PriorityDecidedBy]; + +/** The load-bearing priority inputs (subset of the doc's ~20; all 0..1 normalized except ages). */ +export type PriorityInputs = { + executivePriority: number; + customerImpact: number; + severity: number; + releaseRisk: number; + blockedDownstreamCount: number; + dependencyFanOut: number; + queueAgeMs: number; + hatScarcity: number; + budgetBurn: number; + estimatedEffort: number; +}; + +export type PriorityRecommendation = { + workItemId: string; + score: number; + priorityClass: PriorityClass; + reasonCodes: readonly string[]; + requiredHats: readonly string[]; +}; + +const PRIORITY_ORDER: readonly PriorityClass[] = [ + PriorityClass.Expedite, + PriorityClass.High, + PriorityClass.Normal, + PriorityClass.Defer, + PriorityClass.Paused, +]; + +/** Deterministic scoring → a recommended class + the reason codes that drove it. */ +export function computePriorityRecommendation( + workItemId: string, + inputs: PriorityInputs, + requiredHats: readonly string[], +): PriorityRecommendation { + const reasonCodes: string[] = []; + let score = 0; + const add = (weight: number, value: number, code: string): void => { + if (value > 0) { + score += weight * value; + reasonCodes.push(code); + } + }; + add(4, inputs.executivePriority, "executive_priority"); + add(3, inputs.customerImpact, "customer_impact"); + add(3, inputs.severity, "severity"); + add(2, inputs.releaseRisk, "release_risk"); + add(2, Math.min(1, inputs.blockedDownstreamCount / 5), "blocked_downstream"); + add(1, Math.min(1, inputs.dependencyFanOut / 5), "dependency_fanout"); + add(1, Math.min(1, inputs.queueAgeMs / 3_600_000), "queue_age"); + add(1, inputs.hatScarcity, "hat_scarcity"); + // budget burn + high effort push DOWN + score -= 2 * inputs.budgetBurn; + if (inputs.budgetBurn > 0.5) reasonCodes.push("budget_pressure"); + + const priorityClass = + score >= 9 ? PriorityClass.Expedite : + score >= 5 ? PriorityClass.High : + score >= 2 ? PriorityClass.Normal : + score >= 0 ? PriorityClass.Defer : + PriorityClass.Paused; + + return { workItemId, score: Math.round(score * 100) / 100, priorityClass, reasonCodes, requiredHats }; +} + +/** Which priority classes an authority at this level may set (the LEGAL set). */ +export function legalPriorityClassesFor(level: HatLevel): readonly PriorityClass[] { + switch (level) { + case HatLevel.ExecutiveBoard: + case HatLevel.CSuite: + return PRIORITY_ORDER; // all + case HatLevel.Director: + return [PriorityClass.Expedite, PriorityClass.High, PriorityClass.Normal, PriorityClass.Defer, PriorityClass.Paused]; + case HatLevel.Manager: + return [PriorityClass.High, PriorityClass.Normal, PriorityClass.Defer]; // TPM/Eng Manager: no expedite/pause + case HatLevel.Lead: + case HatLevel.IndividualContributor: + return []; // cannot decide priority + default: { + const unhandled: never = level; + return unhandled; + } + } +} + +export type PriorityDecision = { + workItemId: string; + priorityClass: PriorityClass; + reasonCodes: readonly string[]; + requiredHats: readonly string[]; + decidedByHatId: string; + decidedBy: PriorityDecidedBy; +}; + +export type PriorityDecisionResult = + | { outcome: "decided"; decision: PriorityDecision; event: OrgEvent } + | { outcome: "not_authorized"; reason: string }; + +function decidedByFor(deciderHat: HatDefinition): PriorityDecidedBy { + if (deciderHat.level === HatLevel.ExecutiveBoard || deciderHat.level === HatLevel.CSuite) return PriorityDecidedBy.Executive; + if (deciderHat.level === HatLevel.Director) return PriorityDecidedBy.DepartmentDirector; + if (deciderHat.id === "engineering_manager") return PriorityDecidedBy.EngineeringManager; + if (deciderHat.id === "incident_commander") return PriorityDecidedBy.IncidentCommander; + return PriorityDecidedBy.Tpm; +} + +export type DecidePriorityContext = { + createEventId: () => string; + nowIso: () => string; + organizationId: string; + supervisorChain: readonly string[]; + correlationId: string; + causationId: string; + traceId: string; +}; + +/** + * An authority hat decides the work item's priority. The legal classes are + * bounded by the decider's level; the chooser picks within them (it cannot + * escalate beyond its authority). The recommendation is offered first in the + * legal list so the deterministic default honors it when legal. + */ +export function decidePriority( + input: { + recommendation: PriorityRecommendation; + deciderHat: HatDefinition; + chooser: OrgChooser; + }, + ctx: DecidePriorityContext, +): PriorityDecisionResult { + const legalAll = legalPriorityClassesFor(input.deciderHat.level); + if (legalAll.length === 0) { + return { outcome: "not_authorized", reason: `${input.deciderHat.name} (level ${input.deciderHat.level}) cannot decide priority` }; + } + // offer the recommended class first when it is legal, else legal order + const legal = legalAll.includes(input.recommendation.priorityClass) + ? [input.recommendation.priorityClass, ...legalAll.filter((c) => c !== input.recommendation.priorityClass)] + : legalAll; + + const choice = chooseWithinLegal(legal, `priority for ${input.recommendation.workItemId}`, input.chooser); + if (choice.outcome === "no_legal_option") { + return { outcome: "not_authorized", reason: choice.reason }; + } + const decidedBy = decidedByFor(input.deciderHat); + const decision: PriorityDecision = { + workItemId: input.recommendation.workItemId, + priorityClass: choice.option, + reasonCodes: input.recommendation.reasonCodes, + requiredHats: input.recommendation.requiredHats, + decidedByHatId: input.deciderHat.id, + decidedBy, + }; + const event: OrgEvent = { + id: ctx.createEventId(), + kind: OrgEventKind.PriorityDecision, + occurredAt: ctx.nowIso(), + organizationId: ctx.organizationId, + actorHatId: input.deciderHat.id, + departmentId: input.deciderHat.departmentId, + subjectId: decision.workItemId, + toState: decision.priorityClass, + decision: `${input.deciderHat.name} set ${decision.workItemId} to ${decision.priorityClass} (${choice.reason}); reasons: ${decision.reasonCodes.join(", ")}`, + supervisorChain: ctx.supervisorChain, + evidenceRefs: [], + correlationId: ctx.correlationId, + causationId: ctx.causationId, + traceId: ctx.traceId, + }; + return { outcome: "decided", decision, event }; +} diff --git a/agentic-organization/packages/application/src/reaction-decision.ts b/agentic-organization/packages/application/src/reaction-decision.ts new file mode 100644 index 0000000000..48fde634f3 --- /dev/null +++ b/agentic-organization/packages/application/src/reaction-decision.ts @@ -0,0 +1,131 @@ +/** + * Reaction decision — turns a reaction-plan action into a *real, computed* + * agent decision through the deterministic decision kernel (observe -> decide), + * instead of a hardcoded outcome string. + * + * This is the determinism/autonomy split the org is built on: + * - observe() + DefaultDeterministicRules compute the LEGAL options (the + * determinism that keeps the run — and so the org — within bounds), + * - the composer (EphemeralComposerPort) makes the autonomous CHOICE among + * them. A choice outside the legal set is rejected as a rule violation: the + * agent cannot escape the rules, it only selects within them. + * + * The default composer here is a deterministic first-legal-option policy ("take + * the highest-priority legal move"); a real LLM/sandbox backend implements the + * same EphemeralComposerPort and swaps in without touching any wiring. + */ + +import type { ReactionPlanAction } from "../../domain/src/index.ts"; +import { + ComposerDecision, + DecideOutcome, + RunLifecyclePhase, + RunScope, + asZetaIdDecimal, + decide, + decideAsync, + type AsyncEphemeralComposerPort, + type DecideResult, + type EphemeralComposerPort, + type ObserveFeedback, + type RunSnapshot, +} from "./observe.ts"; + +/** The deterministic baseline agent intelligence: take the highest-priority legal move. */ +export function createFirstLegalOptionComposer(): EphemeralComposerPort { + return { + compose: (request) => { + const option = request.readout.options[0]; + if (option === undefined) { + return { decision: ComposerDecision.Hold, reason: "no legal option to select" }; + } + return { decision: ComposerDecision.Select, option, reason: option.rationale }; + }, + }; +} + +export type DecideReactionActionInput = { + action: ReactionPlanAction; + composer: EphemeralComposerPort; + now: () => string; +}; + +/** + * Run the deterministic decision kernel for a freshly-triggered reaction-plan + * action. The action enters the run lifecycle at the Observing phase (the + * keystone read), and the composer selects a legal next move. + */ +export function decideReactionAction(input: DecideReactionActionInput): DecideResult { + return decide(snapshotForAction(input.action), input.composer, { clock: { now: input.now } }); +} + +export type DecideReactionActionAsyncInput = { + action: ReactionPlanAction; + composer: AsyncEphemeralComposerPort; + now: () => string; +}; + +/** Async sibling: the agent decides through an async (e.g. model-backed) composer, same guardrail. */ +export async function decideReactionActionAsync(input: DecideReactionActionAsyncInput): Promise { + return decideAsync(snapshotForAction(input.action), input.composer, { clock: { now: input.now } }); +} + +function snapshotForAction(action: ReactionPlanAction): RunSnapshot { + return { + runId: deterministicRunIdForAction(action), + scope: RunScope.Run, + phase: RunLifecyclePhase.Observing, + trace: { + correlationId: action.triggerEventId, + causationId: action.triggerEventId, + traceId: action.triggerEventId, + }, + hasGateApproval: false, + hasEvidence: false, + }; +} + +/** Result-as-DU summary: either a run-request summary, or a feedback to fail the run on. */ +export type ReactionDecisionSummary = + | { kind: "summary"; actionSummary: string; learned: string } + | { kind: "feedback"; feedback: ObserveFeedback }; + +export function summarizeReactionDecision(result: DecideResult): ReactionDecisionSummary { + switch (result.outcome) { + case DecideOutcome.Selected: + return { + kind: "summary", + actionSummary: `decided '${result.selection.option.actionType}' -> ${result.selection.option.toPhase}: ${result.selection.reason}`, + learned: `selected ${result.selection.option.actionType} from ${result.readout.options.length} legal option(s) under rules [${result.readout.deterministicRulesApplied.join(", ")}]`, + }; + case DecideOutcome.Held: + return { + kind: "summary", + actionSummary: `held: ${result.reason}`, + learned: `held at phase '${result.readout.phase}' — agent chose to wait`, + }; + case DecideOutcome.Feedback: + return { kind: "feedback", feedback: result.feedback }; + default: { + const unhandled: never = result; + return unhandled; + } + } +} + +/** + * A stable base-10 ZetaId derived from the action's trigger event (FNV-1a). + * Pure + deterministic: the same action always yields the same run id, so the + * decision is replayable under DST. + */ +export function deterministicRunIdForAction(action: ReactionPlanAction): ReturnType { + const FNV_OFFSET = 2166136261; + const FNV_PRIME = 16777619; + let hash = FNV_OFFSET; + for (const codeUnit of action.triggerEventId) { + hash ^= codeUnit.codePointAt(0) ?? 0; + hash = Math.imul(hash, FNV_PRIME); + } + // >>> 0 → unsigned 32-bit, guaranteeing a non-negative base-10 string. + return asZetaIdDecimal(String(hash >>> 0)); +} diff --git a/agentic-organization/packages/application/src/reaction-plan-action-executor.ts b/agentic-organization/packages/application/src/reaction-plan-action-executor.ts index 9b7c213931..909c30ed9e 100644 --- a/agentic-organization/packages/application/src/reaction-plan-action-executor.ts +++ b/agentic-organization/packages/application/src/reaction-plan-action-executor.ts @@ -64,7 +64,10 @@ export type ReactionPlanActorResolverPort = { }; export type CreateApplicationReactionPlanActionExecutorInput = IdGenerator & { - commandPipeline: CommandPipeline; + // ISP-minimal: this executor only ever builds + executes a CreateDiscussionAnchorCommand, + // so it depends on the narrowest pipeline that can do that. Any wider pipeline + // (e.g. CommandPipeline) is assignable here via contravariance. + commandPipeline: CommandPipeline; actorResolver: ReactionPlanActorResolverPort; }; diff --git a/agentic-organization/packages/application/src/review-gate.ts b/agentic-organization/packages/application/src/review-gate.ts new file mode 100644 index 0000000000..1bc9d514dd --- /dev/null +++ b/agentic-organization/packages/application/src/review-gate.ts @@ -0,0 +1,99 @@ +/** + * Review board as a real gate (slice 5). + * + * Connects the qualitative >=3-agent review board (packages/metrics) to the + * domain's quality-gate outcomes (QualityGateOutcome). A review-class gate + * decision is not a single reviewer's call: it is the board's adopted findings. + * This module runs the board and maps its outcome to a QualityGateOutcome + * recommendation that the gate-evaluation path can consume: + * + * - the board could not convene (< quorum reviewers) -> feedback + * - the board adopted a blocking/major finding -> Rejected + * - the board adopted only minor/info findings -> ChangesRequested + * - the board adopted nothing (no finding reached quorum) -> Approved + * + * Lives in the application layer because it composes two packages (domain's + * QualityGateOutcome + metrics' review board); metrics stays dependency-free and + * domain stays unaware of the board. Pure; no I/O. + */ + +import { QualityGateOutcome } from "../../domain/src/index.ts"; +import { + ReviewSeverity, + evaluateReviewBoard, + type CandidateFinding, + type FindingDecision, + type ReviewBoardOutcome, + type ReviewerVote, +} from "../../metrics/src/index.ts"; + +export const ReviewGateFeedbackReason = { + BoardCouldNotConvene: "board_could_not_convene", +} as const; +export type ReviewGateFeedbackReason = + (typeof ReviewGateFeedbackReason)[keyof typeof ReviewGateFeedbackReason]; + +export type ReviewGateResult = + | { + outcome: "ok"; + recommendedGateOutcome: QualityGateOutcome; + board: ReviewBoardOutcome; + adoptedFindingIds: readonly string[]; + reason: string; + } + | { outcome: "feedback"; feedback: { reason: ReviewGateFeedbackReason; message: string } }; + +function isBlockingOrMajor(decision: FindingDecision): boolean { + return ( + decision.finding.severity === ReviewSeverity.Blocking || + decision.finding.severity === ReviewSeverity.Major + ); +} + +/** + * Run the review board over the findings + votes and recommend a gate outcome. + * The adopted findings (those that reached quorum agreement) determine the + * outcome: any adopted blocking/major finding rejects the gate; adopted + * minor/info findings request changes; no adopted finding approves. + */ +export function evaluateReviewGate(input: { + findings: readonly CandidateFinding[]; + votes: readonly ReviewerVote[]; + quorum?: number; +}): ReviewGateResult { + const boardResult = + input.quorum === undefined + ? evaluateReviewBoard({ findings: input.findings, votes: input.votes }) + : evaluateReviewBoard({ findings: input.findings, votes: input.votes, quorum: input.quorum }); + + if (boardResult.outcome === "feedback") { + return { + outcome: "feedback", + feedback: { reason: ReviewGateFeedbackReason.BoardCouldNotConvene, message: boardResult.feedback.message }, + }; + } + + const adopted = boardResult.board.adopted; + const adoptedFindingIds = adopted.map((decision) => decision.finding.findingId); + + let recommendedGateOutcome: QualityGateOutcome; + let reason: string; + if (adopted.length === 0) { + recommendedGateOutcome = QualityGateOutcome.Approved; + reason = "no finding reached quorum agreement; gate approved"; + } else if (adopted.some(isBlockingOrMajor)) { + recommendedGateOutcome = QualityGateOutcome.Rejected; + reason = `${adopted.length} finding(s) adopted, at least one major/blocking; gate rejected`; + } else { + recommendedGateOutcome = QualityGateOutcome.ChangesRequested; + reason = `${adopted.length} minor/info finding(s) adopted; changes requested`; + } + + return { + outcome: "ok", + recommendedGateOutcome, + board: boardResult.board, + adoptedFindingIds, + reason, + }; +} diff --git a/agentic-organization/packages/application/src/rmo.ts b/agentic-organization/packages/application/src/rmo.ts new file mode 100644 index 0000000000..c37a93d241 --- /dev/null +++ b/agentic-organization/packages/application/src/rmo.ts @@ -0,0 +1,152 @@ +/** + * RMO (Resource Management Office) — decides how many of each hat must be staffed + * at any time, driven by the prioritized task workload (ANTI_STALL_PRIORITY_ + * RUNTIME.md §Hat supply and budget review). + * + * The DEMAND (required count per hat) is computed deterministically from the + * non-paused, prioritized work. Supervisors (Directors, Cost Controller, CFO, + * Hat Approval Steward) then VOTE on whether to act and the target count — the + * agents drive the staffing outcome; the tally is deterministic. + */ + +import { OrgEventKind, type OrgEvent } from "../../domain/src/org-event.ts"; +import { PriorityClass } from "./prioritization.ts"; + +/** A prioritized work item's staffing demand. */ +export type WorkloadItem = { + workItemId: string; + priorityClass: PriorityClass; + requiredHats: readonly string[]; +}; + +/** Priority weight: expedite/high reserve extra headroom; paused contributes nothing. */ +function demandWeight(priorityClass: PriorityClass): number { + switch (priorityClass) { + case PriorityClass.Expedite: return 2; + case PriorityClass.High: return 2; + case PriorityClass.Normal: return 1; + case PriorityClass.Defer: return 1; + case PriorityClass.Paused: return 0; + } +} + +/** Required count per hat = ceil-weighted demand across the non-paused workload. */ +export function computeRequiredHatSupply(workload: readonly WorkloadItem[]): ReadonlyMap { + const demand = new Map(); + for (const item of workload) { + const w = demandWeight(item.priorityClass); + if (w === 0) continue; + for (const hatId of item.requiredHats) { + demand.set(hatId, (demand.get(hatId) ?? 0) + w); + } + } + // weighted demand → required wearer count (each wearer covers ~2 weighted units) + const required = new Map(); + for (const [hatId, weighted] of demand) { + required.set(hatId, Math.max(1, Math.ceil(weighted / 2))); + } + return required; +} + +export const HatSupplyAction = { + Expand: "expand", + Release: "release", + Reserve: "reserve", + Preempt: "preempt", + Hold: "hold", +} as const; + +export type HatSupplyAction = (typeof HatSupplyAction)[keyof typeof HatSupplyAction]; + +/** Deterministic recommendation: compare demand vs current staffing. */ +export function recommendSupplyAction(requiredCount: number, currentCount: number): HatSupplyAction { + if (requiredCount > currentCount) return HatSupplyAction.Expand; + if (requiredCount < currentCount) return HatSupplyAction.Release; + return HatSupplyAction.Hold; +} + +export type HatSupplyVote = { + voterHatId: string; + approve: boolean; + proposedTarget: number; +}; + +export type HatSupplyDecision = { + hatId: string; + action: HatSupplyAction; + targetCount: number; + currentCount: number; + requiredCount: number; + approvals: number; + voters: number; + quorumMet: boolean; +}; + +function median(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2) : (sorted[mid] ?? 0); +} + +export type DecideHatSupplyContext = { + createEventId: () => string; + nowIso: () => string; + organizationId: string; + supervisorChain: readonly string[]; + correlationId: string; + causationId: string; + traceId: string; +}; + +/** + * Tally supervisor votes into a supply decision. Quorum = strict majority of + * voters. If quorum approves, the action proceeds with the agreed target (median + * of approvers' proposals); otherwise it holds at the current count. + */ +export function decideHatSupply( + input: { + hatId: string; + hatName: string; + requiredCount: number; + currentCount: number; + votes: readonly HatSupplyVote[]; + }, + ctx: DecideHatSupplyContext, +): { decision: HatSupplyDecision; event: OrgEvent } { + const approvals = input.votes.filter((v) => v.approve).length; + const quorum = Math.floor(input.votes.length / 2) + 1; + const quorumMet = input.votes.length > 0 && approvals >= quorum; + + const recommended = recommendSupplyAction(input.requiredCount, input.currentCount); + const approverTargets = input.votes.filter((v) => v.approve).map((v) => v.proposedTarget); + + const action = quorumMet ? recommended : HatSupplyAction.Hold; + const targetCount = quorumMet && approverTargets.length > 0 ? median(approverTargets) : input.currentCount; + + const decision: HatSupplyDecision = { + hatId: input.hatId, + action, + targetCount, + currentCount: input.currentCount, + requiredCount: input.requiredCount, + approvals, + voters: input.votes.length, + quorumMet, + }; + const event: OrgEvent = { + id: ctx.createEventId(), + kind: OrgEventKind.HatSupplyDecision, + occurredAt: ctx.nowIso(), + organizationId: ctx.organizationId, + subjectId: input.hatId, + toState: action, + decision: `RMO: ${input.hatName} demand=${input.requiredCount} current=${input.currentCount} → ${action} to ${targetCount} (${approvals}/${input.votes.length} approved, quorum ${quorumMet ? "met" : "not met"})`, + supervisorChain: ctx.supervisorChain, + evidenceRefs: [], + correlationId: ctx.correlationId, + causationId: ctx.causationId, + traceId: ctx.traceId, + }; + return { decision, event }; +} diff --git a/agentic-organization/packages/application/src/sandbox-tool.ts b/agentic-organization/packages/application/src/sandbox-tool.ts new file mode 100644 index 0000000000..b19d3ffd1f --- /dev/null +++ b/agentic-organization/packages/application/src/sandbox-tool.ts @@ -0,0 +1,60 @@ +/** + * Sandboxed tool execution — the agent actually RUNS a tool (a bounded, + * env-stripped, timeout-killed subprocess) to produce verifiable evidence, + * rather than asserting an outcome. The port is dependency-inverted; the real + * subprocess adapter lives at the composition root. + * + * Tool failure is supplementary, not fatal: a failed tool yields no evidence + * ref but never fails the agent run (the deterministic decision already + * succeeded). This keeps the agent alive while still doing real work. + */ + +import type { ReactionPlanAction } from "../../domain/src/index.ts"; + +export type SandboxToolRequest = { + /** absolute path to the executable (e.g. the node binary) */ + command: string; + args: readonly string[]; + timeoutMs: number; +}; + +export type SandboxToolResult = + | { ok: true; stdout: string } + | { ok: false; reason: string }; + +/** A bounded sandbox for one tool invocation. The adapter strips env + isolates cwd + kills on timeout. */ +export interface SandboxToolPort { + run: (request: SandboxToolRequest) => Promise; +} + +export const SandboxVerificationEvidencePrefix = "sandbox:sha256:"; + +/** + * Build a verification-tool invocation: a tiny node program that computes a + * sha256 over the action's work item id and prints it. Pure + deterministic, so + * the same action always produces the same evidence (DST-replayable), and it + * runs through the real sandbox (a separate, bounded process). + */ +export function buildVerificationToolRequest(action: ReactionPlanAction, nodeBinary: string): SandboxToolRequest { + const script = + "const c=require('node:crypto');" + + "const id=process.argv[1];" + + "process.stdout.write(c.createHash('sha256').update(id).digest('hex'));"; + return { + command: nodeBinary, + args: ["-e", script, action.workItemId], + timeoutMs: 5_000, + }; +} + +/** Turn a successful sandbox run into an evidence ref, or undefined if the tool produced nothing usable. */ +export function verificationEvidenceRef(result: SandboxToolResult): string | undefined { + if (!result.ok) { + return undefined; + } + const digest = result.stdout.trim(); + if (!/^[0-9a-f]{64}$/.test(digest)) { + return undefined; + } + return `${SandboxVerificationEvidencePrefix}${digest}`; +} diff --git a/agentic-organization/packages/application/src/triage-action-resolver.ts b/agentic-organization/packages/application/src/triage-action-resolver.ts new file mode 100644 index 0000000000..e70e0ed353 --- /dev/null +++ b/agentic-organization/packages/application/src/triage-action-resolver.ts @@ -0,0 +1,154 @@ +/** + * Supervisor-triage action resolution (North Star priority #4). + * + * The domain declares six SupervisorTriageActionType values, but the + * triage_supervisor_signal handler historically implemented only OpenWorkItem + * and rejected the rest as UnsupportedActionType. This module makes the action + * set EXPLICIT and open-for-extension: each action is a discriminated variant + * with its own required inputs, and resolveTriageAction classifies a requested + * action into one of three outcomes so the handler never buries action logic in + * an if-chain (repo rule: IMPLICIT-NOT-EXPLICIT is class error). + * + * This slice adds the two no-new-migration actions on top of OpenWorkItem: + * - AnswerDirectly: the supervisor answers in-line; no follow-up work item. + * - EscalateToNextSupervisor: route the signal up the supervisor chain. + * RequestSecurityReview, ScheduleDiscussion, and RouteToInternalPlatform remain + * declared-but-deferred (they need security/schedule/platform substrate); they + * resolve to a typed "deferred" outcome rather than a generic rejection, so the + * gap is visible rather than hidden. + */ + +import { + SupervisorTriageActionType, + type WorkItemType, +} from "../../domain/src/index.ts"; + +/** A requested triage action + its action-specific inputs, as an explicit DU. */ +export type TriageActionRequest = + | { + actionType: typeof SupervisorTriageActionType.OpenWorkItem; + followUpWorkItemType: WorkItemType; + followUpTitle: string; + followUpDescription: string; + } + | { + actionType: typeof SupervisorTriageActionType.AnswerDirectly; + answer: string; + } + | { + actionType: typeof SupervisorTriageActionType.EscalateToNextSupervisor; + targetSupervisorHatAssignmentId: string; + escalationReason: string; + } + | { + actionType: + | typeof SupervisorTriageActionType.RequestSecurityReview + | typeof SupervisorTriageActionType.ScheduleDiscussion + | typeof SupervisorTriageActionType.RouteToInternalPlatform; + }; + +/** How an action resolves: do effectful work, answer in place, or deferred. */ +export const TriageActionResolution = { + /** Creates a follow-up work item (OpenWorkItem). */ + OpensWorkItem: "opens_work_item", + /** Resolves the signal in place with an answer (AnswerDirectly). */ + AnswersInPlace: "answers_in_place", + /** Routes the signal up the chain (EscalateToNextSupervisor). */ + EscalatesSignal: "escalates_signal", + /** Declared but not yet implemented in this V0 slice. */ + Deferred: "deferred", +} as const; +export type TriageActionResolution = + (typeof TriageActionResolution)[keyof typeof TriageActionResolution]; + +export const TriageActionFeedbackReason = { + AnswerRequired: "answer_required", + EscalationTargetRequired: "escalation_target_required", + EscalationReasonRequired: "escalation_reason_required", +} as const; +export type TriageActionFeedbackReason = + (typeof TriageActionFeedbackReason)[keyof typeof TriageActionFeedbackReason]; + +export type ResolvedTriageAction = + | { + outcome: "ok"; + resolution: typeof TriageActionResolution.OpensWorkItem; + followUpWorkItemType: WorkItemType; + followUpTitle: string; + followUpDescription: string; + } + | { outcome: "ok"; resolution: typeof TriageActionResolution.AnswersInPlace; answer: string } + | { + outcome: "ok"; + resolution: typeof TriageActionResolution.EscalatesSignal; + targetSupervisorHatAssignmentId: string; + escalationReason: string; + } + | { + outcome: "ok"; + resolution: typeof TriageActionResolution.Deferred; + actionType: TriageActionRequest["actionType"]; + } + | { outcome: "feedback"; feedback: { reason: TriageActionFeedbackReason; message: string } }; + +const DEFERRED_ACTIONS: ReadonlySet = new Set([ + SupervisorTriageActionType.RequestSecurityReview, + SupervisorTriageActionType.ScheduleDiscussion, + SupervisorTriageActionType.RouteToInternalPlatform, +]); + +function isBlank(value: string): boolean { + return value.trim().length === 0; +} + +/** + * Classify a triage-action request into a typed resolution. Pure; the handler + * turns the resolution into command effects. Validation of action-specific + * inputs surfaces as explicit feedback variants, never thrown. + */ +export function resolveTriageAction(request: TriageActionRequest): ResolvedTriageAction { + switch (request.actionType) { + case SupervisorTriageActionType.OpenWorkItem: + return { + outcome: "ok", + resolution: TriageActionResolution.OpensWorkItem, + followUpWorkItemType: request.followUpWorkItemType, + followUpTitle: request.followUpTitle, + followUpDescription: request.followUpDescription, + }; + + case SupervisorTriageActionType.AnswerDirectly: + if (isBlank(request.answer)) { + return { outcome: "feedback", feedback: { reason: TriageActionFeedbackReason.AnswerRequired, message: "answer_directly requires a non-empty answer" } }; + } + return { outcome: "ok", resolution: TriageActionResolution.AnswersInPlace, answer: request.answer }; + + case SupervisorTriageActionType.EscalateToNextSupervisor: + if (isBlank(request.targetSupervisorHatAssignmentId)) { + return { outcome: "feedback", feedback: { reason: TriageActionFeedbackReason.EscalationTargetRequired, message: "escalate_to_next_supervisor requires a target supervisor hat assignment id" } }; + } + if (isBlank(request.escalationReason)) { + return { outcome: "feedback", feedback: { reason: TriageActionFeedbackReason.EscalationReasonRequired, message: "escalate_to_next_supervisor requires an escalation reason" } }; + } + return { + outcome: "ok", + resolution: TriageActionResolution.EscalatesSignal, + targetSupervisorHatAssignmentId: request.targetSupervisorHatAssignmentId, + escalationReason: request.escalationReason, + }; + + default: + // The union narrows this branch to exactly the deferred actions + // (RequestSecurityReview | ScheduleDiscussion | RouteToInternalPlatform); + // DEFERRED_ACTIONS is the runtime witness that the branch only ever sees + // a declared-but-deferred action — a defensive assert, not control flow. + assertDeferredAction(request.actionType); + return { outcome: "ok", resolution: TriageActionResolution.Deferred, actionType: request.actionType }; + } +} + +function assertDeferredAction(actionType: TriageActionRequest["actionType"]): void { + if (!DEFERRED_ACTIONS.has(actionType)) { + throw new Error(`resolveTriageAction: unexpected non-deferred action in default branch: ${actionType}`); + } +} diff --git a/agentic-organization/packages/application/test/assignment-engine.test.ts b/agentic-organization/packages/application/test/assignment-engine.test.ts new file mode 100644 index 0000000000..3688577e30 --- /dev/null +++ b/agentic-organization/packages/application/test/assignment-engine.test.ts @@ -0,0 +1,86 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { HatBindingPhase } from "../../domain/src/hat-binding.ts"; +import { buildHatDefinitions } from "../src/org-seed.ts"; +import { firstLegalChooser } from "../src/org-decision.ts"; +import { + assignHat, + rankEligibleCandidates, + type ActiveBindingSummary, + type AgentCandidate, +} from "../src/assignment-engine.ts"; + +const hats = buildHatDefinitions(); +const backend = hats.find((h) => h.id === "backend_implementer")!; // hat_designer conflicts with it (see conflicting-hat test) + +const ctx = { + createEventId: () => "evt-1", + nowIso: () => "2026-05-30T09:00:00.000Z", + organizationId: "org-1", + supervisorChain: ["executive_board_member", "cto", "engineering_director", "backend_implementer"], + correlationId: "c", causationId: "c", traceId: "t", +}; + +function candidate(agentId: string, rep: number): AgentCandidate { + return { agentId, reputationByHat: { backend_implementer: rep } }; +} + +test("ranks eligible candidates by agent-hat reputation, highest first", () => { + const ranked = rankEligibleCandidates({ + hat: backend, + candidates: [candidate("a", 3), candidate("b", 9), candidate("c", 5)], + activeBindings: [], + nowMs: 1_000_000, + }); + equal(ranked.map((c) => c.agentId).join(","), "b,c,a"); +}); + +test("excludes an agent already wearing the hat, in cooldown, or in a conflicting hat", () => { + const now = 1_000_000; + const bindings: ActiveBindingSummary[] = [ + { hatId: "backend_implementer", wearerAgentId: "a", phase: HatBindingPhase.Active }, // already wearing + { hatId: "backend_implementer", wearerAgentId: "b", phase: HatBindingPhase.Expired, cooldownUntil: new Date(now + 10_000).toISOString() }, // cooldown + { hatId: "hat_designer", wearerAgentId: "c", phase: HatBindingPhase.Active }, // conflicts with backend + ]; + const ranked = rankEligibleCandidates({ + hat: backend, + candidates: [candidate("a", 9), candidate("b", 8), candidate("c", 7), candidate("d", 1)], + activeBindings: bindings, + nowMs: now, + }); + // only d is eligible + equal(ranked.map((c) => c.agentId).join(","), "d"); +}); + +test("respects the per-agent active-hat cap", () => { + const bindings: ActiveBindingSummary[] = [ + { hatId: "code_reviewer", wearerAgentId: "a", phase: HatBindingPhase.Active }, + { hatId: "qa_reviewer", wearerAgentId: "a", phase: HatBindingPhase.Active }, + { hatId: "tpm", wearerAgentId: "a", phase: HatBindingPhase.Active }, + ]; + const ranked = rankEligibleCandidates({ hat: backend, candidates: [candidate("a", 9)], activeBindings: bindings, nowMs: 1, agentMaxActiveHats: 3 }); + equal(ranked.length, 0); // a is at cap +}); + +test("assigns the top-ranked eligible candidate when supply has room", () => { + const ranked = rankEligibleCandidates({ hat: backend, candidates: [candidate("a", 3), candidate("b", 9)], activeBindings: [], nowMs: 1 }); + const result = assignHat({ hat: backend, eligibleRanked: ranked, activeWearerCount: 0, supplyTarget: 2, chooser: firstLegalChooser() }, ctx); + equal(result.outcome, "assigned"); + if (result.outcome !== "assigned") return; + equal(result.agentId, "b"); // highest reputation + ok(result.event.decision.includes("assigned to b")); +}); + +test("refuses to over-staff: supply exhausted routes to RMO", () => { + const ranked = rankEligibleCandidates({ hat: backend, candidates: [candidate("a", 9)], activeBindings: [], nowMs: 1 }); + const result = assignHat({ hat: backend, eligibleRanked: ranked, activeWearerCount: 2, supplyTarget: 2, chooser: firstLegalChooser() }, ctx); + equal(result.outcome, "supply_exhausted"); + if (result.outcome !== "supply_exhausted") return; + ok(result.event.decision.includes("supply exhausted")); +}); + +test("no eligible candidate is reported distinctly", () => { + const result = assignHat({ hat: backend, eligibleRanked: [], activeWearerCount: 0, supplyTarget: 2, chooser: firstLegalChooser() }, ctx); + equal(result.outcome, "no_eligible_candidate"); +}); diff --git a/agentic-organization/packages/application/test/graph-projection.test.ts b/agentic-organization/packages/application/test/graph-projection.test.ts new file mode 100644 index 0000000000..e369bcd299 --- /dev/null +++ b/agentic-organization/packages/application/test/graph-projection.test.ts @@ -0,0 +1,97 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; +import type { DecisionRecord, DiscussionAnchor } from "../../domain/src/index.ts"; +import { + GraphEdgeKind, + GraphNodeKind, + decisionsForWorkItem, + neighborsByEdge, + projectOrganizationGraph, +} from "../src/graph-projection.ts"; + +function anchor(id: string, workItemId: string): DiscussionAnchor { + return { + discussionAnchorId: id, + organizationId: "org", + projectId: "proj", + workItemId, + discussionAnchorType: "work_item", + title: "t", + purpose: "p", + expectedOutputs: [], + createdAt: "2026-05-29T00:00:00.000Z", + createdBy: { agentId: "a", hatAssignmentId: "h" }, + metadata: { updatedAt: "2026-05-29T00:00:00.000Z", version: 1, correlationId: "c", causationId: "z", traceId: "t" }, + } as DiscussionAnchor; +} + +function decision(id: string, anchorId: string, workItemId: string, followUps: string[] = []): DecisionRecord { + return { + decisionRecordId: id, + organizationId: "org", + projectId: "proj", + workItemId, + discussionAnchorId: anchorId, + title: "t", + decision: "do it", + rationale: "because", + alternativesConsidered: [], + followUpWorkItemIds: followUps, + decidedAt: "2026-05-29T00:00:00.000Z", + decidedBy: { agentId: "a", hatAssignmentId: "h" }, + metadata: { updatedAt: "2026-05-29T00:00:00.000Z", version: 1, correlationId: "c", causationId: "z", traceId: "t" }, + } as DecisionRecord; +} + +test("projects nodes and the three edge kinds", () => { + const graph = projectOrganizationGraph({ + workItemIds: ["w1"], + discussionAnchors: [anchor("a1", "w1")], + decisions: [decision("d1", "a1", "w1", ["w2"])], + }); + // nodes: w1, a1, d1, w2 + equal(graph.nodes.length, 4); + equal(graph.nodes.some((n) => n.kind === GraphNodeKind.Decision && n.id === "d1"), true); + // edges: anchored_to (a1->w1), decided_in (d1->a1), follows_up (d1->w2) + equal(graph.edges.filter((e) => e.kind === GraphEdgeKind.AnchoredTo).length, 1); + equal(graph.edges.filter((e) => e.kind === GraphEdgeKind.DecidedIn).length, 1); + equal(graph.edges.filter((e) => e.kind === GraphEdgeKind.FollowsUp).length, 1); +}); + +test("nodes are deduped by (kind,id)", () => { + const graph = projectOrganizationGraph({ + workItemIds: ["w1"], + discussionAnchors: [anchor("a1", "w1"), anchor("a2", "w1")], + decisions: [], + }); + // w1 appears once despite two anchors pointing at it + equal(graph.nodes.filter((n) => n.kind === GraphNodeKind.WorkItem && n.id === "w1").length, 1); +}); + +test("decisionsForWorkItem walks anchor then decision edges (the North Star query)", () => { + const graph = projectOrganizationGraph({ + workItemIds: ["w1"], + discussionAnchors: [anchor("a1", "w1"), anchor("a2", "w1")], + decisions: [decision("d1", "a1", "w1"), decision("d2", "a2", "w1"), decision("d3", "a1", "w1")], + }); + const decisions = [...decisionsForWorkItem(graph, "w1")].sort(); + deepEqual(decisions, ["d1", "d2", "d3"]); +}); + +test("decisionsForWorkItem returns empty for an unrelated work item", () => { + const graph = projectOrganizationGraph({ + workItemIds: ["w1", "w9"], + discussionAnchors: [anchor("a1", "w1")], + decisions: [decision("d1", "a1", "w1")], + }); + deepEqual(decisionsForWorkItem(graph, "w9"), []); +}); + +test("neighborsByEdge returns follow-up work items of a decision", () => { + const graph = projectOrganizationGraph({ + workItemIds: ["w1"], + discussionAnchors: [anchor("a1", "w1")], + decisions: [decision("d1", "a1", "w1", ["w2", "w3"])], + }); + deepEqual([...neighborsByEdge(graph, "d1", GraphEdgeKind.FollowsUp)].sort(), ["w2", "w3"]); +}); diff --git a/agentic-organization/packages/application/test/hat-lifecycle.test.ts b/agentic-organization/packages/application/test/hat-lifecycle.test.ts new file mode 100644 index 0000000000..af81264cbf --- /dev/null +++ b/agentic-organization/packages/application/test/hat-lifecycle.test.ts @@ -0,0 +1,114 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { HatBindingPhase } from "../../domain/src/hat-binding.ts"; +import { OrgEventKind } from "../../domain/src/org-event.ts"; +import { buildHatDefinitions } from "../src/org-seed.ts"; +import { + advanceBinding, + beginBinding, + isInCooldown, + planSuccession, + releaseBinding, + type LifecycleContext, +} from "../src/hat-lifecycle.ts"; + +const hats = buildHatDefinitions(); +const backendImplementer = hats.find((h) => h.id === "backend_implementer")!; // IC: ttl 120s, warmup 5s, cooldown 20s + +function clockAt(ms: { value: number }): LifecycleContext { + let n = 0; + return { + clock: { nowMs: () => ms.value, nowIso: () => new Date(ms.value).toISOString() }, + createEventId: () => `evt-${++n}`, + supervisorChain: ["executive_board_member", "cto", "engineering_director", "backend_implementer"], + correlationId: "corr-1", + causationId: "cause-1", + traceId: "trace-1", + }; +} + +test("a binding begins in Warmup and emits a HatBindingTransition event", () => { + const ms = { value: 1_000_000 }; + const ctx = clockAt(ms); + const { binding, event } = beginBinding( + { bindingId: "b-1", hat: backendImplementer, wearerAgentId: "agent-A", organizationId: "org-1" }, + ctx, + ); + equal(binding.phase, HatBindingPhase.Warmup); + equal(event.kind, OrgEventKind.HatBindingTransition); + equal(event.toState, HatBindingPhase.Warmup); + equal(event.actorHatId, "backend_implementer"); + // the supervisor chain is recorded — crystal clear who authorized it + equal(event.supervisorChain[0], "executive_board_member"); +}); + +test("the binding warms up then expires exactly on the TTL boundary (deterministic)", () => { + const ms = { value: 1_000_000 }; + const ctx = clockAt(ms); + let { binding } = beginBinding({ bindingId: "b-1", hat: backendImplementer, wearerAgentId: "agent-A", organizationId: "org-1" }, ctx); + + // before warmup elapses, no transition + ms.value += 4_000; + let adv = advanceBinding(binding, backendImplementer, clockAt(ms)); + equal(adv.binding.phase, HatBindingPhase.Warmup); + equal(adv.event, undefined); + + // after warmup (5s), → Active + ms.value = 1_000_000 + 5_000; + adv = advanceBinding(binding, backendImplementer, clockAt(ms)); + equal(adv.binding.phase, HatBindingPhase.Active); + ok(adv.event !== undefined); + binding = adv.binding; + + // before TTL (120s), still Active + ms.value = 1_000_000 + 100_000; + adv = advanceBinding(binding, backendImplementer, clockAt(ms)); + equal(adv.binding.phase, HatBindingPhase.Active); + + // at/after TTL → Expired with a cooldown stamp + ms.value = 1_000_000 + 120_000; + adv = advanceBinding(binding, backendImplementer, clockAt(ms)); + equal(adv.binding.phase, HatBindingPhase.Expired); + ok(adv.binding.cooldownUntil !== undefined); + ok(adv.event?.decision.includes("expired")); +}); + +test("cooldown blocks the same agent from immediate re-capture", () => { + const ms = { value: 1_000_000 }; + const ctx = clockAt(ms); + const { binding } = beginBinding({ bindingId: "b-1", hat: backendImplementer, wearerAgentId: "agent-A", organizationId: "org-1" }, ctx); + ms.value += 200_000; // past TTL + const expired = advanceBinding(binding, backendImplementer, clockAt(ms)).binding; + + // within cooldown window, agent-A is blocked; a different agent is not + ok(isInCooldown(expired, "agent-A", ms.value + 1_000)); + ok(!isInCooldown(expired, "agent-B", ms.value + 1_000)); + // after cooldown (20s) elapses, agent-A may retake + ok(!isInCooldown(expired, "agent-A", ms.value + 21_000)); +}); + +test("a released binding stamps cooldown and ends", () => { + const ms = { value: 1_000_000 }; + const ctx = clockAt(ms); + const { binding } = beginBinding({ bindingId: "b-1", hat: backendImplementer, wearerAgentId: "agent-A", organizationId: "org-1" }, ctx); + const { binding: released, event } = releaseBinding(binding, backendImplementer, clockAt(ms), "work complete"); + equal(released.phase, HatBindingPhase.Released); + ok(released.cooldownUntil !== undefined); + ok(event.decision.includes("released")); +}); + +test("rotate succession picks the next candidate after the last wearer", () => { + const plan = planSuccession({ hat: backendImplementer, candidateAgentIds: ["a", "b", "c"], lastWearerAgentId: "b" }); + equal(plan.policy, "rotate"); + equal(plan.nextWearerAgentId, "c"); + // wraps around + equal(planSuccession({ hat: backendImplementer, candidateAgentIds: ["a", "b", "c"], lastWearerAgentId: "c" }).nextWearerAgentId, "a"); +}); + +test("election/vote succession leaves the next wearer for an authority to decide", () => { + const ceo = hats.find((h) => h.id === "ceo")!; // executive_vote succession + const plan = planSuccession({ hat: ceo, candidateAgentIds: ["a", "b"], lastWearerAgentId: "a" }); + equal(plan.policy, "executive_vote"); + equal(plan.nextWearerAgentId, undefined); +}); diff --git a/agentic-organization/packages/application/test/hermes-reaction-plan-action-executor.test.ts b/agentic-organization/packages/application/test/hermes-reaction-plan-action-executor.test.ts new file mode 100644 index 0000000000..62a8aaa8b2 --- /dev/null +++ b/agentic-organization/packages/application/test/hermes-reaction-plan-action-executor.test.ts @@ -0,0 +1,87 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; + +import { + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + type ReactionPlanAction, +} from "../../domain/src/index.ts"; +import { createInProcessHermesRuntime } from "../../hermes/src/index.ts"; +import { createInProcessMemory } from "../../memory/src/index.ts"; +import { ReactionPlanExecutionStatus } from "../../runtime/src/index.ts"; +import { + createHermesReactionPlanActionExecutor, + type AgentHeartbeatRecord, + type AgentHeartbeatWriter, +} from "../src/index.ts"; + +function triageAction(overrides: Partial = {}): ReactionPlanAction { + return { + actionType: ReactionPlanActionType.CreateSupervisorTriage, + triggerEventId: "evt-1", + organizationId: "org-1", + projectId: "proj-1", + teamId: "team-1", + workItemId: "wi-1", + requiredHat: RequiredHat.EngineeringManager, + reason: ReactionPlanReason.SupervisorSignalNeedsTriage, + supervisorSignalId: "sig-1", + targetLevel: "manager", + ...overrides, + } as ReactionPlanAction; +} + +function recordingWriter(): AgentHeartbeatWriter & { records: AgentHeartbeatRecord[] } { + const records: AgentHeartbeatRecord[] = []; + return { records, recordAgentHeartbeat: async (r) => void records.push(r) }; +} + +const context = { + reactionPlanId: "rp-1", + claimId: "claim-1", + actionIdempotencyKey: "idem-1", + claimExpiresAt: "2026-05-30T07:00:00.000Z", +}; + +test("runs a reaction-plan action through a Hermes run and persists agent liveness", async () => { + const writer = recordingWriter(); + const executor = createHermesReactionPlanActionExecutor({ + createHermesRuntime: () => createInProcessHermesRuntime(), + createMemory: () => createInProcessMemory(), + agentHeartbeatWriter: writer, + agentHeartbeatDeadlineMs: 60_000, + generateId: (prefix) => `${prefix}-x`, + }); + + const result = await executor.executeReactionPlanAction(triageAction(), context); + + equal(result.status, ReactionPlanExecutionStatus.Succeeded); + // the agent's liveness was persisted for the work item, keyed by org + equal(writer.records.length, 1); + equal(writer.records[0]?.organizationId, "org-1"); + equal(writer.records[0]?.workItemId, "wi-1"); + equal(writer.records[0]?.deadlineMs, 60_000); +}); + +test("a Hermes run that fails to launch surfaces as a retryable failure", async () => { + const writer = recordingWriter(); + const executor = createHermesReactionPlanActionExecutor({ + createHermesRuntime: () => ({ + ...createInProcessHermesRuntime(), + launchRun: async () => ({ outcome: "feedback", feedback: { reason: "unknown_run", message: "no capacity" } }), + }), + createMemory: () => createInProcessMemory(), + agentHeartbeatWriter: writer, + agentHeartbeatDeadlineMs: 60_000, + generateId: (prefix) => `${prefix}-x`, + }); + + const result = await executor.executeReactionPlanAction(triageAction(), context); + + equal(result.status, ReactionPlanExecutionStatus.Failed); + if (result.status !== ReactionPlanExecutionStatus.Failed) return; + equal(result.failure.retryable, true); + // a run that never launched persisted no liveness + deepEqual(writer.records, []); +}); diff --git a/agentic-organization/packages/application/test/model-backed-composer.test.ts b/agentic-organization/packages/application/test/model-backed-composer.test.ts new file mode 100644 index 0000000000..ad73fb9cad --- /dev/null +++ b/agentic-organization/packages/application/test/model-backed-composer.test.ts @@ -0,0 +1,91 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { + ComposerDecision, + RunLifecyclePhase, + RunScope, + asZetaIdDecimal, + type ComposerSelectionRequest, + type RunStateReadout, +} from "../src/observe.ts"; +import { createFirstLegalOptionComposer } from "../src/reaction-decision.ts"; +import { createModelBackedComposer, type ChatCompletionPort } from "../src/model-backed-composer.ts"; + +function readout(): RunStateReadout { + return { + runId: asZetaIdDecimal("123"), + scope: RunScope.WorkItem, + phase: RunLifecyclePhase.AwaitingReview, + trace: { correlationId: "c", causationId: "c", traceId: "t" }, + observedAt: "2026-05-30T07:00:00.000Z", + deterministicRulesApplied: ["gate-precondition", "evidence-precondition"], + options: [ + { actionType: "complete", toPhase: RunLifecyclePhase.Completed, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: true, rationale: "reviewer approved" }, + { actionType: "rework", toPhase: RunLifecyclePhase.Executing, toScope: RunScope.WorkItem, requiresGate: false, requiresEvidence: false, rationale: "reviewer requested changes" }, + ], + }; +} + +const request: ComposerSelectionRequest = { readout: readout() }; + +function chat(reply: string | (() => Promise)): ChatCompletionPort { + return { complete: async () => (typeof reply === "string" ? reply : reply()) }; +} + +test("selects the legal option the model names", async () => { + const composer = createModelBackedComposer({ chat: chat("rework"), fallback: createFirstLegalOptionComposer() }); + const selection = await composer.compose(request); + + equal(selection.decision, ComposerDecision.Select); + if (selection.decision !== ComposerDecision.Select) return; + equal(selection.option.actionType, "rework"); + ok(selection.reason.includes("model selected")); +}); + +test("tolerates chatty model output and still extracts the legal token", async () => { + const composer = createModelBackedComposer({ + chat: chat("I think the best move here is: complete. That closes it out."), + fallback: createFirstLegalOptionComposer(), + }); + const selection = await composer.compose(request); + + equal(selection.decision, ComposerDecision.Select); + if (selection.decision !== ComposerDecision.Select) return; + equal(selection.option.actionType, "complete"); +}); + +test("accepts the model naming the target phase instead of the actionType", async () => { + // a small model often replies with the phase ("rework" -> phase "executing") + const composer = createModelBackedComposer({ chat: chat("ActionType: Executing"), fallback: createFirstLegalOptionComposer() }); + const selection = await composer.compose(request); + + equal(selection.decision, ComposerDecision.Select); + if (selection.decision !== ComposerDecision.Select) return; + // 'executing' is the toPhase of the 'rework' option + equal(selection.option.actionType, "rework"); +}); + +test("falls back to the deterministic composer when the model names an illegal move", async () => { + const composer = createModelBackedComposer({ chat: chat("delete_everything"), fallback: createFirstLegalOptionComposer() }); + const selection = await composer.compose(request); + + // illegal/unparseable → deterministic first legal option (complete) + equal(selection.decision, ComposerDecision.Select); + if (selection.decision !== ComposerDecision.Select) return; + equal(selection.option.actionType, "complete"); +}); + +test("falls back when the model call throws (model unreachable keeps the agent alive)", async () => { + const composer = createModelBackedComposer({ + chat: chat(async () => { + throw new Error("connection refused"); + }), + fallback: createFirstLegalOptionComposer(), + }); + const selection = await composer.compose(request); + + equal(selection.decision, ComposerDecision.Select); + if (selection.decision !== ComposerDecision.Select) return; + equal(selection.option.actionType, "complete"); +}); diff --git a/agentic-organization/packages/application/test/observe-work-item.test.ts b/agentic-organization/packages/application/test/observe-work-item.test.ts new file mode 100644 index 0000000000..f2add358f6 --- /dev/null +++ b/agentic-organization/packages/application/test/observe-work-item.test.ts @@ -0,0 +1,76 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { WorkItemState, WorkItemType, type WorkItem } from "../../domain/src/index.ts"; +import { ObserveOutcome, RunScope } from "../src/observe.ts"; +import { + ObserveWorkItemFeedbackReason, + observeWorkItem, + snapshotForWorkItem, +} from "../src/observe-work-item.ts"; + +const deps = { clock: { now: () => "2026-05-29T00:00:00.000Z" } }; + +function workItem(state: WorkItemState): WorkItem { + return { + workItemId: "w1", + organizationId: "org", + projectId: "proj", + workItemType: WorkItemType.Task, + title: "t", + description: "d", + state, + createdAt: "2026-05-29T00:00:00.000Z", + createdBy: { agentId: "a", hatAssignmentId: "h" }, + } as WorkItem; +} + +const facts = { + runId: "42", + trace: { correlationId: "c", causationId: "z", traceId: "t" }, + hasGateApproval: false, + hasEvidence: false, +}; + +test("snapshotForWorkItem maps a ready work item to the composing phase", () => { + const built = snapshotForWorkItem(workItem(WorkItemState.Ready), facts); + equal(built.outcome, "ok"); + if (built.outcome !== "ok") return; + equal(built.snapshot.phase, "composing"); + equal(built.snapshot.scope, RunScope.WorkItem); + equal(built.snapshot.runId, "42"); +}); + +test("observeWorkItem returns a readout with legal options for an in_progress item", () => { + const result = observeWorkItem(workItem(WorkItemState.InProgress), facts, deps); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.snapshot.phase, "executing"); + equal(result.readout.outcome, ObserveOutcome.Readout); + if (result.readout.outcome !== ObserveOutcome.Readout) return; + // executing -> submit_evidence | fail + equal(result.readout.readout.options.some((o) => o.actionType === "submit_evidence"), true); +}); + +test("a done work item observes to a terminal-phase feedback", () => { + const result = observeWorkItem(workItem(WorkItemState.Done), facts, deps); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.snapshot.phase, "completed"); + // observe() returns feedback for a terminal phase + equal(result.readout.outcome, ObserveOutcome.Feedback); +}); + +test("gate approval flows from facts into the snapshot and unlocks execute", () => { + // a review-state item maps to awaiting_review; awaiting_gate is what gates execute, + // so use a facts-driven snapshot directly to prove the gate flag plumbs through. + const approved = snapshotForWorkItem(workItem(WorkItemState.Review), { ...facts, hasGateApproval: true, hasEvidence: true }); + equal(approved.outcome, "ok"); + if (approved.outcome !== "ok") return; + equal(approved.snapshot.hasGateApproval, true); + equal(approved.snapshot.hasEvidence, true); +}); + +test("feedback reason exists for an unmapped phase (defensive seam)", () => { + // every real WorkItemState maps, so this asserts the reason DU is wired, not a path. + equal(ObserveWorkItemFeedbackReason.PhaseUnmapped, "phase_unmapped"); +}); diff --git a/agentic-organization/packages/application/test/observe.test.ts b/agentic-organization/packages/application/test/observe.test.ts new file mode 100644 index 0000000000..16a0119bb2 --- /dev/null +++ b/agentic-organization/packages/application/test/observe.test.ts @@ -0,0 +1,110 @@ +import { deepEqual, equal, ok, throws } from "node:assert/strict"; +import { test } from "node:test"; +import { + asZetaIdDecimal, + ComposerDecision, + DecideOutcome, + decide, + observe, + ObserveFeedbackReason, + ObserveOutcome, + RunLifecyclePhase, + RunScope, + type EphemeralComposerPort, + type ObserveDependencies, + type RunSnapshot, +} from "../src/observe.ts"; + +const deps: ObserveDependencies = { + clock: { now: () => "2026-05-29T00:00:00.000Z" }, +}; + +function snapshot(overrides: Partial = {}): RunSnapshot { + return { + runId: asZetaIdDecimal("42"), + scope: RunScope.Run, + phase: RunLifecyclePhase.Observing, + trace: { correlationId: "corr-1", causationId: "cause-1", traceId: "trace-1" }, + hasGateApproval: false, + hasEvidence: false, + ...overrides, + }; +} + +test("asZetaIdDecimal rejects non base-10 ids", () => { + throws(() => asZetaIdDecimal("0x2a"), /not a base-10 ZetaId/); + equal(asZetaIdDecimal("128"), "128"); +}); + +test("observe returns a readout with surviving options and applied rule names", () => { + const result = observe(snapshot(), deps); + equal(result.outcome, ObserveOutcome.Readout); + if (result.outcome !== ObserveOutcome.Readout) return; + equal(result.readout.phase, RunLifecyclePhase.Observing); + equal(result.readout.observedAt, "2026-05-29T00:00:00.000Z"); + deepEqual(result.readout.trace, snapshot().trace); + ok(result.readout.options.some((o) => o.actionType === "compose")); + deepEqual(result.readout.deterministicRulesApplied, ["gate-precondition", "evidence-precondition"]); +}); + +test("gate precondition vetoes execute until gate is approved", () => { + const blocked = observe(snapshot({ phase: RunLifecyclePhase.AwaitingGate, hasGateApproval: false }), deps); + equal(blocked.outcome, ObserveOutcome.Readout); + if (blocked.outcome !== ObserveOutcome.Readout) return; + ok(!blocked.readout.options.some((o) => o.actionType === "execute")); + + const approved = observe(snapshot({ phase: RunLifecyclePhase.AwaitingGate, hasGateApproval: true }), deps); + equal(approved.outcome, ObserveOutcome.Readout); + if (approved.outcome !== ObserveOutcome.Readout) return; + ok(approved.readout.options.some((o) => o.actionType === "execute")); +}); + +test("terminal phase yields feedback, not a readout", () => { + const result = observe(snapshot({ phase: RunLifecyclePhase.Completed }), deps); + equal(result.outcome, ObserveOutcome.Feedback); + if (result.outcome !== ObserveOutcome.Feedback) return; + equal(result.feedback.reason, ObserveFeedbackReason.TerminalPhase); +}); + +test("unknown phase yields unknown-phase feedback", () => { + const result = observe(snapshot({ phase: "nonsense" as RunLifecyclePhase }), deps); + equal(result.outcome, ObserveOutcome.Feedback); + if (result.outcome !== ObserveOutcome.Feedback) return; + equal(result.feedback.reason, ObserveFeedbackReason.UnknownPhase); +}); + +test("decide selects an option the memoryless composer picks from the readout", () => { + const composer: EphemeralComposerPort = { + compose: ({ readout }) => ({ + decision: ComposerDecision.Select, + option: readout.options[0]!, + reason: "first legal move", + }), + }; + const result = decide(snapshot(), composer, deps); + equal(result.outcome, DecideOutcome.Selected); + if (result.outcome !== DecideOutcome.Selected) return; + equal(result.selection.option.actionType, "compose"); +}); + +test("decide rejects a composer that selects an option outside the readout", () => { + const rogue: EphemeralComposerPort = { + compose: () => ({ + decision: ComposerDecision.Select, + option: { actionType: "execute", toPhase: RunLifecyclePhase.Executing, toScope: RunScope.WorkItem, requiresGate: true, requiresEvidence: false, rationale: "smuggled" }, + reason: "tries to skip the gate", + }), + }; + const result = decide(snapshot(), rogue, deps); + equal(result.outcome, DecideOutcome.Feedback); + if (result.outcome !== DecideOutcome.Feedback) return; + equal(result.feedback.reason, ObserveFeedbackReason.DeterministicRuleViolation); +}); + +test("decide surfaces a hold when the composer declines to move", () => { + const cautious: EphemeralComposerPort = { + compose: () => ({ decision: ComposerDecision.Hold, reason: "waiting for more context" }), + }; + const result = decide(snapshot(), cautious, deps); + equal(result.outcome, DecideOutcome.Held); +}); diff --git a/agentic-organization/packages/application/test/orchestrate-run.test.ts b/agentic-organization/packages/application/test/orchestrate-run.test.ts new file mode 100644 index 0000000000..f94c67a7a2 --- /dev/null +++ b/agentic-organization/packages/application/test/orchestrate-run.test.ts @@ -0,0 +1,139 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; +import { createInProcessHermesRuntime, HermesRunState } from "../../hermes/src/index.ts"; +import { createInProcessMemory } from "../../memory/src/index.ts"; +import { AgentLiveness, evaluateKeepAlive } from "../../keepalive/src/index.ts"; +import { + runWorkItemThroughHermes, + type AgentHeartbeatRecord, + type AgentHeartbeatWriter, + type WorkItemRunRequest, +} from "../src/orchestrate-run.ts"; + +function request(overrides: Partial = {}): WorkItemRunRequest { + return { + organizationId: "org-1", + workItemId: "wi-1", + agentId: "agent-1", + sessionId: "sess-1", + hatAssignmentId: "hat-1", + promptFlowRunId: "pf-1", + projectId: "proj-1", + priorContextNeeded: true, + actionSummary: "implemented the feature", + evidenceRefs: ["pr-123"], + learned: "the api needs pagination", + ...overrides, + }; +} + +function createRecordingAgentHeartbeatWriter(): AgentHeartbeatWriter & { records: AgentHeartbeatRecord[] } { + const records: AgentHeartbeatRecord[] = []; + return { + records, + recordAgentHeartbeat: async (record) => { + records.push(record); + }, + }; +} + +test("a full run: launch -> recall -> act -> retain -> complete, all attributed", async () => { + const hermes = createInProcessHermesRuntime(); + const memory = createInProcessMemory(); + const result = await runWorkItemThroughHermes(request(), { hermes, memory }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + // the run completed + equal(result.run.state, HermesRunState.Completed); + equal(result.run.outcome?.summary, "implemented the feature"); + // what was learned was retained, attributed to the agent/project + equal(result.retained?.attribution.projectId, "proj-1"); + // recall happened in-scope (empty first time is fine; the point is it was attributed) + equal(result.recalledCount >= 0, true); +}); + +test("the completed run's heartbeat marks the agent ALIVE to the keep-alive engine", async () => { + const hermes = createInProcessHermesRuntime({ clock: { now: () => 5000 }, idGenerator: { nextRunId: () => "r1" } }); + const memory = createInProcessMemory(); + const result = await runWorkItemThroughHermes(request(), { hermes, memory }); + if (result.outcome !== "ok") return; + // feed the run heartbeat into the deterministic keep-alive engine + const ka = evaluateKeepAlive({ + nowMs: 5000, + orgHeartbeatAgeMs: 0, + orgHeartbeatDeadlineMs: 30_000, + agents: [{ agentId: "agent-1", hatAssignmentId: "hat-1", workItemId: "wi-1", heartbeatAgeMs: 0, deadlineMs: 60_000 }], + leases: [], + }); + equal(ka.agentLiveness[0]?.liveness, AgentLiveness.Alive); +}); + +test("when prior context is not needed, recall is skipped", async () => { + const hermes = createInProcessHermesRuntime(); + const memory = createInProcessMemory(); + const result = await runWorkItemThroughHermes(request({ priorContextNeeded: false }), { hermes, memory }); + if (result.outcome !== "ok") return; + equal(result.recalledCount, 0); +}); + +test("nothing-learned means nothing retained (no junk memories)", async () => { + const hermes = createInProcessHermesRuntime(); + const memory = createInProcessMemory(); + const result = await runWorkItemThroughHermes(request({ learned: "" }), { hermes, memory }); + if (result.outcome !== "ok") return; + equal(result.retained, undefined); +}); + +test("a heartbeating run persists agent liveness through the injected writer", async () => { + const hermes = createInProcessHermesRuntime(); + const memory = createInProcessMemory(); + const writer = createRecordingAgentHeartbeatWriter(); + + const result = await runWorkItemThroughHermes(request(), { + hermes, + memory, + agentHeartbeatWriter: writer, + agentHeartbeatDeadlineMs: 60_000, + }); + + equal(result.outcome, "ok"); + deepEqual(writer.records, [ + { + organizationId: "org-1", + agentId: "agent-1", + hatAssignmentId: "hat-1", + workItemId: "wi-1", + deadlineMs: 60_000, + }, + ]); +}); + +test("a run that vanishes before its heartbeat persists no agent liveness", async () => { + const hermes = { + ...createInProcessHermesRuntime(), + heartbeat: async () => ({ outcome: "feedback" as const, feedback: { reason: "unknown_run" as const, message: "run vanished" } }), + }; + const memory = createInProcessMemory(); + const writer = createRecordingAgentHeartbeatWriter(); + + const result = await runWorkItemThroughHermes(request(), { + hermes, + memory, + agentHeartbeatWriter: writer, + agentHeartbeatDeadlineMs: 60_000, + }); + + equal(result.outcome, "feedback"); + deepEqual(writer.records, []); +}); + +test("a launch failure surfaces as feedback, not a throw", async () => { + // a hermes whose launch always fails + const failingHermes = { + ...createInProcessHermesRuntime(), + launchRun: async () => ({ outcome: "feedback" as const, feedback: { reason: "unknown_run" as const, message: "no capacity" } }), + }; + const memory = createInProcessMemory(); + const result = await runWorkItemThroughHermes(request(), { hermes: failingHermes, memory }); + equal(result.outcome, "feedback"); +}); diff --git a/agentic-organization/packages/application/test/org-runtime.test.ts b/agentic-organization/packages/application/test/org-runtime.test.ts new file mode 100644 index 0000000000..d6003e35c7 --- /dev/null +++ b/agentic-organization/packages/application/test/org-runtime.test.ts @@ -0,0 +1,71 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { HatLevel, type HatBinding, type OrgEvent } from "../../domain/src/index.ts"; +import { runOrgCycle } from "../src/org-runtime.ts"; +import { PipelineStage } from "../src/pipeline.ts"; + +function harness() { + const events: OrgEvent[] = []; + const bindings = new Map(); + let n = 0; + return { + events, bindings, + deps: { + organizationId: "org-lfg", + workItemId: "wi-customer-goal-1", + baseTimeMs: Date.parse("2026-05-30T10:00:00.000Z"), + createId: (prefix: string) => `${prefix}-${++n}`, + appendEvent: async (e: OrgEvent) => void events.push(e), + upsertBinding: async (b: HatBinding) => void bindings.set(b.id, b), + }, + }; +} + +test("one org cycle drives a customer goal all the way to Merged", async () => { + const h = harness(); + const report = await runOrgCycle(h.deps); + equal(report.finalStage, PipelineStage.Merged); + equal(report.gatesPassed, 7); // all 7 gates passed +}); + +test("the entire hierarchy acts: events at Executive Board → C-suite → Director → Manager → Lead → IC", async () => { + const h = harness(); + const report = await runOrgCycle(h.deps); + for (const level of Object.values(HatLevel)) { + ok(report.eventsByLevel[level]! >= 1, `no events at hierarchy level ${level}`); + } + // and the top of the chain genuinely acted + ok(report.eventsByLevel[HatLevel.ExecutiveBoard]! >= 1); + ok(report.eventsByLevel[HatLevel.CSuite]! >= 1); +}); + +test("hats are staffed and the binding lifecycle is exercised (expiry + succession observed)", async () => { + const h = harness(); + const report = await runOrgCycle(h.deps); + ok(report.bindingsCreated >= 5); + equal(report.expiriesObserved, 1); + equal(report.successionsPlanned, 1); + // a binding actually ended up Expired in the persisted state + ok([...h.bindings.values()].some((b) => b.phase === "expired")); +}); + +test("the cycle emits a rich, attributed trace (every event names its decision)", async () => { + const h = harness(); + await runOrgCycle(h.deps); + ok(h.events.length >= 20); + // every event carries a human-readable decision and a supervisor chain + for (const e of h.events) { + ok(e.decision.length > 0); + ok(Array.isArray(e.supervisorChain)); + } + // gate evaluations + pipeline transitions + priority + supply + assignment + lifecycle are all present + const kinds = new Set(h.events.map((e) => e.kind)); + ok(kinds.has("priority_decision")); + ok(kinds.has("hat_supply_decision")); + ok(kinds.has("hat_assignment")); + ok(kinds.has("quality_gate_evaluation")); + ok(kinds.has("pipeline_stage_transition")); + ok(kinds.has("hat_binding_transition")); + ok(kinds.has("succession_planned")); +}); diff --git a/agentic-organization/packages/application/test/org-seed.test.ts b/agentic-organization/packages/application/test/org-seed.test.ts new file mode 100644 index 0000000000..6445513dad --- /dev/null +++ b/agentic-organization/packages/application/test/org-seed.test.ts @@ -0,0 +1,95 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { HatLevel } from "../../domain/src/hat-definition.ts"; +import { DepartmentId } from "../../domain/src/department.ts"; +import { + DEPARTMENTS, + OrgGraphValidation, + buildHatDefinitions, + buildOrgSeed, + validateOrgGraph, +} from "../src/org-seed.ts"; + +test("seeds all 16 departments", () => { + equal(DEPARTMENTS.length, 16); + // every DepartmentId enum value is represented exactly once + const seeded = new Set(DEPARTMENTS.map((d) => d.id)); + for (const id of Object.values(DepartmentId)) { + ok(seeded.has(id), `missing department ${id}`); + } +}); + +test("the hat catalog covers every level of the hierarchy", () => { + const hats = buildHatDefinitions(); + ok(hats.length >= 100, `expected the full catalog, got ${hats.length}`); + const levels = new Set(hats.map((h) => h.level)); + for (const level of Object.values(HatLevel)) { + ok(levels.has(level), `no hat at level ${level}`); + } + // exactly one Executive Board root (reportsTo empty) + const roots = hats.filter((h) => h.reportsToHatIds.length === 0); + equal(roots.length, 1); + equal(roots[0]?.id, "executive_board_member"); +}); + +test("the supervisor graph is a DAG with all parents resolvable", () => { + const result = validateOrgGraph(buildHatDefinitions()); + equal(result.outcome, OrgGraphValidation.Acyclic); +}); + +test("supervises is the exact reverse of reportsTo (no inconsistency possible)", () => { + const hats = buildHatDefinitions(); + const byId = new Map(hats.map((h) => [h.id, h])); + for (const hat of hats) { + for (const child of hat.supervisesHatIds) { + ok(byId.get(child)?.reportsToHatIds.includes(hat.id), `${hat.id} supervises ${child} but ${child} does not report to it`); + } + for (const parent of hat.reportsToHatIds) { + ok(byId.get(parent)?.supervisesHatIds.includes(hat.id), `${hat.id} reports to ${parent} but is not supervised by it`); + } + } +}); + +test("every gate-owner hat the pipeline needs exists with the right approval scope", () => { + const hats = buildHatDefinitions(); + const byId = new Map(hats.map((h) => [h.id, h])); + const expectations: ReadonlyArray<[string, string]> = [ + ["product_owner", "customer_rfp_review"], + ["brd_reviewer", "brd_approval"], + ["architect", "architecture_approval"], + ["code_reviewer", "implementation_review"], + ["qa_reviewer", "runtime_validation"], + ["product_owner", "final_business_validation"], + ["release_manager", "release_readiness"], + ]; + for (const [hatId, scope] of expectations) { + const hat = byId.get(hatId); + ok(hat !== undefined, `missing gate-owner hat ${hatId}`); + ok(hat?.approvalScopes.includes(scope), `${hatId} lacks approval scope ${scope}`); + } +}); + +test("hat-supply voters exist (RMO) with the hat_supply voting scope", () => { + const hats = buildHatDefinitions(); + const voters = hats.filter((h) => h.votingScopes.includes("hat_supply")); + // Directors + Cost Controller + CFO + Hat Approval Steward can vote on supply + ok(voters.length >= 5, `expected several supply voters, got ${voters.length}`); + ok(voters.some((h) => h.id === "cfo")); + ok(voters.some((h) => h.id === "cost_controller")); +}); + +test("buildOrgSeed bundles departments + hats", () => { + const seed = buildOrgSeed(); + equal(seed.departments.length, 16); + ok(seed.hats.length >= 100); +}); + +test("a hat reporting to an unknown parent is rejected", () => { + const broken = [ + { ...buildHatDefinitions()[0]!, reportsToHatIds: [] }, + { ...buildHatDefinitions()[1]!, reportsToHatIds: ["does_not_exist"] }, + ]; + const result = validateOrgGraph(broken); + equal(result.outcome, OrgGraphValidation.UnknownParent); +}); diff --git a/agentic-organization/packages/application/test/organization-reaction-plan-action-executor.test.ts b/agentic-organization/packages/application/test/organization-reaction-plan-action-executor.test.ts new file mode 100644 index 0000000000..66747a924d --- /dev/null +++ b/agentic-organization/packages/application/test/organization-reaction-plan-action-executor.test.ts @@ -0,0 +1,100 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; + +import { + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + type ReactionPlanAction, +} from "../../domain/src/index.ts"; +import { + ReactionPlanExecutionStatus, + type ReactionPlanActionExecutionContext, + type ReactionPlanActionExecutionResult, + type ReactionPlanActionExecutorPort, +} from "../../runtime/src/index.ts"; +import { + createOrganizationReactionPlanActionExecutor, + type EnsureWorkItemPort, +} from "../src/index.ts"; + +function action(): ReactionPlanAction { + return { + actionType: ReactionPlanActionType.CreateSupervisorTriage, + triggerEventId: "evt-1", + organizationId: "org-1", + projectId: "proj-1", + teamId: "team-1", + workItemId: "wi-1", + requiredHat: RequiredHat.EngineeringManager, + reason: ReactionPlanReason.SupervisorSignalNeedsTriage, + supervisorSignalId: "sig-1", + targetLevel: "manager", + } as ReactionPlanAction; +} + +const context: ReactionPlanActionExecutionContext = { + reactionPlanId: "rp-1", + claimId: "claim-1", + actionIdempotencyKey: "idem-1", + claimExpiresAt: "2026-05-30T07:00:00.000Z", +}; + +function ok(message: string): ReactionPlanActionExecutionResult { + return { + status: ReactionPlanExecutionStatus.Succeeded, + result: { message, createdWorkItemIds: [], createdDiscussionAnchorIds: ["da-1"] }, + }; +} + +function failed(message: string): ReactionPlanActionExecutionResult { + return { status: ReactionPlanExecutionStatus.Failed, failure: { message, retryable: true } }; +} + +function recordingExecutor(result: ReactionPlanActionExecutionResult): ReactionPlanActionExecutorPort & { + calls: number; +} { + const port = { + calls: 0, + executeReactionPlanAction: async () => { + port.calls += 1; + return result; + }, + }; + return port; +} + +function recordingSeeder(): EnsureWorkItemPort & { seeded: string[] } { + const seeded: string[] = []; + return { seeded, ensureWorkItem: async (a) => void seeded.push(a.workItemId) }; +} + +test("runs the agent (Hermes), ensures the work item exists, then creates the org artifact", async () => { + const agentExecutor = recordingExecutor(ok("agent ran")); + const seeder = recordingSeeder(); + const organizationExecutor = recordingExecutor(ok("discussion anchor created")); + + const executor = createOrganizationReactionPlanActionExecutor({ agentExecutor, ensureWorkItem: seeder, organizationExecutor }); + const result = await executor.executeReactionPlanAction(action(), context); + + equal(agentExecutor.calls, 1); + deepEqual(seeder.seeded, ["wi-1"]); + equal(organizationExecutor.calls, 1); + equal(result.status, ReactionPlanExecutionStatus.Succeeded); + if (result.status !== ReactionPlanExecutionStatus.Succeeded) return; + // the org artifact (discussion anchor) flows through as the result + deepEqual(result.result.createdDiscussionAnchorIds, ["da-1"]); +}); + +test("if the agent run fails, it does NOT seed or create org artifacts (short-circuits)", async () => { + const agentExecutor = recordingExecutor(failed("agent could not run")); + const seeder = recordingSeeder(); + const organizationExecutor = recordingExecutor(ok("should not run")); + + const executor = createOrganizationReactionPlanActionExecutor({ agentExecutor, ensureWorkItem: seeder, organizationExecutor }); + const result = await executor.executeReactionPlanAction(action(), context); + + equal(result.status, ReactionPlanExecutionStatus.Failed); + deepEqual(seeder.seeded, []); + equal(organizationExecutor.calls, 0); +}); diff --git a/agentic-organization/packages/application/test/pipeline.test.ts b/agentic-organization/packages/application/test/pipeline.test.ts new file mode 100644 index 0000000000..473f3c4880 --- /dev/null +++ b/agentic-organization/packages/application/test/pipeline.test.ts @@ -0,0 +1,104 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { QualityGateKind, QualityGateOutcome } from "../../domain/src/records.ts"; +import { buildHatDefinitions } from "../src/org-seed.ts"; +import { firstLegalChooser, type OrgChooser } from "../src/org-decision.ts"; +import { + GateOwnerHats, + PipelineStage, + evaluateGate, + nextLegalGate, + recoveryPathFor, + stageFor, +} from "../src/pipeline.ts"; + +const hats = buildHatDefinitions(); +const byId = new Map(hats.map((h) => [h.id, h])); + +const ctx = { + createEventId: (() => { let n = 0; return () => `evt-${++n}`; })(), + nowIso: () => "2026-05-30T09:00:00.000Z", + organizationId: "org-1", + supervisorChain: ["executive_board_member", "ceo", "product_director", "product_owner"], + correlationId: "c", causationId: "c", traceId: "t", +}; + +// a chooser that always picks a specific outcome +function pick(outcome: QualityGateOutcome): OrgChooser { + return (legal) => ({ index: legal.indexOf(outcome), reason: `chose ${outcome}` }); +} + +test("the first legal gate is customer_rfp_review with nothing passed", () => { + equal(nextLegalGate(new Set()), QualityGateKind.CustomerRfpReview); + equal(stageFor(new Set()), PipelineStage.AwaitingCustomerRfpReview); +}); + +test("gates unlock strictly in order as priors pass", () => { + equal(nextLegalGate(new Set([QualityGateKind.CustomerRfpReview])), QualityGateKind.BrdApproval); + equal(nextLegalGate(new Set([QualityGateKind.CustomerRfpReview, QualityGateKind.BrdApproval])), QualityGateKind.ArchitectureApproval); +}); + +test("when all 7 gates pass, the work item may merge", () => { + const allPassed = new Set(Object.values(QualityGateKind)); + equal(nextLegalGate(allPassed), undefined); + equal(stageFor(allPassed), PipelineStage.Merged); +}); + +test("an owner hat approves a gate and advances the work item one stage", () => { + const productOwner = byId.get("product_owner")!; + const result = evaluateGate({ + workItemId: "wi-1", + gateKind: QualityGateKind.CustomerRfpReview, + evaluatorHat: productOwner, + passedGateKinds: new Set(), + outcomeChooser: pick(QualityGateOutcome.Approved), + }, ctx); + equal(result.outcome, "evaluated"); + if (result.outcome !== "evaluated") return; + equal(result.evaluation.outcome, QualityGateOutcome.Approved); + equal(result.advancedTo, PipelineStage.AwaitingBrdApproval); + // emits both a gate-evaluation event AND a stage-transition event + equal(result.events.length, 2); + ok(result.events.some((e) => e.decision.includes("advanced"))); +}); + +test("a non-owner hat cannot evaluate a gate", () => { + const backend = byId.get("backend_implementer")!; + const result = evaluateGate({ + workItemId: "wi-1", + gateKind: QualityGateKind.CustomerRfpReview, + evaluatorHat: backend, + passedGateKinds: new Set(), + outcomeChooser: firstLegalChooser(), + }, ctx); + equal(result.outcome, "not_authorized"); +}); + +test("changes_requested does NOT advance the stage (stays put)", () => { + const productOwner = byId.get("product_owner")!; + const result = evaluateGate({ + workItemId: "wi-1", + gateKind: QualityGateKind.CustomerRfpReview, + evaluatorHat: productOwner, + passedGateKinds: new Set(), + outcomeChooser: pick(QualityGateOutcome.ChangesRequested), + }, ctx); + equal(result.outcome, "evaluated"); + if (result.outcome !== "evaluated") return; + equal(result.advancedTo, PipelineStage.AwaitingCustomerRfpReview); // unchanged + equal(result.events.length, 1); // only the evaluation event +}); + +test("every gate has at least one owner hat that actually holds its approval scope", () => { + for (const [gateKind, owners] of Object.entries(GateOwnerHats)) { + const valid = owners.filter((id) => byId.get(id)?.approvalScopes.includes(gateKind)); + ok(valid.length >= 1, `gate ${gateKind} has no owner with the approval scope`); + } +}); + +test("failures route to the documented recovery path", () => { + equal(recoveryPathFor(QualityGateKind.ImplementationReview), "back_to_engineering"); + equal(recoveryPathFor(QualityGateKind.ArchitectureApproval), "reopen_architecture"); + equal(recoveryPathFor(QualityGateKind.BrdApproval), "reopen_discovery_or_brd"); +}); diff --git a/agentic-organization/packages/application/test/prioritization-rmo.test.ts b/agentic-organization/packages/application/test/prioritization-rmo.test.ts new file mode 100644 index 0000000000..cf56bcd713 --- /dev/null +++ b/agentic-organization/packages/application/test/prioritization-rmo.test.ts @@ -0,0 +1,121 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { buildHatDefinitions } from "../src/org-seed.ts"; +import { firstLegalChooser } from "../src/org-decision.ts"; +import { + PriorityClass, + PriorityDecidedBy, + computePriorityRecommendation, + decidePriority, + legalPriorityClassesFor, + type PriorityInputs, +} from "../src/prioritization.ts"; +import { + HatSupplyAction, + computeRequiredHatSupply, + decideHatSupply, + recommendSupplyAction, + type HatSupplyVote, + type WorkloadItem, +} from "../src/rmo.ts"; +import { HatLevel } from "../../domain/src/hat-definition.ts"; + +const hats = buildHatDefinitions(); +const tpm = hats.find((h) => h.id === "tpm")!; +const director = hats.find((h) => h.id === "engineering_director")!; +const implementer = hats.find((h) => h.id === "backend_implementer")!; + +const lowInputs: PriorityInputs = { executivePriority: 0, customerImpact: 0, severity: 0, releaseRisk: 0, blockedDownstreamCount: 0, dependencyFanOut: 0, queueAgeMs: 0, hatScarcity: 0, budgetBurn: 0, estimatedEffort: 0.5 }; +const hotInputs: PriorityInputs = { executivePriority: 1, customerImpact: 1, severity: 1, releaseRisk: 1, blockedDownstreamCount: 5, dependencyFanOut: 5, queueAgeMs: 3_600_000, hatScarcity: 1, budgetBurn: 0, estimatedEffort: 0.3 }; + +const ctx = { + createEventId: () => "evt-1", + nowIso: () => "2026-05-30T09:00:00.000Z", + organizationId: "org-1", + supervisorChain: ["executive_board_member", "ceo", "cto", "engineering_director", "tpm"], + correlationId: "c", causationId: "c", traceId: "t", +}; + +test("priority recommendation scores hot work as expedite, idle as defer", () => { + const hot = computePriorityRecommendation("wi-hot", hotInputs, ["backend_implementer"]); + equal(hot.priorityClass, PriorityClass.Expedite); + ok(hot.reasonCodes.includes("executive_priority")); + + const cold = computePriorityRecommendation("wi-cold", lowInputs, []); + ok(cold.priorityClass === PriorityClass.Defer || cold.priorityClass === PriorityClass.Normal); +}); + +test("legal priority classes are bounded by authority level", () => { + ok(legalPriorityClassesFor(HatLevel.IndividualContributor).length === 0); + ok(!legalPriorityClassesFor(HatLevel.Manager).includes(PriorityClass.Expedite)); // TPM cannot expedite + ok(legalPriorityClassesFor(HatLevel.Director).includes(PriorityClass.Expedite)); // Director can +}); + +test("a TPM cannot expedite — the decision is clamped to its legal set", () => { + const rec = computePriorityRecommendation("wi-1", hotInputs, ["backend_implementer"]); // wants expedite + const result = decidePriority({ recommendation: rec, deciderHat: tpm, chooser: firstLegalChooser() }, ctx); + equal(result.outcome, "decided"); + if (result.outcome !== "decided") return; + // expedite is illegal for a TPM → falls to the highest it CAN set (high) + equal(result.decision.priorityClass, PriorityClass.High); + equal(result.decision.decidedBy, PriorityDecidedBy.Tpm); +}); + +test("a Director may honor an expedite recommendation", () => { + const rec = computePriorityRecommendation("wi-1", hotInputs, ["backend_implementer"]); + const result = decidePriority({ recommendation: rec, deciderHat: director, chooser: firstLegalChooser() }, ctx); + equal(result.outcome, "decided"); + if (result.outcome !== "decided") return; + equal(result.decision.priorityClass, PriorityClass.Expedite); + equal(result.decision.decidedBy, PriorityDecidedBy.DepartmentDirector); +}); + +test("an individual contributor cannot decide priority", () => { + const rec = computePriorityRecommendation("wi-1", hotInputs, []); + const result = decidePriority({ recommendation: rec, deciderHat: implementer, chooser: firstLegalChooser() }, ctx); + equal(result.outcome, "not_authorized"); +}); + +test("RMO computes required hat supply from the prioritized workload", () => { + const workload: WorkloadItem[] = [ + { workItemId: "a", priorityClass: PriorityClass.Expedite, requiredHats: ["backend_implementer", "code_reviewer"] }, + { workItemId: "b", priorityClass: PriorityClass.High, requiredHats: ["backend_implementer"] }, + { workItemId: "c", priorityClass: PriorityClass.Paused, requiredHats: ["backend_implementer"] }, // ignored + ]; + const required = computeRequiredHatSupply(workload); + // backend gets weight 2+2 = 4 → ceil(4/2)=2 wearers; code_reviewer weight 2 → 1 + equal(required.get("backend_implementer"), 2); + equal(required.get("code_reviewer"), 1); + ok(!required.has("qa_reviewer")); +}); + +test("recommendSupplyAction compares demand vs current", () => { + equal(recommendSupplyAction(3, 1), HatSupplyAction.Expand); + equal(recommendSupplyAction(1, 3), HatSupplyAction.Release); + equal(recommendSupplyAction(2, 2), HatSupplyAction.Hold); +}); + +test("supervisor vote expands hat supply when quorum approves", () => { + const votes: HatSupplyVote[] = [ + { voterHatId: "engineering_director", approve: true, proposedTarget: 3 }, + { voterHatId: "cfo", approve: true, proposedTarget: 2 }, + { voterHatId: "cost_controller", approve: false, proposedTarget: 1 }, + ]; + const { decision, event } = decideHatSupply({ hatId: "backend_implementer", hatName: "Backend Implementer", requiredCount: 3, currentCount: 1, votes }, ctx); + equal(decision.quorumMet, true); // 2 of 3 approve + equal(decision.action, HatSupplyAction.Expand); + equal(decision.targetCount, 3); // median of approvers' proposals [2,3] = round(2.5) = 3 + ok(event.decision.includes("Backend Implementer")); +}); + +test("no quorum → supply holds at current", () => { + const votes: HatSupplyVote[] = [ + { voterHatId: "engineering_director", approve: false, proposedTarget: 3 }, + { voterHatId: "cfo", approve: false, proposedTarget: 2 }, + ]; + const { decision } = decideHatSupply({ hatId: "backend_implementer", hatName: "Backend Implementer", requiredCount: 5, currentCount: 1, votes }, ctx); + equal(decision.quorumMet, false); + equal(decision.action, HatSupplyAction.Hold); + equal(decision.targetCount, 1); +}); diff --git a/agentic-organization/packages/application/test/reaction-decision.test.ts b/agentic-organization/packages/application/test/reaction-decision.test.ts new file mode 100644 index 0000000000..ad7de72a01 --- /dev/null +++ b/agentic-organization/packages/application/test/reaction-decision.test.ts @@ -0,0 +1,81 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + type ReactionPlanAction, +} from "../../domain/src/index.ts"; +import { ComposerDecision, DecideOutcome, type EphemeralComposerPort } from "../src/observe.ts"; +import { + createFirstLegalOptionComposer, + decideReactionAction, + deterministicRunIdForAction, + summarizeReactionDecision, +} from "../src/reaction-decision.ts"; + +function action(overrides: Partial = {}): ReactionPlanAction { + return { + actionType: ReactionPlanActionType.CreateSupervisorTriage, + triggerEventId: "evt-decision-1", + organizationId: "org-1", + projectId: "proj-1", + teamId: "team-1", + workItemId: "wi-1", + requiredHat: RequiredHat.EngineeringManager, + reason: ReactionPlanReason.SupervisorSignalNeedsTriage, + supervisorSignalId: "sig-1", + targetLevel: "manager", + ...overrides, + } as ReactionPlanAction; +} + +const now = (): string => "2026-05-30T07:00:00.000Z"; + +test("the deterministic composer selects the highest-priority legal move at Observing", () => { + const result = decideReactionAction({ action: action(), composer: createFirstLegalOptionComposer(), now }); + + equal(result.outcome, DecideOutcome.Selected); + if (result.outcome !== DecideOutcome.Selected) return; + // Observing's first legal option is 'compose' (selection before any side effect) + equal(result.selection.option.actionType, "compose"); + // determinism is visible: the rules that shaped the legal set are recorded + ok(result.readout.deterministicRulesApplied.includes("gate-precondition")); +}); + +test("the summary reflects the COMPUTED decision, not a fixed string", () => { + const result = decideReactionAction({ action: action(), composer: createFirstLegalOptionComposer(), now }); + const summary = summarizeReactionDecision(result); + + equal(summary.kind, "summary"); + if (summary.kind !== "summary") return; + ok(summary.actionSummary.startsWith("decided 'compose'")); + ok(summary.learned.includes("legal option")); +}); + +test("a composer that picks an illegal option is rejected by the deterministic kernel", () => { + const rogueComposer: EphemeralComposerPort = { + compose: () => ({ + decision: ComposerDecision.Select, + option: { actionType: "execute", toPhase: "executing", toScope: "work_item", requiresGate: true, requiresEvidence: false, rationale: "skip ahead" } as never, + reason: "trying to skip the gate", + }), + }; + + const result = decideReactionAction({ action: action(), composer: rogueComposer, now }); + + // the agent cannot escape the rules — an out-of-set choice becomes feedback, not action + equal(result.outcome, DecideOutcome.Feedback); + const summary = summarizeReactionDecision(result); + equal(summary.kind, "feedback"); +}); + +test("the run id is a stable base-10 ZetaId derived from the action (DST-replayable)", () => { + const a = deterministicRunIdForAction(action()); + const b = deterministicRunIdForAction(action()); + equal(a, b); + ok(/^[0-9]+$/.test(a)); + // a different trigger event yields a different id + ok(deterministicRunIdForAction(action({ triggerEventId: "evt-other" })) !== a); +}); diff --git a/agentic-organization/packages/application/test/review-gate.test.ts b/agentic-organization/packages/application/test/review-gate.test.ts new file mode 100644 index 0000000000..bdd3d4689a --- /dev/null +++ b/agentic-organization/packages/application/test/review-gate.test.ts @@ -0,0 +1,67 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { QualityGateOutcome } from "../../domain/src/index.ts"; +import { ReviewDimension, ReviewSeverity, ReviewStance, type CandidateFinding, type ReviewerVote } from "../../metrics/src/index.ts"; +import { ReviewGateFeedbackReason, evaluateReviewGate } from "../src/review-gate.ts"; + +function finding(id: string, severity: (typeof ReviewSeverity)[keyof typeof ReviewSeverity]): CandidateFinding { + return { findingId: id, dimension: ReviewDimension.Correctness, severity, subject: "x", comment: "c" }; +} + +function agree(agent: string, findingId: string): ReviewerVote { + return { reviewerAgentId: agent, hatAssignmentId: `${agent}-h`, findingId, stance: ReviewStance.Agree, rationale: "" }; +} + +test("no finding reaches quorum -> gate Approved", () => { + const r = evaluateReviewGate({ + findings: [finding("F1", ReviewSeverity.Major)], + // only 2 distinct agreers, quorum default 3 -> not adopted, but 3 reviewers present + votes: [agree("a", "F1"), agree("b", "F1"), { reviewerAgentId: "c", hatAssignmentId: "c-h", findingId: "F1", stance: ReviewStance.Disagree, rationale: "" }], + }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok") return; + equal(r.recommendedGateOutcome, QualityGateOutcome.Approved); + equal(r.adoptedFindingIds.length, 0); +}); + +test("an adopted major finding -> gate Rejected", () => { + const r = evaluateReviewGate({ + findings: [finding("F1", ReviewSeverity.Major)], + votes: [agree("a", "F1"), agree("b", "F1"), agree("c", "F1")], + }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok") return; + equal(r.recommendedGateOutcome, QualityGateOutcome.Rejected); + equal(r.adoptedFindingIds.includes("F1"), true); +}); + +test("adopted minor-only findings -> ChangesRequested", () => { + const r = evaluateReviewGate({ + findings: [finding("F1", ReviewSeverity.Minor)], + votes: [agree("a", "F1"), agree("b", "F1"), agree("c", "F1")], + }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok") return; + equal(r.recommendedGateOutcome, QualityGateOutcome.ChangesRequested); +}); + +test("fewer than quorum reviewers -> feedback (board could not convene)", () => { + const r = evaluateReviewGate({ + findings: [finding("F1", ReviewSeverity.Major)], + votes: [agree("a", "F1"), agree("b", "F1")], + }); + equal(r.outcome, "feedback"); + if (r.outcome !== "feedback") return; + equal(r.feedback.reason, ReviewGateFeedbackReason.BoardCouldNotConvene); +}); + +test("custom quorum of 2 adopts a major finding and rejects the gate", () => { + const r = evaluateReviewGate({ + findings: [finding("F1", ReviewSeverity.Blocking)], + votes: [agree("a", "F1"), agree("b", "F1")], + quorum: 2, + }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok") return; + equal(r.recommendedGateOutcome, QualityGateOutcome.Rejected); +}); diff --git a/agentic-organization/packages/application/test/sandbox-tool.test.ts b/agentic-organization/packages/application/test/sandbox-tool.test.ts new file mode 100644 index 0000000000..e3b4771663 --- /dev/null +++ b/agentic-organization/packages/application/test/sandbox-tool.test.ts @@ -0,0 +1,53 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { + ReactionPlanActionType, + ReactionPlanReason, + RequiredHat, + type ReactionPlanAction, +} from "../../domain/src/index.ts"; +import { + SandboxVerificationEvidencePrefix, + buildVerificationToolRequest, + verificationEvidenceRef, + type SandboxToolResult, +} from "../src/sandbox-tool.ts"; + +function action(): ReactionPlanAction { + return { + actionType: ReactionPlanActionType.CreateSupervisorTriage, + triggerEventId: "evt-1", + organizationId: "org-1", + projectId: "proj-1", + teamId: "team-1", + workItemId: "wi-sandbox-1", + requiredHat: RequiredHat.EngineeringManager, + reason: ReactionPlanReason.SupervisorSignalNeedsTriage, + supervisorSignalId: "sig-1", + targetLevel: "manager", + } as ReactionPlanAction; +} + +test("builds a bounded verification-tool request over the work item id", () => { + const request = buildVerificationToolRequest(action(), "/usr/bin/node"); + equal(request.command, "/usr/bin/node"); + equal(request.args[0], "-e"); + // the work item id is the program argument the tool hashes + ok(request.args.includes("wi-sandbox-1")); + ok(request.timeoutMs > 0 && request.timeoutMs <= 10_000); +}); + +test("turns a valid sha256 tool result into an evidence ref", () => { + const result: SandboxToolResult = { ok: true, stdout: "a".repeat(64) + "\n" }; + const ref = verificationEvidenceRef(result); + equal(ref, `${SandboxVerificationEvidencePrefix}${"a".repeat(64)}`); +}); + +test("yields no evidence ref on tool failure (supplementary, never fatal)", () => { + equal(verificationEvidenceRef({ ok: false, reason: "timeout" }), undefined); +}); + +test("rejects non-sha256 tool output (does not fabricate evidence)", () => { + equal(verificationEvidenceRef({ ok: true, stdout: "not a digest" }), undefined); +}); diff --git a/agentic-organization/packages/application/test/triage-action-resolver.test.ts b/agentic-organization/packages/application/test/triage-action-resolver.test.ts new file mode 100644 index 0000000000..a947d4a841 --- /dev/null +++ b/agentic-organization/packages/application/test/triage-action-resolver.test.ts @@ -0,0 +1,80 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { SupervisorTriageActionType, WorkItemType } from "../../domain/src/index.ts"; +import { + TriageActionFeedbackReason, + TriageActionResolution, + resolveTriageAction, +} from "../src/triage-action-resolver.ts"; + +test("open_work_item resolves to OpensWorkItem with its inputs", () => { + const r = resolveTriageAction({ + actionType: SupervisorTriageActionType.OpenWorkItem, + followUpWorkItemType: WorkItemType.Task, + followUpTitle: "do the thing", + followUpDescription: "details", + }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok" || r.resolution !== TriageActionResolution.OpensWorkItem) return; + equal(r.followUpTitle, "do the thing"); +}); + +test("answer_directly resolves to AnswersInPlace", () => { + const r = resolveTriageAction({ actionType: SupervisorTriageActionType.AnswerDirectly, answer: "yes, proceed" }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok" || r.resolution !== TriageActionResolution.AnswersInPlace) return; + equal(r.answer, "yes, proceed"); +}); + +test("answer_directly with a blank answer yields feedback", () => { + const r = resolveTriageAction({ actionType: SupervisorTriageActionType.AnswerDirectly, answer: " " }); + equal(r.outcome, "feedback"); + if (r.outcome !== "feedback") return; + equal(r.feedback.reason, TriageActionFeedbackReason.AnswerRequired); +}); + +test("escalate resolves to EscalatesSignal with target + reason", () => { + const r = resolveTriageAction({ + actionType: SupervisorTriageActionType.EscalateToNextSupervisor, + targetSupervisorHatAssignmentId: "hat-9", + escalationReason: "beyond my authority", + }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok" || r.resolution !== TriageActionResolution.EscalatesSignal) return; + equal(r.targetSupervisorHatAssignmentId, "hat-9"); +}); + +test("escalate without a target yields feedback", () => { + const r = resolveTriageAction({ + actionType: SupervisorTriageActionType.EscalateToNextSupervisor, + targetSupervisorHatAssignmentId: "", + escalationReason: "x", + }); + equal(r.outcome, "feedback"); + if (r.outcome !== "feedback") return; + equal(r.feedback.reason, TriageActionFeedbackReason.EscalationTargetRequired); +}); + +test("escalate without a reason yields feedback", () => { + const r = resolveTriageAction({ + actionType: SupervisorTriageActionType.EscalateToNextSupervisor, + targetSupervisorHatAssignmentId: "hat-9", + escalationReason: " ", + }); + equal(r.outcome, "feedback"); + if (r.outcome !== "feedback") return; + equal(r.feedback.reason, TriageActionFeedbackReason.EscalationReasonRequired); +}); + +test("the three deferred actions resolve to Deferred (visible gap, not rejection)", () => { + for (const actionType of [ + SupervisorTriageActionType.RequestSecurityReview, + SupervisorTriageActionType.ScheduleDiscussion, + SupervisorTriageActionType.RouteToInternalPlatform, + ] as const) { + const r = resolveTriageAction({ actionType }); + equal(r.outcome, "ok"); + if (r.outcome !== "ok" || r.resolution !== TriageActionResolution.Deferred) return; + equal(r.actionType, actionType); + } +}); diff --git a/agentic-organization/packages/domain/src/department.ts b/agentic-organization/packages/domain/src/department.ts new file mode 100644 index 0000000000..167735b199 --- /dev/null +++ b/agentic-organization/packages/domain/src/department.ts @@ -0,0 +1,35 @@ +/** + * Departments — the 16 organizational units from DEPARTMENT_HAT_TOOL_INVENTORY.md. + * A department owns a set of hats and reports to another department/role. The + * Organization DB is the source of truth; departments are data, not code paths. + */ + +export const DepartmentId = { + ExecutiveBoardAndGovernance: "executive_board_and_governance", + ProgramAndInitiativeManagement: "program_and_initiative_management", + ProductAndCustomerDiscovery: "product_and_customer_discovery", + BusinessAnalysis: "business_analysis", + Architecture: "architecture", + Engineering: "engineering", + EngineeringManagement: "engineering_management", + QaAndVerification: "qa_and_verification", + QaEngineering: "qa_engineering", + SecurityAndCompliance: "security_and_compliance", + DeliveryAndRelease: "delivery_and_release", + MemoryAndKnowledge: "memory_and_knowledge", + DocumentationAndProjectSkills: "documentation_and_project_skills", + OperationsAndInfrastructure: "operations_and_infrastructure", + ObservabilityAndEvidence: "observability_and_evidence", + CapabilityAndAutomationExpansion: "capability_and_automation_expansion", +} as const; + +export type DepartmentId = (typeof DepartmentId)[keyof typeof DepartmentId]; + +export type Department = { + id: DepartmentId; + name: string; + /** the department/role this department reports to (free text from the doc's "Reports to") */ + reportsTo: string; + /** one-line statement of what the department owns */ + owns: string; +}; diff --git a/agentic-organization/packages/domain/src/hat-binding.ts b/agentic-organization/packages/domain/src/hat-binding.ts new file mode 100644 index 0000000000..61c4e8c7f2 --- /dev/null +++ b/agentic-organization/packages/domain/src/hat-binding.ts @@ -0,0 +1,57 @@ +/** + * HatBinding — an agent wearing a hat for a scoped duration. The phase advances + * deterministically from the bound timestamp + the hat's warmup/TTL against the + * DB clock (CLUSTER_NATIVE_HAT_SYSTEM.md §Time-Bounded Hat Binding). + * + * Pending → Warmup (warmupSeconds) → Active → Expired (tokenTtlSeconds) + * ↘ Released | Succeeded | Revoked | Probation + * + * Cooldown is stamped on release (`cooldownUntil`) so re-capture rules are a + * pure timestamp comparison. + */ + +export const HatBindingPhase = { + Pending: "pending", + Warmup: "warmup", + Active: "active", + Probation: "probation", + Expired: "expired", + Released: "released", + Succeeded: "succeeded", + Revoked: "revoked", +} as const; + +export type HatBindingPhase = (typeof HatBindingPhase)[keyof typeof HatBindingPhase]; + +/** Terminal phases: the binding no longer authorizes anything. */ +export const TerminalHatBindingPhases: ReadonlySet = new Set([ + HatBindingPhase.Expired, + HatBindingPhase.Released, + HatBindingPhase.Succeeded, + HatBindingPhase.Revoked, +]); + +export type HatBinding = { + id: string; + hatId: string; + organizationId: string; + wearerAgentId: string; + phase: HatBindingPhase; + /** ISO timestamp the binding was created (entered Pending) */ + boundAt: string; + /** boundAt + warmupSeconds — Warmup→Active is legal at/after this */ + warmupEndsAt: string; + /** boundAt + tokenTtlSeconds — Active→Expired is forced at/after this */ + expiresAt: string; + /** set when Active */ + activatedAt?: string; + /** set when terminal */ + endedAt?: string; + /** set on release/expiry: endedAt + cooldownSeconds; blocks same-wearer recapture */ + cooldownUntil?: string; + reason?: string; +}; + +export function isTerminalHatBinding(binding: HatBinding): boolean { + return TerminalHatBindingPhases.has(binding.phase); +} diff --git a/agentic-organization/packages/domain/src/hat-definition.ts b/agentic-organization/packages/domain/src/hat-definition.ts new file mode 100644 index 0000000000..5ba0b8cb75 --- /dev/null +++ b/agentic-organization/packages/domain/src/hat-definition.ts @@ -0,0 +1,129 @@ +/** + * HatDefinition — a hat is a persistent role: a bundle of authority, skills, + * tools, scopes, supervisor-graph position, lifecycle timing, and succession + * rules (DEPARTMENT_HAT_TOOL_INVENTORY.md + CLUSTER_NATIVE_HAT_SYSTEM.md). + * Agents wear hats temporarily; the hat persists. + * + * The supervisor graph (`supervisesHatIds` / `reportsToHatIds`) is a DAG and is + * validated acyclic at seed time. + */ + +import type { DepartmentId } from "./department.ts"; + +/** The hierarchy tier — Executive Board → C-suite → Director → Manager → Lead → IC. */ +export const HatLevel = { + ExecutiveBoard: "executive_board", + CSuite: "c_suite", + Director: "director", + Manager: "manager", + Lead: "lead", + IndividualContributor: "individual_contributor", +} as const; + +export type HatLevel = (typeof HatLevel)[keyof typeof HatLevel]; + +/** Reusable MCP tool bundles (DEPARTMENT_HAT_TOOL_INVENTORY.md §Tool Bundles). */ +export const ToolBundle = { + GoalIntake: "goal_intake", + Project: "project", + PortfolioAndInitiative: "portfolio_and_initiative", + HatAuthorization: "hat_authorization", + AgentInsight: "agent_insight", + Voting: "voting", + TeamRuntime: "team_runtime", + WorkRhythm: "work_rhythm", + PromptFlow: "prompt_flow", + UniversalActionGrammar: "universal_action_grammar", + Task: "task", + BacklogAndDefect: "backlog_and_defect", + Messaging: "messaging", + Meeting: "meeting", + ArtifactAndEvidence: "artifact_and_evidence", + Business: "business", + Architecture: "architecture", + ReviewAndGates: "review_and_gates", + Memory: "memory", + CredentialProxy: "credential_proxy", + Qa: "qa", + DevOps: "devops", + Delivery: "delivery", + Status: "status", + Observability: "observability", + AlwaysOnRuntime: "always_on_runtime", + ScheduledReviews: "scheduled_reviews", + DocumentationContext: "documentation_context", + ProjectSkills: "project_skills", + CapabilityExpansion: "capability_expansion", + TemporalWorkflowRegistry: "temporal_workflow_registry", + DaprActorRegistry: "dapr_actor_registry", + NatsAndDlqOperations: "nats_and_dlq_operations", + OzAndHermesRuntime: "oz_and_hermes_runtime", + HumanOverride: "human_override", +} as const; + +export type ToolBundle = (typeof ToolBundle)[keyof typeof ToolBundle]; + +/** How the next wearer of a hat is chosen when a binding ends. */ +export const SuccessionPolicy = { + Rotate: "rotate", + Renew: "renew", + Election: "election", + DirectorAssigned: "director_assigned", + ExecutiveVote: "executive_vote", +} as const; + +export type SuccessionPolicy = (typeof SuccessionPolicy)[keyof typeof SuccessionPolicy]; + +export const RiskLevel = { + Low: "low", + Medium: "medium", + High: "high", + Critical: "critical", +} as const; + +export type RiskLevel = (typeof RiskLevel)[keyof typeof RiskLevel]; + +/** Reputation accrues on the hat AND the pairings — never only the agent. */ +export const ReputationScope = { + Hat: "hat", + AgentHat: "agent_hat", + DepartmentHat: "department_hat", + ProjectHat: "project_hat", +} as const; + +export type ReputationScope = (typeof ReputationScope)[keyof typeof ReputationScope]; + +export type HatDefinition = { + id: string; + name: string; + departmentId: DepartmentId; + level: HatLevel; + /** supervisor-graph edges (DAG): the hats this hat supervises and reports to */ + supervisesHatIds: readonly string[]; + reportsToHatIds: readonly string[]; + /** hats that cannot be co-held by the same wearer in overlapping scope */ + conflictsWithHatIds: readonly string[]; + /** hats with authority to assign this hat (empty → only by approved policy/executive) */ + assignableByHatIds: readonly string[]; + allowedToolBundles: readonly ToolBundle[]; + skills: readonly string[]; + approvalScopes: readonly string[]; + votingScopes: readonly string[]; + memoryScopes: readonly string[]; + credentialScopes: readonly string[]; + documentationScopes: readonly string[]; + /** work-item / gate transitions this hat may drive */ + lifecycleTransitions: readonly string[]; + requiredEvidence: readonly string[]; + maxConcurrentAssignments: number; + tokenTtlSeconds: number; + warmupSeconds: number; + cooldownSeconds: number; + successionPolicy: SuccessionPolicy; + stickyAttribution: boolean; + quorumSize?: number; + reputationScope: readonly ReputationScope[]; + riskLevel: RiskLevel; + requiresTwoPersonApproval: boolean; + requiresHumanApproval: boolean; +}; diff --git a/agentic-organization/packages/domain/src/index.ts b/agentic-organization/packages/domain/src/index.ts index f1cbc23cc3..205d33acd7 100644 --- a/agentic-organization/packages/domain/src/index.ts +++ b/agentic-organization/packages/domain/src/index.ts @@ -8,6 +8,17 @@ export { type EvaluateQualityGateSequencePolicyInput, type QualityGateSequencePolicy, } from "./company-work-policy.ts"; +export { + GateOwner, + RUN_PHASE_FOR_STATE, + STATE_RECONCILIATION, + TypeSpecificRuleKind, + reconcileState, + runPhaseForState, + typeSpecificRulesFor, + type StateReconciliationRow, + type TypeSpecificRule, +} from "./state-reconciliation.ts"; export { AgenticAggregateType, AgenticEventType, @@ -101,3 +112,19 @@ export { isScheduleBlockType, isWorkItemStateChangedPayload, } from "./records.ts"; +export { DepartmentId, type Department } from "./department.ts"; +export { + HatLevel, + ReputationScope, + RiskLevel, + SuccessionPolicy, + ToolBundle, + type HatDefinition, +} from "./hat-definition.ts"; +export { OrgEventKind, type OrgEvent } from "./org-event.ts"; +export { + HatBindingPhase, + TerminalHatBindingPhases, + isTerminalHatBinding, + type HatBinding, +} from "./hat-binding.ts"; diff --git a/agentic-organization/packages/domain/src/org-event.ts b/agentic-organization/packages/domain/src/org-event.ts new file mode 100644 index 0000000000..31b95d1257 --- /dev/null +++ b/agentic-organization/packages/domain/src/org-event.ts @@ -0,0 +1,48 @@ +/** + * OrgEvent — the universal, durable trace record. Generalizing the hat doc's + * "exactly one HatSwap per transition", EVERY organizational state transition + * (hat bind/warmup/expire/succeed, priority decision, supply vote, assignment, + * pipeline stage, gate evaluation) emits exactly one OrgEvent. + * + * Each carries who acted (hat + agent + department), the supervisor-chain path + * that authorized it, what changed (from→to), the decision, evidence, and the + * correlation/causation/trace envelope. "What is happening" is one query over + * the OrgEvent stream — that is the traceability contract. + */ + +import type { DepartmentId } from "./department.ts"; + +export const OrgEventKind = { + HatBindingTransition: "hat_binding_transition", + HatSupplyDecision: "hat_supply_decision", + PriorityDecision: "priority_decision", + HatAssignment: "hat_assignment", + PipelineStageTransition: "pipeline_stage_transition", + QualityGateEvaluation: "quality_gate_evaluation", + SuccessionPlanned: "succession_planned", +} as const; + +export type OrgEventKind = (typeof OrgEventKind)[keyof typeof OrgEventKind]; + +export type OrgEvent = { + id: string; + kind: OrgEventKind; + occurredAt: string; + organizationId: string; + /** the hat that acted (the authority under which the transition happened) */ + actorHatId?: string; + actorAgentId?: string; + departmentId?: DepartmentId; + /** the entity that transitioned (binding id, work item id, hat id, …) */ + subjectId: string; + fromState?: string; + toState?: string; + /** human-readable decision summary — must be crystal clear about what happened */ + decision: string; + /** hat-id path from the Executive Board root down to the actor */ + supervisorChain: readonly string[]; + evidenceRefs: readonly string[]; + correlationId: string; + causationId: string; + traceId: string; +}; diff --git a/agentic-organization/packages/domain/src/state-reconciliation.ts b/agentic-organization/packages/domain/src/state-reconciliation.ts new file mode 100644 index 0000000000..382ea0fe70 --- /dev/null +++ b/agentic-organization/packages/domain/src/state-reconciliation.ts @@ -0,0 +1,268 @@ +import { WorkItemState, WorkItemType } from "./work-item-state-machine.ts"; + +/** + * State Reconciliation Table — the North-Star-mandated single authoritative + * mapping that prevents Work OS / V0 schema / UI / event names / observe.ts + * from diverging (see docs/NORTH_STAR_ALIGNMENT_CHECKPOINT.md "State Name + * Divergence"). + * + * Every distinct kind of fact here is an explicit DU; substantively-distinct + * states are never buried in if-chains or optional fields (repo rule + * IMPLICIT-NOT-EXPLICIT is class error). + */ + +/** + * The party that owns the quality gate guarding entry into / exit from a state. + * Explicit DU rather than a free string so gate ownership is enumerable and + * exhaustively checkable. + */ +export const GateOwner = { + None: "none", + EngineeringManager: "engineering_manager", + CodeReviewer: "code_reviewer", + QaReviewer: "qa_reviewer", + ReleaseManager: "release_manager", +} as const; + +export type GateOwner = (typeof GateOwner)[keyof typeof GateOwner]; + +/** + * One reconciliation row per WorkItemState value. Maps the V0 enum state to its + * conceptual Work OS name, UI column, event name, owning package, and gate owner. + */ +export type StateReconciliationRow = { + workItemState: WorkItemState; + conceptualWorkOsState: string; + uiColumn: string; + eventName: string; + ownerPackage: string; + gateOwner: GateOwner; +}; + +/** + * State transitions emit the real AgenticEventType "work_item.state_changed" + * event (see packages/domain/src/event-envelope.ts). Every row uses this + * single event name because reaching a state IS a state transition. + */ +const STATE_CHANGED_EVENT = "work_item.state_changed"; + +const DOMAIN_PACKAGE = "packages/domain"; + +/** + * The reconciliation map — keyed by WorkItemState so the mapping is + * COMPILE-EXHAUSTIVE: adding a WorkItemState value makes this object literal a + * type error until a row is supplied (OCP — a new variant breaks the build, not + * just a runtime test). Values are consistent with the docs (created -> "Backlog" + * UI column, done -> "Done" with a release gate). + */ +const STATE_RECONCILIATION_MAP: Readonly> = { + [WorkItemState.Created]: { + workItemState: WorkItemState.Created, + conceptualWorkOsState: "Captured", + uiColumn: "Backlog", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // Nothing gates capture; a work item simply exists once created. + gateOwner: GateOwner.None, + }, + [WorkItemState.Intake]: { + workItemState: WorkItemState.Intake, + conceptualWorkOsState: "Intake", + uiColumn: "Backlog", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // Intake is an internal grooming step before triage; no gate owner yet. + gateOwner: GateOwner.None, + }, + [WorkItemState.Triage]: { + workItemState: WorkItemState.Triage, + conceptualWorkOsState: "Triage", + uiColumn: "Triage", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // Engineering management owns the decision to promote triaged work to ready. + gateOwner: GateOwner.EngineeringManager, + }, + [WorkItemState.Ready]: { + workItemState: WorkItemState.Ready, + conceptualWorkOsState: "Ready", + uiColumn: "Ready", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // Engineering management owns assignment + scheduling before in_progress. + gateOwner: GateOwner.EngineeringManager, + }, + [WorkItemState.InProgress]: { + workItemState: WorkItemState.InProgress, + conceptualWorkOsState: "In Progress", + uiColumn: "In Progress", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // No external gate while engineering executes; the run loop governs it. + gateOwner: GateOwner.None, + }, + [WorkItemState.Blocked]: { + workItemState: WorkItemState.Blocked, + conceptualWorkOsState: "Blocked", + uiColumn: "Blocked", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // Engineering management owns blocker resolution / re-prioritisation. + gateOwner: GateOwner.EngineeringManager, + }, + [WorkItemState.Review]: { + workItemState: WorkItemState.Review, + conceptualWorkOsState: "In Review", + uiColumn: "Review", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // Code review gates the review -> done transition. + gateOwner: GateOwner.CodeReviewer, + }, + [WorkItemState.Done]: { + workItemState: WorkItemState.Done, + conceptualWorkOsState: "Done", + uiColumn: "Done", + eventName: STATE_CHANGED_EVENT, + ownerPackage: DOMAIN_PACKAGE, + // Release management owns the terminal release gate. + gateOwner: GateOwner.ReleaseManager, + }, +}; + +/** + * The 8 reconciliation rows as a list, derived from the compile-exhaustive map + * for iteration/rendering. One row per WorkItemState, no dupes, no gaps. + */ +export const STATE_RECONCILIATION: readonly StateReconciliationRow[] = + Object.values(STATE_RECONCILIATION_MAP); + +/** + * Lookup the reconciliation row for a given WorkItemState. Total over the 8 + * enumerated states (the map is exhaustive), but the signature stays honest + * about a lookup with an out-of-enum value. + */ +export function reconcileState(state: WorkItemState): StateReconciliationRow | undefined { + return STATE_RECONCILIATION_MAP[state]; +} + +/** + * observe.ts binding — maps each WorkItemState to the observe.ts + * RunLifecyclePhase string it corresponds to. Values are the literal + * RunLifecyclePhase strings from packages/application/src/observe.ts. We hold + * the strings (not the imported const) so the domain package does not depend on + * the application package; a test asserts coverage of all 8 states. + * + * Per-state rationale: + * - created/intake/triage: the work item is not yet actionable by a run, so it + * sits in the run-loop's "observing" phase (run is watching for legal moves). + * - ready: the run has a selectable work item and begins "composing" a plan. + * - in_progress: side-effecting work is happening -> "executing". + * - blocked: directly maps to observe.ts "blocked". + * - review: a reviewer is being awaited -> "awaiting_review". + * - done: terminal success -> "completed". + */ +export const RUN_PHASE_FOR_STATE: Readonly> = { + [WorkItemState.Created]: "observing", + [WorkItemState.Intake]: "observing", + [WorkItemState.Triage]: "observing", + [WorkItemState.Ready]: "composing", + [WorkItemState.InProgress]: "executing", + [WorkItemState.Blocked]: "blocked", + [WorkItemState.Review]: "awaiting_review", + [WorkItemState.Done]: "completed", +}; + +/** + * The observe.ts RunLifecyclePhase string for a given WorkItemState. + */ +export function runPhaseForState(state: WorkItemState): string { + return RUN_PHASE_FOR_STATE[state]; +} + +/** + * A type-specific lifecycle rule: a transition constraint that applies only to + * particular WorkItemType values (distinct from the generic transitions in + * work-item-state-machine.ts which apply to all types). + * + * Explicit tagged DU keyed by `kind` so each rule class is enumerable and the + * distinct rule shapes are not buried in optional fields. + */ +export const TypeSpecificRuleKind = { + NoSkipIntake: "no_skip_intake", + RequiresTriageEvidence: "requires_triage_evidence", + RequiresAssignedEngineerAndSchedule: "requires_assigned_engineer_and_schedule", +} as const; + +export type TypeSpecificRuleKind = (typeof TypeSpecificRuleKind)[keyof typeof TypeSpecificRuleKind]; + +export type TypeSpecificRule = + | { + kind: typeof TypeSpecificRuleKind.NoSkipIntake; + fromState: WorkItemState; + toState: WorkItemState; + requirement: string; + } + | { + kind: typeof TypeSpecificRuleKind.RequiresTriageEvidence; + fromState: WorkItemState; + toState: WorkItemState; + requirement: string; + } + | { + kind: typeof TypeSpecificRuleKind.RequiresAssignedEngineerAndSchedule; + fromState: WorkItemState; + toState: WorkItemState; + requirement: string; + }; + +/** + * Defect-specific lifecycle rules. These encode the North Star requirement that + * a defect cannot skip created/intake, cannot reach ready without triage + * evidence, and cannot reach in_progress without an assigned + scheduled + * engineer. They mirror assertDefectTransitionRequirements in + * work-item-state-machine.ts. + */ +const DEFECT_RULES: readonly TypeSpecificRule[] = [ + { + kind: TypeSpecificRuleKind.NoSkipIntake, + fromState: WorkItemState.Created, + toState: WorkItemState.Intake, + requirement: "a defect must start in created and pass through intake; it cannot skip created/intake", + }, + { + kind: TypeSpecificRuleKind.RequiresTriageEvidence, + fromState: WorkItemState.Triage, + toState: WorkItemState.Ready, + requirement: "a defect cannot reach ready without triage fields and required evidence", + }, + { + kind: TypeSpecificRuleKind.RequiresAssignedEngineerAndSchedule, + fromState: WorkItemState.Ready, + toState: WorkItemState.InProgress, + requirement: "a defect cannot reach in_progress without an assigned engineer and a scheduled work block", + }, +]; + +/** + * Tasks carry no additional type-specific rules beyond the generic transitions; + * they are a (possibly empty) subset of the defect rule set. + */ +const TASK_RULES: readonly TypeSpecificRule[] = []; + +/** + * The type-specific transition rules for a given WorkItemType, projected to the + * { fromState, toState, requirement } shape the gate consumer needs. Generic + * transitions (which apply to every type) live in work-item-state-machine.ts; + * this returns only the TYPE-SPECIFIC overlay. + */ +export function typeSpecificRulesFor( + type: WorkItemType, +): readonly { fromState: WorkItemState; toState: WorkItemState; requirement: string }[] { + const rules = type === WorkItemType.Defect ? DEFECT_RULES : TASK_RULES; + return rules.map((rule) => ({ + fromState: rule.fromState, + toState: rule.toState, + requirement: rule.requirement, + })); +} diff --git a/agentic-organization/packages/domain/test/state-reconciliation.test.ts b/agentic-organization/packages/domain/test/state-reconciliation.test.ts new file mode 100644 index 0000000000..a3f5090929 --- /dev/null +++ b/agentic-organization/packages/domain/test/state-reconciliation.test.ts @@ -0,0 +1,129 @@ +import { deepEqual, equal, ok } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { WorkItemState, WorkItemType } from "../src/work-item-state-machine.ts"; +import { + GateOwner, + RUN_PHASE_FOR_STATE, + STATE_RECONCILIATION, + TypeSpecificRuleKind, + reconcileState, + runPhaseForState, + typeSpecificRulesFor, +} from "../src/state-reconciliation.ts"; + +const ALL_STATES: readonly WorkItemState[] = Object.values(WorkItemState); + +describe("state reconciliation table", () => { + test("has exactly 8 rows — one per WorkItemState, no dupes, no missing", () => { + equal(STATE_RECONCILIATION.length, 8); + equal(ALL_STATES.length, 8); + + const seen = new Set(STATE_RECONCILIATION.map((row) => row.workItemState)); + // no dupes + equal(seen.size, STATE_RECONCILIATION.length); + // no missing — every WorkItemState appears + const missing = ALL_STATES.filter((state) => !seen.has(state)); + deepEqual(missing, []); + }); + + test("every row carries a valid GateOwner value", () => { + const owners = new Set(Object.values(GateOwner)); + const invalid = STATE_RECONCILIATION.filter((row) => !owners.has(row.gateOwner)); + deepEqual(invalid, []); + }); + + test("created maps to the Backlog UI column and done maps to the Done column", () => { + equal(reconcileState(WorkItemState.Created)?.uiColumn, "Backlog"); + equal(reconcileState(WorkItemState.Done)?.uiColumn, "Done"); + }); + + test("done is owned by the release manager gate", () => { + equal(reconcileState(WorkItemState.Done)?.gateOwner, GateOwner.ReleaseManager); + }); + + test("reconcileState round-trips every state to a row with that state", () => { + for (const state of ALL_STATES) { + const row = reconcileState(state); + ok(row); + equal(row.workItemState, state); + } + }); + + test("reconcileState returns the same object held in the table", () => { + for (const row of STATE_RECONCILIATION) { + equal(reconcileState(row.workItemState), row); + } + }); +}); + +describe("observe.ts run-phase binding", () => { + test("RUN_PHASE_FOR_STATE covers all 8 states", () => { + const keys = Object.keys(RUN_PHASE_FOR_STATE); + equal(keys.length, 8); + const missing = ALL_STATES.filter((state) => !Object.hasOwn(RUN_PHASE_FOR_STATE, state)); + deepEqual(missing, []); + }); + + test("runPhaseForState(Done) maps to the terminal observe phase 'completed'", () => { + equal(runPhaseForState(WorkItemState.Done), "completed"); + }); + + test("runPhaseForState matches RUN_PHASE_FOR_STATE for every state", () => { + for (const state of ALL_STATES) { + equal(runPhaseForState(state), RUN_PHASE_FOR_STATE[state]); + } + }); + + test("blocked maps to the observe 'blocked' phase and review to 'awaiting_review'", () => { + equal(runPhaseForState(WorkItemState.Blocked), "blocked"); + equal(runPhaseForState(WorkItemState.Review), "awaiting_review"); + }); +}); + +describe("type-specific lifecycle rules", () => { + test("Defect rules include the triage-evidence and assigned-engineer rules", () => { + const rules = typeSpecificRulesFor(WorkItemType.Defect); + + const triageEvidence = rules.find( + (rule) => rule.fromState === WorkItemState.Triage && rule.toState === WorkItemState.Ready, + ); + ok(triageEvidence); + ok(/evidence/i.test(triageEvidence.requirement)); + + const assignedEngineer = rules.find( + (rule) => rule.fromState === WorkItemState.Ready && rule.toState === WorkItemState.InProgress, + ); + ok(assignedEngineer); + ok(/assigned engineer/i.test(assignedEngineer.requirement)); + ok(/scheduled/i.test(assignedEngineer.requirement)); + }); + + test("Defect rules include the cannot-skip-intake rule", () => { + const rules = typeSpecificRulesFor(WorkItemType.Defect); + const noSkip = rules.find( + (rule) => rule.fromState === WorkItemState.Created && rule.toState === WorkItemState.Intake, + ); + ok(noSkip); + ok(/intake/i.test(noSkip.requirement)); + }); + + test("Task rules are a (possibly empty) subset of Defect rules", () => { + const defectRequirements = new Set( + typeSpecificRulesFor(WorkItemType.Defect).map((rule) => rule.requirement), + ); + const taskRules = typeSpecificRulesFor(WorkItemType.Task); + const notSubset = taskRules.filter((rule) => !defectRequirements.has(rule.requirement)); + deepEqual(notSubset, []); + // Tasks carry no type-specific overlay in V0. + deepEqual(taskRules, []); + }); + + test("TypeSpecificRuleKind enumerates the three defect rule classes", () => { + deepEqual(Object.values(TypeSpecificRuleKind).sort(), [ + "no_skip_intake", + "requires_assigned_engineer_and_schedule", + "requires_triage_evidence", + ]); + }); +}); diff --git a/agentic-organization/packages/frontmatter-db/src/cockroach-row-sink.ts b/agentic-organization/packages/frontmatter-db/src/cockroach-row-sink.ts new file mode 100644 index 0000000000..4076a979ec --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/cockroach-row-sink.ts @@ -0,0 +1,96 @@ +/** + * The CockroachDB-facing row sink: implements the IndexRowSink + IndexRowSource + * sync ports. CockroachDB is the DERIVED query/index projection of the git + * event log (not the source of truth), so this index is fully rebuildable by + * replaying git through syncGitToIndex. + * + * This module provides an in-memory reference implementation (used in tests and + * as a local projection cache) plus the seam for a real SQL-backed sink. The + * real sink would translate upsertRow/deleteRow into parameterized UPSERT/DELETE + * against the table emitted by schema-to-sql.emitCreateTable. That hosting is a + * // TODO below; the port contract and the change-tracking semantics are done. + */ + +import type { ZetaIdDecimal } from "./event.ts"; +import type { FrontmatterRow } from "./schema.ts"; +import type { IndexRowSink, IndexRowSource } from "./sync.ts"; + +export interface CockroachRowSink extends IndexRowSink, IndexRowSource { + /** Rows changed (upserted) since the last clearChanged(), for index->git. */ + clearChanged(): void; +} + +function rowId(row: FrontmatterRow): ZetaIdDecimal | undefined { + const raw = row.values["id"]; + return typeof raw === "string" && raw.length > 0 ? (raw as ZetaIdDecimal) : undefined; +} + +export function createInMemoryCockroachRowSink(): CockroachRowSink { + // table -> (id -> row) + const tables = new Map>(); + // table -> set of ids changed since last clearChanged() + const changed = new Map>(); + + function tableMap(table: string): Map { + let map = tables.get(table); + if (map === undefined) { + map = new Map(); + tables.set(table, map); + } + return map; + } + + function changedSet(table: string): Set { + let set = changed.get(table); + if (set === undefined) { + set = new Set(); + changed.set(table, set); + } + return set; + } + + return { + upsertRow(row: FrontmatterRow): void { + const id = rowId(row); + if (id === undefined) { + return; + } + tableMap(row.table).set(id, row); + changedSet(row.table).add(id); + }, + + deleteRow(table: string, id: ZetaIdDecimal): void { + tableMap(table).delete(id); + changedSet(table).delete(id); + }, + + currentRows(table: string): ReadonlyMap { + return tableMap(table); + }, + + changedRows(table: string): readonly FrontmatterRow[] { + const map = tableMap(table); + const rows: FrontmatterRow[] = []; + for (const id of changedSet(table)) { + const row = map.get(id); + if (row !== undefined) { + rows.push(row); + } + } + return rows; + }, + + clearChanged(): void { + changed.clear(); + }, + }; +} + +// TODO(cockroach-host): provide a SQL-backed CockroachRowSink. It should: +// - on upsertRow: run an UPSERT into the table emitted by emitCreateTable, +// mapping FrontmatterRow.values to typed columns (fk_array -> TEXT[]). +// - on deleteRow: DELETE FROM
WHERE id = $1. +// - track changedRows via a CDC feed / updated_at watermark rather than an +// in-memory set, since a real index is multi-process. +// - reuse the existing state-cockroach client + outbox transaction so +// index<->git stays consistent with the command pipeline. diff --git a/agentic-organization/packages/frontmatter-db/src/crdt-log.ts b/agentic-organization/packages/frontmatter-db/src/crdt-log.ts new file mode 100644 index 0000000000..74ab89dfa8 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/crdt-log.ts @@ -0,0 +1,48 @@ +/** + * The event log as a G-Set CRDT keyed by unique ZetaId (operator vision + * 2026-05-29: "git as event store via CRDTs in the form of unique ids that + * allow no conflict"). + * + * Merge is set-union over the id key. Because ZetaIds are globally unique: + * - two distinct events never collide -> no content conflict on git merge + * - the same event (same id) dedupes -> idempotent + * Union is commutative, associative, and idempotent: the three CRDT join laws. + * Therefore merging branches in any order converges to the same log, and + * project() over that log converges to the same state (see project.ts). + */ + +import type { FrontmatterEvent, ZetaIdDecimal } from "./event.ts"; + +export type EventLog = ReadonlyMap; + +export function emptyLog(): EventLog { + return new Map(); +} + +export function fromEvents(events: Iterable): EventLog { + const log = new Map(); + for (const event of events) { + log.set(event.id, event); + } + return log; +} + +/** Append one event (grow-only). Re-appending the same id is a no-op (idempotent). */ +export function appendEvent(log: EventLog, event: FrontmatterEvent): EventLog { + const next = new Map(log); + next.set(event.id, event); + return next; +} + +/** CRDT join: the conflict-free union of two logs. */ +export function mergeLogs(a: EventLog, b: EventLog): EventLog { + const next = new Map(a); + for (const [id, event] of b) { + next.set(id, event); + } + return next; +} + +export function logSize(log: EventLog): number { + return log.size; +} diff --git a/agentic-organization/packages/frontmatter-db/src/event-codec.ts b/agentic-organization/packages/frontmatter-db/src/event-codec.ts new file mode 100644 index 0000000000..40cf2c9b6b --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/event-codec.ts @@ -0,0 +1,111 @@ +/** + * Codec for FrontmatterEvent <-> markdown file (the git event-store records). + * + * An event file lives at events/
/.md. Its frontmatter + * carries the event metadata under reserved `$`-prefixed keys (so they cannot + * collide with field/column names) plus the upsert field values flat alongside. + * Reuses the row codec's parser/serializer so quoting/round-trip rules are + * identical (numbers, booleans, arrays, number-looking strings). + */ + +import { + parseFrontmatterDocument, + serializeFrontmatterDocument, +} from "./frontmatter-codec.ts"; +import { + EventOp, + asZetaIdDecimal, + type FrontmatterEvent, +} from "./event.ts"; +import type { FrontmatterValue } from "./schema.ts"; + +const Reserved = { + Id: "$id", + Table: "$table", + AggregateId: "$aggregate_id", + Op: "$op", + SchemaVersion: "$schema_version", +} as const; + +const RESERVED_KEYS: ReadonlySet = new Set(Object.values(Reserved)); + +export const EventCodecFeedbackReason = { + ParseFailed: "parse_failed", + MissingReserved: "missing_reserved", + BadOp: "bad_op", + BadId: "bad_id", +} as const; + +export type EventCodecFeedbackReason = + (typeof EventCodecFeedbackReason)[keyof typeof EventCodecFeedbackReason]; + +export type EventParseResult = + | { outcome: "ok"; event: FrontmatterEvent } + | { outcome: "feedback"; feedback: { reason: EventCodecFeedbackReason; message: string } }; + +const VALID_OPS: ReadonlySet = new Set(Object.values(EventOp)); + +export function serializeEvent(event: FrontmatterEvent): string { + const frontmatter: Record = {}; + frontmatter[Reserved.Id] = event.id; + frontmatter[Reserved.Table] = event.table; + frontmatter[Reserved.AggregateId] = event.aggregateId; + frontmatter[Reserved.Op] = event.op; + frontmatter[Reserved.SchemaVersion] = event.schemaVersion; + for (const [key, value] of Object.entries(event.fields)) { + // The `$`-prefix is the reserved metadata namespace. A field key inside it + // would overwrite event metadata and round-trip as a spoofed event, so + // reject it as a programmer error (parseEvent already filters these out on + // read — this keeps write symmetric with read). + if (key.startsWith("$")) { + throw new Error(`serializeEvent: field key '${key}' is in the reserved '$' namespace`); + } + frontmatter[key] = value; + } + return serializeFrontmatterDocument({ frontmatter, body: "" }); +} + +export function parseEvent(text: string): EventParseResult { + const parsed = parseFrontmatterDocument(text); + if (parsed.outcome === "feedback") { + return { outcome: "feedback", feedback: { reason: EventCodecFeedbackReason.ParseFailed, message: parsed.feedback.message } }; + } + + const fm = parsed.document.frontmatter; + const idRaw = fm[Reserved.Id]; + const tableRaw = fm[Reserved.Table]; + const aggRaw = fm[Reserved.AggregateId]; + const opRaw = fm[Reserved.Op]; + const versionRaw = fm[Reserved.SchemaVersion]; + + if (typeof idRaw !== "string" || typeof tableRaw !== "string" || typeof aggRaw !== "string" || typeof opRaw !== "string") { + return { outcome: "feedback", feedback: { reason: EventCodecFeedbackReason.MissingReserved, message: "event is missing one or more reserved metadata keys" } }; + } + if (!VALID_OPS.has(opRaw)) { + return { outcome: "feedback", feedback: { reason: EventCodecFeedbackReason.BadOp, message: `event op '${opRaw}' is not a known EventOp` } }; + } + if (!/^[0-9]+$/.test(idRaw) || !/^[0-9]+$/.test(aggRaw)) { + return { outcome: "feedback", feedback: { reason: EventCodecFeedbackReason.BadId, message: "event id and aggregate id must be base-10 ZetaIds" } }; + } + + const schemaVersion = typeof versionRaw === "number" ? versionRaw : 1; + + const fields: Record = {}; + for (const [key, value] of Object.entries(fm)) { + if (!RESERVED_KEYS.has(key)) { + fields[key] = value; + } + } + + return { + outcome: "ok", + event: { + id: asZetaIdDecimal(idRaw), + table: tableRaw, + aggregateId: asZetaIdDecimal(aggRaw), + op: opRaw as FrontmatterEvent["op"], + schemaVersion, + fields, + }, + }; +} diff --git a/agentic-organization/packages/frontmatter-db/src/event.ts b/agentic-organization/packages/frontmatter-db/src/event.ts new file mode 100644 index 0000000000..f3a75ee1c1 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/event.ts @@ -0,0 +1,65 @@ +/** + * The event-store record for git-as-event-store (operator vision 2026-05-29). + * + * Git is both the database AND the event store. Each event is one file named by + * its ZetaId (decimal). Because ZetaIds are globally unique (crypto-random + * field), two agents writing concurrently never produce the same filename, so a + * git merge of two branches is a conflict-free UNION of event files — a G-Set + * CRDT (see crdt-log.ts). Timestamp-based ordering is carried INSIDE the id: + * ZetaId embeds a 48-bit timestamp, so the log is self-ordering (see project.ts). + */ + +import type { FrontmatterValue } from "./schema.ts"; + +/** ZetaId rendered base-10 — the unique, conflict-free, time-ordered key. */ +export type ZetaIdDecimal = string & { readonly __brand: "ZetaIdDecimal" }; + +export function asZetaIdDecimal(value: string): ZetaIdDecimal { + if (!/^[0-9]+$/.test(value)) { + throw new Error(`asZetaIdDecimal: '${value}' is not a base-10 ZetaId`); + } + return value as ZetaIdDecimal; +} + +/** + * Bit layout mirrors src/Core.TypeScript/zeta-id/zeta-id.ts (timestamp field at + * offset 75, width 48). Reimplemented locally so this package stays + * self-contained (no cross-tree import); the canonical codec is authoritative. + */ +const TIMESTAMP_OFFSET = 75n; +const TIMESTAMP_WIDTH = 48n; + +/** Extract the event time (ms) carried inside the ZetaId itself. */ +export function timestampMsFromZetaId(id: ZetaIdDecimal): number { + const bits = BigInt(id); + const mask = (1n << TIMESTAMP_WIDTH) - 1n; + return Number((bits >> TIMESTAMP_OFFSET) & mask); +} + +/** Construct a decimal ZetaId whose only set field is the timestamp (test/demo aid). */ +export function zetaIdWithTimestamp(ms: number): ZetaIdDecimal { + const bits = (BigInt(ms) & ((1n << TIMESTAMP_WIDTH) - 1n)) << TIMESTAMP_OFFSET; + return bits.toString() as ZetaIdDecimal; +} + +/** Retraction-native op set (Z-set semantics: upsert adds, retract removes). */ +export const EventOp = { + Upsert: "upsert", + Retract: "retract", +} as const; + +export type EventOp = (typeof EventOp)[keyof typeof EventOp]; + +/** + * One event = one git file. `id` is the unique CRDT key + carries the timestamp. + * `aggregateId` is the row the event mutates. `fields` are the column values an + * upsert sets (ignored for retract). + */ +export type FrontmatterEvent = { + id: ZetaIdDecimal; + table: string; + aggregateId: ZetaIdDecimal; + op: EventOp; + schemaVersion: number; + fields: Readonly>; +}; diff --git a/agentic-organization/packages/frontmatter-db/src/frontmatter-codec.ts b/agentic-organization/packages/frontmatter-db/src/frontmatter-codec.ts new file mode 100644 index 0000000000..30f464105a --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/frontmatter-codec.ts @@ -0,0 +1,344 @@ +import type { FrontmatterValue, FrontmatterRow } from "./schema.ts"; + +/** + * On-disk frontmatter YAML codec for git-as-db. + * + * Covers exactly the {@link FrontmatterValue} shapes the database uses: + * string, number, boolean, and inline string arrays (`[a, b, c]`), plus a + * freeform markdown body. This is a small, self-contained serializer/parser — + * NOT a general YAML implementation. It guarantees round-trip correctness for + * the value set above: `parse(serialize(x))` deep-equals `x`. + * + * A row file is: + * ``` + * --- + * : + * ... + * --- + * + * ``` + */ + +/** A parsed frontmatter document: the key/value frontmatter plus the body. */ +export type ParsedDocument = { + readonly frontmatter: Record; + readonly body: string; +}; + +/** Result-as-DU feedback reasons emitted by {@link parseFrontmatterDocument}. */ +export const ParseFeedbackReason = { + MissingFrontmatter: "missing_frontmatter", + UnterminatedFrontmatter: "unterminated_frontmatter", + MalformedLine: "malformed_line", +} as const; +export type ParseFeedbackReason = + (typeof ParseFeedbackReason)[keyof typeof ParseFeedbackReason]; + +/** Explicit two-variant Result DU for the parser. */ +export type ParseResult = + | { readonly outcome: "ok"; readonly document: ParsedDocument } + | { + readonly outcome: "feedback"; + readonly feedback: { readonly reason: string; readonly message: string }; + }; + +const FRONTMATTER_FENCE = "---"; + +const ok = (document: ParsedDocument): ParseResult => ({ + outcome: "ok", + document, +}); + +const feedback = (reason: ParseFeedbackReason, message: string): ParseResult => ({ + outcome: "feedback", + feedback: { reason, message }, +}); + +/** + * Parse a frontmatter document into its frontmatter map and markdown body. + * + * Feedback reasons: + * - `missing_frontmatter`: the text does not open with a `---` fence line. + * - `unterminated_frontmatter`: the opening fence is never closed by a `---`. + * - `malformed_line`: a frontmatter line is not `key: value` shaped. + */ +export const parseFrontmatterDocument = (text: string): ParseResult => { + const lines = text.split("\n"); + + if (lines.length === 0 || lines[0] !== FRONTMATTER_FENCE) { + return feedback( + ParseFeedbackReason.MissingFrontmatter, + "document does not begin with a '---' frontmatter fence", + ); + } + + // Find the closing fence (first `---` line after index 0). + let closeIndex = -1; + for (let i = 1; i < lines.length; i += 1) { + if (lines[i] === FRONTMATTER_FENCE) { + closeIndex = i; + break; + } + } + + if (closeIndex === -1) { + return feedback( + ParseFeedbackReason.UnterminatedFrontmatter, + "opening '---' fence is never closed by a matching '---' line", + ); + } + + const frontmatter: Record = {}; + for (let i = 1; i < closeIndex; i += 1) { + const line = lines[i]!; + // Skip wholly blank lines inside the frontmatter block. + if (line.trim() === "") { + continue; + } + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) { + return feedback( + ParseFeedbackReason.MalformedLine, + `frontmatter line is not 'key: value' shaped: ${JSON.stringify(line)}`, + ); + } + const key = line.slice(0, colonIndex).trim(); + if (key === "") { + return feedback( + ParseFeedbackReason.MalformedLine, + `frontmatter line has an empty key: ${JSON.stringify(line)}`, + ); + } + const rawValue = line.slice(colonIndex + 1).trim(); + frontmatter[key] = parseScalar(rawValue); + } + + // Body is everything after the closing fence line. serialize writes + // "...\n---\n", so splitting on "\n" and dropping the fence line + // leaves the body verbatim. + const body = lines.slice(closeIndex + 1).join("\n"); + + return ok({ frontmatter, body }); +}; + +/** Parse a single scalar/array frontmatter value into a FrontmatterValue. */ +const parseScalar = (raw: string): FrontmatterValue => { + // Quoted string: strip quotes + unescape; always a string. + if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { + return unquote(raw); + } + + // Inline array: [a, b, c]. Elements are strings (possibly quoted). + if (raw.startsWith("[") && raw.endsWith("]")) { + return parseInlineArray(raw); + } + + // Boolean. + if (raw === "true") { + return true; + } + if (raw === "false") { + return false; + } + + // Number (integer or float; finite only). + if (looksLikeNumber(raw)) { + return Number(raw); + } + + // Bare string. + return raw; +}; + +/** Parse an inline array body `[a, b, c]` into a readonly string[]. */ +const parseInlineArray = (raw: string): readonly string[] => { + const inner = raw.slice(1, -1).trim(); + if (inner === "") { + return []; + } + // Split on commas that are not inside quotes. + const elements: string[] = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]!; + if (inQuotes) { + if (ch === "\\" && i + 1 < inner.length) { + // Preserve the escape sequence so unquote can resolve it later. + current += ch + inner[i + 1]!; + i += 1; + continue; + } + if (ch === '"') { + inQuotes = false; + current += ch; + continue; + } + current += ch; + continue; + } + if (ch === '"') { + inQuotes = true; + current += ch; + continue; + } + if (ch === ",") { + elements.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + elements.push(current.trim()); + + return elements.map((el) => { + if (el.length >= 2 && el.startsWith('"') && el.endsWith('"')) { + return unquote(el); + } + return el; + }); +}; + +/** Strip surrounding double quotes and unescape `\\` and `\"`. */ +const unquote = (raw: string): string => { + const inner = raw.slice(1, -1); + let out = ""; + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]!; + if (ch === "\\" && i + 1 < inner.length) { + const next = inner[i + 1]!; + if (next === "\\" || next === '"') { + out += next; + i += 1; + continue; + } + } + out += ch; + } + return out; +}; + +/** True if `raw` is a finite JSON-style number literal. */ +const looksLikeNumber = (raw: string): boolean => { + if (raw === "") { + return false; + } + // Reject leading/trailing whitespace already trimmed by caller; here be strict. + if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(raw)) { + return false; + } + return Number.isFinite(Number(raw)); +}; + +/** + * Serialize a parsed document back to its on-disk form. + * + * Key order is the insertion order of the frontmatter object (deterministic). + * Arrays render as `[a, b, c]`. Strings are quoted only when necessary so they + * round-trip as strings: when they contain `:` or `#`, have leading/trailing + * whitespace, are empty, or would otherwise parse back as a number, boolean, + * or array. + */ +export const serializeFrontmatterDocument = ( + document: ParsedDocument, +): string => { + const lines: string[] = [FRONTMATTER_FENCE]; + for (const key of Object.keys(document.frontmatter)) { + const value = document.frontmatter[key]!; + lines.push(`${key}: ${serializeValue(value)}`); + } + lines.push(FRONTMATTER_FENCE); + return `${lines.join("\n")}\n${document.body}`; +}; + +/** Serialize a single FrontmatterValue. */ +const serializeValue = (value: FrontmatterValue): string => { + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + if (typeof value === "number") { + return String(value); + } + if (Array.isArray(value)) { + return `[${value.map((el) => serializeArrayElement(el)).join(", ")}]`; + } + // string + return serializeString(value as string); +}; + +/** Serialize an array element: quote when it would not round-trip as a string. */ +const serializeArrayElement = (el: string): string => { + // Inside an array, an element must be quoted if it contains a comma, a + // bracket, leading/trailing whitespace, or would otherwise be ambiguous. + if ( + el === "" || + el !== el.trim() || + el.includes(",") || + el.includes("[") || + el.includes("]") || + el.includes('"') || + el.includes(":") || + el.includes("#") + ) { + return quote(el); + } + return el; +}; + +/** Serialize a top-level scalar string, quoting only when necessary. */ +const serializeString = (str: string): string => { + if (needsQuoting(str)) { + return quote(str); + } + return str; +}; + +/** True if a bare string would not round-trip as itself. */ +const needsQuoting = (str: string): boolean => { + if (str === "") { + return true; + } + if (str !== str.trim()) { + return true; // leading/trailing whitespace + } + if (str.includes(":") || str.includes("#")) { + return true; + } + if (str.startsWith('"')) { + return true; // would be mistaken for a quoted string + } + if (str.startsWith("[") && str.endsWith("]")) { + return true; // would be mistaken for an array + } + if (str === "true" || str === "false") { + return true; // would be mistaken for a boolean + } + if (looksLikeNumber(str)) { + return true; // would be mistaken for a number + } + return false; +}; + +/** Double-quote a string, escaping `\\` and `"`. */ +const quote = (str: string): string => { + const escaped = str.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `"${escaped}"`; +}; + +/** Build a ParsedDocument from a row's values + a markdown body. */ +export const rowToDocument = ( + row: FrontmatterRow, + body: string, +): ParsedDocument => ({ + frontmatter: { ...row.values }, + body, +}); + +/** Build a FrontmatterRow for `table` from a parsed document's frontmatter. */ +export const documentToRow = ( + document: ParsedDocument, + table: string, +): FrontmatterRow => ({ + table, + values: { ...document.frontmatter }, +}); diff --git a/agentic-organization/packages/frontmatter-db/src/git-fs-adapter.ts b/agentic-organization/packages/frontmatter-db/src/git-fs-adapter.ts new file mode 100644 index 0000000000..f67155a94e --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/git-fs-adapter.ts @@ -0,0 +1,108 @@ +/** + * Filesystem-backed Git adapter implementing the sync ports (GitEventSource + + * GitEventSink) over a directory of event files at: + * /events/
/.md + * + * The sync ports are synchronous (readEvents / appendEvent), but filesystem I/O + * is async. The adapter resolves that by: + * 1. load(table) -> async: read every event file into an in-memory snapshot + * 2. ports -> sync: serve reads from the snapshot, buffer appends + * 3. flush() -> async: write buffered appended events to disk + * + * This keeps the tested pure sync core (syncGitToIndex / syncIndexToGit) + * untouched while the actual git/fs work happens at the load/flush edges. + * Committing the written files to git is the caller's concern (or a // TODO + * git-commit hook); this adapter owns the working-tree files only. + */ + +import { parseEvent, serializeEvent } from "./event-codec.ts"; +import type { FrontmatterEvent, ZetaIdDecimal } from "./event.ts"; +import type { GitEventSink, GitEventSource } from "./sync.ts"; + +/** Minimal async filesystem port so the adapter is testable without node:fs. */ +export interface EventFileSystem { + listEventFiles(table: string): Promise; + readEventFile(path: string): Promise; + writeEventFile(path: string, contents: string): Promise; +} + +export const GitFsAdapterFeedbackReason = { + EventFileUnparseable: "event_file_unparseable", +} as const; + +export type GitFsAdapterFeedbackReason = + (typeof GitFsAdapterFeedbackReason)[keyof typeof GitFsAdapterFeedbackReason]; + +export type GitFsLoadResult = + | { outcome: "ok"; loaded: number } + | { outcome: "feedback"; feedback: { reason: GitFsAdapterFeedbackReason; message: string; path: string } }; + +export interface GitFsAdapter extends GitEventSource, GitEventSink { + load(table: string): Promise; + flush(): Promise<{ written: number }>; + pendingCount(): number; +} + +export function eventFilePath(table: string, eventId: ZetaIdDecimal): string { + return `events/${table}/${eventId}.md`; +} + +export function createGitFsAdapter(fs: EventFileSystem): GitFsAdapter { + // table -> (eventId -> event), the loaded snapshot + const snapshot = new Map>(); + // buffered appends not yet flushed to disk + const pending: FrontmatterEvent[] = []; + + function tableMap(table: string): Map { + let map = snapshot.get(table); + if (map === undefined) { + map = new Map(); + snapshot.set(table, map); + } + return map; + } + + return { + async load(table: string): Promise { + const paths = await fs.listEventFiles(table); + const map = tableMap(table); + let loaded = 0; + for (const path of paths) { + const contents = await fs.readEventFile(path); + const parsed = parseEvent(contents); + if (parsed.outcome === "feedback") { + return { + outcome: "feedback", + feedback: { reason: GitFsAdapterFeedbackReason.EventFileUnparseable, message: parsed.feedback.message, path }, + }; + } + map.set(parsed.event.id, parsed.event); + loaded += 1; + } + return { outcome: "ok", loaded }; + }, + + readEvents(table: string): readonly FrontmatterEvent[] { + return [...tableMap(table).values()]; + }, + + appendEvent(event: FrontmatterEvent): void { + tableMap(event.table).set(event.id, event); + pending.push(event); + }, + + async flush(): Promise<{ written: number }> { + let written = 0; + for (const event of pending) { + await fs.writeEventFile(eventFilePath(event.table, event.id), serializeEvent(event)); + written += 1; + } + pending.length = 0; + return { written }; + }, + + pendingCount(): number { + return pending.length; + }, + }; +} diff --git a/agentic-organization/packages/frontmatter-db/src/index.ts b/agentic-organization/packages/frontmatter-db/src/index.ts new file mode 100644 index 0000000000..cb9c8b7fc2 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/index.ts @@ -0,0 +1,81 @@ +export { + ColumnType, + edgeColumns, + findColumn, + primaryKeyColumn, + type ColumnDef, + type FrontmatterRow, + type FrontmatterValue, + type TableSchema, +} from "./schema.ts"; +export { + EventOp, + asZetaIdDecimal, + timestampMsFromZetaId, + zetaIdWithTimestamp, + type FrontmatterEvent, + type ZetaIdDecimal, +} from "./event.ts"; +export { + appendEvent, + emptyLog, + fromEvents, + logSize, + mergeLogs, + type EventLog, +} from "./crdt-log.ts"; +export { project, type Projection } from "./project.ts"; +export { parseCreateTable, type SchemaParseResult } from "./sql-to-schema.ts"; +export { validateRow, type RowViolation, type ValidationResult } from "./validate.ts"; +export { edgesOf, neighbors, type Edge } from "./traverse.ts"; +export { + ParseFeedbackReason, + documentToRow, + parseFrontmatterDocument, + rowToDocument, + serializeFrontmatterDocument, + type ParsedDocument, + type ParseResult, +} from "./frontmatter-codec.ts"; +export { emitCreateTable } from "./schema-to-sql.ts"; +export { + SyncDirection, + syncGitToIndex, + syncIndexToGit, + type GitEventSink, + type GitEventSource, + type GitToIndexResult, + type IdGenerator, + type IndexRowSink, + type IndexRowSource, + type IndexToGitResult, + type SyncFeedback, +} from "./sync.ts"; +export { + EventCodecFeedbackReason, + parseEvent, + serializeEvent, + type EventParseResult, +} from "./event-codec.ts"; +export { + GitFsAdapterFeedbackReason, + createGitFsAdapter, + eventFilePath, + type EventFileSystem, + type GitFsAdapter, + type GitFsLoadResult, +} from "./git-fs-adapter.ts"; +export { + createInMemoryCockroachRowSink, + type CockroachRowSink, +} from "./cockroach-row-sink.ts"; +export { + ReconcileCycleStatus, + ReconcileLane, + createReconcileWorker, + type CreateReconcileWorkerInput, + type ReconcileCycleResult, + type ReconcileLaneFailure, + type ReconcileTableSummary, + type ReconcileWorker, +} from "./reconcile-worker.ts"; diff --git a/agentic-organization/packages/frontmatter-db/src/project.ts b/agentic-organization/packages/frontmatter-db/src/project.ts new file mode 100644 index 0000000000..52343d189a --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/project.ts @@ -0,0 +1,56 @@ +/** + * Project the event log into materialized frontmatter rows (the current state). + * + * State = a deterministic fold over the events of a table, ordered by the + * timestamp carried inside each ZetaId, with the id as a stable tiebreaker. + * Because the order is derived from the ids (not from insertion/merge order), + * project(merge(a, b)) === project(merge(b, a)) — the CRDT convergence property. + * + * Semantics (retraction-native, Z-set style): + * - upsert : merge fields into the aggregate (last-writer-wins by timestamp) + * - retract: tombstone the aggregate (drops out of the projection) + * A later upsert after a retract revives the aggregate (op order is timestamp). + */ + +import type { EventLog } from "./crdt-log.ts"; +import { EventOp, timestampMsFromZetaId, type ZetaIdDecimal } from "./event.ts"; +import type { FrontmatterRow, FrontmatterValue } from "./schema.ts"; + +export type Projection = ReadonlyMap; + +type Accumulator = { values: Record; live: boolean }; + +export function project(log: EventLog, table: string): Projection { + const ordered = [...log.values()] + .filter((event) => event.table === table) + .sort((x, y) => { + const tx = timestampMsFromZetaId(x.id); + const ty = timestampMsFromZetaId(y.id); + if (tx !== ty) { + return tx - ty; + } + return x.id < y.id ? -1 : x.id > y.id ? 1 : 0; + }); + + const accumulators = new Map(); + for (const event of ordered) { + const current = accumulators.get(event.aggregateId) ?? { values: {}, live: false }; + if (event.op === EventOp.Retract) { + current.live = false; + } else { + for (const [key, value] of Object.entries(event.fields)) { + current.values[key] = value; + } + current.live = true; + } + accumulators.set(event.aggregateId, current); + } + + const rows = new Map(); + for (const [aggregateId, accumulator] of accumulators) { + if (accumulator.live) { + rows.set(aggregateId, { table, values: accumulator.values }); + } + } + return rows; +} diff --git a/agentic-organization/packages/frontmatter-db/src/reconcile-worker.ts b/agentic-organization/packages/frontmatter-db/src/reconcile-worker.ts new file mode 100644 index 0000000000..95207de04e --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/reconcile-worker.ts @@ -0,0 +1,137 @@ +/** + * The periodic git<->cockroach reconcile worker. + * + * Mirrors the OrganizationWorkerHost runOnce() shape (packages/workers): a + * single async cycle over injected ports that returns an explicit status DU and + * lane-tagged failures rather than throwing. One cycle per table set: + * 1. load the git event log into the adapter snapshot + * 2. syncIndexToGit -> emit events for index rows changed locally since the + * last cycle, appending them into the in-memory log FIRST so they are not + * seen as absent-from-git (which git->index would tombstone-delete) + * 3. syncGitToIndex -> rebuild/refresh the cockroach index from the unified + * log (now including this cycle's local emissions); git stays canonical + * 4. flush the adapter -> write the new event files to the working tree + * Ordering matters: index->git must precede git->index so a row written only to + * the index this cycle becomes an event BEFORE the projection-vs-index diff runs, + * otherwise git->index would delete it as a tombstone. + */ + +import type { CockroachRowSink } from "./cockroach-row-sink.ts"; +import type { GitFsAdapter } from "./git-fs-adapter.ts"; +import type { IdGenerator } from "./sync.ts"; +import { syncGitToIndex, syncIndexToGit } from "./sync.ts"; + +export const ReconcileCycleStatus = { + Idle: "idle", + Worked: "worked", + Degraded: "degraded", +} as const; +export type ReconcileCycleStatus = (typeof ReconcileCycleStatus)[keyof typeof ReconcileCycleStatus]; + +export const ReconcileLane = { + Load: "load", + GitToIndex: "git_to_index", + IndexToGit: "index_to_git", + Flush: "flush", +} as const; +export type ReconcileLane = (typeof ReconcileLane)[keyof typeof ReconcileLane]; + +export type ReconcileLaneFailure = { lane: ReconcileLane; table: string; message: string }; + +export type ReconcileTableSummary = { + table: string; + upserted: number; + deleted: number; + emitted: number; +}; + +export type ReconcileCycleResult = { + status: ReconcileCycleStatus; + tables: readonly ReconcileTableSummary[]; + written: number; + failures: readonly ReconcileLaneFailure[]; +}; + +export type ReconcileWorker = { + runOnce: () => Promise; +}; + +export type CreateReconcileWorkerInput = { + tables: readonly string[]; + gitAdapter: GitFsAdapter; + indexSink: CockroachRowSink; + ids: IdGenerator; +}; + +export function createReconcileWorker(input: CreateReconcileWorkerInput): ReconcileWorker { + return { + runOnce: async (): Promise => { + const failures: ReconcileLaneFailure[] = []; + const summaries: ReconcileTableSummary[] = []; + + for (const table of input.tables) { + const summary: ReconcileTableSummary = { table, upserted: 0, deleted: 0, emitted: 0 }; + + const loaded = await input.gitAdapter.load(table); + if (loaded.outcome === "feedback") { + failures.push({ lane: ReconcileLane.Load, table, message: loaded.feedback.message }); + summaries.push(summary); + continue; + } + + // index->git FIRST: local index writes become events in the in-memory + // log before the projection diff, so they are not tombstone-deleted. + const toGit = syncIndexToGit(table, { source: input.indexSink, sink: input.gitAdapter, ids: input.ids }); + if (toGit.outcome === "feedback") { + failures.push({ lane: ReconcileLane.IndexToGit, table, message: toGit.feedback.message }); + } else { + summary.emitted = toGit.emitted; + } + + // git->index SECOND: project the unified log (git + this cycle's + // emissions) back into the index; git remains canonical. + const toIndex = syncGitToIndex(table, { source: input.gitAdapter, sink: input.indexSink }); + if (toIndex.outcome === "feedback") { + failures.push({ lane: ReconcileLane.GitToIndex, table, message: toIndex.feedback.message }); + } else { + summary.upserted = toIndex.applied.upserted; + summary.deleted = toIndex.applied.deleted; + } + + summaries.push(summary); + } + + // index->git emissions are now buffered in the adapter; persist them once. + let written = 0; + try { + const flushed = await input.gitAdapter.flush(); + written = flushed.written; + } catch (error) { + failures.push({ lane: ReconcileLane.Flush, table: "*", message: error instanceof Error ? error.message : String(error) }); + } + + // Rows just emitted to git are now durable; clear the changed-set so the + // next cycle does not re-emit them. + input.indexSink.clearChanged(); + + return { + status: resolveStatus(summaries, written, failures), + tables: summaries, + written, + failures, + }; + }, + }; +} + +function resolveStatus( + tables: readonly ReconcileTableSummary[], + written: number, + failures: readonly ReconcileLaneFailure[], +): ReconcileCycleStatus { + if (failures.length > 0) { + return ReconcileCycleStatus.Degraded; + } + const moved = tables.some((t) => t.upserted > 0 || t.deleted > 0 || t.emitted > 0) || written > 0; + return moved ? ReconcileCycleStatus.Worked : ReconcileCycleStatus.Idle; +} diff --git a/agentic-organization/packages/frontmatter-db/src/schema-to-sql.ts b/agentic-organization/packages/frontmatter-db/src/schema-to-sql.ts new file mode 100644 index 0000000000..f6c567d358 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/schema-to-sql.ts @@ -0,0 +1,52 @@ +/** + * Emit a CREATE TABLE statement from a {@link TableSchema}. + * + * Inverse of {@link parseCreateTable} (sql-to-schema.ts). The column mapping + * mirrors that parser's recognised DDL subset: + * - zeta_id pk -> ` TEXT PRIMARY KEY` + * - enum -> ` TEXT[ NOT NULL] CHECK ( IN ('a', 'b'))` + * - fk -> ` TEXT[ NOT NULL] REFERENCES (id)` + * - fk_array -> ` TEXT[] REFERENCES (id)` + * - text -> ` TEXT[ NOT NULL]` + * - int -> ` INTEGER[ NOT NULL]` + * - bool -> ` BOOLEAN[ NOT NULL]` + * - timestamp -> ` TIMESTAMPTZ[ NOT NULL]` + */ +import { ColumnType, type TableSchema, type ColumnDef } from "./schema.ts"; + +function notNull(required: boolean): string { + return required ? " NOT NULL" : ""; +} + +function emitColumn(col: ColumnDef): string { + switch (col.type) { + case ColumnType.ZetaId: + // pk flag drives the PRIMARY KEY clause; a non-pk zeta_id degrades to TEXT. + return col.pk + ? `${col.name} TEXT PRIMARY KEY` + : `${col.name} TEXT`; + case ColumnType.Enum: { + // escape single quotes (' -> '') so enum literals can't break the SQL + // string or open an injection vector when schemas are author-supplied. + const values = col.values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", "); + return `${col.name} TEXT${notNull(col.required)} CHECK (${col.name} IN (${values}))`; + } + case ColumnType.Fk: + return `${col.name} TEXT${notNull(col.required)} REFERENCES ${col.references}(id)`; + case ColumnType.FkArray: + return `${col.name} TEXT[] REFERENCES ${col.references}(id)`; + case ColumnType.Text: + return `${col.name} TEXT${notNull(col.required)}`; + case ColumnType.Int: + return `${col.name} INTEGER${notNull(col.required)}`; + case ColumnType.Bool: + return `${col.name} BOOLEAN${notNull(col.required)}`; + case ColumnType.Timestamp: + return `${col.name} TIMESTAMPTZ${notNull(col.required)}`; + } +} + +export function emitCreateTable(schema: TableSchema): string { + const lines = schema.columns.map((c) => ` ${emitColumn(c)}`); + return `CREATE TABLE ${schema.table} (\n${lines.join(",\n")}\n);`; +} diff --git a/agentic-organization/packages/frontmatter-db/src/schema.ts b/agentic-organization/packages/frontmatter-db/src/schema.ts new file mode 100644 index 0000000000..889a39237f --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/schema.ts @@ -0,0 +1,67 @@ +/** + * frontmatter-db: git-as-database where a markdown file IS a row, its YAML + * frontmatter holds the typed columns + foreign-key edges, and the schema + * itself is expressed as frontmatter (operator vision 2026-05-29). + * + * Path convention:
/.md (ZetaId decimal = the index key). + * Frontmatter = columns. Body = the freeform document (e.g. a task description). + * CockroachDB is a *derived* query/index projection of these rows, not the + * source of truth — see GIT_COCKROACH_SYNC_AND_ZETAID_ADDRESSING.md. + * + * Every column kind is an explicit discriminated-union variant so payload-bearing + * kinds (enum values, fk target) are never buried in optional fields + * (repo rule: IMPLICIT-NOT-EXPLICIT in DUs is class error). + */ + +export const ColumnType = { + ZetaId: "zeta_id", + Text: "text", + Int: "int", + Bool: "bool", + Timestamp: "timestamp", + Enum: "enum", + Fk: "fk", + FkArray: "fk_array", +} as const; + +export type ColumnType = (typeof ColumnType)[keyof typeof ColumnType]; + +export type ColumnDef = + | { name: string; type: typeof ColumnType.ZetaId; pk: boolean; required: boolean } + | { name: string; type: typeof ColumnType.Text; required: boolean } + | { name: string; type: typeof ColumnType.Int; required: boolean } + | { name: string; type: typeof ColumnType.Bool; required: boolean } + | { name: string; type: typeof ColumnType.Timestamp; required: boolean } + | { name: string; type: typeof ColumnType.Enum; required: boolean; values: readonly string[] } + | { name: string; type: typeof ColumnType.Fk; required: boolean; references: string } + | { name: string; type: typeof ColumnType.FkArray; required: boolean; references: string }; + +export type TableSchema = { + table: string; + schemaVersion: number; + columns: readonly ColumnDef[]; +}; + +/** A frontmatter value: scalar or an inline string array (fk arrays / lists). */ +export type FrontmatterValue = string | number | boolean | readonly string[]; + +/** A row = the parsed frontmatter of one markdown file. */ +export type FrontmatterRow = { + table: string; + values: Readonly>; +}; + +export function findColumn(schema: TableSchema, name: string): ColumnDef | undefined { + return schema.columns.find((column) => column.name === name); +} + +export function primaryKeyColumn(schema: TableSchema): ColumnDef | undefined { + return schema.columns.find((column) => column.type === ColumnType.ZetaId && column.pk); +} + +/** Foreign-key columns are the graph edges of the git-as-db. */ +export function edgeColumns(schema: TableSchema): readonly ColumnDef[] { + return schema.columns.filter( + (column) => column.type === ColumnType.Fk || column.type === ColumnType.FkArray, + ); +} diff --git a/agentic-organization/packages/frontmatter-db/src/sql-to-schema.ts b/agentic-organization/packages/frontmatter-db/src/sql-to-schema.ts new file mode 100644 index 0000000000..018cdc8360 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/sql-to-schema.ts @@ -0,0 +1,162 @@ +/** + * Convert a SQL `CREATE TABLE` into a frontmatter TableSchema (operator vision: + * "easily convert sql --> frontmatter schema"). One schema definition then + * governs the git rows, the events, the projection, and the Cockroach index. + * + * Supported subset (the common case): column name, base type, PRIMARY KEY, + * NOT NULL, CHECK (col IN ('a','b')) -> enum, REFERENCES tbl(col) -> fk, + * and TYPE[] -> fk_array when combined with REFERENCES. Table-level constraint + * lines are skipped. Anything unrecognized returns explicit feedback rather + * than guessing (Result as a two-variant DU). + */ + +import { ColumnType, type ColumnDef, type TableSchema } from "./schema.ts"; + +export type SchemaParseResult = + | { outcome: "schema"; schema: TableSchema } + | { outcome: "feedback"; feedback: { reason: string; message: string } }; + +const RESERVED_LEADERS = new Set(["PRIMARY", "FOREIGN", "CONSTRAINT", "UNIQUE", "CHECK"]); + +export function parseCreateTable(sql: string, schemaVersion = 1): SchemaParseResult { + const match = /create\s+table\s+(?:if\s+not\s+exists\s+)?["']?([A-Za-z_][A-Za-z0-9_.]*)["']?\s*\(([\s\S]*)\)\s*;?\s*$/i.exec( + sql.trim(), + ); + if (match === null) { + return { outcome: "feedback", feedback: { reason: "no_create_table", message: "input is not a CREATE TABLE statement" } }; + } + + const table = stripSchemaQualifier(match[1]!); + const body = match[2]!; + const lines = splitTopLevelCommas(body); + + const columns: ColumnDef[] = []; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line.length === 0) { + continue; + } + const leader = line.split(/\s+/)[0]!.toUpperCase(); + if (RESERVED_LEADERS.has(leader)) { + continue; + } + const column = parseColumn(line); + if (column.outcome === "feedback") { + return column; + } + columns.push(column.column); + } + + if (columns.length === 0) { + return { outcome: "feedback", feedback: { reason: "no_columns", message: `table ${table} has no parseable columns` } }; + } + + return { outcome: "schema", schema: { table, schemaVersion, columns } }; +} + +type ColumnParse = + | { outcome: "column"; column: ColumnDef } + | { outcome: "feedback"; feedback: { reason: string; message: string } }; + +function parseColumn(line: string): ColumnParse { + const tokens = line.split(/\s+/); + const name = unquote(tokens[0]!); + const rawType = (tokens[1] ?? "").toUpperCase(); + if (rawType.length === 0) { + return { outcome: "feedback", feedback: { reason: "missing_type", message: `column '${name}' has no type` } }; + } + + const upper = line.toUpperCase(); + const isArray = rawType.endsWith("[]") || /\[\s*\]/.test(upper); + const required = /\bNOT\s+NULL\b/.test(upper) || /\bPRIMARY\s+KEY\b/.test(upper); + const isPk = /\bPRIMARY\s+KEY\b/.test(upper); + + const references = parseReferences(line); + if (references !== undefined) { + return { + outcome: "column", + column: isArray + ? { name, type: ColumnType.FkArray, required, references } + : { name, type: ColumnType.Fk, required, references }, + }; + } + + const enumValues = parseCheckInValues(line, name); + if (enumValues !== undefined) { + return { outcome: "column", column: { name, type: ColumnType.Enum, required, values: enumValues } }; + } + + if (isPk) { + return { outcome: "column", column: { name, type: ColumnType.ZetaId, pk: true, required: true } }; + } + + return { outcome: "column", column: { name, type: mapScalarType(rawType), required } }; +} + +function mapScalarType( + rawType: string, +): typeof ColumnType.Text | typeof ColumnType.Int | typeof ColumnType.Bool | typeof ColumnType.Timestamp { + const base = rawType.replace(/\[\s*\]$/, "").replace(/\(.*$/, ""); + if (/^(TIMESTAMP|TIMESTAMPTZ|DATE|DATETIME)$/.test(base)) { + return ColumnType.Timestamp; + } + if (/^(INT|INTEGER|BIGINT|SMALLINT|SERIAL|BIGSERIAL)$/.test(base)) { + return ColumnType.Int; + } + if (/^(BOOL|BOOLEAN)$/.test(base)) { + return ColumnType.Bool; + } + return ColumnType.Text; +} + +function parseReferences(line: string): string | undefined { + const m = /\breferences\s+["']?([A-Za-z_][A-Za-z0-9_.]*)["']?/i.exec(line); + if (m === null) { + return undefined; + } + return stripSchemaQualifier(m[1]!); +} + +function parseCheckInValues(line: string, columnName: string): readonly string[] | undefined { + const m = new RegExp(`check\\s*\\(\\s*["']?${columnName}["']?\\s+in\\s*\\(([^)]*)\\)`, "i").exec(line); + if (m === null) { + return undefined; + } + const values = m[1]! + .split(",") + .map((part) => unquote(part.trim())) + .filter((part) => part.length > 0); + return values.length > 0 ? values : undefined; +} + +function splitTopLevelCommas(body: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of body) { + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + } + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current.trim().length > 0) { + parts.push(current); + } + return parts; +} + +function unquote(value: string): string { + return value.replace(/^["'`]/, "").replace(/["'`]$/, ""); +} + +function stripSchemaQualifier(value: string): string { + const parts = value.split("."); + return parts[parts.length - 1]!; +} diff --git a/agentic-organization/packages/frontmatter-db/src/sync.ts b/agentic-organization/packages/frontmatter-db/src/sync.ts new file mode 100644 index 0000000000..0f7a7cbe72 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/sync.ts @@ -0,0 +1,154 @@ +/** + * Generic git<->cockroach (event-log <-> index) sync worker. + * + * Pure core over injected ports. No messaging/state package is imported — the + * caller supplies the git event source/sink and the index row sink/source. + * + * Two directions, each an explicit DU: + * - {@link syncGitToIndex}: replay the git event log, project to rows, upsert + * into the index, and tombstone-delete index rows no longer in the + * projection. + * - {@link syncIndexToGit}: emit Upsert events into the git log for each + * changed index row. + */ +import { fromEvents } from "./crdt-log.ts"; +import { project } from "./project.ts"; +import { + EventOp, + asZetaIdDecimal, + type FrontmatterEvent, + type ZetaIdDecimal, +} from "./event.ts"; +import type { FrontmatterRow } from "./schema.ts"; + +export const SyncDirection = { + GitToIndex: "git_to_index", + IndexToGit: "index_to_git", +} as const; +export type SyncDirection = (typeof SyncDirection)[keyof typeof SyncDirection]; + +/** Reads the immutable event log out of git for a table. */ +export interface GitEventSource { + readEvents(table: string): readonly FrontmatterEvent[]; +} + +/** Mutable index sink — receives projected rows + tombstone deletes. */ +export interface IndexRowSink { + upsertRow(row: FrontmatterRow): void; + deleteRow(table: string, id: ZetaIdDecimal): void; + currentRows(table: string): ReadonlyMap; +} + +/** Reads changed rows out of the index. */ +export interface IndexRowSource { + changedRows(table: string): readonly FrontmatterRow[]; +} + +/** Appends events into the git log. */ +export interface GitEventSink { + appendEvent(event: FrontmatterEvent): void; +} + +/** Allocates fresh event ids. */ +export interface IdGenerator { + nextEventId(): ZetaIdDecimal; +} + +export type SyncFeedback = { + readonly reason: string; + readonly message: string; +}; + +export type GitToIndexResult = + | { + readonly outcome: "ok"; + readonly direction: typeof SyncDirection.GitToIndex; + readonly applied: { readonly upserted: number; readonly deleted: number }; + } + | { readonly outcome: "feedback"; readonly feedback: SyncFeedback }; + +export type IndexToGitResult = + | { + readonly outcome: "ok"; + readonly direction: typeof SyncDirection.IndexToGit; + readonly emitted: number; + } + | { readonly outcome: "feedback"; readonly feedback: SyncFeedback }; + +/** + * Git -> index: build the log, project rows, upsert each, and tombstone-delete + * any index row whose id is no longer in the projection. + */ +export function syncGitToIndex( + table: string, + deps: { readonly source: GitEventSource; readonly sink: IndexRowSink }, +): GitToIndexResult { + const log = fromEvents(deps.source.readEvents(table)); + const projection = project(log, table); + + let upserted = 0; + for (const row of projection.values()) { + deps.sink.upsertRow(row); + upserted += 1; + } + + let deleted = 0; + const existing = deps.sink.currentRows(table); + for (const id of existing.keys()) { + if (!projection.has(id)) { + deps.sink.deleteRow(table, id); + deleted += 1; + } + } + + return { + outcome: "ok", + direction: SyncDirection.GitToIndex, + applied: { upserted, deleted }, + }; +} + +/** + * Index -> git: emit an Upsert event for each changed row. A row without a + * non-empty `id` value yields a feedback outcome (`row_missing_id`). + */ +export function syncIndexToGit( + table: string, + deps: { + readonly source: IndexRowSource; + readonly sink: GitEventSink; + readonly ids: IdGenerator; + }, +): IndexToGitResult { + const changed = deps.source.changedRows(table); + + let emitted = 0; + for (const row of changed) { + const rawId = row.values["id"]; + if (typeof rawId !== "string" || rawId.length === 0) { + return { + outcome: "feedback", + feedback: { + reason: "row_missing_id", + message: `changed row in table "${table}" has no usable id value`, + }, + }; + } + const event: FrontmatterEvent = { + id: deps.ids.nextEventId(), + table, + aggregateId: asZetaIdDecimal(rawId), + op: EventOp.Upsert, + schemaVersion: 1, + fields: row.values, + }; + deps.sink.appendEvent(event); + emitted += 1; + } + + return { + outcome: "ok", + direction: SyncDirection.IndexToGit, + emitted, + }; +} diff --git a/agentic-organization/packages/frontmatter-db/src/traverse.ts b/agentic-organization/packages/frontmatter-db/src/traverse.ts new file mode 100644 index 0000000000..7c9da03cec --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/traverse.ts @@ -0,0 +1,53 @@ +/** + * Graph traversal over git-as-db: foreign-key columns in frontmatter are the + * edges (operator vision: "frontmatter can be graph traversed"). An `fk` column + * is one edge; an `fk_array` column is many. Traversal resolves those ids + * against a store of rows keyed by ZetaIdDecimal. + */ + +import type { ZetaIdDecimal } from "./event.ts"; +import { ColumnType, edgeColumns, type FrontmatterRow, type TableSchema } from "./schema.ts"; + +export type Edge = { + column: string; + references: string; + toIds: readonly ZetaIdDecimal[]; +}; + +/** Every outgoing edge from a row, derived from its fk / fk_array columns. */ +export function edgesOf(row: FrontmatterRow, schema: TableSchema): readonly Edge[] { + const edges: Edge[] = []; + for (const column of edgeColumns(schema)) { + const value = row.values[column.name]; + if (value === undefined) { + continue; + } + if (column.type === ColumnType.Fk && typeof value === "string" && value.length > 0) { + edges.push({ column: column.name, references: column.references, toIds: [value as ZetaIdDecimal] }); + } else if (column.type === ColumnType.FkArray && Array.isArray(value)) { + edges.push({ column: column.name, references: column.references, toIds: value as readonly ZetaIdDecimal[] }); + } + } + return edges; +} + +/** Resolve the rows reachable from `row` via one named edge column. */ +export function neighbors( + row: FrontmatterRow, + schema: TableSchema, + column: string, + store: ReadonlyMap, +): readonly FrontmatterRow[] { + const edge = edgesOf(row, schema).find((candidate) => candidate.column === column); + if (edge === undefined) { + return []; + } + const resolved: FrontmatterRow[] = []; + for (const id of edge.toIds) { + const target = store.get(id); + if (target !== undefined) { + resolved.push(target); + } + } + return resolved; +} diff --git a/agentic-organization/packages/frontmatter-db/src/validate.ts b/agentic-organization/packages/frontmatter-db/src/validate.ts new file mode 100644 index 0000000000..2398abb32d --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/src/validate.ts @@ -0,0 +1,99 @@ +/** + * Validate a frontmatter row against its TableSchema. The schema (derived from + * SQL or authored directly) is the contract; rows that violate it are rejected + * with explicit, listed violations (never a thrown exception). + */ + +import { + ColumnType, + findColumn, + type ColumnDef, + type FrontmatterRow, + type FrontmatterValue, + type TableSchema, +} from "./schema.ts"; + +export type RowViolation = { column: string; reason: string; message: string }; + +export type ValidationResult = + | { outcome: "valid" } + | { outcome: "invalid"; violations: readonly RowViolation[] }; + +export function validateRow(row: FrontmatterRow, schema: TableSchema): ValidationResult { + const violations: RowViolation[] = []; + + for (const column of schema.columns) { + const value = row.values[column.name]; + const missing = value === undefined || value === ""; + if (missing) { + if (column.required) { + violations.push({ column: column.name, reason: "required_missing", message: `column '${column.name}' is required` }); + } + continue; + } + checkType(column, value, violations); + } + + // `table` is carried on FrontmatterRow.table, not in values; a `table` key in + // values is a stray frontmatter key like any other and must be flagged, not + // silently skipped (which would hide schema violations). + for (const key of Object.keys(row.values)) { + if (findColumn(schema, key) === undefined) { + violations.push({ column: key, reason: "unknown_column", message: `column '${key}' is not in schema for ${schema.table}` }); + } + } + + return violations.length === 0 ? { outcome: "valid" } : { outcome: "invalid", violations }; +} + +function checkType(column: ColumnDef, value: FrontmatterValue, violations: RowViolation[]): void { + switch (column.type) { + case ColumnType.ZetaId: + if (typeof value !== "string" || !/^[0-9]+$/.test(value)) { + violations.push({ column: column.name, reason: "bad_zeta_id", message: `column '${column.name}' must be a base-10 ZetaId` }); + } + return; + case ColumnType.Enum: + if (typeof value !== "string" || !column.values.includes(value)) { + violations.push({ column: column.name, reason: "enum_out_of_range", message: `column '${column.name}'='${String(value)}' not in [${column.values.join(", ")}]` }); + } + return; + case ColumnType.Fk: + // FK targets a ZetaId pk, so the reference must be a base-10 ZetaId — not + // just any non-empty string (traverse.ts brands these as ZetaIdDecimal). + if (typeof value !== "string" || !/^[0-9]+$/.test(value)) { + violations.push({ column: column.name, reason: "bad_fk", message: `column '${column.name}' must be a base-10 ZetaId reference` }); + } + return; + case ColumnType.FkArray: + if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !/^[0-9]+$/.test(v))) { + violations.push({ column: column.name, reason: "bad_fk_array", message: `column '${column.name}' must be an array of base-10 ZetaId references` }); + } + return; + case ColumnType.Int: + if (typeof value !== "number" || !Number.isFinite(value)) { + violations.push({ column: column.name, reason: "bad_int", message: `column '${column.name}' must be a number` }); + } + return; + case ColumnType.Bool: + if (typeof value !== "boolean") { + violations.push({ column: column.name, reason: "bad_bool", message: `column '${column.name}' must be a boolean` }); + } + return; + case ColumnType.Timestamp: + case ColumnType.Text: + if (typeof value !== "string") { + violations.push({ column: column.name, reason: "bad_text", message: `column '${column.name}' must be a string` }); + } + return; + default: { + // Exhaustiveness: every ColumnDef variant must be handled above. If a new + // ColumnType is added without a case here, `column` is no longer `never` + // and this assignment fails the build — forcing the validator to be + // updated rather than silently dropping the new variant + // (repo rule: IMPLICIT-NOT-EXPLICIT in DUs is class error). + const _exhaustive: never = column; + return _exhaustive; + } + } +} diff --git a/agentic-organization/packages/frontmatter-db/test/crdt-log.test.ts b/agentic-organization/packages/frontmatter-db/test/crdt-log.test.ts new file mode 100644 index 0000000000..4fdd811306 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/crdt-log.test.ts @@ -0,0 +1,53 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; +import { appendEvent, emptyLog, fromEvents, mergeLogs, type EventLog } from "../src/crdt-log.ts"; +import { EventOp, zetaIdWithTimestamp, type FrontmatterEvent } from "../src/event.ts"; +import { asZetaIdDecimal } from "../src/event.ts"; + +function ev(ms: number, agg: string, fields: Record = {}): FrontmatterEvent { + return { + id: zetaIdWithTimestamp(ms), + table: "task", + aggregateId: asZetaIdDecimal(agg), + op: EventOp.Upsert, + schemaVersion: 1, + fields, + }; +} + +function ids(log: EventLog): string[] { + return [...log.keys()].sort(); +} + +const a = ev(100, "1"); +const b = ev(200, "1"); +const c = ev(300, "2"); + +test("merge is commutative", () => { + const ab = mergeLogs(fromEvents([a, b]), fromEvents([c])); + const ba = mergeLogs(fromEvents([c]), fromEvents([a, b])); + deepEqual(ids(ab), ids(ba)); +}); + +test("merge is associative", () => { + const left = mergeLogs(mergeLogs(fromEvents([a]), fromEvents([b])), fromEvents([c])); + const right = mergeLogs(fromEvents([a]), mergeLogs(fromEvents([b]), fromEvents([c]))); + deepEqual(ids(left), ids(right)); +}); + +test("merge is idempotent (same unique ids dedupe, no conflict)", () => { + const log = fromEvents([a, b, c]); + const merged = mergeLogs(log, log); + equal(merged.size, 3); + deepEqual(ids(merged), ids(log)); +}); + +test("distinct unique ids never collide on union", () => { + // two agents each append an event concurrently; union keeps both + let agent1 = emptyLog(); + agent1 = appendEvent(agent1, ev(400, "3", { status: "ready" })); + let agent2 = emptyLog(); + agent2 = appendEvent(agent2, ev(500, "3", { status: "done" })); + const merged = mergeLogs(agent1, agent2); + equal(merged.size, 2); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/event-codec.test.ts b/agentic-organization/packages/frontmatter-db/test/event-codec.test.ts new file mode 100644 index 0000000000..71f904de98 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/event-codec.test.ts @@ -0,0 +1,71 @@ +import { deepEqual, equal, throws } from "node:assert/strict"; +import { test } from "node:test"; +import { EventOp, asZetaIdDecimal, zetaIdWithTimestamp, type FrontmatterEvent } from "../src/event.ts"; +import { EventCodecFeedbackReason, parseEvent, serializeEvent } from "../src/event-codec.ts"; + +test("serializeEvent rejects a field key in the reserved $ namespace (no metadata spoofing)", () => { + const event: FrontmatterEvent = { + id: zetaIdWithTimestamp(1000), + table: "task", + aggregateId: asZetaIdDecimal("7"), + op: EventOp.Upsert, + schemaVersion: 1, + fields: { $id: "999", title: "spoof attempt" }, + }; + throws(() => serializeEvent(event), /reserved '\$' namespace/); +}); + +function sampleEvent(): FrontmatterEvent { + return { + id: zetaIdWithTimestamp(1000), + table: "task", + aggregateId: asZetaIdDecimal("7"), + op: EventOp.Upsert, + schemaVersion: 2, + fields: { status: "ready", title: "fix the thing", estimate: 3, blocked: false, reviewer_ids: ["8", "9"], code: "42" }, + }; +} + +test("event round-trips through serialize/parse", () => { + const event = sampleEvent(); + const text = serializeEvent(event); + const parsed = parseEvent(text); + equal(parsed.outcome, "ok"); + if (parsed.outcome !== "ok") return; + deepEqual(parsed.event, event); +}); + +test("reserved metadata keys are separated from field columns", () => { + const parsed = parseEvent(serializeEvent(sampleEvent())); + if (parsed.outcome !== "ok") throw new Error("expected ok"); + // no $-prefixed key leaks into fields + equal(Object.keys(parsed.event.fields).some((k) => k.startsWith("$")), false); + // number-looking string field survives as a string + equal(parsed.event.fields.code, "42"); + equal(parsed.event.fields.estimate, 3); +}); + +test("retract event needs no fields", () => { + const event: FrontmatterEvent = { id: zetaIdWithTimestamp(2000), table: "task", aggregateId: asZetaIdDecimal("7"), op: EventOp.Retract, schemaVersion: 1, fields: {} }; + const parsed = parseEvent(serializeEvent(event)); + if (parsed.outcome !== "ok") throw new Error("expected ok"); + deepEqual(parsed.event, event); +}); + +test("missing reserved keys yields feedback", () => { + const parsed = parseEvent("---\nstatus: ready\n---\n"); + equal(parsed.outcome, "feedback"); + if (parsed.outcome !== "feedback") return; + equal(parsed.feedback.reason, EventCodecFeedbackReason.MissingReserved); +}); + +test("unknown op yields feedback", () => { + // build a real serialized event then tamper only the op line so quoting/keys + // match the serializer exactly (the bad_op check is what we want to exercise) + const valid = serializeEvent(sampleEvent()); + const tampered = valid.replace(`$op: ${EventOp.Upsert}`, "$op: explode"); + const parsed = parseEvent(tampered); + equal(parsed.outcome, "feedback"); + if (parsed.outcome !== "feedback") return; + equal(parsed.feedback.reason, EventCodecFeedbackReason.BadOp); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/frontmatter-codec.test.ts b/agentic-organization/packages/frontmatter-db/test/frontmatter-codec.test.ts new file mode 100644 index 0000000000..a238a22e54 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/frontmatter-codec.test.ts @@ -0,0 +1,195 @@ +import { equal, deepEqual } from "node:assert/strict"; +import { test } from "node:test"; +import type { FrontmatterValue } from "../src/schema.ts"; +import { + type ParsedDocument, + parseFrontmatterDocument, + serializeFrontmatterDocument, + rowToDocument, + documentToRow, +} from "../src/frontmatter-codec.ts"; + +/** Parse helper that asserts success and returns the document. */ +const parseOk = (text: string): ParsedDocument => { + const result = parseFrontmatterDocument(text); + if (result.outcome !== "ok") { + throw new Error( + `expected ok, got feedback: ${result.feedback.reason} ${result.feedback.message}`, + ); + } + return result.document; +}; + +/** Assert that parse(serialize(doc)) deep-equals doc. */ +const roundTrip = (doc: ParsedDocument): void => { + const text = serializeFrontmatterDocument(doc); + const reparsed = parseOk(text); + deepEqual(reparsed.frontmatter, doc.frontmatter); + equal(reparsed.body, doc.body); +}; + +test("round-trips strings, numbers, booleans", () => { + const doc: ParsedDocument = { + frontmatter: { + title: "Hello World", + count: 42, + ratio: 3.14, + active: true, + archived: false, + }, + body: "Some body text.", + }; + roundTrip(doc); +}); + +test("round-trips inline string arrays", () => { + const doc: ParsedDocument = { + frontmatter: { + tags: ["alpha", "beta", "gamma"], + empty: [], + single: ["solo"], + }, + body: "Body with arrays.", + }; + roundTrip(doc); +}); + +test("round-trips a string that looks like a number", () => { + const doc: ParsedDocument = { + frontmatter: { + zip: "01234", + versionString: "42", + floaty: "3.14", + negativeLike: "-7", + }, + body: "", + }; + roundTrip(doc); + // Confirm the values come back as strings, not numbers. + const reparsed = parseOk(serializeFrontmatterDocument(doc)); + equal(typeof reparsed.frontmatter["zip"], "string"); + equal(typeof reparsed.frontmatter["versionString"], "string"); + equal(typeof reparsed.frontmatter["floaty"], "string"); + equal(typeof reparsed.frontmatter["negativeLike"], "string"); +}); + +test("round-trips strings that look like booleans or arrays", () => { + const doc: ParsedDocument = { + frontmatter: { + truthy: "true", + falsy: "false", + arrayish: "[not, an, array]", + }, + body: "", + }; + roundTrip(doc); + const reparsed = parseOk(serializeFrontmatterDocument(doc)); + equal(reparsed.frontmatter["truthy"], "true"); + equal(typeof reparsed.frontmatter["truthy"], "string"); + equal(reparsed.frontmatter["falsy"], "false"); + equal(typeof reparsed.frontmatter["falsy"], "string"); + equal(reparsed.frontmatter["arrayish"], "[not, an, array]"); + equal(typeof reparsed.frontmatter["arrayish"], "string"); +}); + +test("round-trips strings needing quoting (colon, hash, whitespace, empty)", () => { + const doc: ParsedDocument = { + frontmatter: { + withColon: "key: value", + withHash: "a # comment-ish", + leadingSpace: " padded", + trailingSpace: "padded ", + emptyString: "", + withQuote: 'he said "hi"', + withBackslash: "path\\to\\thing", + }, + body: "", + }; + roundTrip(doc); +}); + +test("round-trips a body with blank lines", () => { + const doc: ParsedDocument = { + frontmatter: { title: "Doc" }, + body: "Line one.\n\nLine three after blank.\n\n\nEnd.", + }; + roundTrip(doc); +}); + +test("round-trips arrays with elements containing commas and special chars", () => { + const doc: ParsedDocument = { + frontmatter: { + messy: ["a, b", "c: d", "[bracket]", 'quote"d', "plain"], + }, + body: "Body.", + }; + roundTrip(doc); + const reparsed = parseOk(serializeFrontmatterDocument(doc)); + deepEqual(reparsed.frontmatter["messy"], ["a, b", "c: d", "[bracket]", 'quote"d', "plain"]); +}); + +test("parses a hand-written document", () => { + const text = ["---", "name: Widget", "qty: 7", "ready: true", "tags: [x, y]", "---", "Hello body."].join("\n"); + const doc = parseOk(text); + equal(doc.frontmatter["name"], "Widget"); + equal(doc.frontmatter["qty"], 7); + equal(doc.frontmatter["ready"], true); + deepEqual(doc.frontmatter["tags"], ["x", "y"]); + equal(doc.body, "Hello body."); +}); + +test("feedback: missing_frontmatter", () => { + const result = parseFrontmatterDocument("no fence here\njust text"); + equal(result.outcome, "feedback"); + if (result.outcome === "feedback") { + equal(result.feedback.reason, "missing_frontmatter"); + } +}); + +test("feedback: unterminated_frontmatter", () => { + const result = parseFrontmatterDocument(["---", "key: value", "still open"].join("\n")); + equal(result.outcome, "feedback"); + if (result.outcome === "feedback") { + equal(result.feedback.reason, "unterminated_frontmatter"); + } +}); + +test("feedback: malformed_line", () => { + const result = parseFrontmatterDocument(["---", "this line has no colon", "---", "body"].join("\n")); + equal(result.outcome, "feedback"); + if (result.outcome === "feedback") { + equal(result.feedback.reason, "malformed_line"); + } +}); + +test("rowToDocument / documentToRow round-trip through values", () => { + const values: Record = { + id: "abc-123", + weight: 9, + flagged: false, + labels: ["one", "two"], + }; + const doc = rowToDocument({ table: "things", values }, "the body"); + deepEqual(doc.frontmatter, values); + equal(doc.body, "the body"); + + const row = documentToRow(doc, "things"); + equal(row.table, "things"); + deepEqual(row.values, values); +}); + +test("full pipeline: row -> document -> serialize -> parse -> row", () => { + const values: Record = { + title: "Mix", + n: 100, + b: true, + arr: ["p", "q"], + numericString: "007", + }; + const doc = rowToDocument({ table: "t", values }, "Body paragraph.\n\nSecond."); + const text = serializeFrontmatterDocument(doc); + const reparsed = parseOk(text); + const row = documentToRow(reparsed, "t"); + deepEqual(row.values, values); + equal(reparsed.body, "Body paragraph.\n\nSecond."); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/git-fs-adapter.test.ts b/agentic-organization/packages/frontmatter-db/test/git-fs-adapter.test.ts new file mode 100644 index 0000000000..7ca077051d --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/git-fs-adapter.test.ts @@ -0,0 +1,67 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { EventOp, asZetaIdDecimal, zetaIdWithTimestamp, type FrontmatterEvent } from "../src/event.ts"; +import { serializeEvent } from "../src/event-codec.ts"; +import { createGitFsAdapter, eventFilePath, type EventFileSystem } from "../src/git-fs-adapter.ts"; + +function fakeFs(seed: Record = {}): EventFileSystem & { files: Map } { + const files = new Map(Object.entries(seed)); + return { + files, + async listEventFiles(table: string): Promise { + const prefix = `events/${table}/`; + return [...files.keys()].filter((p) => p.startsWith(prefix)); + }, + async readEventFile(path: string): Promise { + const contents = files.get(path); + if (contents === undefined) throw new Error(`no such file ${path}`); + return contents; + }, + async writeEventFile(path: string, contents: string): Promise { + files.set(path, contents); + }, + }; +} + +function ev(ms: number, agg: string, status: string): FrontmatterEvent { + return { id: zetaIdWithTimestamp(ms), table: "task", aggregateId: asZetaIdDecimal(agg), op: EventOp.Upsert, schemaVersion: 1, fields: { id: agg, status } }; +} + +test("load reads existing event files into the snapshot", async () => { + const a = ev(100, "1", "ready"); + const fs = fakeFs({ [eventFilePath("task", a.id)]: serializeEvent(a) }); + const adapter = createGitFsAdapter(fs); + const result = await adapter.load("task"); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.loaded, 1); + equal(adapter.readEvents("task").length, 1); +}); + +test("appendEvent buffers then flush writes the file", async () => { + const fs = fakeFs(); + const adapter = createGitFsAdapter(fs); + await adapter.load("task"); + const e = ev(200, "2", "done"); + adapter.appendEvent(e); + equal(adapter.pendingCount(), 1); + equal(fs.files.has(eventFilePath("task", e.id)), false); + const flushed = await adapter.flush(); + equal(flushed.written, 1); + equal(adapter.pendingCount(), 0); + equal(fs.files.has(eventFilePath("task", e.id)), true); +}); + +test("an unparseable event file yields feedback", async () => { + const fs = fakeFs({ "events/task/123.md": "not even frontmatter" }); + const adapter = createGitFsAdapter(fs); + const result = await adapter.load("task"); + equal(result.outcome, "feedback"); +}); + +test("appended events are visible to readEvents before flush", async () => { + const adapter = createGitFsAdapter(fakeFs()); + await adapter.load("task"); + adapter.appendEvent(ev(300, "3", "ready")); + equal(adapter.readEvents("task").length, 1); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/project.test.ts b/agentic-organization/packages/frontmatter-db/test/project.test.ts new file mode 100644 index 0000000000..8b1f6decc4 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/project.test.ts @@ -0,0 +1,63 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; +import { fromEvents, mergeLogs } from "../src/crdt-log.ts"; +import { EventOp, asZetaIdDecimal, zetaIdWithTimestamp, type FrontmatterEvent } from "../src/event.ts"; +import { project, type Projection } from "../src/project.ts"; + +function ev(ms: number, agg: string, op: FrontmatterEvent["op"], fields: Record = {}): FrontmatterEvent { + return { id: zetaIdWithTimestamp(ms), table: "task", aggregateId: asZetaIdDecimal(agg), op, schemaVersion: 1, fields }; +} + +function comparable(p: Projection): Array<[string, Record]> { + return [...p.entries()].map(([id, row]) => [id, row.values] as [string, Record]).sort((x, y) => (x[0] < y[0] ? -1 : 1)); +} + +test("fold applies last-writer-wins by ZetaId timestamp", () => { + const log = fromEvents([ + ev(100, "1", EventOp.Upsert, { status: "ready", title: "t" }), + ev(200, "1", EventOp.Upsert, { status: "done" }), + ]); + const p = project(log, "task"); + equal(p.get(asZetaIdDecimal("1"))?.values.status, "done"); + equal(p.get(asZetaIdDecimal("1"))?.values.title, "t"); +}); + +test("retract tombstones the aggregate; later upsert revives it", () => { + const dropped = project(fromEvents([ + ev(100, "1", EventOp.Upsert, { status: "ready" }), + ev(200, "1", EventOp.Retract), + ]), "task"); + equal(dropped.has(asZetaIdDecimal("1")), false); + + const revived = project(fromEvents([ + ev(100, "1", EventOp.Upsert, { status: "ready" }), + ev(200, "1", EventOp.Retract), + ev(300, "1", EventOp.Upsert, { status: "reopened" }), + ]), "task"); + equal(revived.get(asZetaIdDecimal("1"))?.values.status, "reopened"); +}); + +test("projection converges regardless of merge order (CRDT property)", () => { + const e1 = ev(100, "1", EventOp.Upsert, { status: "ready" }); + const e2 = ev(200, "1", EventOp.Upsert, { status: "done" }); + const shared = ev(150, "2", EventOp.Upsert, { title: "x" }); + + const logA = fromEvents([e1, shared]); + const logB = fromEvents([e2, shared]); + + const ab = project(mergeLogs(logA, logB), "task"); + const ba = project(mergeLogs(logB, logA), "task"); + + deepEqual(comparable(ab), comparable(ba)); + equal(ab.get(asZetaIdDecimal("1"))?.values.status, "done"); + equal(ab.get(asZetaIdDecimal("2"))?.values.title, "x"); +}); + +test("only the requested table projects", () => { + const log = fromEvents([ + ev(100, "1", EventOp.Upsert, { status: "ready" }), + { id: zetaIdWithTimestamp(110), table: "project", aggregateId: asZetaIdDecimal("9"), op: EventOp.Upsert, schemaVersion: 1, fields: { name: "p" } }, + ]); + equal(project(log, "task").size, 1); + equal(project(log, "project").size, 1); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/reconcile-worker.test.ts b/agentic-organization/packages/frontmatter-db/test/reconcile-worker.test.ts new file mode 100644 index 0000000000..f8344a4979 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/reconcile-worker.test.ts @@ -0,0 +1,89 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { createInMemoryCockroachRowSink } from "../src/cockroach-row-sink.ts"; +import { EventOp, asZetaIdDecimal, zetaIdWithTimestamp, type FrontmatterEvent, type ZetaIdDecimal } from "../src/event.ts"; +import { serializeEvent } from "../src/event-codec.ts"; +import { createGitFsAdapter, eventFilePath, type EventFileSystem } from "../src/git-fs-adapter.ts"; +import { ReconcileCycleStatus, createReconcileWorker } from "../src/reconcile-worker.ts"; +import type { IdGenerator } from "../src/sync.ts"; + +function fakeFs(seed: Record = {}): EventFileSystem & { files: Map } { + const files = new Map(Object.entries(seed)); + return { + files, + async listEventFiles(table) { + const prefix = `events/${table}/`; + return [...files.keys()].filter((p) => p.startsWith(prefix)); + }, + async readEventFile(path) { + const c = files.get(path); + if (c === undefined) throw new Error(`no such file ${path}`); + return c; + }, + async writeEventFile(path, contents) { + files.set(path, contents); + }, + }; +} + +function ev(ms: number, agg: string, status: string): FrontmatterEvent { + return { id: zetaIdWithTimestamp(ms), table: "task", aggregateId: asZetaIdDecimal(agg), op: EventOp.Upsert, schemaVersion: 1, fields: { id: agg, status } }; +} + +function counterIds(start: number): IdGenerator { + let n = start; + return { nextEventId: (): ZetaIdDecimal => zetaIdWithTimestamp(n++) }; +} + +test("git->index projects rows into the index on a cycle", async () => { + const a = ev(100, "1", "ready"); + const fs = fakeFs({ [eventFilePath("task", a.id)]: serializeEvent(a) }); + const sink = createInMemoryCockroachRowSink(); + const worker = createReconcileWorker({ tables: ["task"], gitAdapter: createGitFsAdapter(fs), indexSink: sink, ids: counterIds(5000) }); + + // clear the change-set created by git->index upsert so we measure only that direction + const result = await worker.runOnce(); + equal(result.status, ReconcileCycleStatus.Worked); + const taskSummary = result.tables.find((t) => t.table === "task"); + equal(taskSummary?.upserted, 1); + equal(sink.currentRows("task").get(asZetaIdDecimal("1"))?.values.status, "ready"); +}); + +test("index->git emits an event file for a row the index added locally", async () => { + const fs = fakeFs(); + const sink = createInMemoryCockroachRowSink(); + // a row exists only in the index (e.g. written by the command pipeline) + sink.upsertRow({ table: "task", values: { id: "42", status: "in_progress" } }); + + const worker = createReconcileWorker({ tables: ["task"], gitAdapter: createGitFsAdapter(fs), indexSink: sink, ids: counterIds(6000) }); + const result = await worker.runOnce(); + + equal(result.status, ReconcileCycleStatus.Worked); + // one event file written to the working tree for aggregate 42 + const written = [...fs.files.keys()]; + equal(written.length, 1); + equal(result.written, 1); +}); + +test("a second cycle does not re-emit already-reconciled rows", async () => { + const fs = fakeFs(); + const sink = createInMemoryCockroachRowSink(); + sink.upsertRow({ table: "task", values: { id: "42", status: "in_progress" } }); + const worker = createReconcileWorker({ tables: ["task"], gitAdapter: createGitFsAdapter(fs), indexSink: sink, ids: counterIds(7000) }); + + await worker.runOnce(); + const filesAfterFirst = fs.files.size; + const second = await worker.runOnce(); + + equal(fs.files.size, filesAfterFirst, "no new event files on the second cycle"); + equal(second.tables.find((t) => t.table === "task")?.emitted, 0); +}); + +test("an unparseable event file degrades the cycle on the load lane", async () => { + const fs = fakeFs({ "events/task/9.md": "garbage" }); + const sink = createInMemoryCockroachRowSink(); + const worker = createReconcileWorker({ tables: ["task"], gitAdapter: createGitFsAdapter(fs), indexSink: sink, ids: counterIds(8000) }); + const result = await worker.runOnce(); + equal(result.status, ReconcileCycleStatus.Degraded); + equal(result.failures[0]?.lane, "load"); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/schema-to-sql.test.ts b/agentic-organization/packages/frontmatter-db/test/schema-to-sql.test.ts new file mode 100644 index 0000000000..4efead4dd6 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/schema-to-sql.test.ts @@ -0,0 +1,100 @@ +import { deepEqual, equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { emitCreateTable } from "../src/schema-to-sql.ts"; +import { parseCreateTable } from "../src/sql-to-schema.ts"; +import { ColumnType, type TableSchema, type ColumnDef } from "../src/schema.ts"; + +test("escapes single quotes in enum literals so they can't break/inject SQL", () => { + const schema: TableSchema = { + table: "t", + schemaVersion: 1, + columns: [{ type: ColumnType.Enum, name: "label", required: true, values: ["it's", "ok"] } as ColumnDef], + }; + const sql = emitCreateTable(schema); + ok(sql.includes("'it''s'")); + ok(!sql.includes("'it's'")); +}); + +const taskSchema: TableSchema = { + table: "task", + schemaVersion: 1, + columns: [ + { type: ColumnType.ZetaId, name: "id", pk: true, required: true }, + { + type: ColumnType.Enum, + name: "status", + required: true, + values: ["created", "ready", "done"], + }, + { type: ColumnType.Text, name: "title", required: true }, + { type: ColumnType.Fk, name: "project_id", required: false, references: "project" }, + { type: ColumnType.FkArray, name: "reviewer_ids", required: false, references: "hat_assignment" }, + { type: ColumnType.Int, name: "estimate", required: false }, + { type: ColumnType.Timestamp, name: "created_at", required: true }, + ], +}; + +test("emitCreateTable round-trips through parseCreateTable", () => { + const sql = emitCreateTable(taskSchema); + const parsed = parseCreateTable(sql, taskSchema.schemaVersion); + equal(parsed.outcome, "schema"); + if (parsed.outcome !== "schema") return; + equal(parsed.schema.table, taskSchema.table); + equal(parsed.schema.schemaVersion, taskSchema.schemaVersion); + deepEqual(parsed.schema.columns, taskSchema.columns); +}); + +test("emitCreateTable produces a TEXT PRIMARY KEY for zeta_id pk", () => { + const schema: TableSchema = { + table: "t", + schemaVersion: 1, + columns: [{ type: ColumnType.ZetaId, name: "id", pk: true, required: true }], + }; + const sql = emitCreateTable(schema); + equal(sql.includes("id TEXT PRIMARY KEY"), true); +}); + +test("emitCreateTable emits NOT NULL only when required", () => { + const schema: TableSchema = { + table: "t", + schemaVersion: 2, + columns: [ + { type: ColumnType.Text, name: "a", required: true }, + { type: ColumnType.Text, name: "b", required: false }, + ], + }; + const sql = emitCreateTable(schema); + equal(sql.includes("a TEXT NOT NULL"), true); + equal(/\bb TEXT(?! NOT NULL)/.test(sql), true); +}); + +test("each non-pk column round-trips its NOT NULL flag", () => { + const schema: TableSchema = { + table: "flags", + schemaVersion: 3, + columns: [ + { type: ColumnType.Int, name: "i_req", required: true }, + { type: ColumnType.Bool, name: "b_opt", required: false }, + { type: ColumnType.Timestamp, name: "ts_req", required: true }, + { + type: ColumnType.Enum, + name: "e_opt", + required: false, + values: ["x", "y"], + }, + ], + }; + const parsed = parseCreateTable( + emitCreateTable(schema), + schema.schemaVersion, + ); + equal(parsed.outcome, "schema"); + if (parsed.outcome !== "schema") return; + const byName = (n: string): ColumnDef | undefined => + parsed.schema.columns.find((c) => c.name === n); + deepEqual(byName("i_req"), schema.columns[0]); + deepEqual(byName("b_opt"), schema.columns[1]); + deepEqual(byName("ts_req"), schema.columns[2]); + deepEqual(byName("e_opt"), schema.columns[3]); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/sql-to-schema.test.ts b/agentic-organization/packages/frontmatter-db/test/sql-to-schema.test.ts new file mode 100644 index 0000000000..b6c2c4adc3 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/sql-to-schema.test.ts @@ -0,0 +1,60 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; +import { ColumnType, findColumn } from "../src/schema.ts"; +import { parseCreateTable } from "../src/sql-to-schema.ts"; + +const SQL = ` +CREATE TABLE task ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('created', 'ready', 'done')), + title TEXT NOT NULL, + project_id TEXT REFERENCES project(id), + reviewer_ids TEXT[] REFERENCES hat_assignment(id), + estimate INTEGER, + created_at TIMESTAMPTZ NOT NULL +); +`; + +test("parses CREATE TABLE into a frontmatter schema", () => { + const result = parseCreateTable(SQL, 3); + equal(result.outcome, "schema"); + if (result.outcome !== "schema") return; + equal(result.schema.table, "task"); + equal(result.schema.schemaVersion, 3); +}); + +test("PRIMARY KEY column maps to zeta_id pk", () => { + const result = parseCreateTable(SQL); + if (result.outcome !== "schema") throw new Error("expected schema"); + const id = findColumn(result.schema, "id"); + deepEqual(id, { name: "id", type: ColumnType.ZetaId, pk: true, required: true }); +}); + +test("CHECK ... IN (...) maps to an enum with values", () => { + const result = parseCreateTable(SQL); + if (result.outcome !== "schema") throw new Error("expected schema"); + const status = findColumn(result.schema, "status"); + deepEqual(status, { name: "status", type: ColumnType.Enum, required: true, values: ["created", "ready", "done"] }); +}); + +test("REFERENCES maps to fk; TYPE[] REFERENCES maps to fk_array", () => { + const result = parseCreateTable(SQL); + if (result.outcome !== "schema") throw new Error("expected schema"); + deepEqual(findColumn(result.schema, "project_id"), { name: "project_id", type: ColumnType.Fk, required: false, references: "project" }); + deepEqual(findColumn(result.schema, "reviewer_ids"), { name: "reviewer_ids", type: ColumnType.FkArray, required: false, references: "hat_assignment" }); +}); + +test("scalar types and NOT NULL map through", () => { + const result = parseCreateTable(SQL); + if (result.outcome !== "schema") throw new Error("expected schema"); + deepEqual(findColumn(result.schema, "estimate"), { name: "estimate", type: ColumnType.Int, required: false }); + deepEqual(findColumn(result.schema, "created_at"), { name: "created_at", type: ColumnType.Timestamp, required: true }); + equal(findColumn(result.schema, "title")?.required, true); +}); + +test("non-CREATE-TABLE input returns explicit feedback", () => { + const result = parseCreateTable("SELECT 1;"); + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "no_create_table"); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/sync.test.ts b/agentic-organization/packages/frontmatter-db/test/sync.test.ts new file mode 100644 index 0000000000..23829e1102 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/sync.test.ts @@ -0,0 +1,187 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; + +import { + SyncDirection, + syncGitToIndex, + syncIndexToGit, + type GitEventSource, + type IndexRowSink, + type IndexRowSource, + type GitEventSink, + type IdGenerator, +} from "../src/sync.ts"; +import { + EventOp, + asZetaIdDecimal, + type FrontmatterEvent, + type ZetaIdDecimal, +} from "../src/event.ts"; +import type { FrontmatterRow } from "../src/schema.ts"; + +const TABLE = "task"; + +function upsertEvent( + eventId: string, + aggregateId: string, + fields: Record, +): FrontmatterEvent { + return { + id: asZetaIdDecimal(eventId), + table: TABLE, + aggregateId: asZetaIdDecimal(aggregateId), + op: EventOp.Upsert, + schemaVersion: 1, + fields, + }; +} + +function retractEvent(eventId: string, aggregateId: string): FrontmatterEvent { + return { + id: asZetaIdDecimal(eventId), + table: TABLE, + aggregateId: asZetaIdDecimal(aggregateId), + op: EventOp.Retract, + schemaVersion: 1, + fields: {}, + }; +} + +class FakeGitSource implements GitEventSource { + private readonly events: readonly FrontmatterEvent[]; + constructor(events: readonly FrontmatterEvent[]) { + this.events = events; + } + readEvents(table: string): readonly FrontmatterEvent[] { + return this.events.filter((e) => e.table === table); + } +} + +class FakeIndexSink implements IndexRowSink { + readonly rows = new Map(); + upsertRow(row: FrontmatterRow): void { + const id = String(row.values["id"]); + this.rows.set(asZetaIdDecimal(id), row); + } + deleteRow(_table: string, id: ZetaIdDecimal): void { + this.rows.delete(id); + } + currentRows(_table: string): ReadonlyMap { + return this.rows; + } + seed(id: string, fields: Record): void { + this.rows.set(asZetaIdDecimal(id), { table: TABLE, values: fields }); + } +} + +class FakeIndexSource implements IndexRowSource { + private readonly rows: readonly FrontmatterRow[]; + constructor(rows: readonly FrontmatterRow[]) { + this.rows = rows; + } + changedRows(table: string): readonly FrontmatterRow[] { + return this.rows.filter((r) => r.table === table); + } +} + +class FakeGitSink implements GitEventSink { + readonly appended: FrontmatterEvent[] = []; + appendEvent(event: FrontmatterEvent): void { + this.appended.push(event); + } +} + +class FakeIdGenerator implements IdGenerator { + private n = 0; + nextEventId(): ZetaIdDecimal { + this.n += 1; + return asZetaIdDecimal(String(9_000_000_000_000 + this.n)); + } +} + +test("syncGitToIndex upserts projected rows", () => { + const source = new FakeGitSource([ + upsertEvent("1000000000001000000", "100", { id: "100", title: "a" }), + upsertEvent("1000000000002000000", "200", { id: "200", title: "b" }), + ]); + const sink = new FakeIndexSink(); + const result = syncGitToIndex(TABLE, { source, sink }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.direction, SyncDirection.GitToIndex); + equal(result.applied.upserted, 2); + equal(result.applied.deleted, 0); + equal(sink.rows.size, 2); + deepEqual(sink.rows.get(asZetaIdDecimal("100"))?.values, { + id: "100", + title: "a", + }); +}); + +test("syncGitToIndex tombstone-deletes rows missing from projection", () => { + // Aggregate 300 was retracted in git; its index row must be deleted. + const source = new FakeGitSource([ + upsertEvent("1000000000001000000", "100", { id: "100", title: "keep" }), + upsertEvent("1000000000002000000", "300", { id: "300", title: "old" }), + retractEvent("1000000000003000000", "300"), + ]); + const sink = new FakeIndexSink(); + // Index already holds both 100 and 300 (and a stale 999 absent from git). + sink.seed("100", { id: "100", title: "stale" }); + sink.seed("300", { id: "300", title: "stale" }); + sink.seed("999", { id: "999", title: "orphan" }); + + const result = syncGitToIndex(TABLE, { source, sink }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + // Projection has only 100 -> 1 upsert; 300 and 999 are tombstoned. + equal(result.applied.upserted, 1); + equal(result.applied.deleted, 2); + equal(sink.rows.has(asZetaIdDecimal("100")), true); + equal(sink.rows.has(asZetaIdDecimal("300")), false); + equal(sink.rows.has(asZetaIdDecimal("999")), false); +}); + +test("syncIndexToGit emits an Upsert event per changed row", () => { + const source = new FakeIndexSource([ + { table: TABLE, values: { id: "100", title: "a" } }, + { table: TABLE, values: { id: "200", title: "b" } }, + ]); + const sink = new FakeGitSink(); + const ids = new FakeIdGenerator(); + const result = syncIndexToGit(TABLE, { source, sink, ids }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.direction, SyncDirection.IndexToGit); + equal(result.emitted, 2); + equal(sink.appended.length, 2); + equal(sink.appended[0]!.op, EventOp.Upsert); + equal(sink.appended[0]!.aggregateId, asZetaIdDecimal("100")); + equal(sink.appended[0]!.table, TABLE); + deepEqual(sink.appended[1]!.fields, { id: "200", title: "b" }); +}); + +test("syncIndexToGit returns row_missing_id feedback for a row without id", () => { + const source = new FakeIndexSource([ + { table: TABLE, values: { title: "no-id" } }, + ]); + const sink = new FakeGitSink(); + const ids = new FakeIdGenerator(); + const result = syncIndexToGit(TABLE, { source, sink, ids }); + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "row_missing_id"); + equal(sink.appended.length, 0); +}); + +test("syncIndexToGit returns row_missing_id feedback for an empty id", () => { + const source = new FakeIndexSource([ + { table: TABLE, values: { id: "", title: "empty-id" } }, + ]); + const sink = new FakeGitSink(); + const ids = new FakeIdGenerator(); + const result = syncIndexToGit(TABLE, { source, sink, ids }); + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "row_missing_id"); +}); diff --git a/agentic-organization/packages/frontmatter-db/test/validate-and-traverse.test.ts b/agentic-organization/packages/frontmatter-db/test/validate-and-traverse.test.ts new file mode 100644 index 0000000000..6db6a2f703 --- /dev/null +++ b/agentic-organization/packages/frontmatter-db/test/validate-and-traverse.test.ts @@ -0,0 +1,89 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; +import { asZetaIdDecimal, type ZetaIdDecimal } from "../src/event.ts"; +import { ColumnType, type FrontmatterRow, type TableSchema } from "../src/schema.ts"; +import { edgesOf, neighbors } from "../src/traverse.ts"; +import { validateRow } from "../src/validate.ts"; + +const taskSchema: TableSchema = { + table: "task", + schemaVersion: 1, + columns: [ + { name: "id", type: ColumnType.ZetaId, pk: true, required: true }, + { name: "status", type: ColumnType.Enum, required: true, values: ["ready", "done"] }, + { name: "title", type: ColumnType.Text, required: true }, + { name: "project_id", type: ColumnType.Fk, required: false, references: "project" }, + { name: "depends_on", type: ColumnType.FkArray, required: false, references: "task" }, + ], +}; + +function row(values: Record): FrontmatterRow { + return { table: "task", values }; +} + +test("a well-formed row validates", () => { + const result = validateRow(row({ id: "42", status: "ready", title: "t", project_id: "7", depends_on: ["8", "9"] }), taskSchema); + equal(result.outcome, "valid"); +}); + +test("a non-ZetaId FK reference is rejected (FK targets a ZetaId pk)", () => { + const result = validateRow(row({ id: "42", status: "ready", title: "t", project_id: "not-a-zeta-id" }), taskSchema); + equal(result.outcome, "invalid"); + if (result.outcome !== "invalid") return; + ok(result.violations.some((v) => v.column === "project_id" && v.reason === "bad_fk")); +}); + +test("a non-ZetaId element in an FK array is rejected", () => { + const result = validateRow(row({ id: "42", status: "ready", title: "t", depends_on: ["8", "nope"] }), taskSchema); + equal(result.outcome, "invalid"); + if (result.outcome !== "invalid") return; + ok(result.violations.some((v) => v.column === "depends_on" && v.reason === "bad_fk_array")); +}); + +test("a stray 'table' key in values is flagged as unknown column (no bypass)", () => { + const result = validateRow({ table: "task", values: { id: "42", status: "ready", title: "t", table: "task" } }, taskSchema); + equal(result.outcome, "invalid"); + if (result.outcome !== "invalid") return; + ok(result.violations.some((v) => v.column === "table" && v.reason === "unknown_column")); +}); + +test("enum out of range is reported", () => { + const result = validateRow(row({ id: "42", status: "frozen", title: "t" }), taskSchema); + equal(result.outcome, "invalid"); + if (result.outcome !== "invalid") return; + equal(result.violations.some((v) => v.reason === "enum_out_of_range"), true); +}); + +test("missing required column is reported", () => { + const result = validateRow(row({ id: "42", status: "ready" }), taskSchema); + equal(result.outcome, "invalid"); + if (result.outcome !== "invalid") return; + equal(result.violations.some((v) => v.column === "title" && v.reason === "required_missing"), true); +}); + +test("unknown column is reported (schema is the contract)", () => { + const result = validateRow(row({ id: "42", status: "ready", title: "t", nonsense: "x" }), taskSchema); + equal(result.outcome, "invalid"); + if (result.outcome !== "invalid") return; + equal(result.violations.some((v) => v.column === "nonsense" && v.reason === "unknown_column"), true); +}); + +test("edgesOf surfaces fk and fk_array columns as graph edges", () => { + const edges = edgesOf(row({ id: "42", status: "ready", title: "t", project_id: "7", depends_on: ["8", "9"] }), taskSchema); + equal(edges.length, 2); + const project = edges.find((e) => e.column === "project_id"); + equal(project?.references, "project"); + equal(project?.toIds.length, 1); + const deps = edges.find((e) => e.column === "depends_on"); + equal(deps?.toIds.length, 2); +}); + +test("neighbors resolves edge ids against a store", () => { + const store = new Map([ + [asZetaIdDecimal("8"), row({ id: "8", status: "done", title: "dep-8" })], + [asZetaIdDecimal("9"), row({ id: "9", status: "ready", title: "dep-9" })], + ]); + const found = neighbors(row({ id: "42", status: "ready", title: "t", depends_on: ["8", "9"] }), taskSchema, "depends_on", store); + equal(found.length, 2); + equal(found.map((r) => r.values.title).sort().join(","), "dep-8,dep-9"); +}); diff --git a/agentic-organization/packages/governance/src/constitution-gate.ts b/agentic-organization/packages/governance/src/constitution-gate.ts new file mode 100644 index 0000000000..f7e4800d44 --- /dev/null +++ b/agentic-organization/packages/governance/src/constitution-gate.ts @@ -0,0 +1,127 @@ +// Constitution ratification gate — explicit DUs, no buried logic. +// +// This module is FULLY SELF-CONTAINED. It does NOT reference any vote-tally +// module or VoteRecord type (no such artifacts exist in this repo). +// +// A constitution proposal is ratified when at least `quorum` DISTINCT agents +// have agreed AND no agent has objected. Objections veto with highest +// precedence. A single agent agreeing multiple times counts ONCE — no +// self-amplification. + +/** + * Lifecycle states of a constitution ratification. + * + * `Superseded` is part of the DU for lifecycle completeness (a ratified + * constitution may later be replaced by a newer one), but it is NEVER + * produced by the pure `evaluateConstitutionRatification` evaluation below — + * supersession is a lifecycle transition driven by external state, not by + * the agreement set. + */ +export const ConstitutionRatificationState = { + Proposed: "proposed", + Gathering: "gathering", + Ratified: "ratified", + Rejected: "rejected", + Superseded: "superseded", +} as const; +export type ConstitutionRatificationState = + (typeof ConstitutionRatificationState)[keyof typeof ConstitutionRatificationState]; + +/** A single agent's decision on a constitution proposal. */ +export const ConstitutionDecision = { + Agree: "agree", + Object: "object", +} as const; +export type ConstitutionDecision = + (typeof ConstitutionDecision)[keyof typeof ConstitutionDecision]; + +/** One agent's agreement (or objection) record. */ +export type ConstitutionAgreement = { + agentId: string; + hatAssignmentId: string; + decision: ConstitutionDecision; + rationale: string; +}; + +/** Default quorum: at least 3 distinct agents must agree to ratify. */ +export const DEFAULT_CONSTITUTION_QUORUM = 3; + +/** Pure evaluation result. */ +export type ConstitutionRatificationResult = { + state: ConstitutionRatificationState; + distinctAgreeAgents: number; + quorum: number; + objections: number; + reason: string; +}; + +/** + * Evaluate the ratification state of a constitution proposal from its + * agreement set. + * + * Rules (all explicit, none buried): + * - quorum = input.quorum ?? DEFAULT_CONSTITUTION_QUORUM, floored to >= 1. + * - objections = count of agreements with decision === Object. + * - distinctAgreeAgents = size of the Set of agentId over agreements with + * decision === Agree (a single agent agreeing twice counts once). + * - state precedence: + * objections > 0 -> Rejected (objection veto) + * else distinctAgreeAgents >= quorum -> Ratified + * else agreements.length > 0 -> Gathering + * else -> Proposed + * + * `Superseded` is never produced here (see DU doc above). + */ +export function evaluateConstitutionRatification(input: { + agreements: readonly ConstitutionAgreement[]; + quorum?: number; +}): ConstitutionRatificationResult { + const requestedQuorum = input.quorum ?? DEFAULT_CONSTITUTION_QUORUM; + const quorum = requestedQuorum < 1 ? 1 : requestedQuorum; + + let objections = 0; + const distinctAgree = new Set(); + for (const agreement of input.agreements) { + if (agreement.decision === ConstitutionDecision.Object) { + objections += 1; + } else if (agreement.decision === ConstitutionDecision.Agree) { + distinctAgree.add(agreement.agentId); + } + } + const distinctAgreeAgents = distinctAgree.size; + + if (objections > 0) { + return { + state: ConstitutionRatificationState.Rejected, + distinctAgreeAgents, + quorum, + objections, + reason: `rejected: ${objections} objection(s) veto ratification`, + }; + } + if (distinctAgreeAgents >= quorum) { + return { + state: ConstitutionRatificationState.Ratified, + distinctAgreeAgents, + quorum, + objections, + reason: `ratified: ${distinctAgreeAgents} distinct agreeing agent(s) >= quorum ${quorum}, no objections`, + }; + } + if (input.agreements.length > 0) { + return { + state: ConstitutionRatificationState.Gathering, + distinctAgreeAgents, + quorum, + objections, + reason: `gathering: ${distinctAgreeAgents} distinct agreeing agent(s) < quorum ${quorum}`, + }; + } + return { + state: ConstitutionRatificationState.Proposed, + distinctAgreeAgents, + quorum, + objections, + reason: "proposed: no agreements recorded yet", + }; +} diff --git a/agentic-organization/packages/governance/src/index.ts b/agentic-organization/packages/governance/src/index.ts index dfb46ca73c..9fdf341b6b 100644 --- a/agentic-organization/packages/governance/src/index.ts +++ b/agentic-organization/packages/governance/src/index.ts @@ -10,3 +10,11 @@ export { type ValidatePackageDependencyBoundariesInput, type ValidatePackageSourceLayoutInput, } from "./package-dependency-boundaries.ts"; +export { + ConstitutionDecision, + ConstitutionRatificationState, + DEFAULT_CONSTITUTION_QUORUM, + evaluateConstitutionRatification, + type ConstitutionAgreement, + type ConstitutionRatificationResult, +} from "./constitution-gate.ts"; diff --git a/agentic-organization/packages/governance/test/constitution-gate.test.ts b/agentic-organization/packages/governance/test/constitution-gate.test.ts new file mode 100644 index 0000000000..67a0fbce48 --- /dev/null +++ b/agentic-organization/packages/governance/test/constitution-gate.test.ts @@ -0,0 +1,78 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; + +import { + ConstitutionDecision, + ConstitutionRatificationState, + DEFAULT_CONSTITUTION_QUORUM, + evaluateConstitutionRatification, + type ConstitutionAgreement, +} from "../src/constitution-gate.ts"; + +function agree(agentId: string, hatAssignmentId: string): ConstitutionAgreement { + return { + agentId, + hatAssignmentId, + decision: ConstitutionDecision.Agree, + rationale: `agree-${agentId}`, + }; +} + +function object_(agentId: string, hatAssignmentId: string): ConstitutionAgreement { + return { + agentId, + hatAssignmentId, + decision: ConstitutionDecision.Object, + rationale: `object-${agentId}`, + }; +} + +test("three distinct agreers -> Ratified", () => { + const result = evaluateConstitutionRatification({ + agreements: [agree("a", "h1"), agree("b", "h2"), agree("c", "h3")], + }); + equal(result.state, ConstitutionRatificationState.Ratified); + equal(result.distinctAgreeAgents, 3); + equal(result.quorum, DEFAULT_CONSTITUTION_QUORUM); + equal(result.objections, 0); +}); + +test("two distinct agreers with one agent twice -> Gathering, distinct counts once", () => { + const result = evaluateConstitutionRatification({ + agreements: [agree("a", "h1"), agree("a", "h2"), agree("b", "h3")], + }); + equal(result.state, ConstitutionRatificationState.Gathering); + equal(result.distinctAgreeAgents, 2); + equal(result.objections, 0); +}); + +test("three agreers plus one Object -> Rejected, objection veto precedence", () => { + const result = evaluateConstitutionRatification({ + agreements: [ + agree("a", "h1"), + agree("b", "h2"), + agree("c", "h3"), + object_("d", "h4"), + ], + }); + equal(result.state, ConstitutionRatificationState.Rejected); + equal(result.objections, 1); + equal(result.distinctAgreeAgents, 3); +}); + +test("zero agreements -> Proposed", () => { + const result = evaluateConstitutionRatification({ agreements: [] }); + equal(result.state, ConstitutionRatificationState.Proposed); + equal(result.distinctAgreeAgents, 0); + equal(result.objections, 0); +}); + +test("custom quorum 2 with two distinct agreers -> Ratified", () => { + const result = evaluateConstitutionRatification({ + agreements: [agree("a", "h1"), agree("b", "h2")], + quorum: 2, + }); + equal(result.state, ConstitutionRatificationState.Ratified); + equal(result.distinctAgreeAgents, 2); + equal(result.quorum, 2); +}); diff --git a/agentic-organization/packages/hermes/src/hermes-runtime.ts b/agentic-organization/packages/hermes/src/hermes-runtime.ts new file mode 100644 index 0000000000..90ad88f4cb --- /dev/null +++ b/agentic-organization/packages/hermes/src/hermes-runtime.ts @@ -0,0 +1,176 @@ +/** + * Hermes runtime port + in-process simulated adapter. + * + * launch_hermes_run (V0_EXECUTABLE_CONTRACT step 10) binds an Organization work + * item, agent, session, hat assignment, and prompt-flow run to the Hermes/OZ + * runtime. V0 explicitly permits a SIMULATED adapter ("even if the runtime + * adapter is still simulated"). This is the AUTONOMOUS DATA PLANE the keep-alive + * control plane watches: a run emits heartbeats (which keep-alive checks for + * staleness) and terminates Completed or Failed. + * + * The port is the seam a real bubblewrapped-k3s-session adapter implements later; + * the in-process fake makes the V0 vertical slice runnable end to end. Every run + * state is an explicit DU; operations return Result-as-DU (never throw for an + * expected condition like an unknown or terminal run). + */ + +export const HermesRunState = { + Running: "running", + Completed: "completed", + Failed: "failed", +} as const; +export type HermesRunState = (typeof HermesRunState)[keyof typeof HermesRunState]; + +/** The Organization facts bound to a Hermes run (V0 step 10). */ +export type HermesRunBinding = { + workItemId: string; + agentId: string; + sessionId: string; + hatAssignmentId: string; + promptFlowRunId: string; +}; + +export type HermesRunOutcome = { + summary: string; + evidenceRefs: readonly string[]; +}; + +export type HermesRun = { + runId: string; + binding: HermesRunBinding; + state: HermesRunState; + lastHeartbeatMs: number; + outcome?: HermesRunOutcome; + failureReason?: string; +}; + +export const HermesRuntimeFeedbackReason = { + UnknownRun: "unknown_run", + RunNotRunning: "run_not_running", +} as const; +export type HermesRuntimeFeedbackReason = + (typeof HermesRuntimeFeedbackReason)[keyof typeof HermesRuntimeFeedbackReason]; + +export type HermesRunResult = + | { outcome: "ok"; run: HermesRun } + | { outcome: "feedback"; feedback: { reason: HermesRuntimeFeedbackReason; message: string } }; + +export interface HermesRuntime { + launchRun(binding: HermesRunBinding): Promise; + getRun(runId: string): Promise; + heartbeat(runId: string): Promise; + completeRun(runId: string, outcome: HermesRunOutcome): Promise; + failRun(runId: string, reason: string): Promise; +} + +export type HermesRuntimeDeps = { + clock: { now: () => number }; + idGenerator: { nextRunId: () => string }; +}; + +function defaultDeps(): HermesRuntimeDeps { + let counter = 0; + return { + clock: { now: () => Date.now() }, + idGenerator: { nextRunId: () => `hermes-run-${++counter}` }, + }; +} + +/** + * An in-process simulated Hermes runtime. Holds runs in a Map; deterministic + * when given a deterministic clock + id generator. Real adapters (k3s session + + * bubblewrap sandbox) implement the same HermesRuntime port. + */ +export function createInProcessHermesRuntime(deps: HermesRuntimeDeps = defaultDeps()): HermesRuntime { + const runs = new Map(); + + function feedback(reason: HermesRuntimeFeedbackReason, message: string): HermesRunResult { + return { outcome: "feedback", feedback: { reason, message } }; + } + + /** + * Deep snapshot of a run so callers cannot mutate the authoritative record via + * the nested `binding`/`outcome` references (a shallow `{...run}` would leak + * them). Returns a fully detached copy. + */ + function snapshotRun(run: HermesRun): HermesRun { + const copy: HermesRun = { + runId: run.runId, + binding: { ...run.binding }, + state: run.state, + lastHeartbeatMs: run.lastHeartbeatMs, + }; + if (run.outcome !== undefined) { + copy.outcome = { summary: run.outcome.summary, evidenceRefs: [...run.outcome.evidenceRefs] }; + } + if (run.failureReason !== undefined) { + copy.failureReason = run.failureReason; + } + return copy; + } + + // Explicit tagged result so narrowing is on a dedicated `ok` discriminant, + // never the `outcome` field name (which HermesRun itself carries). + type RunningCheck = { ok: true; run: HermesRun } | { ok: false; result: HermesRunResult }; + + function requireRunning(runId: string): RunningCheck { + const run = runs.get(runId); + if (run === undefined) { + return { ok: false, result: feedback(HermesRuntimeFeedbackReason.UnknownRun, `no hermes run '${runId}'`) }; + } + if (run.state !== HermesRunState.Running) { + return { + ok: false, + result: feedback(HermesRuntimeFeedbackReason.RunNotRunning, `hermes run '${runId}' is ${run.state}, not running`), + }; + } + return { ok: true, run }; + } + + return { + async launchRun(binding: HermesRunBinding): Promise { + const run: HermesRun = { + runId: deps.idGenerator.nextRunId(), + binding, + state: HermesRunState.Running, + lastHeartbeatMs: deps.clock.now(), + }; + runs.set(run.runId, run); + return { outcome: "ok", run: snapshotRun(run) }; + }, + + async getRun(runId: string): Promise { + const run = runs.get(runId); + return run === undefined ? undefined : snapshotRun(run); + }, + + async heartbeat(runId: string): Promise { + const check = requireRunning(runId); + if (!check.ok) { + return check.result; + } + check.run.lastHeartbeatMs = deps.clock.now(); + return { outcome: "ok", run: snapshotRun(check.run) }; + }, + + async completeRun(runId: string, outcome: HermesRunOutcome): Promise { + const check = requireRunning(runId); + if (!check.ok) { + return check.result; + } + check.run.state = HermesRunState.Completed; + check.run.outcome = { summary: outcome.summary, evidenceRefs: [...outcome.evidenceRefs] }; + return { outcome: "ok", run: snapshotRun(check.run) }; + }, + + async failRun(runId: string, reason: string): Promise { + const check = requireRunning(runId); + if (!check.ok) { + return check.result; + } + check.run.state = HermesRunState.Failed; + check.run.failureReason = reason; + return { outcome: "ok", run: snapshotRun(check.run) }; + }, + }; +} diff --git a/agentic-organization/packages/hermes/src/index.ts b/agentic-organization/packages/hermes/src/index.ts new file mode 100644 index 0000000000..5f1cfd2087 --- /dev/null +++ b/agentic-organization/packages/hermes/src/index.ts @@ -0,0 +1,11 @@ +export { + HermesRunState, + HermesRuntimeFeedbackReason, + createInProcessHermesRuntime, + type HermesRun, + type HermesRunBinding, + type HermesRunOutcome, + type HermesRunResult, + type HermesRuntime, + type HermesRuntimeDeps, +} from "./hermes-runtime.ts"; diff --git a/agentic-organization/packages/hermes/test/hermes-runtime.test.ts b/agentic-organization/packages/hermes/test/hermes-runtime.test.ts new file mode 100644 index 0000000000..5486e4fa07 --- /dev/null +++ b/agentic-organization/packages/hermes/test/hermes-runtime.test.ts @@ -0,0 +1,82 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { + HermesRunState, + createInProcessHermesRuntime, + type HermesRunBinding, +} from "../src/hermes-runtime.ts"; + +function binding(overrides: Partial = {}): HermesRunBinding { + return { + workItemId: "wi-1", + agentId: "agent-1", + sessionId: "sess-1", + hatAssignmentId: "hat-1", + promptFlowRunId: "pf-1", + ...overrides, + }; +} + +test("launching a run binds the work item/agent/session and returns Running", async () => { + const runtime = createInProcessHermesRuntime(); + const launched = await runtime.launchRun(binding()); + equal(launched.outcome, "ok"); + if (launched.outcome !== "ok") return; + equal(launched.run.state, HermesRunState.Running); + equal(launched.run.binding.workItemId, "wi-1"); + // a fresh run produces a fresh heartbeat + equal(launched.run.lastHeartbeatMs >= 0, true); +}); + +test("a launched run is observable by id and its heartbeat can be refreshed", async () => { + const runtime = createInProcessHermesRuntime(); + const launched = await runtime.launchRun(binding()); + if (launched.outcome !== "ok") return; + const runId = launched.run.runId; + + const before = (await runtime.getRun(runId))?.lastHeartbeatMs ?? -1; + await runtime.heartbeat(runId); + const after = (await runtime.getRun(runId))?.lastHeartbeatMs ?? -1; + equal(after >= before, true); +}); + +test("completing a run moves it to Completed and records the outcome", async () => { + const runtime = createInProcessHermesRuntime(); + const launched = await runtime.launchRun(binding()); + if (launched.outcome !== "ok") return; + const done = await runtime.completeRun(launched.run.runId, { summary: "did the work", evidenceRefs: ["log-1"] }); + equal(done.outcome, "ok"); + if (done.outcome !== "ok") return; + equal(done.run.state, HermesRunState.Completed); + equal(done.run.outcome?.summary, "did the work"); +}); + +test("failing a run moves it to Failed with a reason", async () => { + const runtime = createInProcessHermesRuntime(); + const launched = await runtime.launchRun(binding()); + if (launched.outcome !== "ok") return; + const failed = await runtime.failRun(launched.run.runId, "sandbox denied network"); + equal(failed.outcome, "ok"); + if (failed.outcome !== "ok") return; + equal(failed.run.state, HermesRunState.Failed); + equal(failed.run.failureReason, "sandbox denied network"); +}); + +test("operating on an unknown run returns feedback, not a throw", async () => { + const runtime = createInProcessHermesRuntime(); + const result = await runtime.completeRun("nope", { summary: "x", evidenceRefs: [] }); + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "unknown_run"); +}); + +test("a completed run cannot be completed again (terminal-state guard)", async () => { + const runtime = createInProcessHermesRuntime(); + const launched = await runtime.launchRun(binding()); + if (launched.outcome !== "ok") return; + await runtime.completeRun(launched.run.runId, { summary: "x", evidenceRefs: [] }); + const again = await runtime.completeRun(launched.run.runId, { summary: "y", evidenceRefs: [] }); + equal(again.outcome, "feedback"); + if (again.outcome !== "feedback") return; + equal(again.feedback.reason, "run_not_running"); +}); diff --git a/agentic-organization/packages/keepalive/src/index.ts b/agentic-organization/packages/keepalive/src/index.ts new file mode 100644 index 0000000000..48c8231407 --- /dev/null +++ b/agentic-organization/packages/keepalive/src/index.ts @@ -0,0 +1,22 @@ +export { + AgentLiveness, + KeepAliveActionKind, + OrgLiveness, + evaluateKeepAlive, + type AgentHeartbeat, + type AgentLivenessResult, + type KeepAliveAction, + type KeepAliveResult, + type KeepAliveSnapshot, + type RuntimeLease, +} from "./keepalive.ts"; +export { + KeepAliveLaneStatus, + createKeepAliveLane, + type CreateKeepAliveLaneInput, + type KeepAliveActionSink, + type KeepAliveLane, + type KeepAliveLaneFailure, + type KeepAliveLaneResult, + type KeepAliveSnapshotSource, +} from "./keepalive-lane.ts"; diff --git a/agentic-organization/packages/keepalive/src/keepalive-lane.ts b/agentic-organization/packages/keepalive/src/keepalive-lane.ts new file mode 100644 index 0000000000..b3b3ff396f --- /dev/null +++ b/agentic-organization/packages/keepalive/src/keepalive-lane.ts @@ -0,0 +1,91 @@ +/** + * Keep-alive lane — the SchedulerWorker shape that turns the pure keep-alive + * engine into a self-driving control-plane loop. + * + * The host calls runOnce() on a cadence (e.g. every few seconds). Each tick: + * 1. loads a fresh liveness snapshot (from Cockroach via the source port) + * 2. evaluates it deterministically (evaluateKeepAlive) + * 3. applies each emitted action through the sink (which routes them through + * the command pipeline — heartbeat event, org-stall alert, stale-work + * reassignment signal, lease reap) + * + * Crucially, NOTHING here throws on a transient failure: a source or sink error + * is captured as a lane failure and the loop keeps ticking next cadence. The + * org's heartbeat must not die because one apply failed — that is the whole + * point of "drive the organization to stay alive". Mirrors the worker-host + * lane-failure discipline (packages/workers/src/worker-host.ts). + */ + +import { OrgLiveness, evaluateKeepAlive, type KeepAliveAction, type KeepAliveSnapshot } from "./keepalive.ts"; + +export interface KeepAliveSnapshotSource { + loadSnapshot(): Promise; +} + +export interface KeepAliveActionSink { + applyAction(action: KeepAliveAction): Promise; +} + +export const KeepAliveLaneStatus = { + /** the tick ran and the org is alive */ + Ticked: "ticked", + /** the tick ran but something is degraded (org flatlining or an apply failed) */ + Degraded: "degraded", +} as const; +export type KeepAliveLaneStatus = (typeof KeepAliveLaneStatus)[keyof typeof KeepAliveLaneStatus]; + +export type KeepAliveLaneFailure = { message: string }; + +export type KeepAliveLaneResult = { + status: KeepAliveLaneStatus; + appliedCount: number; + failures: readonly KeepAliveLaneFailure[]; +}; + +export type KeepAliveLane = { + runOnce: () => Promise; +}; + +export type CreateKeepAliveLaneInput = { + source: KeepAliveSnapshotSource; + sink: KeepAliveActionSink; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createKeepAliveLane(input: CreateKeepAliveLaneInput): KeepAliveLane { + return { + runOnce: async (): Promise => { + const failures: KeepAliveLaneFailure[] = []; + + let snapshot: KeepAliveSnapshot; + try { + snapshot = await input.source.loadSnapshot(); + } catch (error) { + // could not even read liveness — degraded, but the loop survives + return { status: KeepAliveLaneStatus.Degraded, appliedCount: 0, failures: [{ message: errorMessage(error) }] }; + } + + const evaluation = evaluateKeepAlive(snapshot); + + let appliedCount = 0; + for (const action of evaluation.actions) { + try { + await input.sink.applyAction(action); + appliedCount += 1; + } catch (error) { + failures.push({ message: errorMessage(error) }); + } + } + + const degraded = failures.length > 0 || evaluation.orgLiveness === OrgLiveness.Flatlining; + return { + status: degraded ? KeepAliveLaneStatus.Degraded : KeepAliveLaneStatus.Ticked, + appliedCount, + failures, + }; + }, + }; +} diff --git a/agentic-organization/packages/keepalive/src/keepalive.ts b/agentic-organization/packages/keepalive/src/keepalive.ts new file mode 100644 index 0000000000..f26dbf061b --- /dev/null +++ b/agentic-organization/packages/keepalive/src/keepalive.ts @@ -0,0 +1,157 @@ +/** + * Deterministic keep-alive engine — the operator's #1 tenet. + * + * "Enough determinism to drive the organization to stay alive and drive the + * agents to stay alive, with sufficient autonomy from the agents themselves." + * + * This is the DETERMINISTIC CONTROL PLANE. evaluateKeepAlive is a pure function + * over a snapshot of liveness facts. It guarantees motion: every tick emits a + * heartbeat (the org keeps proving it is alive), deterministically detects a + * flatlining org, stale agents, and expired leases, and converts each into an + * explicit keep-alive action. It NEVER decides WHAT work to do — that is the + * autonomous data plane (Hermes agents selecting legal options via observe.ts). + * It only guarantees the org and its agents keep moving and that a silent agent + * is detected and its work re-routed. + * + * Pure + port-free: no clock, no I/O. The host calls it on a cadence with a + * fresh snapshot and applies the returned actions through the command pipeline. + * Every distinct liveness state and action is an explicit DU (repo rule: + * IMPLICIT-NOT-EXPLICIT is class error). + */ + +export const OrgLiveness = { + /** control-plane heartbeat is within its deadline */ + Alive: "alive", + /** control-plane heartbeat is past its deadline — the org stopped proving life */ + Flatlining: "flatlining", +} as const; +export type OrgLiveness = (typeof OrgLiveness)[keyof typeof OrgLiveness]; + +export const AgentLiveness = { + Alive: "alive", + Stale: "stale", +} as const; +export type AgentLiveness = (typeof AgentLiveness)[keyof typeof AgentLiveness]; + +/** One agent session's heartbeat fact. */ +export type AgentHeartbeat = { + agentId: string; + hatAssignmentId: string; + workItemId: string; + /** ms since the agent last proved liveness */ + heartbeatAgeMs: number; + /** max ms before the agent is considered stale */ + deadlineMs: number; +}; + +/** A runtime lease guarding a resource with a fencing token. */ +export type RuntimeLease = { + leaseId: string; + resource: string; + holderAgentId: string; + /** epoch ms at which the lease expires */ + expiresAtMs: number; + fencingToken: number; +}; + +/** The liveness snapshot the host loads and hands to the pure evaluator. */ +export type KeepAliveSnapshot = { + /** current epoch ms */ + nowMs: number; + /** ms since the control-plane last ticked */ + orgHeartbeatAgeMs: number; + /** max ms before the org is considered flatlining */ + orgHeartbeatDeadlineMs: number; + agents: readonly AgentHeartbeat[]; + leases: readonly RuntimeLease[]; +}; + +/** The deterministic actions keep-alive emits to guarantee motion. */ +export const KeepAliveActionKind = { + /** always emitted — the org keeps proving it is alive */ + EmitHeartbeat: "emit_heartbeat", + /** the org control-plane went silent — raise a self-heal alert */ + RaiseOrgStallAlert: "raise_org_stall_alert", + /** a stale agent's work is flagged for reassignment (agent-decidable follow-up) */ + ReassignStaleWork: "reassign_stale_work", + /** an expired lease is reaped, freeing the resource */ + ReapLease: "reap_lease", +} as const; +export type KeepAliveActionKind = (typeof KeepAliveActionKind)[keyof typeof KeepAliveActionKind]; + +export type KeepAliveAction = + | { kind: typeof KeepAliveActionKind.EmitHeartbeat; ageMs: number } + | { kind: typeof KeepAliveActionKind.RaiseOrgStallAlert; ageMs: number; deadlineMs: number } + | { kind: typeof KeepAliveActionKind.ReassignStaleWork; staleAgentId: string; hatAssignmentId: string; workItemId: string; heartbeatAgeMs: number } + | { kind: typeof KeepAliveActionKind.ReapLease; leaseId: string; resource: string; holderAgentId: string; fencingToken: number }; + +export type AgentLivenessResult = { + agentId: string; + liveness: AgentLiveness; +}; + +export type KeepAliveResult = { + orgLiveness: OrgLiveness; + agentLiveness: readonly AgentLivenessResult[]; + actions: readonly KeepAliveAction[]; +}; + +/** + * Evaluate liveness deterministically and emit the keep-alive actions. + * Order of actions is stable: heartbeat first, then org alert, then per-agent + * reassignments (in input order), then lease reaps (in input order). + * + * Boundary policy (pinned by tests): heartbeat age uses strict `>` — an entity + * exactly AT its deadline is still Alive (it proved liveness right at the edge). + * Lease expiry uses `<=` — a lease whose expiry is exactly now IS expired and is + * reaped. These two boundaries differ on purpose: "age past deadline" is a + * strict-greater event, "expired at or after now" is at-or-after. + */ +export function evaluateKeepAlive(snapshot: KeepAliveSnapshot): KeepAliveResult { + const actions: KeepAliveAction[] = []; + + // 1. always tick — motion is guaranteed even when idle + actions.push({ kind: KeepAliveActionKind.EmitHeartbeat, ageMs: snapshot.orgHeartbeatAgeMs }); + + // 2. org liveness + const orgLiveness = + snapshot.orgHeartbeatAgeMs > snapshot.orgHeartbeatDeadlineMs ? OrgLiveness.Flatlining : OrgLiveness.Alive; + if (orgLiveness === OrgLiveness.Flatlining) { + actions.push({ + kind: KeepAliveActionKind.RaiseOrgStallAlert, + ageMs: snapshot.orgHeartbeatAgeMs, + deadlineMs: snapshot.orgHeartbeatDeadlineMs, + }); + } + + // 3. per-agent liveness — stale agents get a reassignment action each (no collapse) + const agentLiveness: AgentLivenessResult[] = []; + for (const agent of snapshot.agents) { + const stale = agent.heartbeatAgeMs > agent.deadlineMs; + agentLiveness.push({ agentId: agent.agentId, liveness: stale ? AgentLiveness.Stale : AgentLiveness.Alive }); + if (stale) { + actions.push({ + kind: KeepAliveActionKind.ReassignStaleWork, + staleAgentId: agent.agentId, + hatAssignmentId: agent.hatAssignmentId, + workItemId: agent.workItemId, + heartbeatAgeMs: agent.heartbeatAgeMs, + }); + } + } + + // 4. expired leases are reaped + for (const lease of snapshot.leases) { + if (lease.expiresAtMs <= snapshot.nowMs) { + actions.push({ + kind: KeepAliveActionKind.ReapLease, + leaseId: lease.leaseId, + resource: lease.resource, + holderAgentId: lease.holderAgentId, + fencingToken: lease.fencingToken, + }); + } + } + + return { orgLiveness, agentLiveness, actions }; +} diff --git a/agentic-organization/packages/keepalive/test/keepalive-lane.test.ts b/agentic-organization/packages/keepalive/test/keepalive-lane.test.ts new file mode 100644 index 0000000000..be8bc7cc46 --- /dev/null +++ b/agentic-organization/packages/keepalive/test/keepalive-lane.test.ts @@ -0,0 +1,82 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { KeepAliveActionKind, type KeepAliveSnapshot } from "../src/keepalive.ts"; +import { + KeepAliveLaneStatus, + createKeepAliveLane, + type KeepAliveActionSink, + type KeepAliveSnapshotSource, +} from "../src/keepalive-lane.ts"; + +function fixedSource(snapshot: KeepAliveSnapshot): KeepAliveSnapshotSource { + return { loadSnapshot: async () => snapshot }; +} + +function recordingSink(): KeepAliveActionSink & { kinds: string[] } { + const kinds: string[] = []; + return { + kinds, + applyAction: async (action) => { + kinds.push(action.kind); + }, + }; +} + +const aliveSnapshot: KeepAliveSnapshot = { + nowMs: 1000, + orgHeartbeatAgeMs: 5_000, + orgHeartbeatDeadlineMs: 30_000, + agents: [], + leases: [], +}; + +test("a tick on a live, idle org applies exactly the heartbeat action and reports Ticked", async () => { + const sink = recordingSink(); + const lane = createKeepAliveLane({ source: fixedSource(aliveSnapshot), sink }); + const result = await lane.runOnce(); + equal(result.status, KeepAliveLaneStatus.Ticked); + equal(sink.kinds.length, 1); + equal(sink.kinds[0], KeepAliveActionKind.EmitHeartbeat); + equal(result.appliedCount, 1); +}); + +test("a flatlining org reports Degraded and still applies its actions", async () => { + const sink = recordingSink(); + const lane = createKeepAliveLane({ + source: fixedSource({ ...aliveSnapshot, orgHeartbeatAgeMs: 60_000 }), + sink, + }); + const result = await lane.runOnce(); + equal(result.status, KeepAliveLaneStatus.Degraded); + equal(sink.kinds.includes(KeepAliveActionKind.RaiseOrgStallAlert), true); +}); + +test("a sink failure is captured as a lane failure, not thrown (loop must not die)", async () => { + const lane = createKeepAliveLane({ + source: fixedSource(aliveSnapshot), + sink: { + applyAction: async () => { + throw new Error("sink exploded"); + }, + }, + }); + const result = await lane.runOnce(); + equal(result.status, KeepAliveLaneStatus.Degraded); + equal(result.failures.length, 1); + equal(result.failures[0]?.message.includes("sink exploded"), true); +}); + +test("a source failure is captured as a lane failure, not thrown", async () => { + const lane = createKeepAliveLane({ + source: { + loadSnapshot: async () => { + throw new Error("source exploded"); + }, + }, + sink: recordingSink(), + }); + const result = await lane.runOnce(); + equal(result.status, KeepAliveLaneStatus.Degraded); + equal(result.failures[0]?.message.includes("source exploded"), true); + equal(result.appliedCount, 0); +}); diff --git a/agentic-organization/packages/keepalive/test/keepalive.test.ts b/agentic-organization/packages/keepalive/test/keepalive.test.ts new file mode 100644 index 0000000000..e91ad899c0 --- /dev/null +++ b/agentic-organization/packages/keepalive/test/keepalive.test.ts @@ -0,0 +1,105 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { test } from "node:test"; +import { + AgentLiveness, + KeepAliveActionKind, + OrgLiveness, + evaluateKeepAlive, + type AgentHeartbeat, + type KeepAliveSnapshot, + type RuntimeLease, +} from "../src/keepalive.ts"; + +// fixed "now" in epoch ms for deterministic freshness math +const NOW = 1_000_000; + +function snapshot(overrides: Partial = {}): KeepAliveSnapshot { + return { + nowMs: NOW, + orgHeartbeatAgeMs: 0, + orgHeartbeatDeadlineMs: 30_000, + agents: [], + leases: [], + ...overrides, + }; +} + +function agent(id: string, ageMs: number): AgentHeartbeat { + return { agentId: id, hatAssignmentId: `${id}-hat`, workItemId: `wi-${id}`, heartbeatAgeMs: ageMs, deadlineMs: 60_000 }; +} + +function lease(id: string, expiresInMs: number): RuntimeLease { + return { leaseId: id, resource: `res-${id}`, holderAgentId: "a", expiresAtMs: NOW + expiresInMs, fencingToken: 1 }; +} + +test("org is alive when the control-plane heartbeat is within its deadline", () => { + const result = evaluateKeepAlive(snapshot({ orgHeartbeatAgeMs: 10_000, orgHeartbeatDeadlineMs: 30_000 })); + equal(result.orgLiveness, OrgLiveness.Alive); + // a heartbeat tick is ALWAYS emitted — the org must keep proving it is alive + equal(result.actions.some((a) => a.kind === KeepAliveActionKind.EmitHeartbeat), true); +}); + +test("org is flatlining when the heartbeat is past its deadline (deterministic detection)", () => { + const result = evaluateKeepAlive(snapshot({ orgHeartbeatAgeMs: 45_000, orgHeartbeatDeadlineMs: 30_000 })); + equal(result.orgLiveness, OrgLiveness.Flatlining); + // a flatlining org raises a deterministic self-heal action + equal(result.actions.some((a) => a.kind === KeepAliveActionKind.RaiseOrgStallAlert), true); +}); + +test("a fresh agent is alive and produces no nudge", () => { + const result = evaluateKeepAlive(snapshot({ agents: [agent("a1", 5_000)] })); + const a1 = result.agentLiveness.find((x) => x.agentId === "a1"); + equal(a1?.liveness, AgentLiveness.Alive); + equal(result.actions.some((a) => a.kind === KeepAliveActionKind.ReassignStaleWork), false); +}); + +test("a stale agent is detected deterministically and its work is flagged for reassignment", () => { + const result = evaluateKeepAlive(snapshot({ agents: [agent("a2", 90_000)] })); + const a2 = result.agentLiveness.find((x) => x.agentId === "a2"); + equal(a2?.liveness, AgentLiveness.Stale); + // determinism guarantees the detection; the action converts it to agent-decidable work + const reassign = result.actions.find((a) => a.kind === KeepAliveActionKind.ReassignStaleWork); + equal(reassign?.kind, KeepAliveActionKind.ReassignStaleWork); + if (reassign?.kind !== KeepAliveActionKind.ReassignStaleWork) return; + equal(reassign.workItemId, "wi-a2"); + equal(reassign.staleAgentId, "a2"); +}); + +test("expired leases are reaped (frees the resource for a live holder)", () => { + const result = evaluateKeepAlive(snapshot({ leases: [lease("l1", -1_000), lease("l2", 10_000)] })); + const reaped = result.actions.filter((a) => a.kind === KeepAliveActionKind.ReapLease); + equal(reaped.length, 1); + if (reaped[0]?.kind !== KeepAliveActionKind.ReapLease) return; + equal(reaped[0].leaseId, "l1"); +}); + +test("a quiet but live org with no agents still ticks (keep-alive never silently stops)", () => { + const result = evaluateKeepAlive(snapshot()); + equal(result.orgLiveness, OrgLiveness.Alive); + // even idle, the heartbeat tick is emitted — motion is guaranteed + deepEqual( + result.actions.map((a) => a.kind), + [KeepAliveActionKind.EmitHeartbeat], + ); +}); + +test("multiple stale agents each get their own reassignment action (no collapse)", () => { + const result = evaluateKeepAlive(snapshot({ agents: [agent("a3", 90_000), agent("a4", 120_000), agent("a5", 1_000)] })); + const reassigns = result.actions.filter((a) => a.kind === KeepAliveActionKind.ReassignStaleWork); + equal(reassigns.length, 2); +}); + +test("BOUNDARY: an entity exactly AT its deadline is still alive (> not >=)", () => { + // org at exactly deadline -> Alive + const org = evaluateKeepAlive(snapshot({ orgHeartbeatAgeMs: 30_000, orgHeartbeatDeadlineMs: 30_000 })); + equal(org.orgLiveness, OrgLiveness.Alive); + // agent at exactly deadline -> Alive + const ag = evaluateKeepAlive(snapshot({ agents: [{ agentId: "b1", hatAssignmentId: "b1-hat", workItemId: "wi-b1", heartbeatAgeMs: 60_000, deadlineMs: 60_000 }] })); + equal(ag.agentLiveness.find((x) => x.agentId === "b1")?.liveness, AgentLiveness.Alive); +}); + +test("BOUNDARY: a lease expiring exactly now is reaped (<= not <)", () => { + const result = evaluateKeepAlive(snapshot({ leases: [lease("l0", 0)] })); + const reaped = result.actions.filter((a) => a.kind === KeepAliveActionKind.ReapLease); + equal(reaped.length, 1); +}); diff --git a/agentic-organization/packages/memory/src/index.ts b/agentic-organization/packages/memory/src/index.ts new file mode 100644 index 0000000000..208c8801d6 --- /dev/null +++ b/agentic-organization/packages/memory/src/index.ts @@ -0,0 +1,11 @@ +export { + MemoryOperation, + createInProcessMemory, + type Memory, + type MemoryAttribution, + type MemoryDeps, + type MemoryRecord, + type RecallResult, + type ReflectResult, + type RetainResult, +} from "./memory.ts"; diff --git a/agentic-organization/packages/memory/src/memory.ts b/agentic-organization/packages/memory/src/memory.ts new file mode 100644 index 0000000000..b14cd6b655 --- /dev/null +++ b/agentic-organization/packages/memory/src/memory.ts @@ -0,0 +1,105 @@ +/** + * Memory port (Hindsight contract) + in-process simulated adapter. + * + * The North Star "Hindsight Memory Attribution" gap: memory writes are attributed + * by active hat assignment (agent, hat, project, work item, prompt-flow run) and + * recall is SCOPED through Organization memory policy. Hindsight exposes explicit + * retain / recall / reflect operations (CLUSTER_EXECUTION_AND_MEMORY_SUBSTRATE). + * + * V0 ships a port + in-process fake so the vertical slice (an agent retains what + * it learned, recalls scoped context, reflects at outcome review) runs end to + * end. A real Hindsight adapter implements the same port. Attribution is STICKY: + * a memory keeps its original author even when recalled by another hat (the doc's + * "sticky attribution after hat release"). Every operation result is an explicit + * DU; recall is scoped by projectId, never global. + */ + +export const MemoryOperation = { + Retain: "retain", + Recall: "recall", + Reflect: "reflect", +} as const; +export type MemoryOperation = (typeof MemoryOperation)[keyof typeof MemoryOperation]; + +/** Who/what a memory write is attributed to (the governance scope). */ +export type MemoryAttribution = { + agentId: string; + hatAssignmentId: string; + projectId: string; + workItemId: string; + promptFlowRunId: string; +}; + +export type MemoryRecord = { + memoryId: string; + attribution: MemoryAttribution; + content: string; + retainedAtMs: number; +}; + +export type RetainResult = { operation: typeof MemoryOperation.Retain; memory: MemoryRecord }; +export type RecallResult = { operation: typeof MemoryOperation.Recall; memories: readonly MemoryRecord[] }; +export type ReflectResult = { + operation: typeof MemoryOperation.Reflect; + consideredCount: number; + summary: string; +}; + +export interface Memory { + retain(attribution: MemoryAttribution, content: string): Promise; + recall(attribution: MemoryAttribution): Promise; + reflect(attribution: MemoryAttribution): Promise; +} + +export type MemoryDeps = { + clock: { now: () => number }; + idGenerator: { nextMemoryId: () => string }; +}; + +function defaultDeps(): MemoryDeps { + let counter = 0; + return { + clock: { now: () => Date.now() }, + idGenerator: { nextMemoryId: () => `mem-${++counter}` }, + }; +} + +/** + * In-process simulated memory. Recall is scoped to the requesting attribution's + * projectId (Organization memory policy: scoped recall, not global). Stored + * memories keep their original attribution (sticky). + */ +export function createInProcessMemory(deps: MemoryDeps = defaultDeps()): Memory { + const store: MemoryRecord[] = []; + + function inScope(record: MemoryRecord, attribution: MemoryAttribution): boolean { + return record.attribution.projectId === attribution.projectId; + } + + return { + async retain(attribution: MemoryAttribution, content: string): Promise { + const memory: MemoryRecord = { + memoryId: deps.idGenerator.nextMemoryId(), + attribution: { ...attribution }, + content, + retainedAtMs: deps.clock.now(), + }; + store.push(memory); + return { operation: MemoryOperation.Retain, memory: { ...memory } }; + }, + + async recall(attribution: MemoryAttribution): Promise { + const memories = store.filter((record) => inScope(record, attribution)).map((record) => ({ ...record })); + return { operation: MemoryOperation.Recall, memories }; + }, + + async reflect(attribution: MemoryAttribution): Promise { + const inScopeRecords = store.filter((record) => inScope(record, attribution)); + const summary = + inScopeRecords.length === 0 + ? "no memories in scope to reflect on" + : `reflected on ${inScopeRecords.length} memories in project ${attribution.projectId}`; + return { operation: MemoryOperation.Reflect, consideredCount: inScopeRecords.length, summary }; + }, + }; +} diff --git a/agentic-organization/packages/memory/test/memory.test.ts b/agentic-organization/packages/memory/test/memory.test.ts new file mode 100644 index 0000000000..387723ad60 --- /dev/null +++ b/agentic-organization/packages/memory/test/memory.test.ts @@ -0,0 +1,60 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { + MemoryOperation, + createInProcessMemory, + type MemoryAttribution, +} from "../src/memory.ts"; + +function attribution(overrides: Partial = {}): MemoryAttribution { + return { + agentId: "agent-1", + hatAssignmentId: "hat-1", + projectId: "proj-1", + workItemId: "wi-1", + promptFlowRunId: "pf-1", + ...overrides, + }; +} + +test("retain stores a memory attributed to agent/hat/project/run", async () => { + const memory = createInProcessMemory(); + const r = await memory.retain(attribution(), "the build flag is -O2"); + equal(r.operation, MemoryOperation.Retain); + equal(r.memory.attribution.agentId, "agent-1"); + equal(r.memory.content, "the build flag is -O2"); +}); + +test("recall returns memories within the same project scope", async () => { + const memory = createInProcessMemory(); + await memory.retain(attribution(), "fact A"); + await memory.retain(attribution(), "fact B"); + const recalled = await memory.recall(attribution()); + equal(recalled.memories.length, 2); +}); + +test("recall is SCOPED — a different project does not see another project's memories", async () => { + const memory = createInProcessMemory(); + await memory.retain(attribution({ projectId: "proj-1" }), "secret of proj 1"); + const other = await memory.recall(attribution({ projectId: "proj-2" })); + equal(other.memories.length, 0); +}); + +test("reflect summarizes the memories in scope without losing attribution", async () => { + const memory = createInProcessMemory(); + await memory.retain(attribution(), "fact A"); + await memory.retain(attribution(), "fact B"); + const reflection = await memory.reflect(attribution()); + equal(reflection.operation, MemoryOperation.Reflect); + equal(reflection.consideredCount, 2); + equal(reflection.summary.length > 0, true); +}); + +test("sticky attribution: a memory keeps its original agent even when recalled by another hat", async () => { + const memory = createInProcessMemory(); + await memory.retain(attribution({ agentId: "agent-original" }), "owned by original"); + const recalled = await memory.recall(attribution({ agentId: "agent-other" })); + // same project scope, so visible; attribution stays the original author + equal(recalled.memories.length, 1); + equal(recalled.memories[0]?.attribution.agentId, "agent-original"); +}); diff --git a/agentic-organization/packages/metrics/src/code-metrics.ts b/agentic-organization/packages/metrics/src/code-metrics.ts new file mode 100644 index 0000000000..4c4bd35c56 --- /dev/null +++ b/agentic-organization/packages/metrics/src/code-metrics.ts @@ -0,0 +1,176 @@ +/** + * Quantitative code metrics — "coverage for structure". + * + * These are mechanical, deterministic measurements over source text, gathered + * the way test coverage is: longest function / longest class (god-object + * detection), file length, and maximum nesting depth. They are heuristics + * (brace-aware, not a full parser) intended to FLAG candidates for the + * qualitative review board, not to be authoritative. Pure functions, no I/O. + * + * Every threshold breach is an explicit MetricFinding with a discriminated + * `metric` kind, so a "god class" finding is never indistinguishable from a + * "long file" finding (repo rule: IMPLICIT-NOT-EXPLICIT in DUs is class error). + */ + +export const CodeMetricKind = { + LongestFunction: "longest_function", + LongestClass: "longest_class", + FileLength: "file_length", + MaxNestingDepth: "max_nesting_depth", +} as const; +export type CodeMetricKind = (typeof CodeMetricKind)[keyof typeof CodeMetricKind]; + +export const MetricSeverity = { + Ok: "ok", + Warn: "warn", + Flag: "flag", +} as const; +export type MetricSeverity = (typeof MetricSeverity)[keyof typeof MetricSeverity]; + +export type MetricThresholds = { + longestFunctionLines: { warn: number; flag: number }; + longestClassLines: { warn: number; flag: number }; + fileLengthLines: { warn: number; flag: number }; + maxNestingDepth: { warn: number; flag: number }; +}; + +export const DEFAULT_METRIC_THRESHOLDS: MetricThresholds = { + longestFunctionLines: { warn: 40, flag: 80 }, + longestClassLines: { warn: 200, flag: 400 }, + fileLengthLines: { warn: 400, flag: 800 }, + maxNestingDepth: { warn: 4, flag: 6 }, +}; + +export type NamedSpan = { name: string; lines: number }; + +export type MetricFinding = { + metric: CodeMetricKind; + severity: MetricSeverity; + value: number; + threshold: number; + subject: string; + message: string; +}; + +export type CodeMetricsReport = { + filePath: string; + fileLengthLines: number; + longestFunction: NamedSpan | undefined; + longestClass: NamedSpan | undefined; + maxNestingDepth: number; + findings: readonly MetricFinding[]; +}; + +const FUNCTION_DECL = /\b(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/; +const ARROW_DECL = /\b(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?\([^)]*\)\s*(?::[^=]+)?=>\s*\{/; +const METHOD_DECL = /^\s*(?:public|private|protected|static|async|\s)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^={]+)?\{/; +const CLASS_DECL = /\b(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/; + +/** Count the lines of a brace-balanced block starting at the line that opens it. */ +function blockLineSpan(lines: readonly string[], startIndex: number): number { + let depth = 0; + let started = false; + for (let i = startIndex; i < lines.length; i += 1) { + const line = lines[i]!; + for (const ch of line) { + if (ch === "{") { + depth += 1; + started = true; + } else if (ch === "}") { + depth -= 1; + } + } + if (started && depth <= 0) { + return i - startIndex + 1; + } + } + return lines.length - startIndex; +} + +function longestNamed(lines: readonly string[], patterns: readonly RegExp[]): NamedSpan | undefined { + let best: NamedSpan | undefined; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]!; + for (const pattern of patterns) { + const m = pattern.exec(line); + if (m && m[1] !== undefined && line.includes("{")) { + const span = blockLineSpan(lines, i); + if (best === undefined || span > best.lines) { + best = { name: m[1], lines: span }; + } + break; + } + } + } + return best; +} + +function maxNesting(lines: readonly string[]): number { + let depth = 0; + let max = 0; + for (const line of lines) { + for (const ch of line) { + if (ch === "{") { + depth += 1; + if (depth > max) max = depth; + } else if (ch === "}") { + depth = Math.max(0, depth - 1); + } + } + } + return max; +} + +function severityFor(value: number, band: { warn: number; flag: number }): MetricSeverity { + if (value >= band.flag) return MetricSeverity.Flag; + if (value >= band.warn) return MetricSeverity.Warn; + return MetricSeverity.Ok; +} + +export function analyzeSource( + filePath: string, + source: string, + thresholds: MetricThresholds = DEFAULT_METRIC_THRESHOLDS, +): CodeMetricsReport { + const lines = source.split("\n"); + const fileLengthLines = lines.length; + const longestFunction = longestNamed(lines, [FUNCTION_DECL, ARROW_DECL, METHOD_DECL]); + const longestClass = longestNamed(lines, [CLASS_DECL]); + const maxNestingDepth = maxNesting(lines); + + const findings: MetricFinding[] = []; + + const fileSeverity = severityFor(fileLengthLines, thresholds.fileLengthLines); + if (fileSeverity !== MetricSeverity.Ok) { + findings.push({ metric: CodeMetricKind.FileLength, severity: fileSeverity, value: fileLengthLines, threshold: thresholds.fileLengthLines.warn, subject: filePath, message: `file is ${fileLengthLines} lines` }); + } + + if (longestFunction !== undefined) { + const sev = severityFor(longestFunction.lines, thresholds.longestFunctionLines); + if (sev !== MetricSeverity.Ok) { + findings.push({ metric: CodeMetricKind.LongestFunction, severity: sev, value: longestFunction.lines, threshold: thresholds.longestFunctionLines.warn, subject: longestFunction.name, message: `function '${longestFunction.name}' is ${longestFunction.lines} lines` }); + } + } + + if (longestClass !== undefined) { + const sev = severityFor(longestClass.lines, thresholds.longestClassLines); + if (sev !== MetricSeverity.Ok) { + findings.push({ metric: CodeMetricKind.LongestClass, severity: sev, value: longestClass.lines, threshold: thresholds.longestClassLines.warn, subject: longestClass.name, message: `class '${longestClass.name}' is ${longestClass.lines} lines (god-class risk)` }); + } + } + + const nestSeverity = severityFor(maxNestingDepth, thresholds.maxNestingDepth); + if (nestSeverity !== MetricSeverity.Ok) { + findings.push({ metric: CodeMetricKind.MaxNestingDepth, severity: nestSeverity, value: maxNestingDepth, threshold: thresholds.maxNestingDepth.warn, subject: filePath, message: `max nesting depth is ${maxNestingDepth}` }); + } + + const report: CodeMetricsReport = { + filePath, + fileLengthLines, + longestFunction, + longestClass, + maxNestingDepth, + findings, + }; + return report; +} diff --git a/agentic-organization/packages/metrics/src/index.ts b/agentic-organization/packages/metrics/src/index.ts new file mode 100644 index 0000000000..32f353fe25 --- /dev/null +++ b/agentic-organization/packages/metrics/src/index.ts @@ -0,0 +1,33 @@ +export { + CodeMetricKind, + DEFAULT_METRIC_THRESHOLDS, + MetricSeverity, + analyzeSource, + type CodeMetricsReport, + type MetricFinding, + type MetricThresholds, + type NamedSpan, +} from "./code-metrics.ts"; +export { + DEFAULT_REVIEW_QUORUM, + FindingDecisionState, + ReviewBoardFeedbackReason, + ReviewDimension, + ReviewSeverity, + ReviewStance, + evaluateReviewBoard, + type CandidateFinding, + type FindingDecision, + type ReviewBoardOutcome, + type ReviewBoardResult, + type ReviewerVote, +} from "./review-board.ts"; +export { + METRICS_TOOL_DESCRIPTORS, + MetricsToolName, + dispatchMetricsTool, + type AnalyzeSourceArgs, + type McpToolDescriptor, + type MetricsToolResult, + type RunReviewBoardArgs, +} from "./mcp-tools.ts"; diff --git a/agentic-organization/packages/metrics/src/mcp-tools.ts b/agentic-organization/packages/metrics/src/mcp-tools.ts new file mode 100644 index 0000000000..aee3e458ff --- /dev/null +++ b/agentic-organization/packages/metrics/src/mcp-tools.ts @@ -0,0 +1,106 @@ +/** + * MCP tool INTERFACE for the metrics + review subsystem. + * + * This module defines the tool descriptors and a typed dispatch surface so the + * quantitative metrics and the qualitative review board can be exposed to agents + * as MCP tools. It does NOT host an MCP server — the actual transport/hosting is + * a // TODO at the bottom. Everything here is pure and testable: given a tool + * name + args, it routes to the in-process handler and returns a typed result. + * + * Each tool is an explicit descriptor (name + description + input keys), and the + * dispatch result is a discriminated union so an unknown-tool result is never + * confused with a handler result (IMPLICIT-NOT-EXPLICIT is class error). + */ + +import { analyzeSource, type CodeMetricsReport, type MetricThresholds } from "./code-metrics.ts"; +import { + evaluateReviewBoard, + type CandidateFinding, + type ReviewBoardOutcome, + type ReviewerVote, +} from "./review-board.ts"; + +export const MetricsToolName = { + AnalyzeSource: "analyze_source", + RunReviewBoard: "run_review_board", +} as const; +export type MetricsToolName = (typeof MetricsToolName)[keyof typeof MetricsToolName]; + +export type McpToolDescriptor = { + name: MetricsToolName; + description: string; + inputKeys: readonly string[]; +}; + +/** The catalog an MCP server would advertise via list_tools. */ +export const METRICS_TOOL_DESCRIPTORS: readonly McpToolDescriptor[] = [ + { + name: MetricsToolName.AnalyzeSource, + description: "Gather quantitative code metrics (longest function/class, file length, max nesting) for one source file and flag god-object risks.", + inputKeys: ["filePath", "source", "thresholds?"], + }, + { + name: MetricsToolName.RunReviewBoard, + description: "Run the >=3-agent qualitative review board over candidate findings and reviewer votes; returns which findings the board agreed to adopt.", + inputKeys: ["findings", "votes", "quorum?"], + }, +]; + +export type AnalyzeSourceArgs = { + filePath: string; + source: string; + thresholds?: MetricThresholds; +}; + +export type RunReviewBoardArgs = { + findings: readonly CandidateFinding[]; + votes: readonly ReviewerVote[]; + quorum?: number; +}; + +/** Result of dispatching a metrics tool call. */ +export type MetricsToolResult = + | { outcome: "ok"; tool: typeof MetricsToolName.AnalyzeSource; report: CodeMetricsReport } + | { outcome: "ok"; tool: typeof MetricsToolName.RunReviewBoard; board: ReviewBoardOutcome } + | { outcome: "feedback"; feedback: { reason: string; message: string } }; + +/** + * In-process dispatch — the handler an MCP server's call_tool would delegate to. + * Pure: no transport, no I/O. The server adapter (TODO) just unwraps MCP request + * shapes into these typed args and re-wraps the result. + */ +export function dispatchMetricsTool(name: string, args: unknown): MetricsToolResult { + if (name === MetricsToolName.AnalyzeSource) { + const a = args as AnalyzeSourceArgs; + if (typeof a?.filePath !== "string" || typeof a?.source !== "string") { + return { outcome: "feedback", feedback: { reason: "bad_args", message: "analyze_source requires filePath and source strings" } }; + } + const report = a.thresholds === undefined ? analyzeSource(a.filePath, a.source) : analyzeSource(a.filePath, a.source, a.thresholds); + return { outcome: "ok", tool: MetricsToolName.AnalyzeSource, report }; + } + + if (name === MetricsToolName.RunReviewBoard) { + const a = args as RunReviewBoardArgs; + if (!Array.isArray(a?.findings) || !Array.isArray(a?.votes)) { + return { outcome: "feedback", feedback: { reason: "bad_args", message: "run_review_board requires findings[] and votes[]" } }; + } + const result = a.quorum === undefined + ? evaluateReviewBoard({ findings: a.findings, votes: a.votes }) + : evaluateReviewBoard({ findings: a.findings, votes: a.votes, quorum: a.quorum }); + if (result.outcome === "feedback") { + return { outcome: "feedback", feedback: result.feedback }; + } + return { outcome: "ok", tool: MetricsToolName.RunReviewBoard, board: result.board }; + } + + return { outcome: "feedback", feedback: { reason: "unknown_tool", message: `no metrics tool named '${name}'` } }; +} + +// TODO(mcp-host): wrap dispatchMetricsTool in an actual MCP server. +// - advertise METRICS_TOOL_DESCRIPTORS via the list_tools handler +// - on call_tool(name, arguments): call dispatchMetricsTool(name, arguments) +// and map MetricsToolResult -> MCP content blocks (text/JSON) +// - run over stdio or HTTP transport per the cluster MCP gateway +// - enforce hat-token preflight (validate_hat_token) before dispatch, per +// V0_POLICY_AND_RUNTIME_BOUNDARIES.md — metrics reads are low-risk but the +// review board publishes comments, which is a scoped authority. diff --git a/agentic-organization/packages/metrics/src/review-board.ts b/agentic-organization/packages/metrics/src/review-board.ts new file mode 100644 index 0000000000..40a60f89bc --- /dev/null +++ b/agentic-organization/packages/metrics/src/review-board.ts @@ -0,0 +1,161 @@ +/** + * The qualitative 3-agent code-review board. + * + * Operator vision 2026-05-29: quantitative metrics flag candidates, but the + * review itself runs as a board of >= 3 reviewer agents who must AGREE before a + * comment is published. Reviewers discuss correctness, SOLID principles, and + * adherence to the architecture; a finding is adopted only when a quorum of + * DISTINCT reviewers agree on it. + * + * This mirrors the governance constitution-gate agreement semantics (distinct + * agreers >= quorum, objection vetoes) applied to review findings rather than + * constitutions. The two share a shape but live in different packages, so the + * agreement logic is restated here (no cross-package import); both are explicit + * DUs with no buried thresholds. + */ + +/** The dimensions a reviewer evaluates a candidate finding along. */ +export const ReviewDimension = { + Correctness: "correctness", + Solid: "solid", + ArchitectureAdherence: "architecture_adherence", + Performance: "performance", + Testing: "testing", +} as const; +export type ReviewDimension = (typeof ReviewDimension)[keyof typeof ReviewDimension]; + +/** A reviewer's stance on one candidate finding. */ +export const ReviewStance = { + Agree: "agree", + Disagree: "disagree", + Abstain: "abstain", +} as const; +export type ReviewStance = (typeof ReviewStance)[keyof typeof ReviewStance]; + +export const ReviewSeverity = { + Info: "info", + Minor: "minor", + Major: "major", + Blocking: "blocking", +} as const; +export type ReviewSeverity = (typeof ReviewSeverity)[keyof typeof ReviewSeverity]; + +/** A proposed review comment, before the board has agreed on it. */ +export type CandidateFinding = { + findingId: string; + dimension: ReviewDimension; + severity: ReviewSeverity; + subject: string; + comment: string; +}; + +/** One reviewer agent's vote on one candidate finding. */ +export type ReviewerVote = { + reviewerAgentId: string; + hatAssignmentId: string; + findingId: string; + stance: ReviewStance; + rationale: string; +}; + +/** The adopted/withheld decision for a single finding. */ +export const FindingDecisionState = { + Adopted: "adopted", + Withheld: "withheld", + Contested: "contested", +} as const; +export type FindingDecisionState = (typeof FindingDecisionState)[keyof typeof FindingDecisionState]; + +export type FindingDecision = { + finding: CandidateFinding; + state: FindingDecisionState; + distinctAgree: number; + distinctDisagree: number; + quorum: number; + reason: string; +}; + +export type ReviewBoardOutcome = { + quorum: number; + reviewerCount: number; + adopted: readonly FindingDecision[]; + withheld: readonly FindingDecision[]; + decisions: readonly FindingDecision[]; +}; + +export const DEFAULT_REVIEW_QUORUM = 3; + +export const ReviewBoardFeedbackReason = { + TooFewReviewers: "too_few_reviewers", +} as const; +export type ReviewBoardFeedbackReason = + (typeof ReviewBoardFeedbackReason)[keyof typeof ReviewBoardFeedbackReason]; + +export type ReviewBoardResult = + | { outcome: "ok"; board: ReviewBoardOutcome } + | { outcome: "feedback"; feedback: { reason: ReviewBoardFeedbackReason; message: string } }; + +function distinctStance(votes: readonly ReviewerVote[], stance: ReviewStance): number { + const agents = new Set(); + for (const vote of votes) { + if (vote.stance === stance) { + agents.add(vote.reviewerAgentId); + } + } + return agents.size; +} + +function decideFinding(finding: CandidateFinding, votes: readonly ReviewerVote[], quorum: number): FindingDecision { + const forFinding = votes.filter((vote) => vote.findingId === finding.findingId); + const distinctAgree = distinctStance(forFinding, ReviewStance.Agree); + const distinctDisagree = distinctStance(forFinding, ReviewStance.Disagree); + + // Agreement gate: a finding is adopted only when a quorum of DISTINCT + // reviewers agree AND no fewer disagree than agree at quorum strength. + if (distinctAgree >= quorum && distinctDisagree < quorum) { + return { finding, state: FindingDecisionState.Adopted, distinctAgree, distinctDisagree, quorum, reason: `${distinctAgree} distinct reviewers agreed (quorum ${quorum})` }; + } + if (distinctAgree >= quorum && distinctDisagree >= quorum) { + return { finding, state: FindingDecisionState.Contested, distinctAgree, distinctDisagree, quorum, reason: `quorum agreed (${distinctAgree}) but quorum also disagreed (${distinctDisagree}) — escalate` }; + } + return { finding, state: FindingDecisionState.Withheld, distinctAgree, distinctDisagree, quorum, reason: `only ${distinctAgree} distinct reviewers agreed (quorum ${quorum})` }; +} + +/** Distinct reviewer agents who cast any vote. */ +function distinctReviewers(votes: readonly ReviewerVote[]): number { + const agents = new Set(); + for (const vote of votes) { + agents.add(vote.reviewerAgentId); + } + return agents.size; +} + +/** + * Run the review board over a set of candidate findings and reviewer votes. + * Requires at least `quorum` distinct reviewers to have participated, otherwise + * returns feedback (a board of fewer than quorum cannot adopt anything). + */ +export function evaluateReviewBoard(input: { + findings: readonly CandidateFinding[]; + votes: readonly ReviewerVote[]; + quorum?: number; +}): ReviewBoardResult { + const quorum = Math.max(1, input.quorum ?? DEFAULT_REVIEW_QUORUM); + const reviewerCount = distinctReviewers(input.votes); + + if (reviewerCount < quorum) { + return { + outcome: "feedback", + feedback: { reason: ReviewBoardFeedbackReason.TooFewReviewers, message: `review board needs >= ${quorum} distinct reviewers, got ${reviewerCount}` }, + }; + } + + const decisions = input.findings.map((finding) => decideFinding(finding, input.votes, quorum)); + const adopted = decisions.filter((d) => d.state === FindingDecisionState.Adopted); + const withheld = decisions.filter((d) => d.state !== FindingDecisionState.Adopted); + + return { + outcome: "ok", + board: { quorum, reviewerCount, adopted, withheld, decisions }, + }; +} diff --git a/agentic-organization/packages/metrics/test/code-metrics.test.ts b/agentic-organization/packages/metrics/test/code-metrics.test.ts new file mode 100644 index 0000000000..9725ba471b --- /dev/null +++ b/agentic-organization/packages/metrics/test/code-metrics.test.ts @@ -0,0 +1,43 @@ +import { deepEqual, equal, ok } from "node:assert/strict"; +import { test } from "node:test"; +import { CodeMetricKind, MetricSeverity, analyzeSource } from "../src/code-metrics.ts"; + +test("measures file length and flags a long file", () => { + const source = Array.from({ length: 850 }, (_, i) => `const x${i} = ${i};`).join("\n"); + const report = analyzeSource("big.ts", source); + equal(report.fileLengthLines, 850); + const fileFinding = report.findings.find((f) => f.metric === CodeMetricKind.FileLength); + equal(fileFinding?.severity, MetricSeverity.Flag); +}); + +test("finds the longest function and warns past threshold", () => { + const body = Array.from({ length: 50 }, () => " doThing();").join("\n"); + const source = `function shortOne() {\n return 1;\n}\n\nfunction bigOne() {\n${body}\n}\n`; + const report = analyzeSource("f.ts", source); + equal(report.longestFunction?.name, "bigOne"); + ok((report.longestFunction?.lines ?? 0) >= 50); + const fnFinding = report.findings.find((f) => f.metric === CodeMetricKind.LongestFunction); + equal(fnFinding?.severity, MetricSeverity.Warn); +}); + +test("detects a god class past the flag threshold", () => { + const members = Array.from({ length: 420 }, (_, i) => ` m${i}() { return ${i}; }`).join("\n"); + const source = `export class GodClass {\n${members}\n}\n`; + const report = analyzeSource("g.ts", source); + equal(report.longestClass?.name, "GodClass"); + const classFinding = report.findings.find((f) => f.metric === CodeMetricKind.LongestClass); + equal(classFinding?.severity, MetricSeverity.Flag); + ok(classFinding?.message.includes("god-class")); +}); + +test("a small clean file produces no findings", () => { + const source = "export function add(a: number, b: number): number {\n return a + b;\n}\n"; + const report = analyzeSource("clean.ts", source); + deepEqual(report.findings, []); +}); + +test("reports max nesting depth", () => { + const source = "function f() {\n if (a) {\n if (b) {\n if (c) {\n if (d) {\n g();\n }\n }\n }\n }\n}\n"; + const report = analyzeSource("nest.ts", source); + ok(report.maxNestingDepth >= 5); +}); diff --git a/agentic-organization/packages/metrics/test/mcp-tools.test.ts b/agentic-organization/packages/metrics/test/mcp-tools.test.ts new file mode 100644 index 0000000000..a16c15b741 --- /dev/null +++ b/agentic-organization/packages/metrics/test/mcp-tools.test.ts @@ -0,0 +1,42 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; +import { + METRICS_TOOL_DESCRIPTORS, + MetricsToolName, + dispatchMetricsTool, +} from "../src/mcp-tools.ts"; +import { ReviewDimension, ReviewSeverity, ReviewStance } from "../src/review-board.ts"; + +test("descriptors advertise both tools", () => { + const names = METRICS_TOOL_DESCRIPTORS.map((d) => d.name); + ok(names.includes(MetricsToolName.AnalyzeSource)); + ok(names.includes(MetricsToolName.RunReviewBoard)); +}); + +test("dispatch analyze_source returns a metrics report", () => { + const result = dispatchMetricsTool(MetricsToolName.AnalyzeSource, { filePath: "x.ts", source: "function a(){return 1;}\n" }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok" || result.tool !== MetricsToolName.AnalyzeSource) return; + equal(result.report.filePath, "x.ts"); +}); + +test("dispatch run_review_board routes through the board", () => { + const finding = { findingId: "F1", dimension: ReviewDimension.Correctness, severity: ReviewSeverity.Major, subject: "x", comment: "c" }; + const mk = (a: string) => ({ reviewerAgentId: a, hatAssignmentId: `${a}-h`, findingId: "F1", stance: ReviewStance.Agree, rationale: "" }); + const result = dispatchMetricsTool(MetricsToolName.RunReviewBoard, { findings: [finding], votes: [mk("a"), mk("b"), mk("c")] }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok" || result.tool !== MetricsToolName.RunReviewBoard) return; + equal(result.board.adopted.length, 1); +}); + +test("unknown tool yields feedback", () => { + const result = dispatchMetricsTool("nope", {}); + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "unknown_tool"); +}); + +test("bad args yield feedback", () => { + const result = dispatchMetricsTool(MetricsToolName.AnalyzeSource, { filePath: 123 }); + equal(result.outcome, "feedback"); +}); diff --git a/agentic-organization/packages/metrics/test/review-board.test.ts b/agentic-organization/packages/metrics/test/review-board.test.ts new file mode 100644 index 0000000000..dd9c254419 --- /dev/null +++ b/agentic-organization/packages/metrics/test/review-board.test.ts @@ -0,0 +1,79 @@ +import { equal } from "node:assert/strict"; +import { test } from "node:test"; +import { + FindingDecisionState, + ReviewDimension, + ReviewSeverity, + ReviewStance, + evaluateReviewBoard, + type CandidateFinding, + type ReviewerVote, +} from "../src/review-board.ts"; + +const finding: CandidateFinding = { + findingId: "F1", + dimension: ReviewDimension.Solid, + severity: ReviewSeverity.Major, + subject: "GodClass", + comment: "violates SRP", +}; + +function vote(agent: string, stance: (typeof ReviewStance)[keyof typeof ReviewStance]): ReviewerVote { + return { reviewerAgentId: agent, hatAssignmentId: `${agent}-hat`, findingId: "F1", stance, rationale: "" }; +} + +test("three distinct agreeing reviewers adopt the finding", () => { + const result = evaluateReviewBoard({ + findings: [finding], + votes: [vote("a", ReviewStance.Agree), vote("b", ReviewStance.Agree), vote("c", ReviewStance.Agree)], + }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.board.adopted.length, 1); + equal(result.board.adopted[0]?.state, FindingDecisionState.Adopted); + equal(result.board.adopted[0]?.distinctAgree, 3); +}); + +test("one agent agreeing three times does NOT adopt (no self-amplification)", () => { + const result = evaluateReviewBoard({ + findings: [finding], + votes: [vote("a", ReviewStance.Agree), vote("a", ReviewStance.Agree), vote("a", ReviewStance.Agree), vote("b", ReviewStance.Agree), vote("c", ReviewStance.Abstain)], + }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + // distinct agreers = a,b = 2 < quorum 3 + equal(result.board.decisions[0]?.distinctAgree, 2); + equal(result.board.decisions[0]?.state, FindingDecisionState.Withheld); +}); + +test("fewer than quorum reviewers yields feedback", () => { + const result = evaluateReviewBoard({ + findings: [finding], + votes: [vote("a", ReviewStance.Agree), vote("b", ReviewStance.Agree)], + }); + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "too_few_reviewers"); +}); + +test("quorum agree AND quorum disagree marks the finding contested", () => { + const votes: ReviewerVote[] = [ + vote("a", ReviewStance.Agree), vote("b", ReviewStance.Agree), vote("c", ReviewStance.Agree), + vote("d", ReviewStance.Disagree), vote("e", ReviewStance.Disagree), vote("f", ReviewStance.Disagree), + ]; + const result = evaluateReviewBoard({ findings: [finding], votes }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.board.decisions[0]?.state, FindingDecisionState.Contested); +}); + +test("custom quorum of 2 adopts with two distinct agreers", () => { + const result = evaluateReviewBoard({ + findings: [finding], + votes: [vote("a", ReviewStance.Agree), vote("b", ReviewStance.Agree)], + quorum: 2, + }); + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.board.adopted.length, 1); +}); diff --git a/agentic-organization/packages/observability/src/index.ts b/agentic-organization/packages/observability/src/index.ts index 8f29f13df0..e095be3220 100644 --- a/agentic-organization/packages/observability/src/index.ts +++ b/agentic-organization/packages/observability/src/index.ts @@ -33,3 +33,14 @@ export { type WeakPointIndicator, type WorkflowVisibilityRecord, } from "./workflow-visibility.ts"; +export { + buildOrgSnapshot, + renderOrgSnapshot, + type ActiveBindingView, + type BuildOrgSnapshotInput, + type DepartmentSnapshot, + type HatLevelActivity, + type OrgSnapshot, + type PipelineView, + type RecentEventView, +} from "./org-snapshot.ts"; diff --git a/agentic-organization/packages/observability/src/org-snapshot.ts b/agentic-organization/packages/observability/src/org-snapshot.ts new file mode 100644 index 0000000000..97a84817af --- /dev/null +++ b/agentic-organization/packages/observability/src/org-snapshot.ts @@ -0,0 +1,208 @@ +/** + * Org snapshot — the "what is happening right now" projection. A pure fold over + * the hat catalog, current hat bindings, and the OrgEvent trace. It makes the + * whole organization legible in one structure: + * + * - activity at every hierarchy level (Executive Board → IC) — proof the whole + * hierarchy is working, + * - active wearers per department + per hat, + * - the pipeline stage of every work item, + * - bindings expiring soon, + * - the latest RMO supply + priority decisions, + * - the most recent decisions, in plain language. + * + * Everything is derived from the durable OrgEvent stream + binding rows, so the + * snapshot can never drift from the trace. + */ + +import { HatLevel, type DepartmentId, type HatDefinition } from "../../domain/src/index.ts"; +import { OrgEventKind, type OrgEvent } from "../../domain/src/index.ts"; +import { TerminalHatBindingPhases, type HatBinding } from "../../domain/src/index.ts"; + +const HIERARCHY_ORDER: readonly HatLevel[] = [ + HatLevel.ExecutiveBoard, + HatLevel.CSuite, + HatLevel.Director, + HatLevel.Manager, + HatLevel.Lead, + HatLevel.IndividualContributor, +]; + +export type HatLevelActivity = { + level: HatLevel; + eventCount: number; + actingHatIds: readonly string[]; +}; + +export type ActiveBindingView = { + hatId: string; + hatName: string; + departmentId: DepartmentId; + level: HatLevel; + wearerAgentId: string; + phase: string; + expiresInSeconds: number; +}; + +export type DepartmentSnapshot = { + departmentId: DepartmentId; + activeHatCount: number; + activeWearerCount: number; +}; + +export type PipelineView = { + workItemId: string; + stage: string; +}; + +export type RecentEventView = { + kind: OrgEventKind; + occurredAt: string; + actorHatId?: string; + decision: string; +}; + +export type OrgSnapshot = { + generatedAt: string; + totalHats: number; + totalActiveBindings: number; + /** Executive Board → IC; eventCount/actingHatIds prove activity at each level */ + hierarchyActivity: readonly HatLevelActivity[]; + departments: readonly DepartmentSnapshot[]; + activeBindings: readonly ActiveBindingView[]; + expiringSoon: readonly ActiveBindingView[]; + pipeline: readonly PipelineView[]; + latestPriorityByWorkItem: Readonly>; + latestSupplyByHat: Readonly>; + recentEvents: readonly RecentEventView[]; +}; + +export type BuildOrgSnapshotInput = { + hats: readonly HatDefinition[]; + bindings: readonly HatBinding[]; + events: readonly OrgEvent[]; + nowMs: number; + nowIso: string; + /** bindings within this window are "expiring soon" (default 30s) */ + expiringWindowSeconds?: number; + /** how many recent events to include (default 25) */ + recentEventLimit?: number; +}; + +export function buildOrgSnapshot(input: BuildOrgSnapshotInput): OrgSnapshot { + const byHatId = new Map(input.hats.map((h) => [h.id, h])); + const expiringWindow = (input.expiringWindowSeconds ?? 30) * 1000; + const recentLimit = input.recentEventLimit ?? 25; + + const activeBindings = input.bindings.filter((b) => !TerminalHatBindingPhases.has(b.phase)); + + const activeViews: ActiveBindingView[] = activeBindings.map((b) => { + const hat = byHatId.get(b.hatId); + return { + hatId: b.hatId, + hatName: hat?.name ?? b.hatId, + departmentId: (hat?.departmentId ?? "unknown") as DepartmentId, + level: hat?.level ?? HatLevel.IndividualContributor, + wearerAgentId: b.wearerAgentId, + phase: b.phase, + expiresInSeconds: Math.max(0, Math.round((Date.parse(b.expiresAt) - input.nowMs) / 1000)), + }; + }); + + // department rollup + const deptMap = new Map; wearers: number }>(); + for (const v of activeViews) { + const entry = deptMap.get(v.departmentId) ?? { hats: new Set(), wearers: 0 }; + entry.hats.add(v.hatId); + entry.wearers += 1; + deptMap.set(v.departmentId, entry); + } + const departments: DepartmentSnapshot[] = [...deptMap.entries()].map(([departmentId, e]) => ({ + departmentId, + activeHatCount: e.hats.size, + activeWearerCount: e.wearers, + })); + + // hierarchy activity from the event stream (by the level of the acting hat) + const levelEvents = new Map }>( + HIERARCHY_ORDER.map((l) => [l, { count: 0, hats: new Set() }]), + ); + for (const e of input.events) { + if (e.actorHatId === undefined) continue; + const hat = byHatId.get(e.actorHatId); + if (hat === undefined) continue; + const entry = levelEvents.get(hat.level); + if (entry === undefined) continue; + entry.count += 1; + entry.hats.add(hat.id); + } + const hierarchyActivity: HatLevelActivity[] = HIERARCHY_ORDER.map((level) => { + const e = levelEvents.get(level)!; + return { level, eventCount: e.count, actingHatIds: [...e.hats] }; + }); + + // latest-state-per-subject, computed order-independently so the snapshot is + // correct regardless of whether the event store returns rows ASC or DESC. + // We keep the winning event's occurredAt and only overwrite on a strictly + // newer (or equal — last-seen breaks ties) timestamp. + const pipelineMap = new Map(); + const priorityMap = new Map(); + const supplyMap = new Map(); + const latestAt = new Map(); // key = `${kind}:${subjectId}` + const keepLatest = (map: Map, kind: string, e: { subjectId: string; toState?: string; occurredAt: string }): void => { + if (e.toState === undefined) return; + const key = `${kind}:${e.subjectId}`; + const at = Date.parse(e.occurredAt); + const prior = latestAt.get(key); + if (prior === undefined || at >= prior) { + latestAt.set(key, at); + map.set(e.subjectId, e.toState); + } + }; + for (const e of input.events) { + if (e.kind === OrgEventKind.PipelineStageTransition) { + keepLatest(pipelineMap, "pipeline", e); + } else if (e.kind === OrgEventKind.PriorityDecision) { + keepLatest(priorityMap, "priority", e); + } else if (e.kind === OrgEventKind.HatSupplyDecision) { + keepLatest(supplyMap, "supply", e); + } + } + + const recentEvents: RecentEventView[] = [...input.events] + .sort((a, b) => Date.parse(b.occurredAt) - Date.parse(a.occurredAt)) + .slice(0, recentLimit) + .map((e) => ({ kind: e.kind, occurredAt: e.occurredAt, ...(e.actorHatId !== undefined ? { actorHatId: e.actorHatId } : {}), decision: e.decision })); + + return { + generatedAt: input.nowIso, + totalHats: input.hats.length, + totalActiveBindings: activeBindings.length, + hierarchyActivity, + departments, + activeBindings: activeViews, + expiringSoon: activeViews.filter((v) => v.expiresInSeconds * 1000 <= expiringWindow), + pipeline: [...pipelineMap.entries()].map(([workItemId, stage]) => ({ workItemId, stage })), + latestPriorityByWorkItem: Object.fromEntries(priorityMap), + latestSupplyByHat: Object.fromEntries(supplyMap), + recentEvents, + }; +} + +/** Render the snapshot as a compact human-readable report (the in-cluster "what's happening"). */ +export function renderOrgSnapshot(snapshot: OrgSnapshot): string { + const lines: string[] = []; + lines.push(`ORG SNAPSHOT @ ${snapshot.generatedAt}`); + lines.push(`hats=${snapshot.totalHats} active_bindings=${snapshot.totalActiveBindings}`); + lines.push("HIERARCHY ACTIVITY (events by acting-hat level):"); + for (const h of snapshot.hierarchyActivity) { + lines.push(` ${h.level.padEnd(22)} events=${h.eventCount} hats=[${h.actingHatIds.join(", ")}]`); + } + lines.push(`DEPARTMENTS active: ${snapshot.departments.length}`); + lines.push("PIPELINE:"); + for (const p of snapshot.pipeline) { + lines.push(` ${p.workItemId} → ${p.stage}`); + } + lines.push(`EXPIRING SOON: ${snapshot.expiringSoon.map((b) => `${b.hatId}(${b.expiresInSeconds}s)`).join(", ")}`); + return lines.join("\n"); +} diff --git a/agentic-organization/packages/observability/test/org-snapshot.test.ts b/agentic-organization/packages/observability/test/org-snapshot.test.ts new file mode 100644 index 0000000000..8337dff50c --- /dev/null +++ b/agentic-organization/packages/observability/test/org-snapshot.test.ts @@ -0,0 +1,101 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { HatBindingPhase } from "../../domain/src/index.ts"; +import { HatLevel } from "../../domain/src/index.ts"; +import { OrgEventKind, type OrgEvent } from "../../domain/src/index.ts"; +import type { HatBinding } from "../../domain/src/index.ts"; +import { buildHatDefinitions } from "../../application/src/index.ts"; +import { buildOrgSnapshot, renderOrgSnapshot } from "../src/org-snapshot.ts"; + +const hats = buildHatDefinitions(); +const NOW = 2_000_000; + +function binding(id: string, hatId: string, agent: string, phase: HatBindingPhase, expiresInS: number): HatBinding { + return { + id, hatId, organizationId: "org-1", wearerAgentId: agent, phase, + boundAt: new Date(NOW - 1000).toISOString(), + warmupEndsAt: new Date(NOW - 500).toISOString(), + expiresAt: new Date(NOW + expiresInS * 1000).toISOString(), + }; +} + +function ev(kind: OrgEventKind, actorHatId: string | undefined, subjectId: string, toState: string, decision: string, atMs: number): OrgEvent { + return { + id: `e-${atMs}`, kind, occurredAt: new Date(atMs).toISOString(), organizationId: "org-1", + ...(actorHatId !== undefined ? { actorHatId } : {}), + subjectId, toState, decision, supervisorChain: [], evidenceRefs: [], + correlationId: "c", causationId: "c", traceId: "t", + }; +} + +test("the snapshot surfaces activity at every hierarchy level (whole hierarchy working)", () => { + // one event from each level of the hierarchy + const events: OrgEvent[] = [ + ev(OrgEventKind.PriorityDecision, "executive_board_member", "wi-1", "expedite", "board set priority", NOW - 6000), + ev(OrgEventKind.HatSupplyDecision, "cfo", "backend_implementer", "expand", "cfo approved supply", NOW - 5000), + ev(OrgEventKind.PriorityDecision, "engineering_director", "wi-2", "high", "director set priority", NOW - 4000), + ev(OrgEventKind.HatAssignment, "engineering_manager", "code_reviewer", "assigned", "manager assigned", NOW - 3000), + ev(OrgEventKind.HatBindingTransition, "team_lead", "b-1", "active", "lead active", NOW - 2000), + ev(OrgEventKind.QualityGateEvaluation, "backend_implementer", "wi-1", "approved", "ic acted", NOW - 1000), + ]; + const snap = buildOrgSnapshot({ hats, bindings: [], events, nowMs: NOW, nowIso: new Date(NOW).toISOString() }); + + // every hierarchy level shows at least one event → the whole chain is acting + for (const level of Object.values(HatLevel)) { + const activity = snap.hierarchyActivity.find((h) => h.level === level)!; + ok(activity.eventCount >= 1, `no activity at level ${level}`); + } + // the hierarchy is ordered Executive Board first + equal(snap.hierarchyActivity[0]?.level, HatLevel.ExecutiveBoard); +}); + +test("active bindings roll up per department and show time-to-expiry", () => { + const bindings: HatBinding[] = [ + binding("b-1", "backend_implementer", "agent-A", HatBindingPhase.Active, 10), + binding("b-2", "code_reviewer", "agent-B", HatBindingPhase.Active, 90), + binding("b-3", "tpm", "agent-C", HatBindingPhase.Expired, -5), // terminal → excluded + ]; + const snap = buildOrgSnapshot({ hats, bindings, events: [], nowMs: NOW, nowIso: new Date(NOW).toISOString() }); + equal(snap.totalActiveBindings, 2); // expired excluded + ok(snap.departments.length >= 1); + // backend expires in ~10s → expiring soon (default 30s window) + ok(snap.expiringSoon.some((b) => b.hatId === "backend_implementer")); + ok(!snap.expiringSoon.some((b) => b.hatId === "code_reviewer")); // 90s out +}); + +test("pipeline stage, priority, and supply are folded from the event stream", () => { + const events: OrgEvent[] = [ + ev(OrgEventKind.PipelineStageTransition, "product_owner", "wi-1", "awaiting_brd_approval", "advanced", NOW - 2000), + ev(OrgEventKind.PipelineStageTransition, "brd_reviewer", "wi-1", "awaiting_architecture_approval", "advanced", NOW - 1000), // later wins + ev(OrgEventKind.PriorityDecision, "engineering_director", "wi-1", "high", "priority", NOW - 1500), + ev(OrgEventKind.HatSupplyDecision, undefined, "backend_implementer", "expand", "supply", NOW - 1200), + ]; + const snap = buildOrgSnapshot({ hats, bindings: [], events, nowMs: NOW, nowIso: new Date(NOW).toISOString() }); + equal(snap.pipeline.find((p) => p.workItemId === "wi-1")?.stage, "awaiting_architecture_approval"); + equal(snap.latestPriorityByWorkItem["wi-1"], "high"); + equal(snap.latestSupplyByHat["backend_implementer"], "expand"); +}); + +test("the fold is order-independent: newest-state wins even when events arrive DESC (store returns newest-first)", () => { + // Same 7-gate progression, but presented newest→oldest the way the Cockroach + // store returns rows (ORDER BY occurred_at DESC). A naive last-write-wins fold + // would pick the OLDEST stage; the correct fold keeps the newest by timestamp. + const stages = [ + "awaiting_brd_approval", "awaiting_architecture_approval", "awaiting_implementation_review", + "awaiting_runtime_validation", "awaiting_final_business_validation", "awaiting_release_readiness", "merged", + ]; + const ascending: OrgEvent[] = stages.map((s, i) => + ev(OrgEventKind.PipelineStageTransition, "product_owner", "wi-9", s, "advanced", NOW - (stages.length - i) * 1000), + ); + const descending = [...ascending].reverse(); // newest first, as the store returns them + const snap = buildOrgSnapshot({ hats, bindings: [], events: descending, nowMs: NOW, nowIso: new Date(NOW).toISOString() }); + equal(snap.pipeline.find((p) => p.workItemId === "wi-9")?.stage, "merged"); +}); + +test("renderOrgSnapshot produces a readable report", () => { + const snap = buildOrgSnapshot({ hats, bindings: [binding("b-1", "ceo", "agent-A", HatBindingPhase.Active, 100)], events: [], nowMs: NOW, nowIso: new Date(NOW).toISOString() }); + const report = renderOrgSnapshot(snap); + ok(report.includes("ORG SNAPSHOT")); + ok(report.includes("HIERARCHY ACTIVITY")); +}); diff --git a/agentic-organization/packages/state-cockroach/migrations/0011_agentic_org_control_plane_keep_alive.sql b/agentic-organization/packages/state-cockroach/migrations/0011_agentic_org_control_plane_keep_alive.sql new file mode 100644 index 0000000000..7cd17c4156 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/migrations/0011_agentic_org_control_plane_keep_alive.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS agentic_org_control_plane_heartbeat ( + organization_id STRING PRIMARY KEY, + last_tick_at TIMESTAMPTZ NOT NULL, + version INT8 NOT NULL +); + +CREATE TABLE IF NOT EXISTS agentic_org_control_plane_alerts ( + control_plane_alert_id STRING PRIMARY KEY, + organization_id STRING NOT NULL, + kind STRING NOT NULL, + detail_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); diff --git a/agentic-organization/packages/state-cockroach/migrations/0012_agentic_org_agent_liveness.sql b/agentic-organization/packages/state-cockroach/migrations/0012_agentic_org_agent_liveness.sql new file mode 100644 index 0000000000..77a72cedcc --- /dev/null +++ b/agentic-organization/packages/state-cockroach/migrations/0012_agentic_org_agent_liveness.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS agentic_org_agent_heartbeat ( + organization_id STRING NOT NULL, + agent_id STRING NOT NULL, + hat_assignment_id STRING NOT NULL, + work_item_id STRING NOT NULL, + last_heartbeat_at TIMESTAMPTZ NOT NULL, + deadline_ms INT8 NOT NULL, + version INT8 NOT NULL, + PRIMARY KEY (organization_id, agent_id) +); diff --git a/agentic-organization/packages/state-cockroach/migrations/0013_agentic_org_hindsight_memory.sql b/agentic-organization/packages/state-cockroach/migrations/0013_agentic_org_hindsight_memory.sql new file mode 100644 index 0000000000..d2d3112423 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/migrations/0013_agentic_org_hindsight_memory.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS agentic_org_hindsight_memory ( + memory_id STRING PRIMARY KEY, + agent_id STRING NOT NULL, + hat_assignment_id STRING NOT NULL, + project_id STRING NOT NULL, + work_item_id STRING NOT NULL, + prompt_flow_run_id STRING NOT NULL, + content STRING NOT NULL, + retained_at TIMESTAMPTZ NOT NULL +); diff --git a/agentic-organization/packages/state-cockroach/migrations/0014_agentic_org_hermes_run.sql b/agentic-organization/packages/state-cockroach/migrations/0014_agentic_org_hermes_run.sql new file mode 100644 index 0000000000..7e1609bb39 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/migrations/0014_agentic_org_hermes_run.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS agentic_org_hermes_run ( + run_id STRING PRIMARY KEY, + work_item_id STRING NOT NULL, + agent_id STRING NOT NULL, + session_id STRING NOT NULL, + hat_assignment_id STRING NOT NULL, + prompt_flow_run_id STRING NOT NULL, + state STRING NOT NULL, + last_heartbeat_at TIMESTAMPTZ NOT NULL, + outcome_summary STRING, + outcome_evidence_refs JSONB, + failure_reason STRING +); diff --git a/agentic-organization/packages/state-cockroach/migrations/0015_agentic_org_org_system.sql b/agentic-organization/packages/state-cockroach/migrations/0015_agentic_org_org_system.sql new file mode 100644 index 0000000000..ccda22a0f2 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/migrations/0015_agentic_org_org_system.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS agentic_org_org_events ( + org_event_id STRING PRIMARY KEY, + kind STRING NOT NULL, + organization_id STRING NOT NULL, + actor_hat_id STRING NULL, + actor_agent_id STRING NULL, + department_id STRING NULL, + subject_id STRING NOT NULL, + from_state STRING NULL, + to_state STRING NULL, + decision STRING NOT NULL, + supervisor_chain JSONB NOT NULL, + evidence_refs JSONB NOT NULL, + correlation_id STRING NOT NULL, + causation_id STRING NOT NULL, + trace_id STRING NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + INDEX org_events_by_org_time (organization_id, occurred_at), + INDEX org_events_by_subject (subject_id, occurred_at) +); +CREATE TABLE IF NOT EXISTS agentic_org_hat_bindings ( + binding_id STRING PRIMARY KEY, + hat_id STRING NOT NULL, + organization_id STRING NOT NULL, + wearer_agent_id STRING NOT NULL, + phase STRING NOT NULL, + bound_at TIMESTAMPTZ NOT NULL, + warmup_ends_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + activated_at TIMESTAMPTZ NULL, + ended_at TIMESTAMPTZ NULL, + cooldown_until TIMESTAMPTZ NULL, + reason STRING NULL, + INDEX hat_bindings_by_org_phase (organization_id, phase), + INDEX hat_bindings_by_hat (hat_id, phase) +); diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-control-plane-state-store.ts b/agentic-organization/packages/state-cockroach/src/cockroach-control-plane-state-store.ts new file mode 100644 index 0000000000..2b987d30db --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-control-plane-state-store.ts @@ -0,0 +1,196 @@ +/** + * Cockroach-backed control-plane state store — the durable substrate for the + * deterministic keep-alive control plane (operator tenet #1). + * + * Two responsibilities, two tables (DV2.0 change-rate split): + * - heartbeat: ONE row per org, UPSERTed every keep-alive tick. last_tick_at + * advancing IS the org's observable proof of life. + * - alerts: append-only self-heal signal log (org stall, stale-work + * reassignment, lease reap) the deterministic engine emitted. + * + * Age is computed by the DATABASE clock (now() - last_tick_at), not the worker + * clock, so liveness stays deterministic across replicas with skewed clocks. + */ + +import type { CockroachGenericSqlExecutor } from "./cockroach-sql-executor.ts"; + +/** The kinds of self-heal signal the keep-alive engine appends to the alert log. */ +export const ControlPlaneAlertKind = { + OrgStall: "org_stall", + StaleWorkReassignment: "stale_work_reassignment", + LeaseReap: "lease_reap", +} as const; +export type ControlPlaneAlertKind = (typeof ControlPlaneAlertKind)[keyof typeof ControlPlaneAlertKind]; + +export const CockroachControlPlaneStateStoreStatement = { + ReadOrgHeartbeatAge: "read_org_heartbeat_age", + TickOrgHeartbeat: "tick_org_heartbeat", + AppendControlPlaneAlert: "append_control_plane_alert", + RecordAgentHeartbeat: "record_agent_heartbeat", + ReadAgentHeartbeats: "read_agent_heartbeats", +} as const; +export type CockroachControlPlaneStateStoreStatement = + (typeof CockroachControlPlaneStateStoreStatement)[keyof typeof CockroachControlPlaneStateStoreStatement]; + +export type AppendControlPlaneAlertInput = { + alertId: string; + organizationId: string; + kind: ControlPlaneAlertKind; + detail: Record; +}; + +export type RecordAgentHeartbeatInput = { + organizationId: string; + agentId: string; + hatAssignmentId: string; + workItemId: string; + deadlineMs: number; +}; + +/** + * One agent session's liveness fact, in the keep-alive engine's shape. Age is + * computed by the DB clock (now() - last_heartbeat_at), so agent staleness is + * deterministic across replicas. + */ +export type AgentHeartbeatRecord = { + agentId: string; + hatAssignmentId: string; + workItemId: string; + heartbeatAgeMs: number; + deadlineMs: number; +}; + +export type CockroachControlPlaneStateStore = { + /** ms since the org last ticked, or undefined if it has never ticked. */ + readOrgHeartbeatAgeMs: (organizationId: string) => Promise; + /** UPSERT the org heartbeat row — last_tick_at = now(), version + 1. */ + tickOrgHeartbeat: (organizationId: string) => Promise; + /** Append one immutable self-heal signal to the alert log. */ + appendAlert: (input: AppendControlPlaneAlertInput) => Promise; + /** UPSERT an agent session's heartbeat — last_heartbeat_at = now(), version + 1. */ + recordAgentHeartbeat: (input: RecordAgentHeartbeatInput) => Promise; + /** Read every agent session's liveness for an org, in the engine's shape. */ + readAgentHeartbeats: (organizationId: string) => Promise; +}; + +export type CreateCockroachControlPlaneStateStoreInput = { + executor: CockroachGenericSqlExecutor; +}; + +type OrgHeartbeatAgeRow = { + age_ms: number | string; +}; + +type AgentHeartbeatRow = { + agent_id: string; + hat_assignment_id: string; + work_item_id: string; + age_ms: number | string; + deadline_ms: number | string; +}; + +export function createCockroachControlPlaneStateStore( + input: CreateCockroachControlPlaneStateStoreInput, +): CockroachControlPlaneStateStore { + return { + readOrgHeartbeatAgeMs: async (organizationId: string): Promise => { + const result = await input.executor.execute({ + name: CockroachControlPlaneStateStoreStatement.ReadOrgHeartbeatAge, + sql: ` + SELECT (EXTRACT(EPOCH FROM (now() - last_tick_at)) * 1000)::INT8 AS age_ms + FROM agentic_org_control_plane_heartbeat + WHERE organization_id = $1 + `, + parameters: [organizationId], + }); + + const row = result.rows[0]; + if (row === undefined) { + return undefined; + } + + return Number(row.age_ms); + }, + tickOrgHeartbeat: async (organizationId: string): Promise => { + await input.executor.execute({ + name: CockroachControlPlaneStateStoreStatement.TickOrgHeartbeat, + sql: ` + INSERT INTO agentic_org_control_plane_heartbeat (organization_id, last_tick_at, version) + VALUES ($1, now(), 1) + ON CONFLICT (organization_id) DO UPDATE + SET last_tick_at = now(), version = agentic_org_control_plane_heartbeat.version + 1 + `, + parameters: [organizationId], + }); + }, + appendAlert: async (alert: AppendControlPlaneAlertInput): Promise => { + await input.executor.execute({ + name: CockroachControlPlaneStateStoreStatement.AppendControlPlaneAlert, + sql: ` + INSERT INTO agentic_org_control_plane_alerts ( + control_plane_alert_id, + organization_id, + kind, + detail_json, + created_at + ) VALUES ($1, $2, $3, $4, now()) + `, + parameters: [alert.alertId, alert.organizationId, alert.kind, JSON.stringify(alert.detail)], + }); + }, + recordAgentHeartbeat: async (heartbeat: RecordAgentHeartbeatInput): Promise => { + await input.executor.execute({ + name: CockroachControlPlaneStateStoreStatement.RecordAgentHeartbeat, + sql: ` + INSERT INTO agentic_org_agent_heartbeat ( + organization_id, + agent_id, + hat_assignment_id, + work_item_id, + last_heartbeat_at, + deadline_ms, + version + ) VALUES ($1, $2, $3, $4, now(), $5, 1) + ON CONFLICT (organization_id, agent_id) DO UPDATE + SET hat_assignment_id = excluded.hat_assignment_id, + work_item_id = excluded.work_item_id, + last_heartbeat_at = now(), + deadline_ms = excluded.deadline_ms, + version = agentic_org_agent_heartbeat.version + 1 + `, + parameters: [ + heartbeat.organizationId, + heartbeat.agentId, + heartbeat.hatAssignmentId, + heartbeat.workItemId, + heartbeat.deadlineMs, + ], + }); + }, + readAgentHeartbeats: async (organizationId: string): Promise => { + const result = await input.executor.execute({ + name: CockroachControlPlaneStateStoreStatement.ReadAgentHeartbeats, + sql: ` + SELECT + agent_id, + hat_assignment_id, + work_item_id, + (EXTRACT(EPOCH FROM (now() - last_heartbeat_at)) * 1000)::INT8 AS age_ms, + deadline_ms + FROM agentic_org_agent_heartbeat + WHERE organization_id = $1 + ORDER BY agent_id + `, + parameters: [organizationId], + }); + + return result.rows.map((row) => ({ + agentId: row.agent_id, + hatAssignmentId: row.hat_assignment_id, + workItemId: row.work_item_id, + heartbeatAgeMs: Number(row.age_ms), + deadlineMs: Number(row.deadline_ms), + })); + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-hat-binding-store.ts b/agentic-organization/packages/state-cockroach/src/cockroach-hat-binding-store.ts new file mode 100644 index 0000000000..bd575bf60f --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-hat-binding-store.ts @@ -0,0 +1,102 @@ +/** + * Cockroach-backed HatBinding store — the current "who is wearing which hat" + * state. Upserted on every lifecycle transition so active wearers, warmup/active + * phase, expiry, and cooldown are queryable for the org snapshot and the + * assignment engine. + */ + +import { HatBindingPhase, type HatBinding } from "../../domain/src/index.ts"; +import type { CockroachGenericSqlExecutor } from "./cockroach-sql-executor.ts"; + +export type HatBindingStore = { + upsert: (binding: HatBinding) => Promise; + listActive: (organizationId: string) => Promise; + listAll: (organizationId: string) => Promise; +}; + +export type CreateCockroachHatBindingStoreInput = { + executor: CockroachGenericSqlExecutor; +}; + +type HatBindingRow = { + binding_id: string; + hat_id: string; + organization_id: string; + wearer_agent_id: string; + phase: string; + bound_at: string | Date; + warmup_ends_at: string | Date; + expires_at: string | Date; + activated_at: string | Date | null; + ended_at: string | Date | null; + cooldown_until: string | Date | null; + reason: string | null; +}; + +function toIso(value: string | Date): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function rowToBinding(row: HatBindingRow): HatBinding { + return { + id: row.binding_id, + hatId: row.hat_id, + organizationId: row.organization_id, + wearerAgentId: row.wearer_agent_id, + phase: row.phase as HatBindingPhase, + boundAt: toIso(row.bound_at), + warmupEndsAt: toIso(row.warmup_ends_at), + expiresAt: toIso(row.expires_at), + ...(row.activated_at !== null ? { activatedAt: toIso(row.activated_at) } : {}), + ...(row.ended_at !== null ? { endedAt: toIso(row.ended_at) } : {}), + ...(row.cooldown_until !== null ? { cooldownUntil: toIso(row.cooldown_until) } : {}), + ...(row.reason !== null ? { reason: row.reason } : {}), + }; +} + +const NON_TERMINAL_PHASES = [HatBindingPhase.Pending, HatBindingPhase.Warmup, HatBindingPhase.Active, HatBindingPhase.Probation]; + +export function createCockroachHatBindingStore(input: CreateCockroachHatBindingStoreInput): HatBindingStore { + return { + async upsert(binding: HatBinding): Promise { + await input.executor.execute({ + name: "upsert_hat_binding", + sql: ` + INSERT INTO agentic_org_hat_bindings ( + binding_id, hat_id, organization_id, wearer_agent_id, phase, + bound_at, warmup_ends_at, expires_at, activated_at, ended_at, cooldown_until, reason + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (binding_id) DO UPDATE SET + phase = excluded.phase, + activated_at = excluded.activated_at, + ended_at = excluded.ended_at, + cooldown_until = excluded.cooldown_until, + reason = excluded.reason`, + parameters: [ + binding.id, binding.hatId, binding.organizationId, binding.wearerAgentId, binding.phase, + binding.boundAt, binding.warmupEndsAt, binding.expiresAt, + binding.activatedAt ?? null, binding.endedAt ?? null, binding.cooldownUntil ?? null, binding.reason ?? null, + ], + }); + }, + + async listActive(organizationId: string): Promise { + const placeholders = NON_TERMINAL_PHASES.map((_, i) => `$${i + 2}`).join(", "); + const result = await input.executor.execute({ + name: "list_active_hat_bindings", + sql: `SELECT * FROM agentic_org_hat_bindings WHERE organization_id = $1 AND phase IN (${placeholders}) ORDER BY expires_at ASC`, + parameters: [organizationId, ...NON_TERMINAL_PHASES], + }); + return (result.rows as HatBindingRow[]).map(rowToBinding); + }, + + async listAll(organizationId: string): Promise { + const result = await input.executor.execute({ + name: "list_all_hat_bindings", + sql: `SELECT * FROM agentic_org_hat_bindings WHERE organization_id = $1 ORDER BY bound_at DESC`, + parameters: [organizationId], + }); + return (result.rows as HatBindingRow[]).map(rowToBinding); + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-hermes-runtime.ts b/agentic-organization/packages/state-cockroach/src/cockroach-hermes-runtime.ts new file mode 100644 index 0000000000..24bd09dfc2 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-hermes-runtime.ts @@ -0,0 +1,222 @@ +/** + * Cockroach-backed Hermes runtime — the durable HermesRuntime adapter behind the + * same port the in-process fake implements. Every agent run is a durable, + * auditable row: the Organization binding, the run state, the last heartbeat, and + * the outcome/failure. State transitions are conditional (heartbeat/complete/fail + * only apply to a Running run); an unknown run returns feedback, never throws. + * Persists across restarts so the org keeps a history of who ran on what. + */ + +import { randomUUID } from "node:crypto"; + +import { + HermesRunState, + HermesRuntimeFeedbackReason, + type HermesRun, + type HermesRunBinding, + type HermesRunOutcome, + type HermesRunResult, + type HermesRuntime, +} from "../../hermes/src/index.ts"; +import type { CockroachGenericSqlExecutor } from "./cockroach-sql-executor.ts"; + +export const CockroachHermesRuntimeStatement = { + LaunchRun: "launch_hermes_run", + GetRun: "get_hermes_run", + HeartbeatRun: "heartbeat_hermes_run", + CompleteRun: "complete_hermes_run", + FailRun: "fail_hermes_run", +} as const; +export type CockroachHermesRuntimeStatement = + (typeof CockroachHermesRuntimeStatement)[keyof typeof CockroachHermesRuntimeStatement]; + +export type CockroachHermesRuntimeDeps = { + executor: CockroachGenericSqlExecutor; + idGenerator?: { nextRunId: () => string }; + clock?: { now: () => number }; +}; + +type HermesRunRow = { + run_id: string; + work_item_id: string; + agent_id: string; + session_id: string; + hat_assignment_id: string; + prompt_flow_run_id: string; + state: string; + last_heartbeat_ms: number | string; + outcome_summary: string | null; + outcome_evidence_refs: readonly string[] | string | null; + failure_reason: string | null; +}; + +export function createCockroachHermesRuntime(deps: CockroachHermesRuntimeDeps): HermesRuntime { + // default to globally-unique ids (NOT a per-instance counter): a fresh runtime + // is created per execution, so a counter would collide on retry / across runs + const nextRunId = deps.idGenerator?.nextRunId ?? (() => `hermes-run-${randomUUID()}`); + const now = deps.clock?.now ?? (() => Date.now()); + + async function readRun(runId: string): Promise { + const result = await deps.executor.execute({ + name: CockroachHermesRuntimeStatement.GetRun, + sql: ` + SELECT run_id, work_item_id, agent_id, session_id, hat_assignment_id, prompt_flow_run_id, state, + (EXTRACT(EPOCH FROM last_heartbeat_at) * 1000)::INT8 AS last_heartbeat_ms, + outcome_summary, outcome_evidence_refs, failure_reason + FROM agentic_org_hermes_run + WHERE run_id = $1 + `, + parameters: [runId], + }); + const row = result.rows[0]; + return row === undefined ? undefined : toHermesRun(row); + } + + return { + async launchRun(binding: HermesRunBinding): Promise { + const runId = nextRunId(); + const lastHeartbeatMs = now(); + await deps.executor.execute({ + name: CockroachHermesRuntimeStatement.LaunchRun, + sql: ` + INSERT INTO agentic_org_hermes_run ( + run_id, work_item_id, agent_id, session_id, hat_assignment_id, prompt_flow_run_id, + state, last_heartbeat_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + `, + parameters: [ + runId, + binding.workItemId, + binding.agentId, + binding.sessionId, + binding.hatAssignmentId, + binding.promptFlowRunId, + HermesRunState.Running, + ], + }); + return { outcome: "ok", run: { runId, binding: { ...binding }, state: HermesRunState.Running, lastHeartbeatMs } }; + }, + + async getRun(runId: string): Promise { + return await readRun(runId); + }, + + async heartbeat(runId: string): Promise { + const run = await readRun(runId); + const guard = requireRunning(runId, run); + if (guard.outcome === "feedback") { + return guard; + } + await deps.executor.execute({ + name: CockroachHermesRuntimeStatement.HeartbeatRun, + sql: `UPDATE agentic_org_hermes_run SET last_heartbeat_at = now() WHERE run_id = $1 AND state = $2`, + parameters: [runId, HermesRunState.Running], + }); + return { outcome: "ok", run: { ...guard.run, lastHeartbeatMs: now() } }; + }, + + async completeRun(runId: string, outcome: HermesRunOutcome): Promise { + const run = await readRun(runId); + const guard = requireRunning(runId, run); + if (guard.outcome === "feedback") { + return guard; + } + await deps.executor.execute({ + name: CockroachHermesRuntimeStatement.CompleteRun, + sql: ` + UPDATE agentic_org_hermes_run + SET state = $2, outcome_summary = $3, outcome_evidence_refs = $4::JSONB, last_heartbeat_at = now() + WHERE run_id = $1 AND state = $5 + `, + parameters: [ + runId, + HermesRunState.Completed, + outcome.summary, + JSON.stringify(outcome.evidenceRefs), + HermesRunState.Running, + ], + }); + return { + outcome: "ok", + run: { + ...guard.run, + state: HermesRunState.Completed, + outcome: { summary: outcome.summary, evidenceRefs: [...outcome.evidenceRefs] }, + }, + }; + }, + + async failRun(runId: string, reason: string): Promise { + const run = await readRun(runId); + const guard = requireRunning(runId, run); + if (guard.outcome === "feedback") { + return guard; + } + await deps.executor.execute({ + name: CockroachHermesRuntimeStatement.FailRun, + sql: ` + UPDATE agentic_org_hermes_run + SET state = $2, failure_reason = $3, last_heartbeat_at = now() + WHERE run_id = $1 AND state = $4 + `, + parameters: [runId, HermesRunState.Failed, reason, HermesRunState.Running], + }); + return { outcome: "ok", run: { ...guard.run, state: HermesRunState.Failed, failureReason: reason } }; + }, + }; +} + +type RunningGuard = { outcome: "ok"; run: HermesRun } | HermesRunResult; + +function requireRunning(runId: string, run: HermesRun | undefined): RunningGuard { + if (run === undefined) { + return { + outcome: "feedback", + feedback: { reason: HermesRuntimeFeedbackReason.UnknownRun, message: `no hermes run '${runId}'` }, + }; + } + if (run.state !== HermesRunState.Running) { + return { + outcome: "feedback", + feedback: { reason: HermesRuntimeFeedbackReason.RunNotRunning, message: `hermes run '${runId}' is ${run.state}` }, + }; + } + return { outcome: "ok", run }; +} + +function toHermesRun(row: HermesRunRow): HermesRun { + const run: HermesRun = { + runId: row.run_id, + binding: { + workItemId: row.work_item_id, + agentId: row.agent_id, + sessionId: row.session_id, + hatAssignmentId: row.hat_assignment_id, + promptFlowRunId: row.prompt_flow_run_id, + }, + state: row.state as HermesRunState, + lastHeartbeatMs: Number(row.last_heartbeat_ms), + }; + if (row.outcome_summary !== null) { + run.outcome = { summary: row.outcome_summary, evidenceRefs: parseEvidenceRefs(row.outcome_evidence_refs) }; + } + if (row.failure_reason !== null) { + run.failureReason = row.failure_reason; + } + return run; +} + +function parseEvidenceRefs(value: readonly string[] | string | null): readonly string[] { + if (value === null) { + return []; + } + if (Array.isArray(value)) { + return value; + } + try { + const parsed: unknown = JSON.parse(value as string); + return Array.isArray(parsed) ? (parsed as readonly string[]) : []; + } catch { + return []; + } +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-keep-alive-action-sink.ts b/agentic-organization/packages/state-cockroach/src/cockroach-keep-alive-action-sink.ts new file mode 100644 index 0000000000..23c96dee85 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-keep-alive-action-sink.ts @@ -0,0 +1,85 @@ +/** + * Cockroach-backed keep-alive action sink. + * + * Routes each deterministic engine action to durable control-plane state: + * - EmitHeartbeat -> tick the org heartbeat row (the org proves life) + * - RaiseOrgStallAlert -> append an org-stall alert + * - ReassignStaleWork -> append a stale-work-reassignment alert + * - ReapLease -> append a lease-reap alert + * + * The switch is exhaustive over KeepAliveActionKind (repo rule: IMPLICIT-NOT- + * EXPLICIT is a class error). An unhandled kind is a programmer error and + * throws — it can only happen if a new action kind is added without updating + * this sink, and the compiler's `never` check catches that at build time. + */ + +import { KeepAliveActionKind, type KeepAliveAction } from "../../keepalive/src/index.ts"; +import { + ControlPlaneAlertKind, + type CockroachControlPlaneStateStore, +} from "./cockroach-control-plane-state-store.ts"; + +export type KeepAliveActionSink = { + applyAction: (action: KeepAliveAction) => Promise; +}; + +export type CreateCockroachKeepAliveActionSinkInput = { + store: CockroachControlPlaneStateStore; + organizationId: string; + /** mint a unique id for each appended alert (production: crypto.randomUUID). */ + generateAlertId: () => string; +}; + +export function createCockroachKeepAliveActionSink( + input: CreateCockroachKeepAliveActionSinkInput, +): KeepAliveActionSink { + return { + applyAction: async (action: KeepAliveAction): Promise => { + switch (action.kind) { + case KeepAliveActionKind.EmitHeartbeat: + await input.store.tickOrgHeartbeat(input.organizationId); + return; + case KeepAliveActionKind.RaiseOrgStallAlert: + await input.store.appendAlert({ + alertId: input.generateAlertId(), + organizationId: input.organizationId, + kind: ControlPlaneAlertKind.OrgStall, + detail: { ageMs: action.ageMs, deadlineMs: action.deadlineMs }, + }); + return; + case KeepAliveActionKind.ReassignStaleWork: + await input.store.appendAlert({ + alertId: input.generateAlertId(), + organizationId: input.organizationId, + kind: ControlPlaneAlertKind.StaleWorkReassignment, + detail: { + staleAgentId: action.staleAgentId, + hatAssignmentId: action.hatAssignmentId, + workItemId: action.workItemId, + heartbeatAgeMs: action.heartbeatAgeMs, + }, + }); + return; + case KeepAliveActionKind.ReapLease: + await input.store.appendAlert({ + alertId: input.generateAlertId(), + organizationId: input.organizationId, + kind: ControlPlaneAlertKind.LeaseReap, + detail: { + leaseId: action.leaseId, + resource: action.resource, + holderAgentId: action.holderAgentId, + fencingToken: action.fencingToken, + }, + }); + return; + default: { + // compile-time exhaustiveness: if a new action kind is added without a + // case above, `action` is no longer `never` here and this line errors. + const unhandled: never = action; + throw new Error(`unhandled keep-alive action kind: ${(unhandled as { kind: string }).kind}`); + } + } + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-keep-alive-snapshot-source.ts b/agentic-organization/packages/state-cockroach/src/cockroach-keep-alive-snapshot-source.ts new file mode 100644 index 0000000000..6c7d42391e --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-keep-alive-snapshot-source.ts @@ -0,0 +1,49 @@ +/** + * Cockroach-backed keep-alive snapshot source. + * + * Loads the org's liveness facts into the pure engine's KeepAliveSnapshot: + * - org heartbeat age (control-plane store; DB-clock) + * - agent heartbeats (agent-liveness store; DB-clock) — stale agents drive + * deterministic reassignment signals + * - org-heartbeat deadline (config); clock (for lease expiry) + * + * leases[] remains empty until runtime resource leases are persisted; the engine + * handles an empty lease set. ORG liveness and AGENT liveness — both halves of + * tenet #1 — are real here. + */ + +import type { KeepAliveSnapshot, KeepAliveSnapshotSource } from "../../keepalive/src/index.ts"; +import type { CockroachControlPlaneStateStore } from "./cockroach-control-plane-state-store.ts"; + +export type KeepAliveClock = { + /** current epoch ms */ + now: () => number; +}; + +export type CreateCockroachKeepAliveSnapshotSourceInput = { + store: CockroachControlPlaneStateStore; + organizationId: string; + orgHeartbeatDeadlineMs: number; + clock: KeepAliveClock; +}; + +export function createCockroachKeepAliveSnapshotSource( + input: CreateCockroachKeepAliveSnapshotSourceInput, +): KeepAliveSnapshotSource { + return { + loadSnapshot: async (): Promise => { + const ageMs = await input.store.readOrgHeartbeatAgeMs(input.organizationId); + const agents = await input.store.readAgentHeartbeats(input.organizationId); + + return { + nowMs: input.clock.now(), + // a never-ticked org is age 0 (just born, alive) — the first tick's + // EmitHeartbeat action will register the row + orgHeartbeatAgeMs: ageMs ?? 0, + orgHeartbeatDeadlineMs: input.orgHeartbeatDeadlineMs, + agents, + leases: [], + }; + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-memory.ts b/agentic-organization/packages/state-cockroach/src/cockroach-memory.ts new file mode 100644 index 0000000000..c50fe34c32 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-memory.ts @@ -0,0 +1,131 @@ +/** + * Cockroach-backed Hindsight memory — the durable Memory adapter behind the same + * port the in-process fake implements. An agent retains what it learned + * (attributed by hat assignment); recall is SCOPED by project (Organization + * memory policy: scoped recall, never global); attribution is STICKY (a memory + * keeps its original author even when recalled by another hat). Persists across + * worker restarts. + */ + +import { randomUUID } from "node:crypto"; + +import { + MemoryOperation, + type Memory, + type MemoryAttribution, + type MemoryRecord, + type RecallResult, + type ReflectResult, + type RetainResult, +} from "../../memory/src/index.ts"; +import type { CockroachGenericSqlExecutor } from "./cockroach-sql-executor.ts"; + +export const CockroachMemoryStatement = { + RetainMemory: "retain_memory", + RecallMemories: "recall_memories", + ReflectMemories: "reflect_memories", +} as const; +export type CockroachMemoryStatement = (typeof CockroachMemoryStatement)[keyof typeof CockroachMemoryStatement]; + +export type CockroachMemoryDeps = { + executor: CockroachGenericSqlExecutor; + idGenerator?: { nextMemoryId: () => string }; + clock?: { now: () => number }; +}; + +type MemoryRow = { + memory_id: string; + agent_id: string; + hat_assignment_id: string; + project_id: string; + work_item_id: string; + prompt_flow_run_id: string; + content: string; + retained_at_ms: number | string; +}; + +export function createCockroachMemory(deps: CockroachMemoryDeps): Memory { + // default to globally-unique ids (NOT a per-instance counter): a fresh memory + // adapter is created per execution, so a counter would collide across runs + const nextMemoryId = deps.idGenerator?.nextMemoryId ?? (() => `mem-${randomUUID()}`); + const now = deps.clock?.now ?? (() => Date.now()); + + return { + async retain(attribution: MemoryAttribution, content: string): Promise { + const memoryId = nextMemoryId(); + const retainedAtMs = now(); + await deps.executor.execute({ + name: CockroachMemoryStatement.RetainMemory, + sql: ` + INSERT INTO agentic_org_hindsight_memory ( + memory_id, agent_id, hat_assignment_id, project_id, work_item_id, prompt_flow_run_id, content, retained_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + `, + parameters: [ + memoryId, + attribution.agentId, + attribution.hatAssignmentId, + attribution.projectId, + attribution.workItemId, + attribution.promptFlowRunId, + content, + ], + }); + + return { + operation: MemoryOperation.Retain, + memory: { memoryId, attribution: { ...attribution }, content, retainedAtMs }, + }; + }, + + async recall(attribution: MemoryAttribution): Promise { + // scoped by project (never global); rows carry the original (sticky) author + const result = await deps.executor.execute({ + name: CockroachMemoryStatement.RecallMemories, + sql: ` + SELECT + memory_id, agent_id, hat_assignment_id, project_id, work_item_id, prompt_flow_run_id, content, + (EXTRACT(EPOCH FROM retained_at) * 1000)::INT8 AS retained_at_ms + FROM agentic_org_hindsight_memory + WHERE project_id = $1 + ORDER BY retained_at ASC + `, + parameters: [attribution.projectId], + }); + + return { operation: MemoryOperation.Recall, memories: result.rows.map(toMemoryRecord) }; + }, + + async reflect(attribution: MemoryAttribution): Promise { + const result = await deps.executor.execute<{ considered_count: number | string }>({ + name: CockroachMemoryStatement.ReflectMemories, + sql: `SELECT count(*) AS considered_count FROM agentic_org_hindsight_memory WHERE project_id = $1`, + parameters: [attribution.projectId], + }); + const consideredCount = Number(result.rows[0]?.considered_count ?? 0); + return { + operation: MemoryOperation.Reflect, + consideredCount, + summary: + consideredCount === 0 + ? "no memories in scope to reflect on" + : `reflected on ${consideredCount} memories in project ${attribution.projectId}`, + }; + }, + }; +} + +function toMemoryRecord(row: MemoryRow): MemoryRecord { + return { + memoryId: row.memory_id, + attribution: { + agentId: row.agent_id, + hatAssignmentId: row.hat_assignment_id, + projectId: row.project_id, + workItemId: row.work_item_id, + promptFlowRunId: row.prompt_flow_run_id, + }, + content: row.content, + retainedAtMs: Number(row.retained_at_ms), + }; +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-migration-runner.ts b/agentic-organization/packages/state-cockroach/src/cockroach-migration-runner.ts index 62a81c4edd..d79fb18f36 100644 --- a/agentic-organization/packages/state-cockroach/src/cockroach-migration-runner.ts +++ b/agentic-organization/packages/state-cockroach/src/cockroach-migration-runner.ts @@ -1,5 +1,6 @@ import type { CockroachGenericSqlExecutor } from "./cockroach-sql-executor.ts"; import type { CockroachSchemaMigration } from "./cockroach-schema.ts"; +import { splitSqlStatements } from "./sql-statement-splitter.ts"; export const CockroachMigrationStatement = { ApplyMigration: "apply_migration", @@ -21,11 +22,18 @@ export function createCockroachMigrationRunner(input: CreateCockroachMigrationRu return { applyMigrations: async () => { for (const migration of input.migrations) { - await input.executor.execute({ - name: CockroachMigrationStatement.ApplyMigration, - sql: migration.sql, - parameters: [], - }); + // Execute each statement separately (its own implicit transaction). + // CockroachDB runs a multi-statement string as one implicit txn and + // forbids referencing a column added by an earlier DDL statement in the + // same txn (e.g. ADD COLUMN version; UPDATE ... SET version -> SQLSTATE + // 42703). Per-statement execution makes the ADD COLUMN commit first. + for (const statement of splitSqlStatements(migration.sql)) { + await input.executor.execute({ + name: CockroachMigrationStatement.ApplyMigration, + sql: statement, + parameters: [], + }); + } } }, }; diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-org-event-store.ts b/agentic-organization/packages/state-cockroach/src/cockroach-org-event-store.ts new file mode 100644 index 0000000000..0fd5aa89ed --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/cockroach-org-event-store.ts @@ -0,0 +1,124 @@ +/** + * Cockroach-backed OrgEvent store — the durable, append-only organizational trace. + * Every org transition writes exactly one row here; "what is happening" is a + * query over this table (plus the binding rows). The supervisor chain + evidence + * refs are JSONB so the full authorization context is preserved per event. + */ + +import { randomUUID } from "node:crypto"; + +import { OrgEventKind, type DepartmentId, type OrgEvent } from "../../domain/src/index.ts"; +import type { CockroachGenericSqlExecutor } from "./cockroach-sql-executor.ts"; + +export type OrgEventStore = { + append: (event: OrgEvent) => Promise; + listByOrganization: (organizationId: string, limit: number) => Promise; + listBySubject: (subjectId: string, limit: number) => Promise; +}; + +export type CreateCockroachOrgEventStoreInput = { + executor: CockroachGenericSqlExecutor; + generateId?: () => string; +}; + +type OrgEventRow = { + org_event_id: string; + kind: string; + organization_id: string; + actor_hat_id: string | null; + actor_agent_id: string | null; + department_id: string | null; + subject_id: string; + from_state: string | null; + to_state: string | null; + decision: string; + supervisor_chain: unknown; + evidence_refs: unknown; + correlation_id: string; + causation_id: string; + trace_id: string; + occurred_at: string | Date; +}; + +function toIso(value: string | Date): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function asStringArray(value: unknown): readonly string[] { + if (Array.isArray(value)) return value.map((v) => String(v)); + if (typeof value === "string") { + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map((v) => String(v)) : []; + } catch { + return []; + } + } + return []; +} + +function rowToEvent(row: OrgEventRow): OrgEvent { + return { + id: row.org_event_id, + kind: row.kind as OrgEventKind, + occurredAt: toIso(row.occurred_at), + organizationId: row.organization_id, + ...(row.actor_hat_id !== null ? { actorHatId: row.actor_hat_id } : {}), + ...(row.actor_agent_id !== null ? { actorAgentId: row.actor_agent_id } : {}), + ...(row.department_id !== null ? { departmentId: row.department_id as DepartmentId } : {}), + subjectId: row.subject_id, + ...(row.from_state !== null ? { fromState: row.from_state } : {}), + ...(row.to_state !== null ? { toState: row.to_state } : {}), + decision: row.decision, + supervisorChain: asStringArray(row.supervisor_chain), + evidenceRefs: asStringArray(row.evidence_refs), + correlationId: row.correlation_id, + causationId: row.causation_id, + traceId: row.trace_id, + }; +} + +export function createCockroachOrgEventStore(input: CreateCockroachOrgEventStoreInput): OrgEventStore { + const nextId = input.generateId ?? (() => `orgevt-${randomUUID()}`); + return { + async append(eventInput: OrgEvent): Promise { + const id = eventInput.id !== "" ? eventInput.id : nextId(); + await input.executor.execute({ + name: "append_org_event", + sql: ` + INSERT INTO agentic_org_org_events ( + org_event_id, kind, organization_id, actor_hat_id, actor_agent_id, department_id, + subject_id, from_state, to_state, decision, supervisor_chain, evidence_refs, + correlation_id, causation_id, trace_id, occurred_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::JSONB, $12::JSONB, $13, $14, $15, $16 + ) + ON CONFLICT (org_event_id) DO NOTHING`, + parameters: [ + id, eventInput.kind, eventInput.organizationId, eventInput.actorHatId ?? null, eventInput.actorAgentId ?? null, eventInput.departmentId ?? null, + eventInput.subjectId, eventInput.fromState ?? null, eventInput.toState ?? null, eventInput.decision, + JSON.stringify(eventInput.supervisorChain), JSON.stringify(eventInput.evidenceRefs), + eventInput.correlationId, eventInput.causationId, eventInput.traceId, eventInput.occurredAt, + ], + }); + }, + + async listByOrganization(organizationId: string, limit: number): Promise { + const result = await input.executor.execute({ + name: "list_org_events_by_org", + sql: `SELECT * FROM agentic_org_org_events WHERE organization_id = $1 ORDER BY occurred_at DESC LIMIT $2`, + parameters: [organizationId, limit], + }); + return (result.rows as OrgEventRow[]).map(rowToEvent); + }, + + async listBySubject(subjectId: string, limit: number): Promise { + const result = await input.executor.execute({ + name: "list_org_events_by_subject", + sql: `SELECT * FROM agentic_org_org_events WHERE subject_id = $1 ORDER BY occurred_at DESC LIMIT $2`, + parameters: [subjectId, limit], + }); + return (result.rows as OrgEventRow[]).map(rowToEvent); + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-reaction-plan-work-queue.ts b/agentic-organization/packages/state-cockroach/src/cockroach-reaction-plan-work-queue.ts index 1dfd925c54..31a996a0fa 100644 --- a/agentic-organization/packages/state-cockroach/src/cockroach-reaction-plan-work-queue.ts +++ b/agentic-organization/packages/state-cockroach/src/cockroach-reaction-plan-work-queue.ts @@ -289,7 +289,7 @@ const CockroachReactionPlanWorkQueueSql = { status = '${ReactionPlanStatus.Claimed}', claim_id = $2, claimed_at = now(), - claim_expires_at = now() + ($3 * INTERVAL '1 millisecond') + claim_expires_at = now() + ($3::INT8 * INTERVAL '1 millisecond') WHERE reaction_plan_id IN ( SELECT reaction_plan_id FROM ${CockroachTableName.ReactionPlans} @@ -348,7 +348,7 @@ const CockroachReactionPlanWorkQueueSql = { claim_expires_at = CASE WHEN ($3->>'retryable')::BOOL AND attempt_count + 1 < $5 THEN NULL ELSE claim_expires_at END, attempt_count = attempt_count + 1, next_attempt_at = CASE - WHEN ($3->>'retryable')::BOOL AND attempt_count + 1 < $5 THEN now() + ($4 * INTERVAL '1 millisecond') + WHEN ($3->>'retryable')::BOOL AND attempt_count + 1 < $5 THEN now() + ($4::INT8 * INTERVAL '1 millisecond') ELSE NULL END, failed_at = now(), diff --git a/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts b/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts index d66e68fcd8..b69823a415 100644 --- a/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts +++ b/agentic-organization/packages/state-cockroach/src/cockroach-schema.ts @@ -22,6 +22,11 @@ export const CockroachCoreStateMigrationName = { HatAssignmentAuthorityProjectionV8: "0008_agentic_org_hat_assignment_authority_projection", ReactionPlanExecutionLifecycleV9: "0009_agentic_org_reaction_plan_execution_lifecycle", QualityGateEvaluationKernelV10: "0010_agentic_org_quality_gate_evaluation_kernel", + ControlPlaneKeepAliveV11: "0011_agentic_org_control_plane_keep_alive", + AgentLivenessV12: "0012_agentic_org_agent_liveness", + HindsightMemoryV13: "0013_agentic_org_hindsight_memory", + HermesRunV14: "0014_agentic_org_hermes_run", + OrgSystemV15: "0015_agentic_org_org_system", } as const; export type CockroachCoreStateMigrationName = @@ -45,6 +50,13 @@ export const CockroachTableName = { ReactionPlans: "agentic_org_reaction_plans", IdempotencyRecords: "agentic_org_idempotency_records", PolicyObservations: "agentic_org_policy_observations", + ControlPlaneHeartbeat: "agentic_org_control_plane_heartbeat", + ControlPlaneAlerts: "agentic_org_control_plane_alerts", + AgentHeartbeat: "agentic_org_agent_heartbeat", + HindsightMemory: "agentic_org_hindsight_memory", + HermesRun: "agentic_org_hermes_run", + OrgEvents: "agentic_org_org_events", + HatBindings: "agentic_org_hat_bindings", } as const; export type CockroachTableName = (typeof CockroachTableName)[keyof typeof CockroachTableName]; @@ -124,6 +136,11 @@ export function createCockroachCoreStateMigrations(): readonly CockroachSchemaMi createCockroachHatAssignmentAuthorityProjectionMigration(), createCockroachReactionPlanExecutionLifecycleMigration(), createCockroachQualityGateEvaluationKernelMigration(), + createCockroachControlPlaneKeepAliveMigration(), + createCockroachAgentLivenessMigration(), + createCockroachHindsightMemoryMigration(), + createCockroachHermesRunMigration(), + createCockroachOrgSystemMigration(), ]; } @@ -176,6 +193,181 @@ export function createCockroachQualityGateEvaluationKernelMigration(): Cockroach }; } +/** + * Control-plane keep-alive tables — the durable substrate for the operator's + * #1 tenet ("drive the organization to stay alive"). Two tables, two change + * rates (DV2.0 split): + * - heartbeat: ONE row per org, UPSERTed every keep-alive tick. last_tick_at + * advancing IS the org's observable proof of life ("SELECT last_tick_at"). + * - alerts: append-only log of self-heal signals (org stall, stale-work + * reassignment, lease reap) the deterministic engine emitted. + */ +export function createCockroachControlPlaneKeepAliveMigration(): CockroachSchemaMigration { + return { + name: CockroachCoreStateMigrationName.ControlPlaneKeepAliveV11, + sql: [createControlPlaneHeartbeatTableSql(), createControlPlaneAlertsTableSql()].join("\n\n"), + }; +} + +/** + * Agent liveness — the second half of the keep-alive tenet ("drive the agents + * to stay alive"). One row per (org, agent): an agent session UPSERTs its + * heartbeat as it works. The keep-alive engine reads these, and a heartbeat + * older than its deadline_ms marks the agent stale -> its work is flagged for + * reassignment (an agent-decidable follow-up; the control plane only signals). + */ +export function createCockroachAgentLivenessMigration(): CockroachSchemaMigration { + return { + name: CockroachCoreStateMigrationName.AgentLivenessV12, + sql: createAgentHeartbeatTableSql(), + }; +} + +/** + * Hindsight memory — the durable substrate for "set up hermes... the memory". + * An agent retains what it learned (attributed by hat assignment); recall is + * SCOPED by project (Organization memory policy: scoped recall, never global); + * attribution is STICKY (a memory keeps its original author even when recalled + * by another hat). The Cockroach Memory adapter persists this across restarts. + */ +export function createCockroachHindsightMemoryMigration(): CockroachSchemaMigration { + return { + name: CockroachCoreStateMigrationName.HindsightMemoryV13, + sql: createHindsightMemoryTableSql(), + }; +} + +/** + * Hermes runs — the durable, auditable record of every agent run (the autonomous + * data plane). One row per run: the Organization binding (work item, agent, + * session, hat, prompt-flow run), the run state (running/completed/failed), the + * last heartbeat, and the outcome/failure. The Cockroach Hermes runtime adapter + * persists this across restarts so the org has a durable history of who ran on + * what and how it ended. + */ +export function createCockroachHermesRunMigration(): CockroachSchemaMigration { + return { + name: CockroachCoreStateMigrationName.HermesRunV14, + sql: createHermesRunTableSql(), + }; +} + +export function createCockroachOrgSystemMigration(): CockroachSchemaMigration { + return { + name: CockroachCoreStateMigrationName.OrgSystemV15, + sql: `${createOrgEventsTableSql()}\n${createHatBindingsTableSql()}`, + }; +} + +function createOrgEventsTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.OrgEvents} ( + org_event_id STRING PRIMARY KEY, + kind STRING NOT NULL, + organization_id STRING NOT NULL, + actor_hat_id STRING NULL, + actor_agent_id STRING NULL, + department_id STRING NULL, + subject_id STRING NOT NULL, + from_state STRING NULL, + to_state STRING NULL, + decision STRING NOT NULL, + supervisor_chain JSONB NOT NULL, + evidence_refs JSONB NOT NULL, + correlation_id STRING NOT NULL, + causation_id STRING NOT NULL, + trace_id STRING NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + INDEX org_events_by_org_time (organization_id, occurred_at), + INDEX org_events_by_subject (subject_id, occurred_at) +);`.trim(); +} + +function createHatBindingsTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.HatBindings} ( + binding_id STRING PRIMARY KEY, + hat_id STRING NOT NULL, + organization_id STRING NOT NULL, + wearer_agent_id STRING NOT NULL, + phase STRING NOT NULL, + bound_at TIMESTAMPTZ NOT NULL, + warmup_ends_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + activated_at TIMESTAMPTZ NULL, + ended_at TIMESTAMPTZ NULL, + cooldown_until TIMESTAMPTZ NULL, + reason STRING NULL, + INDEX hat_bindings_by_org_phase (organization_id, phase), + INDEX hat_bindings_by_hat (hat_id, phase) +);`.trim(); +} + +function createControlPlaneHeartbeatTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.ControlPlaneHeartbeat} ( + organization_id STRING PRIMARY KEY, + last_tick_at TIMESTAMPTZ NOT NULL, + version INT8 NOT NULL +);`.trim(); +} + +function createControlPlaneAlertsTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.ControlPlaneAlerts} ( + control_plane_alert_id STRING PRIMARY KEY, + organization_id STRING NOT NULL, + kind STRING NOT NULL, + detail_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL +);`.trim(); +} + +function createAgentHeartbeatTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.AgentHeartbeat} ( + organization_id STRING NOT NULL, + agent_id STRING NOT NULL, + hat_assignment_id STRING NOT NULL, + work_item_id STRING NOT NULL, + last_heartbeat_at TIMESTAMPTZ NOT NULL, + deadline_ms INT8 NOT NULL, + version INT8 NOT NULL, + PRIMARY KEY (organization_id, agent_id) +);`.trim(); +} + +function createHindsightMemoryTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.HindsightMemory} ( + memory_id STRING PRIMARY KEY, + agent_id STRING NOT NULL, + hat_assignment_id STRING NOT NULL, + project_id STRING NOT NULL, + work_item_id STRING NOT NULL, + prompt_flow_run_id STRING NOT NULL, + content STRING NOT NULL, + retained_at TIMESTAMPTZ NOT NULL +);`.trim(); +} + +function createHermesRunTableSql(): string { + return ` +CREATE TABLE IF NOT EXISTS ${CockroachTableName.HermesRun} ( + run_id STRING PRIMARY KEY, + work_item_id STRING NOT NULL, + agent_id STRING NOT NULL, + session_id STRING NOT NULL, + hat_assignment_id STRING NOT NULL, + prompt_flow_run_id STRING NOT NULL, + state STRING NOT NULL, + last_heartbeat_at TIMESTAMPTZ NOT NULL, + outcome_summary STRING, + outcome_evidence_refs JSONB, + failure_reason STRING +);`.trim(); +} + function createProjectsTableSql(): string { return ` CREATE TABLE IF NOT EXISTS ${CockroachTableName.Projects} ( diff --git a/agentic-organization/packages/state-cockroach/src/index.ts b/agentic-organization/packages/state-cockroach/src/index.ts index fb7eb368a1..8b1ff70cac 100644 --- a/agentic-organization/packages/state-cockroach/src/index.ts +++ b/agentic-organization/packages/state-cockroach/src/index.ts @@ -1,3 +1,34 @@ +export { splitSqlStatements } from "./sql-statement-splitter.ts"; +export { + ControlPlaneAlertKind, + CockroachControlPlaneStateStoreStatement, + createCockroachControlPlaneStateStore, + type AgentHeartbeatRecord, + type AppendControlPlaneAlertInput, + type CockroachControlPlaneStateStore, + type CreateCockroachControlPlaneStateStoreInput, + type RecordAgentHeartbeatInput, +} from "./cockroach-control-plane-state-store.ts"; +export { + createCockroachKeepAliveSnapshotSource, + type CreateCockroachKeepAliveSnapshotSourceInput, + type KeepAliveClock, +} from "./cockroach-keep-alive-snapshot-source.ts"; +export { + createCockroachKeepAliveActionSink, + type CreateCockroachKeepAliveActionSinkInput, + type KeepAliveActionSink as CockroachKeepAliveActionSink, +} from "./cockroach-keep-alive-action-sink.ts"; +export { + CockroachMemoryStatement, + createCockroachMemory, + type CockroachMemoryDeps, +} from "./cockroach-memory.ts"; +export { + CockroachHermesRuntimeStatement, + createCockroachHermesRuntime, + type CockroachHermesRuntimeDeps, +} from "./cockroach-hermes-runtime.ts"; export { CockroachCommandStateStoreStatement, createCockroachCommandStateStoreFactory, @@ -101,6 +132,10 @@ export { CockroachTableName, createCockroachCoreStateMigrations, createCockroachCoreStateMigration, + createCockroachAgentLivenessMigration, + createCockroachControlPlaneKeepAliveMigration, + createCockroachHindsightMemoryMigration, + createCockroachHermesRunMigration, createCockroachDecisionRecordKernelMigration, createCockroachDiscussionAnchorKernelMigration, createCockroachHatAssignmentAuthorityProjectionMigration, @@ -120,3 +155,6 @@ export { type CockroachSqlClient, type CreateCockroachSqlExecutorInput, } from "./cockroach-sql-executor.ts"; +export { createCockroachOrgEventStore, type CreateCockroachOrgEventStoreInput, type OrgEventStore } from "./cockroach-org-event-store.ts"; +export { createCockroachHatBindingStore, type CreateCockroachHatBindingStoreInput, type HatBindingStore } from "./cockroach-hat-binding-store.ts"; +export { createCockroachOrgSystemMigration } from "./cockroach-schema.ts"; diff --git a/agentic-organization/packages/state-cockroach/src/sql-statement-splitter.ts b/agentic-organization/packages/state-cockroach/src/sql-statement-splitter.ts new file mode 100644 index 0000000000..13b03588da --- /dev/null +++ b/agentic-organization/packages/state-cockroach/src/sql-statement-splitter.ts @@ -0,0 +1,79 @@ +/** + * Split a multi-statement SQL string into individual statements. + * + * Why this exists: CockroachDB runs a multi-statement query string sent over the + * simple query protocol as ONE implicit transaction, and it forbids referencing + * a column added by a DDL statement earlier in the same transaction. Our + * work-anchor-kernel migration does `ALTER TABLE ... ADD COLUMN version` then + * `UPDATE ... SET version = ...` — which fails with `column "version" does not + * exist` (SQLSTATE 42703) when run as one query. Splitting into separate + * statements (each its own implicit transaction) fixes it: the ADD COLUMN + * commits before the UPDATE references the column. + * + * The splitter is semicolon-based but respects: + * - single-quoted string literals, including the SQL '' escaped-quote + * - line comments (-- to end of line) which may contain semicolons + * Our migrations use no dollar-quoted ($$) blocks or block comments, so those + * are intentionally out of scope (adding one would require extending this). + */ + +export function splitSqlStatements(sql: string): readonly string[] { + const statements: string[] = []; + let current = ""; + let inString = false; + let inLineComment = false; + + for (let i = 0; i < sql.length; i += 1) { + const ch = sql[i]!; + const next = i + 1 < sql.length ? sql[i + 1] : ""; + + if (inLineComment) { + current += ch; + if (ch === "\n") { + inLineComment = false; + } + continue; + } + + if (inString) { + current += ch; + if (ch === "'") { + if (next === "'") { + // escaped quote: consume both, stay in string + current += next; + i += 1; + } else { + inString = false; + } + } + continue; + } + + // not in string or comment + if (ch === "-" && next === "-") { + inLineComment = true; + current += ch; + continue; + } + if (ch === "'") { + inString = true; + current += ch; + continue; + } + if (ch === ";") { + const trimmed = current.trim(); + if (trimmed.length > 0) { + statements.push(trimmed); + } + current = ""; + continue; + } + current += ch; + } + + const tail = current.trim(); + if (tail.length > 0) { + statements.push(tail); + } + return statements; +} diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-control-plane-state-store.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-control-plane-state-store.test.ts new file mode 100644 index 0000000000..bb51e6b388 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-control-plane-state-store.test.ts @@ -0,0 +1,124 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { + ControlPlaneAlertKind, + createCockroachControlPlaneStateStore, + type CockroachAnySqlStatement, + type CockroachGenericSqlExecutor, +} from "../src/index.ts"; + +describe("cockroach control-plane state store (the org's durable proof of life)", () => { + test("reads the org heartbeat age in milliseconds", async () => { + const executor = createRecordingSqlExecutor([{ age_ms: 1500 }]); + const store = createCockroachControlPlaneStateStore({ executor }); + + const ageMs = await store.readOrgHeartbeatAgeMs("org-1"); + + equal(ageMs, 1500); + equal(executor.statements[0]?.parameters[0], "org-1"); + equal(executor.statements[0]?.sql.includes("agentic_org_control_plane_heartbeat"), true); + }); + + test("returns undefined when the org has never ticked (no heartbeat row yet)", async () => { + const executor = createRecordingSqlExecutor([]); + const store = createCockroachControlPlaneStateStore({ executor }); + + const ageMs = await store.readOrgHeartbeatAgeMs("org-1"); + + equal(ageMs, undefined); + }); + + test("ticks the org heartbeat with an UPSERT that advances last_tick_at and version", async () => { + const executor = createRecordingSqlExecutor([]); + const store = createCockroachControlPlaneStateStore({ executor }); + + await store.tickOrgHeartbeat("org-1"); + + const statement = executor.statements[0]; + equal(statement?.parameters[0], "org-1"); + equal(statement?.sql.includes("INSERT INTO agentic_org_control_plane_heartbeat"), true); + equal(statement?.sql.includes("ON CONFLICT (organization_id) DO UPDATE"), true); + equal(statement?.sql.includes("last_tick_at = now()"), true); + equal(statement?.sql.includes("version = agentic_org_control_plane_heartbeat.version + 1"), true); + }); + + test("appends a control-plane alert as an immutable row with JSON detail", async () => { + const executor = createRecordingSqlExecutor([]); + const store = createCockroachControlPlaneStateStore({ executor }); + + await store.appendAlert({ + alertId: "alert-1", + organizationId: "org-1", + kind: ControlPlaneAlertKind.OrgStall, + detail: { ageMs: 9000, deadlineMs: 5000 }, + }); + + const statement = executor.statements[0]; + equal(statement?.sql.includes("INSERT INTO agentic_org_control_plane_alerts"), true); + deepEqual(statement?.parameters, [ + "alert-1", + "org-1", + ControlPlaneAlertKind.OrgStall, + JSON.stringify({ ageMs: 9000, deadlineMs: 5000 }), + ]); + }); + + test("records an agent heartbeat with an UPSERT that advances last_heartbeat_at and version", async () => { + const executor = createRecordingSqlExecutor([]); + const store = createCockroachControlPlaneStateStore({ executor }); + + await store.recordAgentHeartbeat({ + organizationId: "org-1", + agentId: "agent-7", + hatAssignmentId: "hat-3", + workItemId: "work-9", + deadlineMs: 8000, + }); + + const statement = executor.statements[0]; + equal(statement?.sql.includes("INSERT INTO agentic_org_agent_heartbeat"), true); + equal(statement?.sql.includes("ON CONFLICT (organization_id, agent_id) DO UPDATE"), true); + equal(statement?.sql.includes("last_heartbeat_at = now()"), true); + equal(statement?.sql.includes("version = agentic_org_agent_heartbeat.version + 1"), true); + deepEqual(statement?.parameters, ["org-1", "agent-7", "hat-3", "work-9", 8000]); + }); + + test("reads agent heartbeats with DB-clock age, mapped to the engine's shape", async () => { + const executor = createRecordingSqlExecutor([ + { agent_id: "agent-7", hat_assignment_id: "hat-3", work_item_id: "work-9", age_ms: 1200, deadline_ms: 8000 }, + { agent_id: "agent-8", hat_assignment_id: "hat-4", work_item_id: "work-10", age_ms: 9999, deadline_ms: 8000 }, + ]); + const store = createCockroachControlPlaneStateStore({ executor }); + + const agents = await store.readAgentHeartbeats("org-1"); + + equal(executor.statements[0]?.parameters[0], "org-1"); + equal(executor.statements[0]?.sql.includes("agentic_org_agent_heartbeat"), true); + deepEqual(agents, [ + { agentId: "agent-7", hatAssignmentId: "hat-3", workItemId: "work-9", heartbeatAgeMs: 1200, deadlineMs: 8000 }, + { agentId: "agent-8", hatAssignmentId: "hat-4", workItemId: "work-10", heartbeatAgeMs: 9999, deadlineMs: 8000 }, + ]); + }); +}); + +function createRecordingSqlExecutor(rows: readonly Record[]): CockroachGenericSqlExecutor & { + statements: CockroachAnySqlStatement[]; +} { + const statements: CockroachAnySqlStatement[] = []; + + return { + statements, + execute: async >(statement: CockroachAnySqlStatement) => { + statements.push(statement); + return { rows: rows as readonly Row[] }; + }, + executeTransaction: async (operation) => + await operation({ + execute: async >(statement: CockroachAnySqlStatement) => { + statements.push(statement); + return { rows: rows as readonly Row[] }; + }, + }), + }; +} diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-hermes-runtime.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-hermes-runtime.test.ts new file mode 100644 index 0000000000..5dfe705506 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-hermes-runtime.test.ts @@ -0,0 +1,121 @@ +import { equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { HermesRunState, type HermesRunBinding } from "../../hermes/src/index.ts"; +import { + createCockroachHermesRuntime, + type CockroachAnySqlStatement, + type CockroachGenericSqlExecutor, +} from "../src/index.ts"; + +function binding(): HermesRunBinding { + return { + workItemId: "wi-1", + agentId: "agent-1", + sessionId: "sess-1", + hatAssignmentId: "hat-1", + promptFlowRunId: "pf-1", + }; +} + +function runRow(overrides: Record = {}): Record { + return { + run_id: "run-1", + work_item_id: "wi-1", + agent_id: "agent-1", + session_id: "sess-1", + hat_assignment_id: "hat-1", + prompt_flow_run_id: "pf-1", + state: HermesRunState.Running, + last_heartbeat_ms: 1000, + outcome_summary: null, + outcome_evidence_refs: null, + failure_reason: null, + ...overrides, + }; +} + +describe("cockroach hermes runtime (durable agent runs)", () => { + test("launchRun inserts a Running run bound to the org facts", async () => { + const executor = recordingExecutor([]); + const runtime = createCockroachHermesRuntime({ executor, idGenerator: { nextRunId: () => "run-1" } }); + + const result = await runtime.launchRun(binding()); + + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.run.runId, "run-1"); + equal(result.run.state, HermesRunState.Running); + const insert = executor.statements[0]; + equal(insert?.sql.includes("INSERT INTO agentic_org_hermes_run"), true); + equal(insert?.parameters[0], "run-1"); + equal(insert?.parameters[6], HermesRunState.Running); + }); + + test("heartbeat on a Running run advances last_heartbeat_at", async () => { + const executor = recordingExecutor([runRow()]); + const runtime = createCockroachHermesRuntime({ executor }); + + const result = await runtime.heartbeat("run-1"); + + equal(result.outcome, "ok"); + const update = executor.statements.find((s) => s.sql.includes("UPDATE agentic_org_hermes_run")); + equal(update?.sql.includes("last_heartbeat_at = now()"), true); + }); + + test("heartbeat on an unknown run returns unknown_run feedback (never throws)", async () => { + const executor = recordingExecutor([]); // no row + const runtime = createCockroachHermesRuntime({ executor }); + + const result = await runtime.heartbeat("missing"); + + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "unknown_run"); + }); + + test("completeRun on a Running run sets Completed + outcome", async () => { + const executor = recordingExecutor([runRow()]); + const runtime = createCockroachHermesRuntime({ executor }); + + const result = await runtime.completeRun("run-1", { summary: "done", evidenceRefs: ["pr-1"] }); + + equal(result.outcome, "ok"); + if (result.outcome !== "ok") return; + equal(result.run.state, HermesRunState.Completed); + equal(result.run.outcome?.summary, "done"); + const update = executor.statements.find((s) => s.sql.includes("SET state =")); + equal(update?.sql.includes(HermesRunState.Completed) || update?.parameters.includes(HermesRunState.Completed), true); + }); + + test("completeRun on a non-Running run returns run_not_running feedback", async () => { + const executor = recordingExecutor([runRow({ state: HermesRunState.Completed })]); + const runtime = createCockroachHermesRuntime({ executor }); + + const result = await runtime.completeRun("run-1", { summary: "x", evidenceRefs: [] }); + + equal(result.outcome, "feedback"); + if (result.outcome !== "feedback") return; + equal(result.feedback.reason, "run_not_running"); + }); +}); + +function recordingExecutor(rows: readonly Record[]): CockroachGenericSqlExecutor & { + statements: CockroachAnySqlStatement[]; +} { + const statements: CockroachAnySqlStatement[] = []; + return { + statements, + execute: async >(statement: CockroachAnySqlStatement) => { + statements.push(statement); + return { rows: rows as readonly Row[] }; + }, + executeTransaction: async (operation) => + await operation({ + execute: async >(statement: CockroachAnySqlStatement) => { + statements.push(statement); + return { rows: rows as readonly Row[] }; + }, + }), + }; +} diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-keep-alive-action-sink.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-keep-alive-action-sink.test.ts new file mode 100644 index 0000000000..33b1098734 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-keep-alive-action-sink.test.ts @@ -0,0 +1,137 @@ +import { deepEqual, rejects } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { KeepAliveActionKind } from "../../keepalive/src/index.ts"; +import { + ControlPlaneAlertKind, + createCockroachKeepAliveActionSink, + type AppendControlPlaneAlertInput, + type CockroachControlPlaneStateStore, +} from "../src/index.ts"; + +describe("cockroach keep-alive action sink (routes engine actions to durable state)", () => { + test("EmitHeartbeat ticks the org heartbeat — the org proves it is alive", async () => { + const recorder = createRecordingStore(); + const sink = createCockroachKeepAliveActionSink({ + store: recorder.store, + organizationId: "org-1", + generateAlertId: () => "alert-x", + }); + + await sink.applyAction({ kind: KeepAliveActionKind.EmitHeartbeat, ageMs: 100 }); + + deepEqual(recorder.ticks, ["org-1"]); + deepEqual(recorder.alerts, []); + }); + + test("RaiseOrgStallAlert appends an org-stall alert with the age + deadline detail", async () => { + const recorder = createRecordingStore(); + const sink = createCockroachKeepAliveActionSink({ + store: recorder.store, + organizationId: "org-1", + generateAlertId: () => "alert-1", + }); + + await sink.applyAction({ kind: KeepAliveActionKind.RaiseOrgStallAlert, ageMs: 9000, deadlineMs: 5000 }); + + deepEqual(recorder.alerts, [ + { + alertId: "alert-1", + organizationId: "org-1", + kind: ControlPlaneAlertKind.OrgStall, + detail: { ageMs: 9000, deadlineMs: 5000 }, + }, + ]); + }); + + test("ReassignStaleWork appends a reassignment alert naming the stale agent's work", async () => { + const recorder = createRecordingStore(); + const sink = createCockroachKeepAliveActionSink({ + store: recorder.store, + organizationId: "org-1", + generateAlertId: () => "alert-2", + }); + + await sink.applyAction({ + kind: KeepAliveActionKind.ReassignStaleWork, + staleAgentId: "agent-7", + hatAssignmentId: "hat-3", + workItemId: "work-9", + heartbeatAgeMs: 12000, + }); + + deepEqual(recorder.alerts, [ + { + alertId: "alert-2", + organizationId: "org-1", + kind: ControlPlaneAlertKind.StaleWorkReassignment, + detail: { staleAgentId: "agent-7", hatAssignmentId: "hat-3", workItemId: "work-9", heartbeatAgeMs: 12000 }, + }, + ]); + }); + + test("ReapLease appends a lease-reap alert naming the reaped resource + fencing token", async () => { + const recorder = createRecordingStore(); + const sink = createCockroachKeepAliveActionSink({ + store: recorder.store, + organizationId: "org-1", + generateAlertId: () => "alert-3", + }); + + await sink.applyAction({ + kind: KeepAliveActionKind.ReapLease, + leaseId: "lease-5", + resource: "build-runner", + holderAgentId: "agent-2", + fencingToken: 42, + }); + + deepEqual(recorder.alerts, [ + { + alertId: "alert-3", + organizationId: "org-1", + kind: ControlPlaneAlertKind.LeaseReap, + detail: { leaseId: "lease-5", resource: "build-runner", holderAgentId: "agent-2", fencingToken: 42 }, + }, + ]); + }); + + test("rejects an unknown action kind as a programmer error (exhaustive DU)", async () => { + const recorder = createRecordingStore(); + const sink = createCockroachKeepAliveActionSink({ + store: recorder.store, + organizationId: "org-1", + generateAlertId: () => "alert-x", + }); + + await rejects( + // deliberately bypass the type to prove the exhaustive default throws + async () => sink.applyAction({ kind: "not_a_real_kind" } as never), + ); + }); +}); + +function createRecordingStore(): { + store: CockroachControlPlaneStateStore; + ticks: string[]; + alerts: AppendControlPlaneAlertInput[]; +} { + const ticks: string[] = []; + const alerts: AppendControlPlaneAlertInput[] = []; + + return { + ticks, + alerts, + store: { + readOrgHeartbeatAgeMs: async () => undefined, + tickOrgHeartbeat: async (organizationId: string) => { + ticks.push(organizationId); + }, + appendAlert: async (alert: AppendControlPlaneAlertInput) => { + alerts.push(alert); + }, + recordAgentHeartbeat: async () => {}, + readAgentHeartbeats: async () => [], + }, + }; +} diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-keep-alive-snapshot-source.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-keep-alive-snapshot-source.test.ts new file mode 100644 index 0000000000..89578ecacf --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-keep-alive-snapshot-source.test.ts @@ -0,0 +1,75 @@ +import { deepEqual } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import type { AgentHeartbeatRecord, CockroachControlPlaneStateStore } from "../src/index.ts"; +import { createCockroachKeepAliveSnapshotSource } from "../src/index.ts"; + +describe("cockroach keep-alive snapshot source", () => { + test("loads the org heartbeat age, deadline, and clock into a deterministic snapshot", async () => { + const source = createCockroachKeepAliveSnapshotSource({ + store: createStubStore(1500), + organizationId: "org-1", + orgHeartbeatDeadlineMs: 5000, + clock: { now: () => 1_000 }, + }); + + const snapshot = await source.loadSnapshot(); + + deepEqual(snapshot, { + nowMs: 1_000, + orgHeartbeatAgeMs: 1500, + orgHeartbeatDeadlineMs: 5000, + agents: [], + leases: [], + }); + }); + + test("treats a never-ticked org as age 0 (just born, alive) so the first tick registers it", async () => { + const source = createCockroachKeepAliveSnapshotSource({ + store: createStubStore(undefined), + organizationId: "org-1", + orgHeartbeatDeadlineMs: 5000, + clock: { now: () => 2_000 }, + }); + + const snapshot = await source.loadSnapshot(); + + deepEqual(snapshot, { + nowMs: 2_000, + orgHeartbeatAgeMs: 0, + orgHeartbeatDeadlineMs: 5000, + agents: [], + leases: [], + }); + }); + + test("loads real agent heartbeats into the snapshot so stale agents are detected", async () => { + const agents: AgentHeartbeatRecord[] = [ + { agentId: "agent-7", hatAssignmentId: "hat-3", workItemId: "work-9", heartbeatAgeMs: 1200, deadlineMs: 8000 }, + { agentId: "agent-8", hatAssignmentId: "hat-4", workItemId: "work-10", heartbeatAgeMs: 9999, deadlineMs: 8000 }, + ]; + const source = createCockroachKeepAliveSnapshotSource({ + store: createStubStore(1500, agents), + organizationId: "org-1", + orgHeartbeatDeadlineMs: 5000, + clock: { now: () => 1_000 }, + }); + + const snapshot = await source.loadSnapshot(); + + deepEqual(snapshot.agents, agents); + }); +}); + +function createStubStore( + ageMs: number | undefined, + agents: readonly AgentHeartbeatRecord[] = [], +): CockroachControlPlaneStateStore { + return { + readOrgHeartbeatAgeMs: async () => ageMs, + tickOrgHeartbeat: async () => {}, + appendAlert: async () => {}, + recordAgentHeartbeat: async () => {}, + readAgentHeartbeats: async () => agents, + }; +} diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-memory.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-memory.test.ts new file mode 100644 index 0000000000..2e8e16c709 --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-memory.test.ts @@ -0,0 +1,105 @@ +import { deepEqual, equal } from "node:assert/strict"; +import { describe, test } from "node:test"; + +import { MemoryOperation, type MemoryAttribution } from "../../memory/src/index.ts"; +import { + createCockroachMemory, + type CockroachAnySqlStatement, + type CockroachGenericSqlExecutor, +} from "../src/index.ts"; + +function attribution(overrides: Partial = {}): MemoryAttribution { + return { + agentId: "agent-1", + hatAssignmentId: "hat-1", + projectId: "proj-1", + workItemId: "wi-1", + promptFlowRunId: "pf-1", + ...overrides, + }; +} + +describe("cockroach hindsight memory (durable, scoped, sticky)", () => { + test("retain inserts a durable memory row attributed to the writing hat", async () => { + const executor = recordingExecutor([]); + const memory = createCockroachMemory({ executor, idGenerator: { nextMemoryId: () => "mem-1" }, clock: { now: () => 1000 } }); + + const result = await memory.retain(attribution(), "the api needs pagination"); + + equal(result.operation, MemoryOperation.Retain); + equal(result.memory.memoryId, "mem-1"); + equal(result.memory.content, "the api needs pagination"); + const insert = executor.statements[0]; + equal(insert?.sql.includes("INSERT INTO agentic_org_hindsight_memory"), true); + deepEqual(insert?.parameters, ["mem-1", "agent-1", "hat-1", "proj-1", "wi-1", "pf-1", "the api needs pagination"]); + }); + + test("recall is SCOPED by project (never global) and maps rows to the sticky author", async () => { + const executor = recordingExecutor([ + { + memory_id: "mem-1", + agent_id: "author-agent", + hat_assignment_id: "author-hat", + project_id: "proj-1", + work_item_id: "wi-9", + prompt_flow_run_id: "pf-9", + content: "prior lesson", + retained_at_ms: 500, + }, + ]); + const memory = createCockroachMemory({ executor }); + + // a DIFFERENT hat recalls; the memory keeps its ORIGINAL (sticky) attribution + const result = await memory.recall(attribution({ agentId: "reader-agent", hatAssignmentId: "reader-hat" })); + + equal(result.operation, MemoryOperation.Recall); + equal(executor.statements[0]?.sql.includes("WHERE project_id = $1"), true); + equal(executor.statements[0]?.parameters[0], "proj-1"); + deepEqual(result.memories, [ + { + memoryId: "mem-1", + attribution: { + agentId: "author-agent", + hatAssignmentId: "author-hat", + projectId: "proj-1", + workItemId: "wi-9", + promptFlowRunId: "pf-9", + }, + content: "prior lesson", + retainedAtMs: 500, + }, + ]); + }); + + test("reflect summarizes the in-scope memory count", async () => { + const executor = recordingExecutor([{ considered_count: 3 }]); + const memory = createCockroachMemory({ executor }); + + const result = await memory.reflect(attribution()); + + equal(result.operation, MemoryOperation.Reflect); + equal(result.consideredCount, 3); + equal(result.summary.includes("3"), true); + equal(executor.statements[0]?.sql.includes("count(*)"), true); + }); +}); + +function recordingExecutor(rows: readonly Record[]): CockroachGenericSqlExecutor & { + statements: CockroachAnySqlStatement[]; +} { + const statements: CockroachAnySqlStatement[] = []; + return { + statements, + execute: async >(statement: CockroachAnySqlStatement) => { + statements.push(statement); + return { rows: rows as readonly Row[] }; + }, + executeTransaction: async (operation) => + await operation({ + execute: async >(statement: CockroachAnySqlStatement) => { + statements.push(statement); + return { rows: rows as readonly Row[] }; + }, + }), + }; +} diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-migration-runner.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-migration-runner.test.ts index 7461d65f37..21eaf93d44 100644 --- a/agentic-organization/packages/state-cockroach/test/cockroach-migration-runner.test.ts +++ b/agentic-organization/packages/state-cockroach/test/cockroach-migration-runner.test.ts @@ -6,12 +6,13 @@ import { createCockroachCoreStateMigrations, createCockroachCoreStateMigration, createCockroachMigrationRunner, + splitSqlStatements, type CockroachAnySqlStatement, type CockroachGenericSqlExecutor, } from "../src/index.ts"; describe("cockroach migration runner", () => { - test("applies the core state migration through the generic SQL executor", async () => { + test("applies each statement of a migration separately (CockroachDB DDL+DML txn rule)", async () => { const executor = createRecordingSqlExecutor(); const migration = createCockroachCoreStateMigration(); const runner = createCockroachMigrationRunner({ @@ -21,16 +22,19 @@ describe("cockroach migration runner", () => { await runner.applyMigrations(); - deepEqual(executor.statements, [ - { + // each statement of the migration is executed on its own — never the whole + // multi-statement SQL as one query (which Cockroach runs as one implicit txn) + deepEqual( + executor.statements, + splitSqlStatements(migration.sql).map((sql) => ({ name: CockroachMigrationStatement.ApplyMigration, - sql: migration.sql, + sql, parameters: [], - }, - ]); + })), + ); }); - test("applies ordered core migrations including additive outbox claim fencing", async () => { + test("applies ordered core migrations as a flat, ordered statement stream", async () => { const executor = createRecordingSqlExecutor(); const migrations = createCockroachCoreStateMigrations(); const runner = createCockroachMigrationRunner({ @@ -40,9 +44,10 @@ describe("cockroach migration runner", () => { await runner.applyMigrations(); + // the executed statements equal every migration's statements, in order deepEqual( executor.statements.map((statement) => statement.sql), - migrations.map((migration) => migration.sql), + migrations.flatMap((migration) => splitSqlStatements(migration.sql)), ); }); }); diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-org-stores.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-org-stores.test.ts new file mode 100644 index 0000000000..72a9b6138f --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/cockroach-org-stores.test.ts @@ -0,0 +1,119 @@ +import { equal, ok } from "node:assert/strict"; +import { test } from "node:test"; + +import { HatBindingPhase, OrgEventKind, type HatBinding, type OrgEvent } from "../../domain/src/index.ts"; +import { createCockroachOrgEventStore } from "../src/cockroach-org-event-store.ts"; +import { createCockroachHatBindingStore } from "../src/cockroach-hat-binding-store.ts"; +import type { CockroachGenericSqlExecutor } from "../src/cockroach-sql-executor.ts"; + +/** An in-memory fake of the SQL executor: records INSERT/UPSERT rows, answers SELECTs. */ +function fakeExecutor(): { executor: CockroachGenericSqlExecutor; statements: { name: string; sql: string; parameters: readonly unknown[] }[] } { + const orgEvents: Record[] = []; + const bindings = new Map>(); + const statements: { name: string; sql: string; parameters: readonly unknown[] }[] = []; + + const execute = async >(s: { name: string; sql: string; parameters: readonly unknown[] }): Promise<{ rows: readonly Row[] }> => { + statements.push(s); + const p = s.parameters; + if (s.name === "append_org_event") { + orgEvents.push({ + org_event_id: p[0], kind: p[1], organization_id: p[2], actor_hat_id: p[3], actor_agent_id: p[4], department_id: p[5], + subject_id: p[6], from_state: p[7], to_state: p[8], decision: p[9], supervisor_chain: p[10], evidence_refs: p[11], + correlation_id: p[12], causation_id: p[13], trace_id: p[14], occurred_at: p[15], + }); + return { rows: [] }; + } + if (s.name === "list_org_events_by_org") { + return { rows: orgEvents.filter((e) => e.organization_id === p[0]) as Row[] }; + } + if (s.name === "list_org_events_by_subject") { + return { rows: orgEvents.filter((e) => e.subject_id === p[0]) as Row[] }; + } + if (s.name === "upsert_hat_binding") { + bindings.set(String(p[0]), { + binding_id: p[0], hat_id: p[1], organization_id: p[2], wearer_agent_id: p[3], phase: p[4], + bound_at: p[5], warmup_ends_at: p[6], expires_at: p[7], activated_at: p[8], ended_at: p[9], cooldown_until: p[10], reason: p[11], + }); + return { rows: [] }; + } + if (s.name === "list_active_hat_bindings") { + const nonTerminal = new Set([HatBindingPhase.Pending, HatBindingPhase.Warmup, HatBindingPhase.Active, HatBindingPhase.Probation]); + return { rows: [...bindings.values()].filter((b) => b.organization_id === p[0] && nonTerminal.has(String(b.phase))) as Row[] }; + } + if (s.name === "list_all_hat_bindings") { + return { rows: [...bindings.values()].filter((b) => b.organization_id === p[0]) as Row[] }; + } + return { rows: [] }; + }; + + return { executor: { execute, executeTransaction: async (op) => op({ execute }) } as CockroachGenericSqlExecutor, statements }; +} + +function sampleEvent(): OrgEvent { + return { + id: "evt-1", kind: OrgEventKind.PriorityDecision, occurredAt: "2026-05-30T09:00:00.000Z", organizationId: "org-1", + actorHatId: "engineering_director", departmentId: "engineering", subjectId: "wi-1", toState: "high", + decision: "director set wi-1 to high", supervisorChain: ["executive_board_member", "cto", "engineering_director"], + evidenceRefs: ["evidence-A"], correlationId: "c", causationId: "c", traceId: "t", + }; +} + +test("org events round-trip with JSONB supervisor chain + evidence preserved", async () => { + const { executor } = fakeExecutor(); + const store = createCockroachOrgEventStore({ executor }); + await store.append(sampleEvent()); + + const byOrg = await store.listByOrganization("org-1", 10); + equal(byOrg.length, 1); + equal(byOrg[0]?.actorHatId, "engineering_director"); + equal(byOrg[0]?.decision, "director set wi-1 to high"); + // supervisor chain (JSONB) survives the round trip + equal(byOrg[0]?.supervisorChain.join(","), "executive_board_member,cto,engineering_director"); + equal(byOrg[0]?.evidenceRefs[0], "evidence-A"); + + const bySubject = await store.listBySubject("wi-1", 10); + equal(bySubject.length, 1); +}); + +test("the SQL casts the JSONB columns (no string-into-JSONB error)", async () => { + const { executor, statements } = fakeExecutor(); + const store = createCockroachOrgEventStore({ executor }); + await store.append(sampleEvent()); + const insert = statements.find((s) => s.name === "append_org_event")!; + ok(insert.sql.includes("$11::JSONB")); + ok(insert.sql.includes("$12::JSONB")); +}); + +test("hat bindings upsert and list-active excludes terminal phases", async () => { + const { executor } = fakeExecutor(); + const store = createCockroachHatBindingStore({ executor }); + const base: HatBinding = { + id: "b-1", hatId: "backend_implementer", organizationId: "org-1", wearerAgentId: "agent-A", + phase: HatBindingPhase.Active, boundAt: "2026-05-30T09:00:00.000Z", warmupEndsAt: "2026-05-30T09:00:05.000Z", expiresAt: "2026-05-30T09:02:00.000Z", + }; + await store.upsert(base); + await store.upsert({ ...base, id: "b-2", phase: HatBindingPhase.Expired, endedAt: "2026-05-30T09:02:00.000Z", cooldownUntil: "2026-05-30T09:02:20.000Z" }); + + const active = await store.listActive("org-1"); + equal(active.length, 1); // expired excluded + equal(active[0]?.id, "b-1"); + + const all = await store.listAll("org-1"); + equal(all.length, 2); + // the cooldown timestamp survives on the expired binding + ok(all.find((b) => b.id === "b-2")?.cooldownUntil !== undefined); +}); + +test("upsert transitions a binding's phase in place", async () => { + const { executor } = fakeExecutor(); + const store = createCockroachHatBindingStore({ executor }); + const b: HatBinding = { + id: "b-1", hatId: "ceo", organizationId: "org-1", wearerAgentId: "agent-A", + phase: HatBindingPhase.Warmup, boundAt: "2026-05-30T09:00:00.000Z", warmupEndsAt: "2026-05-30T09:00:15.000Z", expiresAt: "2026-05-30T09:06:00.000Z", + }; + await store.upsert(b); + await store.upsert({ ...b, phase: HatBindingPhase.Active, activatedAt: "2026-05-30T09:00:15.000Z" }); + const all = await store.listAll("org-1"); + equal(all.length, 1); // same binding, updated in place + equal(all[0]?.phase, HatBindingPhase.Active); +}); diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-reaction-plan-work-queue.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-reaction-plan-work-queue.test.ts index 49895285ae..9e56329759 100644 --- a/agentic-organization/packages/state-cockroach/test/cockroach-reaction-plan-work-queue.test.ts +++ b/agentic-organization/packages/state-cockroach/test/cockroach-reaction-plan-work-queue.test.ts @@ -59,7 +59,7 @@ describe("cockroach reaction plan work queue", () => { ]); equal(executor.statements[0]?.sql.includes("FOR UPDATE SKIP LOCKED"), true); equal(executor.statements[0]?.sql.includes("claim_expires_at <= now()"), true); - equal(executor.statements[0]?.sql.includes("claim_expires_at = now() + ($3 * INTERVAL '1 millisecond')"), true); + equal(executor.statements[0]?.sql.includes("claim_expires_at = now() + ($3::INT8 * INTERVAL '1 millisecond')"), true); equal(executor.statements[0]?.sql.includes("$4"), false); equal(executor.statements[0]?.sql.includes("next_attempt_at IS NULL OR next_attempt_at <= now()"), true); deepEqual(claim.reactionPlans[0], { diff --git a/agentic-organization/packages/state-cockroach/test/cockroach-schema.test.ts b/agentic-organization/packages/state-cockroach/test/cockroach-schema.test.ts index 34a1c32ed6..e8431e1217 100644 --- a/agentic-organization/packages/state-cockroach/test/cockroach-schema.test.ts +++ b/agentic-organization/packages/state-cockroach/test/cockroach-schema.test.ts @@ -20,6 +20,10 @@ import { CockroachTableName, createCockroachCoreStateMigrations, createCockroachCoreStateMigration, + createCockroachAgentLivenessMigration, + createCockroachControlPlaneKeepAliveMigration, + createCockroachHindsightMemoryMigration, + createCockroachHermesRunMigration, createCockroachDecisionRecordKernelMigration, createCockroachDiscussionAnchorKernelMigration, createCockroachHatAssignmentAuthorityProjectionMigration, @@ -85,6 +89,58 @@ describe("cockroach core state schema", () => { equal(migrations[7]?.name, CockroachCoreStateMigrationName.HatAssignmentAuthorityProjectionV8); equal(migrations[8]?.name, CockroachCoreStateMigrationName.ReactionPlanExecutionLifecycleV9); equal(migrations[9]?.name, CockroachCoreStateMigrationName.QualityGateEvaluationKernelV10); + equal(migrations[10]?.name, CockroachCoreStateMigrationName.ControlPlaneKeepAliveV11); + equal(migrations[11]?.name, CockroachCoreStateMigrationName.AgentLivenessV12); + equal(migrations[12]?.name, CockroachCoreStateMigrationName.HindsightMemoryV13); + equal(migrations[13]?.name, CockroachCoreStateMigrationName.HermesRunV14); + equal(migrations[14]?.name, CockroachCoreStateMigrationName.OrgSystemV15); + }); + + test("declares the hermes run table (durable agent-run history)", () => { + const migration = createCockroachHermesRunMigration(); + + equal(migration.name, CockroachCoreStateMigrationName.HermesRunV14); + ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.HermesRun}`)); + ok(migration.sql.includes("run_id STRING PRIMARY KEY")); + ok(migration.sql.includes("state STRING NOT NULL")); + ok(migration.sql.includes("last_heartbeat_at TIMESTAMPTZ NOT NULL")); + ok(migration.sql.includes("outcome_evidence_refs JSONB")); + }); + + test("declares the hindsight memory table (durable Hindsight)", () => { + const migration = createCockroachHindsightMemoryMigration(); + + equal(migration.name, CockroachCoreStateMigrationName.HindsightMemoryV13); + ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.HindsightMemory}`)); + ok(migration.sql.includes("memory_id STRING PRIMARY KEY")); + ok(migration.sql.includes("project_id STRING NOT NULL")); + ok(migration.sql.includes("content STRING NOT NULL")); + ok(migration.sql.includes("retained_at TIMESTAMPTZ NOT NULL")); + }); + + test("declares the control-plane keep-alive tables (org proof-of-life + alert log)", () => { + const migration = createCockroachControlPlaneKeepAliveMigration(); + + equal(migration.name, CockroachCoreStateMigrationName.ControlPlaneKeepAliveV11); + ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.ControlPlaneHeartbeat}`)); + ok(migration.sql.includes("organization_id STRING PRIMARY KEY")); + ok(migration.sql.includes("last_tick_at TIMESTAMPTZ NOT NULL")); + ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.ControlPlaneAlerts}`)); + ok(migration.sql.includes("control_plane_alert_id STRING PRIMARY KEY")); + ok(migration.sql.includes("detail_json JSONB NOT NULL")); + }); + + test("declares the agent-liveness heartbeat table (one row per org+agent)", () => { + const migration = createCockroachAgentLivenessMigration(); + + equal(migration.name, CockroachCoreStateMigrationName.AgentLivenessV12); + ok(migration.sql.includes(`CREATE TABLE IF NOT EXISTS ${CockroachTableName.AgentHeartbeat}`)); + ok(migration.sql.includes("agent_id STRING NOT NULL")); + ok(migration.sql.includes("hat_assignment_id STRING NOT NULL")); + ok(migration.sql.includes("work_item_id STRING NOT NULL")); + ok(migration.sql.includes("last_heartbeat_at TIMESTAMPTZ NOT NULL")); + ok(migration.sql.includes("deadline_ms INT8 NOT NULL")); + ok(migration.sql.includes("PRIMARY KEY (organization_id, agent_id)")); }); test("declares an additive work-anchor kernel migration for existing databases", () => { diff --git a/agentic-organization/packages/state-cockroach/test/sql-statement-splitter.test.ts b/agentic-organization/packages/state-cockroach/test/sql-statement-splitter.test.ts new file mode 100644 index 0000000000..4a0fef05fb --- /dev/null +++ b/agentic-organization/packages/state-cockroach/test/sql-statement-splitter.test.ts @@ -0,0 +1,52 @@ +import { deepEqual } from "node:assert/strict"; +import { test } from "node:test"; +import { splitSqlStatements } from "../src/sql-statement-splitter.ts"; + +test("splits simple semicolon-separated statements", () => { + deepEqual(splitSqlStatements("CREATE TABLE a (id INT); INSERT INTO a VALUES (1);"), [ + "CREATE TABLE a (id INT)", + "INSERT INTO a VALUES (1)", + ]); +}); + +test("ignores a trailing empty statement after the final semicolon", () => { + deepEqual(splitSqlStatements("SELECT 1;\n\n"), ["SELECT 1"]); +}); + +test("a statement without a trailing semicolon is still returned", () => { + deepEqual(splitSqlStatements("SELECT 1"), ["SELECT 1"]); +}); + +test("does NOT split on a semicolon inside a single-quoted string", () => { + deepEqual(splitSqlStatements("INSERT INTO a VALUES ('x; y'); SELECT 1;"), [ + "INSERT INTO a VALUES ('x; y')", + "SELECT 1", + ]); +}); + +test("handles an escaped single quote inside a string ('' is a literal quote)", () => { + deepEqual(splitSqlStatements("INSERT INTO a VALUES ('it''s; fine'); SELECT 2;"), [ + "INSERT INTO a VALUES ('it''s; fine')", + "SELECT 2", + ]); +}); + +test("does not split on a semicolon inside a line comment (comment is preserved)", () => { + deepEqual(splitSqlStatements("SELECT 1; -- a comment with ; semicolon\nSELECT 2;"), [ + "SELECT 1", + "-- a comment with ; semicolon\nSELECT 2", + ]); +}); + +test("splits the real ADD COLUMN + UPDATE pattern into separate statements", () => { + const sql = "ALTER TABLE t ADD COLUMN IF NOT EXISTS version INT8 DEFAULT 1;\nUPDATE t SET version = COALESCE(version, 1);"; + deepEqual(splitSqlStatements(sql), [ + "ALTER TABLE t ADD COLUMN IF NOT EXISTS version INT8 DEFAULT 1", + "UPDATE t SET version = COALESCE(version, 1)", + ]); +}); + +test("preserves multi-line statements (whitespace within a statement is kept, edges trimmed)", () => { + const sql = "ALTER TABLE t\n ADD COLUMN x INT;\n"; + deepEqual(splitSqlStatements(sql), ["ALTER TABLE t\n ADD COLUMN x INT"]); +}); diff --git a/agentic-organization/packages/test-node.d.ts b/agentic-organization/packages/test-node.d.ts index 36d469372c..6f2677cbfa 100644 --- a/agentic-organization/packages/test-node.d.ts +++ b/agentic-organization/packages/test-node.d.ts @@ -1,7 +1,7 @@ declare module "node:assert/strict" { export function deepEqual(actual: unknown, expected: unknown): void; export function equal(actual: unknown, expected: unknown, message?: string): void; - export function ok(value: unknown): asserts value; + export function ok(value: unknown, message?: string): asserts value; export function rejects(action: () => Promise, expected?: (error: unknown) => boolean): Promise; export function throws(action: () => void, expected?: RegExp): void; } @@ -27,6 +27,35 @@ declare module "node:test" { declare module "node:process" { export const env: Record; + export const execPath: string; + export const argv: string[]; +} + +declare module "node:child_process" { + export type ExecFileOptions = { + cwd?: string; + timeout?: number; + killSignal?: string; + maxBuffer?: number; + env?: Record; + windowsHide?: boolean; + encoding?: "utf8"; + }; + export function execFile( + file: string, + args: readonly string[], + options: ExecFileOptions, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ): void; +} + +declare module "node:fs" { + export function mkdtempSync(prefix: string): string; + export function rmSync(path: string, options: { recursive: boolean; force: boolean }): void; +} + +declare module "node:os" { + export function tmpdir(): string; } declare module "node:crypto" {