diff --git a/packages/core/src/enums.test.ts b/packages/core/src/enums.test.ts new file mode 100644 index 0000000..f113a0d --- /dev/null +++ b/packages/core/src/enums.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { + ApprovalDecisionActionValues, + BackendValues, + CapabilityValues, + RiskLevelValues, +} from "./enums.js"; + +describe("core enums", () => { + it("keeps approval decisions separate from run pause controls", () => { + expect(ApprovalDecisionActionValues).toEqual(["approve", "reject", "request_changes", "abort"]); + expect(ApprovalDecisionActionValues).not.toContain("pause"); + }); + + it("exports the locked backend, risk, and capability sets", () => { + expect(BackendValues).toEqual(["codex", "claude", "fake"]); + expect(RiskLevelValues).toEqual(["low", "medium", "high"]); + expect(CapabilityValues).toContain("test_first_development"); + expect(CapabilityValues).toContain("backtest_run"); + }); +}); diff --git a/packages/core/src/enums.ts b/packages/core/src/enums.ts index fa98a33..56ef0d0 100644 --- a/packages/core/src/enums.ts +++ b/packages/core/src/enums.ts @@ -3,3 +3,88 @@ import { z } from "zod"; export const BackendValues = ["codex", "claude", "fake"] as const; export const Backend = z.enum(BackendValues); export type Backend = z.infer; + +export const CapabilityValues = [ + "spec_write", + "phase_planning", + "task_dag_planning", + "code_edit", + "test_first_development", + "code_review", + "evidence_check", + "command_execute", + "backtest_run", + "metric_extract", + "failure_mining", + "objective_eval", + "final_report_compose", +] as const; +export const Capability = z.enum(CapabilityValues); +export type Capability = z.infer; + +export const RiskLevelValues = ["low", "medium", "high"] as const; +export const RiskLevel = z.enum(RiskLevelValues); +export type RiskLevel = z.infer; + +export const ApprovalDecisionActionValues = [ + "approve", + "reject", + "request_changes", + "abort", +] as const; +export const ApprovalDecisionAction = z.enum(ApprovalDecisionActionValues); +export type ApprovalDecisionAction = z.infer; + +export const ApprovalStateValues = [ + "pending", + "approved", + "rejected", + "changes_requested", + "aborted", + "paused", +] as const; +export const ApprovalState = z.enum(ApprovalStateValues); +export type ApprovalState = z.infer; + +export const RunStateValues = [ + "created", + "bound", + "planning", + "awaiting_approval", + "executing", + "paused", + "completed", + "failed", + "aborted", +] as const; +export const RunState = z.enum(RunStateValues); +export type RunState = z.infer; + +export const RunPhaseStateValues = [ + "pending", + "running", + "awaiting_artifact", + "validating", + "awaiting_approval", + "completed", + "failed", + "skipped", +] as const; +export const RunPhaseState = z.enum(RunPhaseStateValues); +export type RunPhaseState = z.infer; + +export const SessionStateValues = [ + "CREATED", + "BOOTSTRAPPING", + "READY", + "BUSY", + "WAITING_FOR_APPROVAL", + "ARTIFACT_TIMEOUT", + "HUNG", + "CRASHED", + "RESUMING", + "REBOOTSTRAPPED", + "FAILED_NEEDS_HUMAN", +] as const; +export const SessionState = z.enum(SessionStateValues); +export type SessionState = z.infer; diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts new file mode 100644 index 0000000..96fd639 --- /dev/null +++ b/packages/core/src/errors.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { DevflowError } from "./errors.js"; + +describe("DevflowError", () => { + it("carries stable classification metadata", () => { + const error = new DevflowError("blocked", { + class: "human_required", + code: "destructive_command_blocked", + recoveryHint: "Ask for approval before running rm -rf", + }); + + expect(error.class).toBe("human_required"); + expect(error.code).toBe("destructive_command_blocked"); + expect(error.recoveryHint).toContain("approval"); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..8632e7e --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,30 @@ +export type ErrorClass = "recoverable" | "human_required" | "fatal"; + +export interface DevflowErrorOptions { + class: ErrorClass; + code: string; + runId?: string; + phaseId?: string; + recoveryHint?: string; + cause?: unknown; +} + +export class DevflowError extends Error { + readonly class: ErrorClass; + readonly code: string; + readonly runId: string | undefined; + readonly phaseId: string | undefined; + readonly recoveryHint: string | undefined; + override readonly cause: unknown; + + constructor(message: string, options: DevflowErrorOptions) { + super(message, { cause: options.cause }); + this.name = "DevflowError"; + this.class = options.class; + this.code = options.code; + this.runId = options.runId; + this.phaseId = options.phaseId; + this.recoveryHint = options.recoveryHint; + this.cause = options.cause; + } +} diff --git a/packages/core/src/hash.test.ts b/packages/core/src/hash.test.ts new file mode 100644 index 0000000..f8c5e0d --- /dev/null +++ b/packages/core/src/hash.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { canonicalize, hash } from "./hash.js"; + +describe("content hashing", () => { + it("canonicalizes object keys lexicographically while preserving array order", () => { + expect(canonicalize({ z: 1, a: [{ b: true, a: null }] })).toBe( + '{"a":[{"a":null,"b":true}],"z":1}', + ); + }); + + it("hashes equivalent object key orders to the same sha256 hex", () => { + const left = hash({ z: 1, a: 2 }); + const right = hash({ a: 2, z: 1 }); + + expect(left).toBe(right); + expect(left).toMatch(/^[a-f0-9]{64}$/); + }); + + it("rejects values that are not JSON-safe", () => { + expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow( + /non-plain object/, + ); + expect(() => canonicalize({ missing: undefined })).toThrow(/undefined/); + }); +}); diff --git a/packages/core/src/hash.ts b/packages/core/src/hash.ts new file mode 100644 index 0000000..ded3a1b --- /dev/null +++ b/packages/core/src/hash.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export function canonicalize(value: unknown): string { + return renderCanonical(assertJsonValue(value)); +} + +export function hash(value: unknown): string { + return createHash("sha256").update(canonicalize(value)).digest("hex"); +} + +function renderCanonical(value: JsonValue): string { + if (value === null || typeof value === "boolean" || typeof value === "string") { + return JSON.stringify(value); + } + + if (typeof value === "number") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => renderCanonical(item)).join(",")}]`; + } + + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${renderCanonical(value[key] as JsonValue)}`) + .join(",")}}`; +} + +function assertJsonValue(value: unknown): JsonValue { + if ( + value === null || + typeof value === "boolean" || + typeof value === "string" || + Array.isArray(value) + ) { + if (Array.isArray(value)) { + return value.map((item) => assertJsonValue(item)); + } + + return value; + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Cannot canonicalize non-finite numbers"); + } + + return value; + } + + if (typeof value === "object") { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("Cannot canonicalize non-plain object"); + } + + const objectValue: Record = {}; + + for (const [key, childValue] of Object.entries(value as Record)) { + if (childValue === undefined) { + throw new TypeError(`Cannot canonicalize undefined at key ${key}`); + } + + objectValue[key] = assertJsonValue(childValue); + } + + return objectValue; + } + + throw new TypeError(`Cannot canonicalize ${typeof value}`); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 896ec92..46a3aa5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,6 @@ export * from "./config.js"; export * from "./enums.js"; +export * from "./errors.js"; +export * from "./hash.js"; +export * from "./prompt-envelope.js"; +export * from "./run-event.js"; diff --git a/packages/core/src/prompt-envelope.test.ts b/packages/core/src/prompt-envelope.test.ts new file mode 100644 index 0000000..54a592b --- /dev/null +++ b/packages/core/src/prompt-envelope.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { PromptEnvelope, renderPromptEnvelope } from "./prompt-envelope.js"; + +describe("prompt envelope", () => { + it("validates and renders the locked wire markers", () => { + const envelope = PromptEnvelope.parse({ + uuid: "00000000-0000-4000-8000-000000000000", + runId: "11111111-1111-4111-8111-111111111111", + roleId: "planner", + phaseKey: "plan", + attempt: 0, + expectedArtifact: "/tmp/devflow/spec.json", + expectedSchema: "dev/spec@1", + dedupKey: "a".repeat(64), + instructions: "Write the spec.", + }); + + expect(renderPromptEnvelope(envelope)).toContain( + "DEVFLOW_PROMPT_BEGIN 00000000-0000-4000-8000-000000000000", + ); + expect(renderPromptEnvelope(envelope)).toContain("Phase: plan"); + expect(renderPromptEnvelope(envelope)).toContain("DEVFLOW_PROMPT_END"); + }); +}); diff --git a/packages/core/src/prompt-envelope.ts b/packages/core/src/prompt-envelope.ts new file mode 100644 index 0000000..4ccf304 --- /dev/null +++ b/packages/core/src/prompt-envelope.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const PromptEnvelope = z.object({ + uuid: z.string().uuid(), + runId: z.string().uuid(), + roleId: z.string().min(1), + phaseKey: z.string().min(1), + attempt: z.number().int().nonnegative(), + expectedArtifact: z.string().min(1), + expectedSchema: z.string().min(1), + dedupKey: z.string().regex(/^[a-f0-9]{64}$/), + instructions: z.string(), +}); + +export type PromptEnvelope = z.infer; + +export function renderPromptEnvelope(envelope: PromptEnvelope): string { + return [ + `DEVFLOW_PROMPT_BEGIN ${envelope.uuid}`, + `Run: ${envelope.runId}`, + `Role: ${envelope.roleId}`, + `Phase: ${envelope.phaseKey}`, + `Attempt: ${envelope.attempt}`, + `Expected artifact: ${envelope.expectedArtifact}`, + `Expected schema: ${envelope.expectedSchema}`, + `Dedup-Key: ${envelope.dedupKey}`, + "Instructions:", + envelope.instructions, + `DEVFLOW_PROMPT_END ${envelope.uuid}`, + ].join("\n"); +} diff --git a/packages/core/src/run-event.test.ts b/packages/core/src/run-event.test.ts new file mode 100644 index 0000000..623dead --- /dev/null +++ b/packages/core/src/run-event.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { RunEventPayloadSchemas, RunEventTypeValues } from "./run-event.js"; + +describe("run events", () => { + it("keeps a payload schema for every closed run event type", () => { + expect(Object.keys(RunEventPayloadSchemas).sort()).toEqual([...RunEventTypeValues].sort()); + }); +}); diff --git a/packages/core/src/run-event.ts b/packages/core/src/run-event.ts new file mode 100644 index 0000000..4a79a8c --- /dev/null +++ b/packages/core/src/run-event.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const RunEventTypeValues = [ + "run.created", + "run.started", + "run.paused", + "run.resumed", + "run.completed", + "run.failed", + "run.aborted", + "phase.started", + "phase.completed", + "phase.failed", + "phase.skipped", + "prompt.sent", + "prompt.repaired", + "artifact.expected", + "artifact.validated", + "artifact.invalid", + "artifact.timeout", + "approval.requested", + "approval.resolved", + "session.created", + "session.ready", + "session.busy", + "session.idle", + "session.crashed", + "session.recovered", + "session.failed", + "command.started", + "command.completed", + "command.failed", + "review.batch_recorded", + "finding.verifier_resolved", + "backtest.iteration_started", + "backtest.iteration_completed", + "backtest.objective_evaluated", +] as const; + +export const RunEventType = z.enum(RunEventTypeValues); +export type RunEventType = z.infer; + +const payloadSchema = z.record(z.unknown()); + +export const RunEventPayloadSchemas = Object.freeze( + Object.fromEntries(RunEventTypeValues.map((type) => [type, payloadSchema])), +) as Readonly>; + +export const RunEvent = z.object({ + type: RunEventType, + payload: payloadSchema, +}); + +export type RunEvent = z.infer;