diff --git a/packages/paperclip-runner/.gitignore b/packages/paperclip-runner/.gitignore
new file mode 100644
index 00000000000..cb98e73ffe5
--- /dev/null
+++ b/packages/paperclip-runner/.gitignore
@@ -0,0 +1,3 @@
+/dist-browser/
+/playwright-report/
+/test-results/
diff --git a/packages/paperclip-runner/devtools/browser/index.html b/packages/paperclip-runner/devtools/browser/index.html
new file mode 100644
index 00000000000..301dbcf965f
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Paperclip Runner · PRP Static Replay
+
+
+
+
+
+
diff --git a/packages/paperclip-runner/devtools/browser/playwright.config.ts b/packages/paperclip-runner/devtools/browser/playwright.config.ts
new file mode 100644
index 00000000000..0eb2bc3a5c6
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/playwright.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: "static-replay.spec.ts",
+ fullyParallel: false,
+ workers: 1,
+ reporter: "line",
+ use: {
+ baseURL: "http://127.0.0.1:4179",
+ viewport: { width: 1440, height: 1000 },
+ },
+ webServer: {
+ command:
+ "pnpm exec vite preview --config vite.config.ts --host 127.0.0.1 --port 4179",
+ url: "http://127.0.0.1:4179",
+ reuseExistingServer: false,
+ timeout: 30_000,
+ },
+});
diff --git a/packages/paperclip-runner/devtools/browser/src/App.tsx b/packages/paperclip-runner/devtools/browser/src/App.tsx
new file mode 100644
index 00000000000..9d202fc5260
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/App.tsx
@@ -0,0 +1,229 @@
+import { useMemo, useState } from "react";
+
+import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay";
+import type { SessionSnapshot } from "../../../src/reducer/session-reducer";
+import { Badge } from "./components/ui/badge";
+import { Button } from "./components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "./components/ui/card";
+import { Textarea } from "./components/ui/textarea";
+
+const fixtureFiles = import.meta.glob(
+ "../../../protocol/fixtures/phase-01/*.json",
+ { eager: true, query: "?raw", import: "default" },
+) as Record;
+
+const fixtureOrder = [
+ "happy-path",
+ "failed-run",
+ "interrupted-run",
+ "duplicate-event",
+ "source-gap",
+ "unknown-optional-fields",
+ "unsupported-required-version",
+];
+
+interface BrowserFixture {
+ key: string;
+ label: string;
+ source: string;
+}
+
+const browserFixtures = Object.entries(fixtureFiles)
+ .map(([path, source]): BrowserFixture => {
+ const key = path.split("/").at(-1)?.replace(/\.json$/, "") ?? path;
+ const parsed = JSON.parse(source) as { name?: string };
+ return { key, label: parsed.name ?? key, source };
+ })
+ .sort((left, right) => fixtureOrder.indexOf(left.key) - fixtureOrder.indexOf(right.key));
+
+function terminalTone(snapshot: SessionSnapshot) {
+ if (snapshot.integrity === "gap_detected") {
+ return "warning" as const;
+ }
+ if (snapshot.terminal?.runTerminalState === "succeeded") {
+ return "success" as const;
+ }
+ if (snapshot.terminal?.runTerminalState === "failed") {
+ return "danger" as const;
+ }
+ return "neutral" as const;
+}
+
+function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) {
+ return (
+ <>
+
+
+
Final session snapshot
+
{snapshot.fixtureName}
+
+
+ {snapshot.integrity === "gap_detected"
+ ? "Gap detected"
+ : (snapshot.terminal?.runTerminalState ?? snapshot.runPhase)}
+
+
+
+
+
+
- Run
+ - {snapshot.identity.runId}
+
+
+
- Session
+ - {snapshot.identity.normalizedSessionId}
+
+
+
- Turn
+ - {snapshot.terminal?.turnTerminalState ?? snapshot.turnState}
+
+
+
- Events
+ - {snapshot.timeline.length}
+
+
+
+ {snapshot.proposedResult ? (
+
+
Reported {snapshot.proposedResult.reportedWorkDisposition}
+
{snapshot.proposedResult.summary}
+
+ ) : null}
+
+ {snapshot.gaps.length > 0 ? (
+
+ Missing source sequence {snapshot.gaps.flatMap((gap) => gap.missing).join(", ")}.
+ Replay remains inspectable but is not complete.
+
+ ) : null}
+
+
+
Ordered timeline
+ {snapshot.duplicateEventIds.length} duplicate events ignored
+
+
+ {snapshot.timeline.map((event) => (
+ -
+
+ {event.sourceSeq}
+
+
+
+ {event.eventType}
+
+
+
{event.summary}
+
+
+ ))}
+
+
+
+ Inspect snapshot JSON
+ {JSON.stringify(snapshot, null, 2)}
+
+ >
+ );
+}
+
+export function App() {
+ const defaultFixture = browserFixtures[0];
+ if (defaultFixture === undefined) {
+ throw new Error("No Phase 1 fixtures were bundled");
+ }
+ const [selectedFixture, setSelectedFixture] = useState(defaultFixture.key);
+ const [source, setSource] = useState(defaultFixture.source);
+ const [replay, setReplay] = useState(() => replayPhase1FixtureText(defaultFixture.source));
+ const selected = useMemo(
+ () => browserFixtures.find((fixture) => fixture.key === selectedFixture),
+ [selectedFixture],
+ );
+
+ function chooseFixture(key: string) {
+ const fixture = browserFixtures.find((candidate) => candidate.key === key);
+ if (fixture === undefined) {
+ return;
+ }
+ setSelectedFixture(key);
+ setSource(fixture.source);
+ setReplay(replayPhase1FixtureText(fixture.source));
+ }
+
+ return (
+
+
+
+
+
+
+ Fixture input
+
+ Choose a conformance case or edit the JSON, then validate and replay it.
+
+
+
+
+
+
+
+
+
+
+ {replay.ok ? (
+
+ ) : (
+
+
Validation failed
+
Fixture cannot be replayed
+
+ {replay.issues.map((issue, index) => (
+ -
+
{issue.path} — {issue.message}
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx b/packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx
new file mode 100644
index 00000000000..261ceb4dd0a
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx
@@ -0,0 +1,19 @@
+import * as React from "react";
+
+/** Source-adapted shadcn Badge with semantic state supplied through data-tone. */
+export function Badge({
+ className = "",
+ tone = "neutral",
+ ...props
+}: React.ComponentProps<"span"> & {
+ tone?: "neutral" | "success" | "warning" | "danger";
+}) {
+ return (
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/components/ui/button.tsx b/packages/paperclip-runner/devtools/browser/src/components/ui/button.tsx
new file mode 100644
index 00000000000..8184dbab840
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/components/ui/button.tsx
@@ -0,0 +1,15 @@
+import * as React from "react";
+
+/** Source-adapted shadcn Button for the standalone, non-Tailwind devtool. */
+export function Button({
+ className = "",
+ ...props
+}: React.ComponentProps<"button">) {
+ return (
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/components/ui/card.tsx b/packages/paperclip-runner/devtools/browser/src/components/ui/card.tsx
new file mode 100644
index 00000000000..ba52d1ce93f
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/components/ui/card.tsx
@@ -0,0 +1,26 @@
+import * as React from "react";
+
+function slotClass(slot: string, className: string | undefined) {
+ return `${slot} ${className ?? ""}`.trim();
+}
+
+/** Source-adapted shadcn Card family for the standalone devtool. */
+export function Card({ className, ...props }: React.ComponentProps<"section">) {
+ return ;
+}
+
+export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return ;
+}
+
+export function CardTitle({ className, ...props }: React.ComponentProps<"h2">) {
+ return ;
+}
+
+export function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return ;
+}
+
+export function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return ;
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/components/ui/textarea.tsx b/packages/paperclip-runner/devtools/browser/src/components/ui/textarea.tsx
new file mode 100644
index 00000000000..c5bd29f44d2
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/components/ui/textarea.tsx
@@ -0,0 +1,15 @@
+import * as React from "react";
+
+/** Source-adapted shadcn Textarea for editable fixture JSON. */
+export function Textarea({
+ className = "",
+ ...props
+}: React.ComponentProps<"textarea">) {
+ return (
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/main.tsx b/packages/paperclip-runner/devtools/browser/src/main.tsx
new file mode 100644
index 00000000000..8bcbe49a016
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { App } from "./App";
+import "./styles.css";
+
+const root = document.getElementById("root");
+if (root === null) {
+ throw new Error("Browser replay root element is missing");
+}
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/packages/paperclip-runner/devtools/browser/src/styles.css b/packages/paperclip-runner/devtools/browser/src/styles.css
new file mode 100644
index 00000000000..770f35334a7
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/styles.css
@@ -0,0 +1,430 @@
+:root {
+ color-scheme: light;
+ --background: #f7f7f5;
+ --foreground: #191917;
+ --card: #ffffff;
+ --card-foreground: #191917;
+ --muted: #efefeb;
+ --muted-foreground: #66665f;
+ --border: #deded7;
+ --primary: #242420;
+ --primary-foreground: #ffffff;
+ --success: #17633a;
+ --success-surface: #e9f5ed;
+ --warning: #8a5a10;
+ --warning-surface: #fff4dc;
+ --danger: #a02f2f;
+ --danger-surface: #fceaea;
+ --ring: #66665f;
+ --font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.875rem;
+ --font-size-md: 1rem;
+ --font-size-lg: 1.25rem;
+ --font-size-xl: 1.75rem;
+ --line-tight: 1.2;
+ --line-normal: 1.5;
+ --weight-medium: 500;
+ --weight-semibold: 600;
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.625rem;
+ --shadow-sm: 0 0.0625rem 0.125rem rgb(0 0 0 / 0.05);
+ --editor-height: 32rem;
+ --page-width: 90rem;
+ --control-height: 2.5rem;
+ --timeline-column: 2rem;
+ --motion-fast: 120ms;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 20rem;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ font-size: var(--font-size-sm);
+ line-height: var(--line-normal);
+}
+
+button,
+select,
+textarea {
+ font: inherit;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+
+.app-shell {
+ width: min(100%, var(--page-width));
+ margin-inline: auto;
+ padding: var(--space-8);
+}
+
+.page-header,
+.snapshot-heading,
+.timeline-heading,
+.editor-actions,
+.timeline-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+}
+
+.page-header {
+ align-items: flex-start;
+ margin-bottom: var(--space-8);
+}
+
+.page-header > div {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.page-header h1 {
+ font-size: var(--font-size-xl);
+ line-height: var(--line-tight);
+ letter-spacing: -0.02em;
+}
+
+.page-header p:not(.eyebrow),
+.ui-card-description,
+.timeline-heading span,
+.editor-actions span,
+dt {
+ color: var(--muted-foreground);
+}
+
+.eyebrow {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.workspace-grid {
+ display: grid;
+ grid-template-columns: minmax(22rem, 0.8fr) minmax(28rem, 1.2fr);
+ gap: var(--space-8);
+ align-items: start;
+}
+
+.ui-card {
+ display: grid;
+ gap: var(--space-5);
+ padding: var(--space-6);
+ background: var(--card);
+ color: var(--card-foreground);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+}
+
+.ui-card-header,
+.ui-card-content,
+.editor-content {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.ui-card-title,
+.snapshot-heading h2 {
+ font-size: var(--font-size-lg);
+ line-height: var(--line-tight);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-card-description {
+ font-size: var(--font-size-sm);
+}
+
+.ui-button {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-4);
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: var(--primary);
+ color: var(--primary-foreground);
+ font-weight: var(--weight-medium);
+ cursor: pointer;
+ transition: opacity var(--motion-fast) ease;
+}
+
+.ui-button:hover {
+ opacity: 0.88;
+}
+
+.ui-button:focus-visible,
+select:focus-visible,
+.ui-textarea:focus-visible {
+ outline: 0.125rem solid var(--ring);
+ outline-offset: 0.125rem;
+}
+
+.ui-badge {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ min-height: 1.625rem;
+ padding-inline: var(--space-2);
+ border-radius: 999rem;
+ background: var(--muted);
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.ui-badge[data-tone="success"] {
+ background: var(--success-surface);
+ color: var(--success);
+}
+
+.ui-badge[data-tone="warning"] {
+ background: var(--warning-surface);
+ color: var(--warning);
+}
+
+.ui-badge[data-tone="danger"] {
+ background: var(--danger-surface);
+ color: var(--danger);
+}
+
+label,
+dt {
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+select,
+.ui-textarea {
+ width: 100%;
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-foreground);
+}
+
+select {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-3);
+}
+
+.ui-textarea {
+ min-height: var(--editor-height);
+ resize: vertical;
+ padding: var(--space-3);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+ line-height: var(--line-normal);
+ tab-size: 2;
+}
+
+.replay-panel {
+ display: grid;
+ gap: var(--space-6);
+}
+
+.snapshot-heading {
+ align-items: flex-start;
+}
+
+.snapshot-heading > div {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.snapshot-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--space-4);
+ margin: 0;
+ padding-block: var(--space-5);
+ border-block: 0.0625rem solid var(--border);
+}
+
+.snapshot-grid div {
+ min-width: 0;
+}
+
+dd {
+ margin: var(--space-1) 0 0;
+ overflow: hidden;
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.result-summary {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.result-summary span {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+ text-transform: uppercase;
+}
+
+.result-summary p {
+ font-size: var(--font-size-md);
+}
+
+.diagnostic,
+.validation-errors {
+ padding: var(--space-4);
+ border-radius: var(--radius-sm);
+ background: var(--warning-surface);
+ color: var(--warning);
+}
+
+.validation-errors {
+ display: grid;
+ gap: var(--space-4);
+ background: var(--danger-surface);
+ color: var(--danger);
+}
+
+.validation-errors ul {
+ margin: 0;
+ padding-left: var(--space-5);
+}
+
+.timeline-heading {
+ margin-top: var(--space-2);
+}
+
+.timeline-heading h3 {
+ font-size: var(--font-size-md);
+}
+
+.timeline-heading span {
+ font-size: var(--font-size-xs);
+}
+
+.timeline {
+ display: grid;
+ gap: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.timeline li {
+ display: grid;
+ grid-template-columns: var(--timeline-column) minmax(0, 1fr);
+ gap: var(--space-3);
+ padding-block: var(--space-3);
+}
+
+.timeline li + li {
+ border-top: 0.0625rem solid var(--border);
+}
+
+.sequence {
+ display: inline-grid;
+ place-items: center;
+ align-self: start;
+ width: var(--timeline-column);
+ height: var(--timeline-column);
+ border-radius: 999rem;
+ background: var(--muted);
+ color: var(--muted-foreground);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+}
+
+.timeline-title code,
+.snapshot-json pre,
+.validation-errors code {
+ font-family: var(--font-mono);
+}
+
+.timeline-title code {
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+.timeline-title time,
+.timeline li p {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+}
+
+.timeline li p {
+ margin-top: var(--space-1);
+}
+
+.snapshot-json {
+ border-top: 0.0625rem solid var(--border);
+ padding-top: var(--space-4);
+}
+
+.snapshot-json summary {
+ cursor: pointer;
+ font-weight: var(--weight-medium);
+}
+
+.snapshot-json pre {
+ max-height: var(--editor-height);
+ overflow: auto;
+ padding: var(--space-4);
+ border-radius: var(--radius-sm);
+ background: var(--muted);
+ font-size: var(--font-size-xs);
+}
+
+@media (max-width: 60rem) {
+ .workspace-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .ui-textarea {
+ min-height: 20rem;
+ }
+}
+
+@media (max-width: 36rem) {
+ .app-shell {
+ padding: var(--space-5);
+ }
+
+ .page-header,
+ .snapshot-heading,
+ .timeline-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .snapshot-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --motion-fast: 0ms;
+ }
+}
diff --git a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts
new file mode 100644
index 00000000000..e21b73c675a
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "@playwright/test";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
+
+test("validates and renders the shared happy-path fixture", async ({ page }) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "Static session replay" })).toBeVisible();
+ await expect(page.getByTestId("terminal-badge")).toHaveText("succeeded");
+ await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9);
+ await expect(page.getByTestId("result-summary")).toContainText(
+ "The scripted run completed successfully.",
+ );
+ await page.screenshot({
+ path: resolve(packageRoot, "knowledge/evidence/phase-01-static-replay.png"),
+ fullPage: true,
+ });
+});
+
+test("shows duplicate and unsupported-version fixture states", async ({ page }) => {
+ await page.goto("/");
+ await page.getByLabel("Fixture", { exact: true }).selectOption("duplicate-event");
+ await expect(page.getByText("1 duplicate events ignored")).toBeVisible();
+ await page
+ .getByLabel("Fixture", { exact: true })
+ .selectOption("unsupported-required-version");
+ await expect(page.getByRole("heading", { name: "Fixture cannot be replayed" })).toBeVisible();
+ await expect(page.getByText(/protocolVersion 2 is unsupported/)).toBeVisible();
+});
diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json
index a7015406a00..996853a2b7e 100644
--- a/packages/paperclip-runner/package.json
+++ b/packages/paperclip-runner/package.json
@@ -16,26 +16,47 @@
"README.md"
],
"scripts": {
- "build": "pnpm run build:typescript && pnpm run build:rust",
- "build:typescript": "tsc -p tsconfig.json",
+ "build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser",
+ "build:typescript": "pnpm run generate:protocol-types && tsc -p tsconfig.json",
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace",
- "typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust",
- "typecheck:typescript": "tsc -p tsconfig.json --noEmit",
+ "build:browser": "vite build --config vite.config.ts",
+ "typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust && pnpm run typecheck:browser",
+ "typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
+ "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
"test:typescript": "vitest run",
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
+ "test:browser": "pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
+ "generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs",
+ "check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check",
+ "generate:phase1-goldens": "pnpm run build:typescript && node scripts/generate-phase1-goldens.mjs",
+ "check:phase1-goldens": "node scripts/generate-phase1-goldens.mjs --check",
+ "check:browser-tokens": "node scripts/check-browser-tokens.mjs",
"check:forbidden-imports": "node scripts/check-forbidden-imports.mjs",
"check:phase0-parity": "vitest run src/tracer/phase0-runner.test.ts && cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core runs_the_mock_core_path_with_stable_output && node -e \"process.stdout.write('Rust and TypeScript Phase 0 tracer output matches the shared golden fixture.\\n')\"",
+ "check:phase1-parity": "vitest run src/reducer/session-reducer.test.ts && cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core phase1_fixture_parity && node -e \"process.stdout.write('Rust and TypeScript Phase 1 reducer summaries match the shared golden fixtures.\\n')\"",
"docs:validate": "node scripts/validate-doc-links.mjs && node scripts/validate-okf.mjs",
"trace:phase0": "pnpm run trace:phase0:rust",
"trace:phase0:rust": "pnpm run build:rust && cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin phase0-tracer",
"trace:phase0:typescript": "pnpm run build:typescript && node dist/tracer/phase0.js",
- "verify": "pnpm run build && pnpm run test && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run trace:phase0"
+ "replay:phase1": "pnpm run build:typescript && node dist/cli/phase1-replay.js",
+ "browser:dev": "vite --config vite.config.ts",
+ "browser:preview": "vite preview --config vite.config.ts",
+ "verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1"
},
"devDependencies": {
+ "@playwright/test": "^1.61.1",
"@types/node": "^22.20.1",
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^4.7.0",
+ "ajv": "^8.20.0",
+ "json-schema-to-ts": "^3.1.1",
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
"typescript": "^5.7.3",
+ "vite": "^6.1.0",
"vitest": "^4.1.10"
}
}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/duplicate-event.json b/packages/paperclip-runner/protocol/fixtures/phase-01/duplicate-event.json
new file mode 100644
index 00000000000..39db5c8b1eb
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/duplicate-event.json
@@ -0,0 +1,30 @@
+{
+ "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 1,
+ "name": "Duplicate event", "description": "At-least-once delivery repeats one source event without repeating its effect.",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1", "companyId": "company_phase1", "issueId": "issue_phase1_duplicate",
+ "runId": "run_phase1_duplicate", "environmentLeaseId": "lease_phase1_duplicate", "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_duplicate", "driverSessionId": "driver_phase1_duplicate"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "fake", "version": "1.0.0" },
+ "steer": false, "interrupt": true, "resume": true, "runtimeRequests": false, "structuredResult": true, "typedEvents": true
+ },
+ "commands": [
+ { "schema": "paperclip.prp.command.v1", "commandId": "command_duplicate_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T12:30:00.000Z", "payload": {} }
+ ],
+ "events": [
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_01", "sourceSeq": 1, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_duplicate", "normalizedSessionId": "session_phase1_duplicate", "eventType": "session.started", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.010Z", "payload": {} },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_02", "sourceSeq": 2, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_duplicate", "normalizedSessionId": "session_phase1_duplicate", "turnId": "turn_phase1_duplicate", "eventType": "turn.started", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:30:00.020Z", "payload": {} },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_03", "sourceSeq": 3, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_duplicate", "normalizedSessionId": "session_phase1_duplicate", "turnId": "turn_phase1_duplicate", "itemId": "item_phase1_duplicate", "eventType": "item.completed", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:30:00.030Z", "payload": { "kind": "assistant_message", "text": "Exactly one projection." } },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_03", "sourceSeq": 3, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_duplicate", "normalizedSessionId": "session_phase1_duplicate", "turnId": "turn_phase1_duplicate", "itemId": "item_phase1_duplicate", "eventType": "item.completed", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:30:00.030Z", "payload": { "kind": "assistant_message", "text": "Exactly one projection." } },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_04", "sourceSeq": 4, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_duplicate", "normalizedSessionId": "session_phase1_duplicate", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.040Z",
+ "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Duplicate delivery was idempotent.", "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] }
+ },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_05", "sourceSeq": 5, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_duplicate", "normalizedSessionId": "session_phase1_duplicate", "turnId": "turn_phase1_duplicate", "eventType": "turn.completed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.050Z", "payload": {} },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_duplicate_06", "sourceSeq": 6, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_duplicate", "normalizedSessionId": "session_phase1_duplicate", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:30:00.060Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done" } }
+ ],
+ "requests": [],
+ "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Duplicate delivery was idempotent.", "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] }
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/failed-run.json b/packages/paperclip-runner/protocol/fixtures/phase-01/failed-run.json
new file mode 100644
index 00000000000..2b2aa167bd1
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/failed-run.json
@@ -0,0 +1,106 @@
+{
+ "schema": "paperclip.prp.fixture.v1",
+ "fixtureVersion": 1,
+ "protocolVersion": 1,
+ "name": "Failed run",
+ "description": "The driver reports an item and turn failure before the run terminalizes.",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_failed",
+ "runId": "run_phase1_failed",
+ "environmentLeaseId": "lease_phase1_failed",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_failed",
+ "driverSessionId": "driver_phase1_failed"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "new_per_run",
+ "driver": { "kind": "fake", "version": "1.0.0" },
+ "steer": true,
+ "interrupt": true,
+ "resume": true,
+ "runtimeRequests": true,
+ "structuredResult": true,
+ "typedEvents": true
+ },
+ "commands": [
+ {
+ "schema": "paperclip.prp.command.v1",
+ "commandId": "command_failed_prepare",
+ "controllerSeq": 1,
+ "type": "run.prepare",
+ "issuedAt": "2026-08-07T12:10:00.000Z",
+ "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.command.v1",
+ "commandId": "command_failed_turn",
+ "controllerSeq": 2,
+ "type": "turn.start",
+ "issuedAt": "2026-08-07T12:10:00.010Z",
+ "payload": { "text": "Run failing scenario" }
+ }
+ ],
+ "events": [
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_01", "sourceSeq": 1,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_failed",
+ "normalizedSessionId": "session_phase1_failed", "eventType": "session.started", "schemaVersion": 1,
+ "priority": 0, "emittedAt": "2026-08-07T12:10:00.020Z", "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_02", "sourceSeq": 2,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_failed",
+ "normalizedSessionId": "session_phase1_failed", "turnId": "turn_phase1_failed",
+ "eventType": "turn.started", "schemaVersion": 1, "priority": 1,
+ "emittedAt": "2026-08-07T12:10:00.030Z", "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_03", "sourceSeq": 3,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_failed",
+ "normalizedSessionId": "session_phase1_failed", "turnId": "turn_phase1_failed", "itemId": "item_failed_command",
+ "eventType": "item.failed", "schemaVersion": 1, "priority": 0,
+ "emittedAt": "2026-08-07T12:10:00.040Z", "payload": { "kind": "command", "message": "Command exited with code 1" }
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_04", "sourceSeq": 4,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_failed",
+ "normalizedSessionId": "session_phase1_failed", "turnId": "turn_phase1_failed",
+ "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0,
+ "emittedAt": "2026-08-07T12:10:00.050Z",
+ "payload": {
+ "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded",
+ "summary": "The scripted command failed and needs a retry.",
+ "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Retry the failed command", "blocksCompletion": true }] },
+ "evidence": [], "verification": [{ "commandOrCheck": "fake command", "status": "failed" }],
+ "attentionRequests": [], "artifacts": [],
+ "continuation": { "kind": "retry", "summary": "Retry after correcting the command", "idempotencyKey": "retry_phase1_failed" }
+ }
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_05", "sourceSeq": 5,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_failed",
+ "normalizedSessionId": "session_phase1_failed", "turnId": "turn_phase1_failed",
+ "eventType": "turn.failed", "schemaVersion": 1, "priority": 0,
+ "emittedAt": "2026-08-07T12:10:00.060Z", "payload": { "message": "Fake driver failure" }
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_failed_06", "sourceSeq": 6,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_failed",
+ "normalizedSessionId": "session_phase1_failed", "eventType": "run.terminal", "schemaVersion": 1,
+ "priority": 0, "emittedAt": "2026-08-07T12:10:00.070Z",
+ "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "failed", "runTerminalState": "failed", "reportedWorkDisposition": "yielded" }
+ }
+ ],
+ "requests": [],
+ "result": {
+ "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded",
+ "summary": "The scripted command failed and needs a retry.",
+ "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Retry the failed command", "blocksCompletion": true }] },
+ "evidence": [], "verification": [{ "commandOrCheck": "fake command", "status": "failed" }],
+ "attentionRequests": [], "artifacts": [],
+ "continuation": { "kind": "retry", "summary": "Retry after correcting the command", "idempotencyKey": "retry_phase1_failed" }
+ }
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.snapshot.json
new file mode 100644
index 00000000000..b16a5fbbf24
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.snapshot.json
@@ -0,0 +1,130 @@
+{
+ "schema": "paperclip.prp.session-snapshot.v1",
+ "fixtureName": "Duplicate event",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_duplicate",
+ "runId": "run_phase1_duplicate",
+ "environmentLeaseId": "lease_phase1_duplicate",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_duplicate",
+ "driverSessionId": "driver_phase1_duplicate"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "new_per_run",
+ "driver": {
+ "kind": "fake",
+ "version": "1.0.0"
+ },
+ "steer": false,
+ "interrupt": true,
+ "resume": true,
+ "runtimeRequests": false,
+ "structuredResult": true,
+ "typedEvents": true
+ },
+ "runPhase": "terminal",
+ "sessionState": "running",
+ "turnState": "completed",
+ "activeTurnId": null,
+ "items": [
+ {
+ "itemId": "item_phase1_duplicate",
+ "kind": "assistant_message",
+ "status": "completed",
+ "text": "Exactly one projection."
+ }
+ ],
+ "requests": [],
+ "proposedResult": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "Duplicate delivery was idempotent.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [],
+ "remainingWork": []
+ },
+ "evidence": [],
+ "verification": [],
+ "attentionRequests": [],
+ "artifacts": []
+ },
+ "terminal": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "done"
+ },
+ "timeline": [
+ {
+ "position": 1,
+ "sourceEventId": "event_duplicate_01",
+ "sourceSeq": 1,
+ "eventType": "session.started",
+ "emittedAt": "2026-08-07T12:30:00.010Z",
+ "summary": "session started"
+ },
+ {
+ "position": 2,
+ "sourceEventId": "event_duplicate_02",
+ "sourceSeq": 2,
+ "eventType": "turn.started",
+ "emittedAt": "2026-08-07T12:30:00.020Z",
+ "summary": "turn started"
+ },
+ {
+ "position": 3,
+ "sourceEventId": "event_duplicate_03",
+ "sourceSeq": 3,
+ "eventType": "item.completed",
+ "emittedAt": "2026-08-07T12:30:00.030Z",
+ "itemId": "item_phase1_duplicate",
+ "summary": "Completed assistant_message"
+ },
+ {
+ "position": 4,
+ "sourceEventId": "event_duplicate_04",
+ "sourceSeq": 4,
+ "eventType": "run.result.proposed",
+ "emittedAt": "2026-08-07T12:30:00.040Z",
+ "summary": "Duplicate delivery was idempotent."
+ },
+ {
+ "position": 5,
+ "sourceEventId": "event_duplicate_05",
+ "sourceSeq": 5,
+ "eventType": "turn.completed",
+ "emittedAt": "2026-08-07T12:30:00.050Z",
+ "summary": "turn completed"
+ },
+ {
+ "position": 6,
+ "sourceEventId": "event_duplicate_06",
+ "sourceSeq": 6,
+ "eventType": "run.terminal",
+ "emittedAt": "2026-08-07T12:30:00.060Z",
+ "summary": "Run succeeded"
+ }
+ ],
+ "sourceCursors": {
+ "runner:runner_phase1": 6
+ },
+ "processedEventIds": [
+ "event_duplicate_01",
+ "event_duplicate_02",
+ "event_duplicate_03",
+ "event_duplicate_04",
+ "event_duplicate_05",
+ "event_duplicate_06"
+ ],
+ "duplicateEventIds": [
+ "event_duplicate_03"
+ ],
+ "outOfOrderEventIds": [],
+ "gaps": [],
+ "integrity": "complete"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.summary.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.summary.json
new file mode 100644
index 00000000000..28616e9e3a0
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.summary.json
@@ -0,0 +1,11 @@
+{
+ "runId": "run_phase1_duplicate",
+ "integrity": "complete",
+ "timelineCount": 6,
+ "duplicateEventIds": [
+ "event_duplicate_03"
+ ],
+ "gaps": [],
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.snapshot.json
new file mode 100644
index 00000000000..4151a85207b
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.snapshot.json
@@ -0,0 +1,143 @@
+{
+ "schema": "paperclip.prp.session-snapshot.v1",
+ "fixtureName": "Failed run",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_failed",
+ "runId": "run_phase1_failed",
+ "environmentLeaseId": "lease_phase1_failed",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_failed",
+ "driverSessionId": "driver_phase1_failed"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "new_per_run",
+ "driver": {
+ "kind": "fake",
+ "version": "1.0.0"
+ },
+ "steer": true,
+ "interrupt": true,
+ "resume": true,
+ "runtimeRequests": true,
+ "structuredResult": true,
+ "typedEvents": true
+ },
+ "runPhase": "terminal",
+ "sessionState": "running",
+ "turnState": "failed",
+ "activeTurnId": null,
+ "items": [
+ {
+ "itemId": "item_failed_command",
+ "kind": "command",
+ "status": "failed",
+ "text": ""
+ }
+ ],
+ "requests": [],
+ "proposedResult": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "yielded",
+ "summary": "The scripted command failed and needs a retry.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": false,
+ "criteria": [],
+ "remainingWork": [
+ {
+ "description": "Retry the failed command",
+ "blocksCompletion": true
+ }
+ ]
+ },
+ "evidence": [],
+ "verification": [
+ {
+ "commandOrCheck": "fake command",
+ "status": "failed"
+ }
+ ],
+ "attentionRequests": [],
+ "artifacts": [],
+ "continuation": {
+ "kind": "retry",
+ "summary": "Retry after correcting the command",
+ "idempotencyKey": "retry_phase1_failed"
+ }
+ },
+ "terminal": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "failed",
+ "runTerminalState": "failed",
+ "reportedWorkDisposition": "yielded"
+ },
+ "timeline": [
+ {
+ "position": 1,
+ "sourceEventId": "event_failed_01",
+ "sourceSeq": 1,
+ "eventType": "session.started",
+ "emittedAt": "2026-08-07T12:10:00.020Z",
+ "summary": "session started"
+ },
+ {
+ "position": 2,
+ "sourceEventId": "event_failed_02",
+ "sourceSeq": 2,
+ "eventType": "turn.started",
+ "emittedAt": "2026-08-07T12:10:00.030Z",
+ "summary": "turn started"
+ },
+ {
+ "position": 3,
+ "sourceEventId": "event_failed_03",
+ "sourceSeq": 3,
+ "eventType": "item.failed",
+ "emittedAt": "2026-08-07T12:10:00.040Z",
+ "itemId": "item_failed_command",
+ "summary": "Command exited with code 1"
+ },
+ {
+ "position": 4,
+ "sourceEventId": "event_failed_04",
+ "sourceSeq": 4,
+ "eventType": "run.result.proposed",
+ "emittedAt": "2026-08-07T12:10:00.050Z",
+ "summary": "The scripted command failed and needs a retry."
+ },
+ {
+ "position": 5,
+ "sourceEventId": "event_failed_05",
+ "sourceSeq": 5,
+ "eventType": "turn.failed",
+ "emittedAt": "2026-08-07T12:10:00.060Z",
+ "summary": "turn failed"
+ },
+ {
+ "position": 6,
+ "sourceEventId": "event_failed_06",
+ "sourceSeq": 6,
+ "eventType": "run.terminal",
+ "emittedAt": "2026-08-07T12:10:00.070Z",
+ "summary": "Run failed"
+ }
+ ],
+ "sourceCursors": {
+ "runner:runner_phase1": 6
+ },
+ "processedEventIds": [
+ "event_failed_01",
+ "event_failed_02",
+ "event_failed_03",
+ "event_failed_04",
+ "event_failed_05",
+ "event_failed_06"
+ ],
+ "duplicateEventIds": [],
+ "outOfOrderEventIds": [],
+ "gaps": [],
+ "integrity": "complete"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.summary.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.summary.json
new file mode 100644
index 00000000000..05013879af3
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.summary.json
@@ -0,0 +1,9 @@
+{
+ "runId": "run_phase1_failed",
+ "integrity": "complete",
+ "timelineCount": 6,
+ "duplicateEventIds": [],
+ "gaps": [],
+ "turnTerminalState": "failed",
+ "runTerminalState": "failed"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json
new file mode 100644
index 00000000000..f19c4146659
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json
@@ -0,0 +1,166 @@
+{
+ "schema": "paperclip.prp.session-snapshot.v1",
+ "fixtureName": "Happy path",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_happy",
+ "runId": "run_phase1_happy",
+ "environmentLeaseId": "lease_phase1_happy",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_happy",
+ "driverSessionId": "driver_phase1_happy",
+ "providerSessionId": "provider_phase1_happy"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "new_per_run",
+ "driver": {
+ "kind": "fake",
+ "version": "1.0.0"
+ },
+ "steer": true,
+ "interrupt": true,
+ "resume": true,
+ "runtimeRequests": true,
+ "structuredResult": true,
+ "typedEvents": true,
+ "unsupported": []
+ },
+ "runPhase": "terminal",
+ "sessionState": "running",
+ "turnState": "completed",
+ "activeTurnId": null,
+ "items": [
+ {
+ "itemId": "item_phase1_message",
+ "kind": "assistant_message",
+ "status": "completed",
+ "text": "Validated fixture. Replay complete."
+ }
+ ],
+ "requests": [],
+ "proposedResult": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "The scripted run completed successfully.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [],
+ "remainingWork": []
+ },
+ "evidence": [],
+ "verification": [
+ {
+ "commandOrCheck": "fixture replay",
+ "status": "passed"
+ }
+ ],
+ "attentionRequests": [],
+ "artifacts": []
+ },
+ "terminal": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "done",
+ "workAssessmentId": "assessment_phase1_happy",
+ "statusDecisionId": "decision_phase1_happy"
+ },
+ "timeline": [
+ {
+ "position": 1,
+ "sourceEventId": "event_happy_01",
+ "sourceSeq": 1,
+ "eventType": "runtime.phase.changed",
+ "emittedAt": "2026-08-07T12:00:00.030Z",
+ "summary": "Phase changed to workspace_preparing"
+ },
+ {
+ "position": 2,
+ "sourceEventId": "event_happy_02",
+ "sourceSeq": 2,
+ "eventType": "session.started",
+ "emittedAt": "2026-08-07T12:00:00.040Z",
+ "summary": "session started"
+ },
+ {
+ "position": 3,
+ "sourceEventId": "event_happy_03",
+ "sourceSeq": 3,
+ "eventType": "turn.started",
+ "emittedAt": "2026-08-07T12:00:00.050Z",
+ "summary": "turn started"
+ },
+ {
+ "position": 4,
+ "sourceEventId": "event_happy_04",
+ "sourceSeq": 4,
+ "eventType": "item.started",
+ "emittedAt": "2026-08-07T12:00:00.060Z",
+ "itemId": "item_phase1_message",
+ "summary": "Started assistant_message"
+ },
+ {
+ "position": 5,
+ "sourceEventId": "event_happy_05",
+ "sourceSeq": 5,
+ "eventType": "item.delta",
+ "emittedAt": "2026-08-07T12:00:00.070Z",
+ "itemId": "item_phase1_message",
+ "summary": "Validated fixture. "
+ },
+ {
+ "position": 6,
+ "sourceEventId": "event_happy_06",
+ "sourceSeq": 6,
+ "eventType": "item.completed",
+ "emittedAt": "2026-08-07T12:00:00.080Z",
+ "itemId": "item_phase1_message",
+ "summary": "Completed assistant_message"
+ },
+ {
+ "position": 7,
+ "sourceEventId": "event_happy_07",
+ "sourceSeq": 7,
+ "eventType": "run.result.proposed",
+ "emittedAt": "2026-08-07T12:00:00.090Z",
+ "summary": "The scripted run completed successfully."
+ },
+ {
+ "position": 8,
+ "sourceEventId": "event_happy_08",
+ "sourceSeq": 8,
+ "eventType": "turn.completed",
+ "emittedAt": "2026-08-07T12:00:00.100Z",
+ "summary": "turn completed"
+ },
+ {
+ "position": 9,
+ "sourceEventId": "event_happy_09",
+ "sourceSeq": 9,
+ "eventType": "run.terminal",
+ "emittedAt": "2026-08-07T12:00:00.110Z",
+ "summary": "Run succeeded"
+ }
+ ],
+ "sourceCursors": {
+ "runner:runner_phase1": 9
+ },
+ "processedEventIds": [
+ "event_happy_01",
+ "event_happy_02",
+ "event_happy_03",
+ "event_happy_04",
+ "event_happy_05",
+ "event_happy_06",
+ "event_happy_07",
+ "event_happy_08",
+ "event_happy_09"
+ ],
+ "duplicateEventIds": [],
+ "outOfOrderEventIds": [],
+ "gaps": [],
+ "integrity": "complete"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.summary.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.summary.json
new file mode 100644
index 00000000000..913d0d7d075
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.summary.json
@@ -0,0 +1,9 @@
+{
+ "runId": "run_phase1_happy",
+ "integrity": "complete",
+ "timelineCount": 9,
+ "duplicateEventIds": [],
+ "gaps": [],
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json
new file mode 100644
index 00000000000..c2f9f55ecf2
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json
@@ -0,0 +1,137 @@
+{
+ "schema": "paperclip.prp.session-snapshot.v1",
+ "fixtureName": "Interrupted run",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_interrupted",
+ "runId": "run_phase1_interrupted",
+ "environmentLeaseId": "lease_phase1_interrupted",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_interrupted",
+ "driverSessionId": "driver_phase1_interrupted"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "reuse_per_issue",
+ "driver": {
+ "kind": "fake",
+ "version": "1.0.0"
+ },
+ "steer": true,
+ "interrupt": true,
+ "resume": true,
+ "runtimeRequests": true,
+ "structuredResult": true,
+ "typedEvents": true
+ },
+ "runPhase": "terminal",
+ "sessionState": "running",
+ "turnState": "interrupted",
+ "activeTurnId": null,
+ "items": [],
+ "requests": [
+ {
+ "requestId": "request_interrupted_permission",
+ "requestKind": "runtime",
+ "status": "pending",
+ "prompt": "Allow the fake command?"
+ }
+ ],
+ "proposedResult": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "yielded",
+ "summary": "The turn was interrupted.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": false,
+ "criteria": [],
+ "remainingWork": [
+ {
+ "description": "Resume the interrupted turn",
+ "blocksCompletion": true
+ }
+ ]
+ },
+ "evidence": [],
+ "verification": [],
+ "attentionRequests": [],
+ "artifacts": [],
+ "continuation": {
+ "kind": "same_agent",
+ "summary": "Resume in the same normalized session",
+ "idempotencyKey": "resume_phase1_interrupted"
+ }
+ },
+ "terminal": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "interrupted",
+ "runTerminalState": "cancelled",
+ "reportedWorkDisposition": "yielded"
+ },
+ "timeline": [
+ {
+ "position": 1,
+ "sourceEventId": "event_interrupted_01",
+ "sourceSeq": 1,
+ "eventType": "session.started",
+ "emittedAt": "2026-08-07T12:20:00.020Z",
+ "summary": "session started"
+ },
+ {
+ "position": 2,
+ "sourceEventId": "event_interrupted_02",
+ "sourceSeq": 2,
+ "eventType": "turn.started",
+ "emittedAt": "2026-08-07T12:20:00.030Z",
+ "summary": "turn started"
+ },
+ {
+ "position": 3,
+ "sourceEventId": "event_interrupted_03",
+ "sourceSeq": 3,
+ "eventType": "runtime_request.created",
+ "emittedAt": "2026-08-07T12:20:00.040Z",
+ "summary": "runtime_request created"
+ },
+ {
+ "position": 4,
+ "sourceEventId": "event_interrupted_04",
+ "sourceSeq": 4,
+ "eventType": "turn.interrupted",
+ "emittedAt": "2026-08-07T12:20:00.060Z",
+ "summary": "turn interrupted"
+ },
+ {
+ "position": 5,
+ "sourceEventId": "event_interrupted_05",
+ "sourceSeq": 5,
+ "eventType": "run.result.proposed",
+ "emittedAt": "2026-08-07T12:20:00.070Z",
+ "summary": "The turn was interrupted."
+ },
+ {
+ "position": 6,
+ "sourceEventId": "event_interrupted_06",
+ "sourceSeq": 6,
+ "eventType": "run.terminal",
+ "emittedAt": "2026-08-07T12:20:00.080Z",
+ "summary": "Run cancelled"
+ }
+ ],
+ "sourceCursors": {
+ "runner:runner_phase1": 6
+ },
+ "processedEventIds": [
+ "event_interrupted_01",
+ "event_interrupted_02",
+ "event_interrupted_03",
+ "event_interrupted_04",
+ "event_interrupted_05",
+ "event_interrupted_06"
+ ],
+ "duplicateEventIds": [],
+ "outOfOrderEventIds": [],
+ "gaps": [],
+ "integrity": "complete"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.summary.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.summary.json
new file mode 100644
index 00000000000..f0bd9da2f01
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.summary.json
@@ -0,0 +1,9 @@
+{
+ "runId": "run_phase1_interrupted",
+ "integrity": "complete",
+ "timelineCount": 6,
+ "duplicateEventIds": [],
+ "gaps": [],
+ "turnTerminalState": "interrupted",
+ "runTerminalState": "cancelled"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.snapshot.json
new file mode 100644
index 00000000000..fe8e12a8dd2
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.snapshot.json
@@ -0,0 +1,125 @@
+{
+ "schema": "paperclip.prp.session-snapshot.v1",
+ "fixtureName": "Source sequence gap",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_gap",
+ "runId": "run_phase1_gap",
+ "environmentLeaseId": "lease_phase1_gap",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_gap",
+ "driverSessionId": "driver_phase1_gap"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "new_per_run",
+ "driver": {
+ "kind": "fake",
+ "version": "1.0.0"
+ },
+ "steer": false,
+ "interrupt": false,
+ "resume": true,
+ "runtimeRequests": false,
+ "structuredResult": true,
+ "typedEvents": true
+ },
+ "runPhase": "terminal",
+ "sessionState": "running",
+ "turnState": "completed",
+ "activeTurnId": null,
+ "items": [],
+ "requests": [],
+ "proposedResult": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "needs_review",
+ "summary": "The source gap requires reconciliation.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": false,
+ "criteria": [],
+ "remainingWork": [
+ {
+ "description": "Replay missing source event",
+ "blocksCompletion": true
+ }
+ ]
+ },
+ "evidence": [],
+ "verification": [],
+ "attentionRequests": [],
+ "artifacts": []
+ },
+ "terminal": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "needs_review"
+ },
+ "timeline": [
+ {
+ "position": 1,
+ "sourceEventId": "event_gap_01",
+ "sourceSeq": 1,
+ "eventType": "session.started",
+ "emittedAt": "2026-08-07T12:40:00.010Z",
+ "summary": "session started"
+ },
+ {
+ "position": 2,
+ "sourceEventId": "event_gap_02",
+ "sourceSeq": 2,
+ "eventType": "turn.started",
+ "emittedAt": "2026-08-07T12:40:00.020Z",
+ "summary": "turn started"
+ },
+ {
+ "position": 3,
+ "sourceEventId": "event_gap_04",
+ "sourceSeq": 4,
+ "eventType": "run.result.proposed",
+ "emittedAt": "2026-08-07T12:40:00.040Z",
+ "summary": "The source gap requires reconciliation."
+ },
+ {
+ "position": 4,
+ "sourceEventId": "event_gap_05",
+ "sourceSeq": 5,
+ "eventType": "turn.completed",
+ "emittedAt": "2026-08-07T12:40:00.050Z",
+ "summary": "turn completed"
+ },
+ {
+ "position": 5,
+ "sourceEventId": "event_gap_06",
+ "sourceSeq": 6,
+ "eventType": "run.terminal",
+ "emittedAt": "2026-08-07T12:40:00.060Z",
+ "summary": "Run succeeded"
+ }
+ ],
+ "sourceCursors": {
+ "runner:runner_phase1": 6
+ },
+ "processedEventIds": [
+ "event_gap_01",
+ "event_gap_02",
+ "event_gap_04",
+ "event_gap_05",
+ "event_gap_06"
+ ],
+ "duplicateEventIds": [],
+ "outOfOrderEventIds": [],
+ "gaps": [
+ {
+ "sourceKey": "runner:runner_phase1",
+ "expected": 3,
+ "received": 4,
+ "missing": [
+ 3
+ ]
+ }
+ ],
+ "integrity": "gap_detected"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.summary.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.summary.json
new file mode 100644
index 00000000000..ec1ff0a6484
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.summary.json
@@ -0,0 +1,18 @@
+{
+ "runId": "run_phase1_gap",
+ "integrity": "gap_detected",
+ "timelineCount": 5,
+ "duplicateEventIds": [],
+ "gaps": [
+ {
+ "sourceKey": "runner:runner_phase1",
+ "expected": 3,
+ "received": 4,
+ "missing": [
+ 3
+ ]
+ }
+ ],
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.snapshot.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.snapshot.json
new file mode 100644
index 00000000000..1627a30b120
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.snapshot.json
@@ -0,0 +1,111 @@
+{
+ "schema": "paperclip.prp.session-snapshot.v1",
+ "fixtureName": "Unknown optional fields",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_future",
+ "runId": "run_phase1_future",
+ "environmentLeaseId": "lease_phase1_future",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_future",
+ "driverSessionId": "driver_phase1_future",
+ "futureIdentityLabel": "preserved"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "new_per_run",
+ "driver": {
+ "kind": "fake",
+ "version": "1.1.0",
+ "futureDriverField": true
+ },
+ "steer": true,
+ "interrupt": true,
+ "resume": true,
+ "runtimeRequests": true,
+ "structuredResult": true,
+ "typedEvents": true,
+ "futureCapability": {
+ "mode": "optional"
+ }
+ },
+ "runPhase": "terminal",
+ "sessionState": "running",
+ "turnState": "completed",
+ "activeTurnId": null,
+ "items": [],
+ "requests": [],
+ "proposedResult": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "Unknown optional fields did not change the v1 projection.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [],
+ "remainingWork": []
+ },
+ "evidence": [],
+ "verification": [],
+ "attentionRequests": [],
+ "artifacts": [],
+ "futureResultAnnotation": {
+ "safe": true
+ }
+ },
+ "terminal": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "done",
+ "futureTerminalReason": "optional"
+ },
+ "timeline": [
+ {
+ "position": 1,
+ "sourceEventId": "event_future_01",
+ "sourceSeq": 1,
+ "eventType": "session.started",
+ "emittedAt": "2026-08-07T12:50:00.010Z",
+ "summary": "session started"
+ },
+ {
+ "position": 2,
+ "sourceEventId": "event_future_02",
+ "sourceSeq": 2,
+ "eventType": "run.result.proposed",
+ "emittedAt": "2026-08-07T12:50:00.020Z",
+ "summary": "Unknown optional fields did not change the v1 projection."
+ },
+ {
+ "position": 3,
+ "sourceEventId": "event_future_03",
+ "sourceSeq": 3,
+ "eventType": "turn.completed",
+ "emittedAt": "2026-08-07T12:50:00.030Z",
+ "summary": "turn completed"
+ },
+ {
+ "position": 4,
+ "sourceEventId": "event_future_04",
+ "sourceSeq": 4,
+ "eventType": "run.terminal",
+ "emittedAt": "2026-08-07T12:50:00.040Z",
+ "summary": "Run succeeded"
+ }
+ ],
+ "sourceCursors": {
+ "runner:runner_phase1": 4
+ },
+ "processedEventIds": [
+ "event_future_01",
+ "event_future_02",
+ "event_future_03",
+ "event_future_04"
+ ],
+ "duplicateEventIds": [],
+ "outOfOrderEventIds": [],
+ "gaps": [],
+ "integrity": "complete"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.summary.json b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.summary.json
new file mode 100644
index 00000000000..c8d98d53165
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.summary.json
@@ -0,0 +1,9 @@
+{
+ "runId": "run_phase1_future",
+ "integrity": "complete",
+ "timelineCount": 4,
+ "duplicateEventIds": [],
+ "gaps": [],
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded"
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/happy-path.json b/packages/paperclip-runner/protocol/fixtures/phase-01/happy-path.json
new file mode 100644
index 00000000000..934f1dc54f2
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/happy-path.json
@@ -0,0 +1,230 @@
+{
+ "schema": "paperclip.prp.fixture.v1",
+ "fixtureVersion": 1,
+ "protocolVersion": 1,
+ "name": "Happy path",
+ "description": "A typed assistant message completes and the run proposes a successful result.",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1",
+ "companyId": "company_phase1",
+ "issueId": "issue_phase1_happy",
+ "runId": "run_phase1_happy",
+ "environmentLeaseId": "lease_phase1_happy",
+ "runnerInstanceId": "runner_phase1",
+ "normalizedSessionId": "session_phase1_happy",
+ "driverSessionId": "driver_phase1_happy",
+ "providerSessionId": "provider_phase1_happy"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1",
+ "sessionReusePolicy": "new_per_run",
+ "driver": { "kind": "fake", "version": "1.0.0" },
+ "steer": true,
+ "interrupt": true,
+ "resume": true,
+ "runtimeRequests": true,
+ "structuredResult": true,
+ "typedEvents": true,
+ "unsupported": []
+ },
+ "commands": [
+ {
+ "schema": "paperclip.prp.command.v1",
+ "commandId": "command_happy_prepare",
+ "controllerSeq": 1,
+ "type": "run.prepare",
+ "issuedAt": "2026-08-07T12:00:00.000Z",
+ "payload": { "workspace": "fixture" }
+ },
+ {
+ "schema": "paperclip.prp.command.v1",
+ "commandId": "command_happy_session",
+ "controllerSeq": 2,
+ "type": "session.open",
+ "issuedAt": "2026-08-07T12:00:00.010Z",
+ "payload": { "reuse": "new_per_run" }
+ },
+ {
+ "schema": "paperclip.prp.command.v1",
+ "commandId": "command_happy_turn",
+ "controllerSeq": 3,
+ "type": "turn.start",
+ "issuedAt": "2026-08-07T12:00:00.020Z",
+ "payload": { "text": "Inspect the fixture" }
+ }
+ ],
+ "events": [
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_01",
+ "sourceSeq": 1,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "eventType": "runtime.phase.changed",
+ "schemaVersion": 1,
+ "priority": 1,
+ "emittedAt": "2026-08-07T12:00:00.030Z",
+ "payload": { "phase": "workspace_preparing" }
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_02",
+ "sourceSeq": 2,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "eventType": "session.started",
+ "schemaVersion": 1,
+ "priority": 0,
+ "emittedAt": "2026-08-07T12:00:00.040Z",
+ "payload": { "driverSessionId": "driver_phase1_happy" }
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_03",
+ "sourceSeq": 3,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "turnId": "turn_phase1_happy",
+ "eventType": "turn.started",
+ "schemaVersion": 1,
+ "priority": 1,
+ "emittedAt": "2026-08-07T12:00:00.050Z",
+ "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_04",
+ "sourceSeq": 4,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "turnId": "turn_phase1_happy",
+ "itemId": "item_phase1_message",
+ "eventType": "item.started",
+ "schemaVersion": 1,
+ "priority": 1,
+ "emittedAt": "2026-08-07T12:00:00.060Z",
+ "payload": { "kind": "assistant_message", "text": "" }
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_05",
+ "sourceSeq": 5,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "turnId": "turn_phase1_happy",
+ "itemId": "item_phase1_message",
+ "eventType": "item.delta",
+ "schemaVersion": 1,
+ "priority": 2,
+ "emittedAt": "2026-08-07T12:00:00.070Z",
+ "payload": { "text": "Validated fixture. " }
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_06",
+ "sourceSeq": 6,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "turnId": "turn_phase1_happy",
+ "itemId": "item_phase1_message",
+ "eventType": "item.completed",
+ "schemaVersion": 1,
+ "priority": 1,
+ "emittedAt": "2026-08-07T12:00:00.080Z",
+ "payload": { "kind": "assistant_message", "text": "Validated fixture. Replay complete." }
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_07",
+ "sourceSeq": 7,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "turnId": "turn_phase1_happy",
+ "eventType": "run.result.proposed",
+ "schemaVersion": 1,
+ "priority": 0,
+ "emittedAt": "2026-08-07T12:00:00.090Z",
+ "payload": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "The scripted run completed successfully.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [],
+ "remainingWork": []
+ },
+ "evidence": [],
+ "verification": [{ "commandOrCheck": "fixture replay", "status": "passed" }],
+ "attentionRequests": [],
+ "artifacts": []
+ }
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_08",
+ "sourceSeq": 8,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "turnId": "turn_phase1_happy",
+ "eventType": "turn.completed",
+ "schemaVersion": 1,
+ "priority": 0,
+ "emittedAt": "2026-08-07T12:00:00.100Z",
+ "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.event.v1",
+ "sourceEventId": "event_happy_09",
+ "sourceSeq": 9,
+ "sourceInstanceId": "runner_phase1",
+ "sourceKind": "runner",
+ "runId": "run_phase1_happy",
+ "normalizedSessionId": "session_phase1_happy",
+ "eventType": "run.terminal",
+ "schemaVersion": 1,
+ "priority": 0,
+ "emittedAt": "2026-08-07T12:00:00.110Z",
+ "payload": {
+ "schema": "paperclip.prp.terminal.v1",
+ "turnTerminalState": "completed",
+ "runTerminalState": "succeeded",
+ "reportedWorkDisposition": "done",
+ "workAssessmentId": "assessment_phase1_happy",
+ "statusDecisionId": "decision_phase1_happy"
+ }
+ }
+ ],
+ "requests": [],
+ "result": {
+ "schema": "paperclip.run_result.v1",
+ "reportedWorkDisposition": "done",
+ "summary": "The scripted run completed successfully.",
+ "completionClaim": {
+ "contractRevision": "phase1-contract-v1",
+ "objectiveSatisfied": true,
+ "criteria": [],
+ "remainingWork": []
+ },
+ "evidence": [],
+ "verification": [{ "commandOrCheck": "fixture replay", "status": "passed" }],
+ "attentionRequests": [],
+ "artifacts": []
+ }
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/interrupted-run.json b/packages/paperclip-runner/protocol/fixtures/phase-01/interrupted-run.json
new file mode 100644
index 00000000000..ea82a42085a
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/interrupted-run.json
@@ -0,0 +1,90 @@
+{
+ "schema": "paperclip.prp.fixture.v1",
+ "fixtureVersion": 1,
+ "protocolVersion": 1,
+ "name": "Interrupted run",
+ "description": "A pending runtime permission request is followed by an explicit turn interruption.",
+ "identity": {
+ "schema": "paperclip.prp.identity.v1", "companyId": "company_phase1", "issueId": "issue_phase1_interrupted",
+ "runId": "run_phase1_interrupted", "environmentLeaseId": "lease_phase1_interrupted",
+ "runnerInstanceId": "runner_phase1", "normalizedSessionId": "session_phase1_interrupted",
+ "driverSessionId": "driver_phase1_interrupted"
+ },
+ "capabilities": {
+ "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "reuse_per_issue",
+ "driver": { "kind": "fake", "version": "1.0.0" }, "steer": true, "interrupt": true,
+ "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true
+ },
+ "commands": [
+ {
+ "schema": "paperclip.prp.command.v1", "commandId": "command_interrupted_prepare", "controllerSeq": 1,
+ "type": "run.prepare", "issuedAt": "2026-08-07T12:20:00.000Z", "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.command.v1", "commandId": "command_interrupted_turn", "controllerSeq": 2,
+ "type": "turn.start", "issuedAt": "2026-08-07T12:20:00.010Z", "payload": { "text": "Wait for interruption" }
+ },
+ {
+ "schema": "paperclip.prp.command.v1", "commandId": "command_interrupted_interrupt", "controllerSeq": 3,
+ "type": "turn.interrupt", "issuedAt": "2026-08-07T12:20:00.050Z", "payload": { "reason": "operator" }
+ }
+ ],
+ "events": [
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_01", "sourceSeq": 1,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_interrupted",
+ "normalizedSessionId": "session_phase1_interrupted", "eventType": "session.started", "schemaVersion": 1,
+ "priority": 0, "emittedAt": "2026-08-07T12:20:00.020Z", "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_02", "sourceSeq": 2,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_interrupted",
+ "normalizedSessionId": "session_phase1_interrupted", "turnId": "turn_phase1_interrupted",
+ "eventType": "turn.started", "schemaVersion": 1, "priority": 1,
+ "emittedAt": "2026-08-07T12:20:00.030Z", "payload": {}
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_03", "sourceSeq": 3,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_interrupted",
+ "normalizedSessionId": "session_phase1_interrupted", "turnId": "turn_phase1_interrupted",
+ "eventType": "runtime_request.created", "schemaVersion": 1, "priority": 0,
+ "emittedAt": "2026-08-07T12:20:00.040Z",
+ "payload": { "request": { "schema": "paperclip.runtime_request.v1", "requestKind": "runtime", "requestId": "request_interrupted_permission", "type": "permission", "status": "pending", "prompt": "Allow the fake command?" } }
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_04", "sourceSeq": 4,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_interrupted",
+ "normalizedSessionId": "session_phase1_interrupted", "turnId": "turn_phase1_interrupted",
+ "eventType": "turn.interrupted", "schemaVersion": 1, "priority": 0,
+ "emittedAt": "2026-08-07T12:20:00.060Z", "payload": { "reason": "operator" }
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_05", "sourceSeq": 5,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_interrupted",
+ "normalizedSessionId": "session_phase1_interrupted", "eventType": "run.result.proposed", "schemaVersion": 1,
+ "priority": 0, "emittedAt": "2026-08-07T12:20:00.070Z",
+ "payload": {
+ "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded", "summary": "The turn was interrupted.",
+ "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Resume the interrupted turn", "blocksCompletion": true }] },
+ "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [],
+ "continuation": { "kind": "same_agent", "summary": "Resume in the same normalized session", "idempotencyKey": "resume_phase1_interrupted" }
+ }
+ },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_interrupted_06", "sourceSeq": 6,
+ "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_interrupted",
+ "normalizedSessionId": "session_phase1_interrupted", "eventType": "run.terminal", "schemaVersion": 1,
+ "priority": 0, "emittedAt": "2026-08-07T12:20:00.080Z",
+ "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "interrupted", "runTerminalState": "cancelled", "reportedWorkDisposition": "yielded" }
+ }
+ ],
+ "requests": [
+ { "schema": "paperclip.runtime_request.v1", "requestKind": "runtime", "requestId": "request_interrupted_permission", "type": "permission", "status": "pending", "prompt": "Allow the fake command?" }
+ ],
+ "result": {
+ "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "yielded", "summary": "The turn was interrupted.",
+ "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Resume the interrupted turn", "blocksCompletion": true }] },
+ "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [],
+ "continuation": { "kind": "same_agent", "summary": "Resume in the same normalized session", "idempotencyKey": "resume_phase1_interrupted" }
+ }
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json b/packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json
new file mode 100644
index 00000000000..8f20a862e7b
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json
@@ -0,0 +1,19 @@
+{
+ "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 1,
+ "name": "Source sequence gap", "description": "The runner stream skips source sequence 3 and replay records the missing cursor.",
+ "identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_phase1", "issueId": "issue_phase1_gap", "runId": "run_phase1_gap", "environmentLeaseId": "lease_phase1_gap", "runnerInstanceId": "runner_phase1", "normalizedSessionId": "session_phase1_gap", "driverSessionId": "driver_phase1_gap" },
+ "capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "fake", "version": "1.0.0" }, "steer": false, "interrupt": false, "resume": true, "runtimeRequests": false, "structuredResult": true, "typedEvents": true },
+ "commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_gap_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T12:40:00.000Z", "payload": {} }],
+ "events": [
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_01", "sourceSeq": 1, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_gap", "normalizedSessionId": "session_phase1_gap", "eventType": "session.started", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.010Z", "payload": {} },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_02", "sourceSeq": 2, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_gap", "normalizedSessionId": "session_phase1_gap", "turnId": "turn_phase1_gap", "eventType": "turn.started", "schemaVersion": 1, "priority": 1, "emittedAt": "2026-08-07T12:40:00.020Z", "payload": {} },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_04", "sourceSeq": 4, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_gap", "normalizedSessionId": "session_phase1_gap", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.040Z",
+ "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "needs_review", "summary": "The source gap requires reconciliation.", "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Replay missing source event", "blocksCompletion": true }] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] }
+ },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_05", "sourceSeq": 5, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_gap", "normalizedSessionId": "session_phase1_gap", "turnId": "turn_phase1_gap", "eventType": "turn.completed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.050Z", "payload": {} },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_gap_06", "sourceSeq": 6, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_gap", "normalizedSessionId": "session_phase1_gap", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:40:00.060Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "needs_review" } }
+ ],
+ "requests": [],
+ "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "needs_review", "summary": "The source gap requires reconciliation.", "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": false, "criteria": [], "remainingWork": [{ "description": "Replay missing source event", "blocksCompletion": true }] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] }
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/unknown-optional-fields.json b/packages/paperclip-runner/protocol/fixtures/phase-01/unknown-optional-fields.json
new file mode 100644
index 00000000000..911627b4138
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/unknown-optional-fields.json
@@ -0,0 +1,19 @@
+{
+ "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 1,
+ "name": "Unknown optional fields", "description": "A future producer adds optional fields that a PRP v1 consumer preserves and ignores safely.",
+ "futureFixtureHint": { "producerVersion": "1.1-preview" },
+ "identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_phase1", "issueId": "issue_phase1_future", "runId": "run_phase1_future", "environmentLeaseId": "lease_phase1_future", "runnerInstanceId": "runner_phase1", "normalizedSessionId": "session_phase1_future", "driverSessionId": "driver_phase1_future", "futureIdentityLabel": "preserved" },
+ "capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "fake", "version": "1.1.0", "futureDriverField": true }, "steer": true, "interrupt": true, "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true, "futureCapability": { "mode": "optional" } },
+ "commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_future_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T12:50:00.000Z", "payload": {}, "futureDeadlineMode": "soft" }],
+ "events": [
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_01", "sourceSeq": 1, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_future", "normalizedSessionId": "session_phase1_future", "eventType": "session.started", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.010Z", "payload": { "futureSessionDetail": "ignored" }, "futureEnvelopeField": 42 },
+ {
+ "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_02", "sourceSeq": 2, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_future", "normalizedSessionId": "session_phase1_future", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.020Z",
+ "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Unknown optional fields did not change the v1 projection.", "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [], "futureResultAnnotation": { "safe": true } }
+ },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_03", "sourceSeq": 3, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_future", "normalizedSessionId": "session_phase1_future", "turnId": "turn_phase1_future", "eventType": "turn.completed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.030Z", "payload": {} },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_future_04", "sourceSeq": 4, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_future", "normalizedSessionId": "session_phase1_future", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T12:50:00.040Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done", "futureTerminalReason": "optional" } }
+ ],
+ "requests": [],
+ "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "Unknown optional fields did not change the v1 projection.", "completionClaim": { "contractRevision": "phase1-contract-v1", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [], "futureResultAnnotation": { "safe": true } }
+}
diff --git a/packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json b/packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json
new file mode 100644
index 00000000000..3c6d4402771
--- /dev/null
+++ b/packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json
@@ -0,0 +1,13 @@
+{
+ "schema": "paperclip.prp.fixture.v1", "fixtureVersion": 1, "protocolVersion": 2,
+ "name": "Unsupported required version", "description": "A fixture that requires PRP v2 must fail closed in a PRP v1 consumer.",
+ "identity": { "schema": "paperclip.prp.identity.v1", "companyId": "company_phase1", "issueId": "issue_phase1_v2", "runId": "run_phase1_v2", "environmentLeaseId": "lease_phase1_v2", "runnerInstanceId": "runner_phase1", "normalizedSessionId": "session_phase1_v2", "driverSessionId": "driver_phase1_v2" },
+ "capabilities": { "schema": "paperclip.prp.capabilities.v1", "sessionReusePolicy": "new_per_run", "driver": { "kind": "future", "version": "2.0.0" }, "steer": true, "interrupt": true, "resume": true, "runtimeRequests": true, "structuredResult": true, "typedEvents": true },
+ "commands": [{ "schema": "paperclip.prp.command.v1", "commandId": "command_v2_prepare", "controllerSeq": 1, "type": "run.prepare", "issuedAt": "2026-08-07T13:00:00.000Z", "payload": {} }],
+ "events": [
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_v2_01", "sourceSeq": 1, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_v2", "normalizedSessionId": "session_phase1_v2", "eventType": "run.result.proposed", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T13:00:00.010Z", "payload": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "This result requires PRP v2.", "completionClaim": { "contractRevision": "phase1-contract-v2", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] } },
+ { "schema": "paperclip.prp.event.v1", "sourceEventId": "event_v2_02", "sourceSeq": 2, "sourceInstanceId": "runner_phase1", "sourceKind": "runner", "runId": "run_phase1_v2", "normalizedSessionId": "session_phase1_v2", "eventType": "run.terminal", "schemaVersion": 1, "priority": 0, "emittedAt": "2026-08-07T13:00:00.020Z", "payload": { "schema": "paperclip.prp.terminal.v1", "turnTerminalState": "completed", "runTerminalState": "succeeded", "reportedWorkDisposition": "done" } }
+ ],
+ "requests": [],
+ "result": { "schema": "paperclip.run_result.v1", "reportedWorkDisposition": "done", "summary": "This result requires PRP v2.", "completionClaim": { "contractRevision": "phase1-contract-v2", "objectiveSatisfied": true, "criteria": [], "remainingWork": [] }, "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] }
+}
diff --git a/packages/paperclip-runner/protocol/schemas/capabilities.schema.json b/packages/paperclip-runner/protocol/schemas/capabilities.schema.json
new file mode 100644
index 00000000000..bdf38a2cb33
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/capabilities.schema.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json",
+ "title": "PRP negotiated capabilities",
+ "type": "object",
+ "required": [
+ "schema",
+ "sessionReusePolicy",
+ "driver",
+ "steer",
+ "interrupt",
+ "resume",
+ "runtimeRequests",
+ "structuredResult",
+ "typedEvents"
+ ],
+ "properties": {
+ "schema": { "const": "paperclip.prp.capabilities.v1" },
+ "sessionReusePolicy": {
+ "enum": ["new_per_run", "reuse_per_issue", "reuse_per_workspace"]
+ },
+ "driver": {
+ "type": "object",
+ "required": ["kind", "version"],
+ "properties": {
+ "kind": { "type": "string", "minLength": 1, "maxLength": 80 },
+ "version": { "type": "string", "minLength": 1, "maxLength": 80 }
+ },
+ "additionalProperties": true
+ },
+ "steer": { "type": "boolean" },
+ "interrupt": { "type": "boolean" },
+ "resume": { "type": "boolean" },
+ "runtimeRequests": { "type": "boolean" },
+ "structuredResult": { "type": "boolean" },
+ "typedEvents": { "type": "boolean" },
+ "unsupported": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/packages/paperclip-runner/protocol/schemas/command.schema.json b/packages/paperclip-runner/protocol/schemas/command.schema.json
new file mode 100644
index 00000000000..60c662fecb4
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/command.schema.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/command.schema.json",
+ "title": "PRP runner command",
+ "type": "object",
+ "required": ["schema", "commandId", "controllerSeq", "type", "issuedAt", "payload"],
+ "properties": {
+ "schema": { "const": "paperclip.prp.command.v1" },
+ "commandId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "controllerSeq": { "type": "integer", "minimum": 1 },
+ "type": {
+ "enum": [
+ "run.prepare",
+ "session.open",
+ "turn.start",
+ "turn.steer",
+ "turn.interrupt",
+ "turn.stop",
+ "request.resolve",
+ "interaction.receipt",
+ "session.snapshot",
+ "session.close",
+ "run.cancel",
+ "runner.drain",
+ "runner.shutdown"
+ ]
+ },
+ "issuedAt": { "type": "string", "format": "date-time" },
+ "deadlineAt": { "type": "string", "format": "date-time" },
+ "precondition": {
+ "type": "object",
+ "properties": {
+ "runnerState": { "type": "array", "items": { "type": "string" } },
+ "runState": { "type": "array", "items": { "type": "string" } },
+ "sessionState": { "type": "array", "items": { "type": "string" } },
+ "activeTurnId": { "type": ["string", "null"] }
+ },
+ "additionalProperties": true
+ },
+ "payload": { "type": "object", "additionalProperties": true }
+ },
+ "additionalProperties": true
+}
diff --git a/packages/paperclip-runner/protocol/schemas/event.schema.json b/packages/paperclip-runner/protocol/schemas/event.schema.json
new file mode 100644
index 00000000000..3fe654d1f6c
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/event.schema.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/event.schema.json",
+ "title": "PRP native event",
+ "type": "object",
+ "required": [
+ "schema",
+ "sourceEventId",
+ "sourceSeq",
+ "sourceInstanceId",
+ "sourceKind",
+ "runId",
+ "eventType",
+ "schemaVersion",
+ "priority",
+ "emittedAt",
+ "payload"
+ ],
+ "properties": {
+ "schema": { "const": "paperclip.prp.event.v1" },
+ "sourceEventId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "sourceSeq": { "type": "integer", "minimum": 1 },
+ "sourceInstanceId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "sourceKind": { "enum": ["runner", "control_plane"] },
+ "runId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "normalizedSessionId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "turnId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "itemId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "eventType": {
+ "enum": [
+ "runner.connected", "runner.reconnected", "runner.disconnected", "runner.draining", "runner.diagnostic",
+ "runtime.phase.changed", "sandbox.metric", "workspace.ready",
+ "harness.starting", "harness.ready", "harness.exited", "harness.diagnostic",
+ "session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.closed", "session.failed",
+ "turn.submitted", "turn.accepted", "turn.started", "turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled",
+ "item.started", "item.delta", "item.completed", "item.failed",
+ "mcp_app.discovered", "mcp_app.resource.resolved", "mcp_app.initializing", "mcp_app.ready", "mcp_app.tool_input", "mcp_app.tool_result", "mcp_app.action.requested", "mcp_app.action.resolved", "mcp_app.host_context.changed", "mcp_app.failed", "mcp_app.teardown",
+ "runtime_request.created", "runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled",
+ "interaction.request.proposed", "interaction.request.materialized", "interaction.request.rejected", "interaction.response.progressed", "interaction.response.resolved", "interaction.response.delivered",
+ "run.result.proposed", "run.result.accepted", "run.result.rejected",
+ "attention.request.proposed", "attention.request.routed", "attention.request.resolved", "attention.request.expired", "attention.request.superseded",
+ "work.assessment.recorded", "issue.status.decision.recorded", "issue.status.decision.applied", "issue.status.decision.rejected", "issue.status.decision.superseded",
+ "run.terminal"
+ ]
+ },
+ "schemaVersion": { "const": 1 },
+ "priority": { "enum": [0, 1, 2] },
+ "emittedAt": { "type": "string", "format": "date-time" },
+ "observedAt": { "type": "string", "format": "date-time" },
+ "payload": { "type": "object", "additionalProperties": true },
+ "debug": { "type": "object", "additionalProperties": true }
+ },
+ "allOf": [
+ {
+ "if": { "properties": { "eventType": { "const": "run.terminal" } } },
+ "then": {
+ "properties": {
+ "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json" }
+ }
+ }
+ },
+ {
+ "if": { "properties": { "eventType": { "const": "run.result.proposed" } } },
+ "then": {
+ "properties": {
+ "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json" }
+ }
+ }
+ }
+ ],
+ "additionalProperties": true
+}
diff --git a/packages/paperclip-runner/protocol/schemas/fixture.schema.json b/packages/paperclip-runner/protocol/schemas/fixture.schema.json
new file mode 100644
index 00000000000..18d33ade541
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/fixture.schema.json
@@ -0,0 +1,42 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json",
+ "title": "PRP scripted replay fixture",
+ "type": "object",
+ "required": [
+ "schema",
+ "fixtureVersion",
+ "protocolVersion",
+ "name",
+ "description",
+ "identity",
+ "capabilities",
+ "commands",
+ "events",
+ "result"
+ ],
+ "properties": {
+ "schema": { "const": "paperclip.prp.fixture.v1" },
+ "fixtureVersion": { "const": 1 },
+ "protocolVersion": { "const": 1 },
+ "name": { "type": "string", "minLength": 1, "maxLength": 120 },
+ "description": { "type": "string", "minLength": 1, "maxLength": 1000 },
+ "identity": { "$ref": "https://paperclip.dev/schemas/prp/v1/identity.schema.json" },
+ "capabilities": { "$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json" },
+ "commands": {
+ "type": "array",
+ "items": { "$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json" }
+ },
+ "events": {
+ "type": "array",
+ "minItems": 1,
+ "items": { "$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json" }
+ },
+ "requests": {
+ "type": "array",
+ "items": { "$ref": "https://paperclip.dev/schemas/prp/v1/request.schema.json" }
+ },
+ "result": { "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json" }
+ },
+ "additionalProperties": true
+}
diff --git a/packages/paperclip-runner/protocol/schemas/identity.schema.json b/packages/paperclip-runner/protocol/schemas/identity.schema.json
new file mode 100644
index 00000000000..8279ef857c7
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/identity.schema.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/identity.schema.json",
+ "title": "PRP identity graph",
+ "type": "object",
+ "required": [
+ "schema",
+ "companyId",
+ "issueId",
+ "runId",
+ "environmentLeaseId",
+ "runnerInstanceId",
+ "normalizedSessionId",
+ "driverSessionId"
+ ],
+ "properties": {
+ "schema": { "const": "paperclip.prp.identity.v1" },
+ "companyId": { "$ref": "#/$defs/stableId" },
+ "issueId": { "$ref": "#/$defs/stableId" },
+ "runId": { "$ref": "#/$defs/stableId" },
+ "environmentLeaseId": { "$ref": "#/$defs/stableId" },
+ "runnerInstanceId": { "$ref": "#/$defs/stableId" },
+ "normalizedSessionId": { "$ref": "#/$defs/stableId" },
+ "driverSessionId": { "$ref": "#/$defs/stableId" },
+ "providerSessionId": { "$ref": "#/$defs/stableId" }
+ },
+ "additionalProperties": true,
+ "$defs": {
+ "stableId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160,
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
+ }
+ }
+}
diff --git a/packages/paperclip-runner/protocol/schemas/request.schema.json b/packages/paperclip-runner/protocol/schemas/request.schema.json
new file mode 100644
index 00000000000..2175bba9333
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/request.schema.json
@@ -0,0 +1,53 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/request.schema.json",
+ "title": "PRP request",
+ "oneOf": [
+ {
+ "type": "object",
+ "required": ["schema", "requestKind", "requestId", "type", "status", "prompt"],
+ "properties": {
+ "schema": { "const": "paperclip.runtime_request.v1" },
+ "requestKind": { "const": "runtime" },
+ "requestId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "type": { "enum": ["permission", "input"] },
+ "status": { "enum": ["pending", "resolved", "expired", "cancelled"] },
+ "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 },
+ "choices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["key", "label"],
+ "properties": {
+ "key": { "type": "string", "minLength": 1 },
+ "label": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true
+ },
+ {
+ "type": "object",
+ "required": ["schema", "requestKind", "requestId", "kind", "blocking", "payload"],
+ "properties": {
+ "schema": { "const": "paperclip.interaction_request.v1" },
+ "requestKind": { "const": "issue_thread" },
+ "requestId": { "type": "string", "minLength": 1, "maxLength": 160 },
+ "kind": {
+ "enum": [
+ "request_confirmation",
+ "request_checkbox_confirmation",
+ "request_item_verdicts",
+ "ask_user_questions",
+ "suggest_tasks"
+ ]
+ },
+ "blocking": { "type": "boolean" },
+ "payload": { "type": "object", "additionalProperties": true }
+ },
+ "additionalProperties": true
+ }
+ ]
+}
diff --git a/packages/paperclip-runner/protocol/schemas/result.schema.json b/packages/paperclip-runner/protocol/schemas/result.schema.json
new file mode 100644
index 00000000000..09faaa92363
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/result.schema.json
@@ -0,0 +1,121 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/result.schema.json",
+ "title": "PRP structured run result",
+ "type": "object",
+ "required": [
+ "schema",
+ "reportedWorkDisposition",
+ "summary",
+ "completionClaim",
+ "evidence",
+ "verification",
+ "attentionRequests",
+ "artifacts"
+ ],
+ "properties": {
+ "schema": { "const": "paperclip.run_result.v1" },
+ "reportedWorkDisposition": {
+ "enum": ["done", "blocked", "needs_review", "yielded"]
+ },
+ "summary": { "type": "string", "minLength": 1, "maxLength": 12000 },
+ "completionClaim": {
+ "type": "object",
+ "required": ["contractRevision", "objectiveSatisfied", "criteria", "remainingWork"],
+ "properties": {
+ "contractRevision": { "type": "string", "minLength": 1 },
+ "objectiveSatisfied": { "type": "boolean" },
+ "criteria": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["criterionId", "status", "evidenceRefs"],
+ "properties": {
+ "criterionId": { "type": "string", "minLength": 1 },
+ "status": { "enum": ["satisfied", "not_satisfied", "unknown"] },
+ "evidenceRefs": { "type": "array", "items": { "type": "string" } },
+ "explanation": { "type": "string" }
+ },
+ "additionalProperties": true
+ }
+ },
+ "remainingWork": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["description", "blocksCompletion"],
+ "properties": {
+ "description": { "type": "string", "minLength": 1 },
+ "blocksCompletion": { "type": "boolean" }
+ },
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true
+ },
+ "evidence": { "type": "array", "items": { "type": "object" } },
+ "verification": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["commandOrCheck", "status"],
+ "properties": {
+ "commandOrCheck": { "type": "string", "minLength": 1 },
+ "status": { "enum": ["passed", "failed", "not_run"] },
+ "detail": { "type": "string" },
+ "artifactRef": { "type": "string" }
+ },
+ "additionalProperties": true
+ }
+ },
+ "blocker": {
+ "type": "object",
+ "required": ["reasonCode", "owner", "unblockAction", "scope"],
+ "properties": {
+ "reasonCode": { "type": "string", "minLength": 1 },
+ "owner": { "type": "object", "additionalProperties": true },
+ "unblockAction": { "type": "string", "minLength": 1 },
+ "scope": { "enum": ["current_track", "task_wide"] }
+ },
+ "additionalProperties": true
+ },
+ "attentionRequests": { "type": "array", "items": { "type": "object" } },
+ "artifacts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["kind", "ref"],
+ "properties": {
+ "kind": { "type": "string", "minLength": 1 },
+ "ref": { "type": "string", "minLength": 1 },
+ "title": { "type": "string" }
+ },
+ "additionalProperties": true
+ }
+ },
+ "continuation": {
+ "type": "object",
+ "required": ["kind", "summary", "idempotencyKey"],
+ "properties": {
+ "kind": {
+ "enum": ["same_agent", "retry", "delegated_issue", "response_wake", "monitor"]
+ },
+ "summary": { "type": "string", "minLength": 1 },
+ "idempotencyKey": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": true
+ }
+ },
+ "allOf": [
+ {
+ "if": { "properties": { "reportedWorkDisposition": { "const": "blocked" } } },
+ "then": { "required": ["blocker"] }
+ },
+ {
+ "if": { "properties": { "reportedWorkDisposition": { "const": "yielded" } } },
+ "then": { "required": ["continuation"] }
+ }
+ ],
+ "additionalProperties": true
+}
diff --git a/packages/paperclip-runner/protocol/schemas/terminal.schema.json b/packages/paperclip-runner/protocol/schemas/terminal.schema.json
new file mode 100644
index 00000000000..69397e9c76d
--- /dev/null
+++ b/packages/paperclip-runner/protocol/schemas/terminal.schema.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json",
+ "title": "PRP run terminal state",
+ "type": "object",
+ "required": [
+ "schema",
+ "turnTerminalState",
+ "runTerminalState",
+ "reportedWorkDisposition"
+ ],
+ "properties": {
+ "schema": { "const": "paperclip.prp.terminal.v1" },
+ "turnTerminalState": {
+ "enum": ["completed", "failed", "interrupted", "cancelled"]
+ },
+ "runTerminalState": { "enum": ["succeeded", "failed", "cancelled"] },
+ "reportedWorkDisposition": {
+ "enum": ["done", "blocked", "needs_review", "yielded"]
+ },
+ "workAssessmentId": { "type": "string", "minLength": 1 },
+ "statusDecisionId": { "type": "string", "minLength": 1 }
+ },
+ "additionalProperties": true
+}
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs
index b0730569ac7..0b85caa636d 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs
@@ -1,5 +1,7 @@
#![forbid(unsafe_code)]
+pub mod phase1;
+
use std::error::Error;
use std::fmt::{self, Display, Formatter};
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/phase1.rs b/packages/paperclip-runner/runner/crates/runner-core/src/phase1.rs
new file mode 100644
index 00000000000..e8533475289
--- /dev/null
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/phase1.rs
@@ -0,0 +1,308 @@
+use std::collections::HashMap;
+use std::error::Error;
+use std::fmt::{self, Display, Formatter};
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+pub const PRP_PROTOCOL_VERSION: u64 = 1;
+const PRP_FIXTURE_VERSION: u64 = 1;
+const PRP_FIXTURE_SCHEMA: &str = "paperclip.prp.fixture.v1";
+const PRP_EVENT_SCHEMA: &str = "paperclip.prp.event.v1";
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Phase1Fixture {
+ schema: String,
+ fixture_version: u64,
+ protocol_version: u64,
+ identity: Phase1Identity,
+ events: Vec,
+ result: Value,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Phase1Identity {
+ run_id: String,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct Phase1Event {
+ schema: String,
+ source_event_id: String,
+ source_seq: u64,
+ source_instance_id: String,
+ source_kind: String,
+ run_id: String,
+ schema_version: u64,
+ event_type: String,
+ payload: Value,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Phase1SequenceGap {
+ source_key: String,
+ expected: u64,
+ received: u64,
+ missing: Vec,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Phase1ParitySummary {
+ run_id: String,
+ integrity: String,
+ timeline_count: usize,
+ duplicate_event_ids: Vec,
+ gaps: Vec,
+ turn_terminal_state: Option,
+ run_terminal_state: Option,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Phase1Error(String);
+
+impl Phase1Error {
+ fn invalid(message: impl Into) -> Self {
+ Self(message.into())
+ }
+}
+
+impl Display for Phase1Error {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.0)
+ }
+}
+
+impl Error for Phase1Error {}
+
+pub fn reduce_phase1_fixture(input: &str) -> Result {
+ let fixture: Phase1Fixture = serde_json::from_str(input)
+ .map_err(|error| Phase1Error::invalid(format!("fixture must be valid JSON: {error}")))?;
+ if fixture.protocol_version != PRP_PROTOCOL_VERSION {
+ return Err(Phase1Error::invalid(format!(
+ "unsupported required protocolVersion {}; expected {PRP_PROTOCOL_VERSION}",
+ fixture.protocol_version
+ )));
+ }
+ if fixture.fixture_version != PRP_FIXTURE_VERSION {
+ return Err(Phase1Error::invalid(format!(
+ "unsupported required fixtureVersion {}; expected {PRP_FIXTURE_VERSION}",
+ fixture.fixture_version
+ )));
+ }
+ if fixture.schema != PRP_FIXTURE_SCHEMA {
+ return Err(Phase1Error::invalid(format!(
+ "unsupported required fixture schema {}; expected {PRP_FIXTURE_SCHEMA}",
+ fixture.schema
+ )));
+ }
+
+ let mut delivery_counts = HashMap::<&str, usize>::new();
+ for event in &fixture.events {
+ *delivery_counts
+ .entry(event.source_event_id.as_str())
+ .or_default() += 1;
+ }
+ let mut duplicate_event_ids = delivery_counts
+ .into_iter()
+ .filter_map(|(event_id, count)| (count > 1).then(|| event_id.to_owned()))
+ .collect::>();
+ duplicate_event_ids.sort();
+
+ let mut seen = HashMap::<&str, &Phase1Event>::new();
+ let mut cursors = HashMap::::new();
+ let mut gaps = Vec::::new();
+ let mut timeline_count = 0_usize;
+ let mut turn_terminal_state = None;
+ let mut run_terminal_state = None;
+ let mut proposed_result_count = 0_usize;
+ let mut terminal_count = 0_usize;
+
+ for event in &fixture.events {
+ if event.schema != PRP_EVENT_SCHEMA {
+ return Err(Phase1Error::invalid(format!(
+ "unsupported required event schema {}; expected {PRP_EVENT_SCHEMA}",
+ event.schema
+ )));
+ }
+ if event.schema_version != 1 {
+ return Err(Phase1Error::invalid(format!(
+ "unsupported required event schemaVersion {}; expected 1",
+ event.schema_version
+ )));
+ }
+ if event.run_id != fixture.identity.run_id {
+ return Err(Phase1Error::invalid(
+ "event runId must match identity.runId",
+ ));
+ }
+ if let Some(previous) = seen.get(event.source_event_id.as_str()) {
+ if *previous != event {
+ return Err(Phase1Error::invalid(
+ "duplicate sourceEventId deliveries must be byte-equivalent",
+ ));
+ }
+ continue;
+ }
+ seen.insert(event.source_event_id.as_str(), event);
+
+ let key = format!("{}:{}", event.source_kind, event.source_instance_id);
+ let cursor = *cursors.get(&key).unwrap_or(&0);
+ let expected = cursor + 1;
+ if event.source_seq <= cursor {
+ continue;
+ }
+ if event.source_seq > expected {
+ gaps.push(Phase1SequenceGap {
+ source_key: key.clone(),
+ expected,
+ received: event.source_seq,
+ missing: (expected..event.source_seq).collect(),
+ });
+ }
+ cursors.insert(key, event.source_seq);
+ timeline_count += 1;
+
+ match event.event_type.as_str() {
+ "run.result.proposed" => {
+ proposed_result_count += 1;
+ if event.payload != fixture.result {
+ return Err(Phase1Error::invalid(
+ "fixture result must match the run.result.proposed event payload",
+ ));
+ }
+ }
+ "run.terminal" => {
+ terminal_count += 1;
+ turn_terminal_state = event
+ .payload
+ .get("turnTerminalState")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ run_terminal_state = event
+ .payload
+ .get("runTerminalState")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ }
+ _ => {}
+ }
+ }
+
+ if proposed_result_count != 1 {
+ return Err(Phase1Error::invalid(
+ "scripted fixtures must contain exactly one unique run.result.proposed event",
+ ));
+ }
+ if terminal_count != 1 {
+ return Err(Phase1Error::invalid(
+ "scripted fixtures must contain exactly one unique run.terminal event",
+ ));
+ }
+
+ Ok(Phase1ParitySummary {
+ run_id: fixture.identity.run_id,
+ integrity: if gaps.is_empty() {
+ "complete".to_owned()
+ } else {
+ "gap_detected".to_owned()
+ },
+ timeline_count,
+ duplicate_event_ids,
+ gaps,
+ turn_terminal_state,
+ run_terminal_state,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{reduce_phase1_fixture, Phase1ParitySummary};
+ use serde_json::Value;
+
+ fn assert_fixture_parity(fixture: &str, expected: &str) {
+ let actual = reduce_phase1_fixture(fixture).expect("fixture should reduce");
+ let expected: Phase1ParitySummary =
+ serde_json::from_str(expected).expect("golden summary should parse");
+ assert_eq!(actual, expected);
+ }
+
+ macro_rules! parity_case {
+ ($name:ident, $fixture:literal, $golden:literal) => {
+ #[test]
+ fn $name() {
+ assert_fixture_parity(
+ include_str!(concat!(
+ "../../../../protocol/fixtures/phase-01/",
+ $fixture,
+ ".json"
+ )),
+ include_str!(concat!(
+ "../../../../protocol/fixtures/phase-01/golden/",
+ $golden,
+ ".summary.json"
+ )),
+ );
+ }
+ };
+ }
+
+ parity_case!(phase1_fixture_parity_happy_path, "happy-path", "happy-path");
+ parity_case!(phase1_fixture_parity_failed_run, "failed-run", "failed-run");
+ parity_case!(
+ phase1_fixture_parity_interrupted_run,
+ "interrupted-run",
+ "interrupted-run"
+ );
+ parity_case!(
+ phase1_fixture_parity_duplicate_event,
+ "duplicate-event",
+ "duplicate-event"
+ );
+ parity_case!(phase1_fixture_parity_source_gap, "source-gap", "source-gap");
+ parity_case!(
+ phase1_fixture_parity_unknown_optional_fields,
+ "unknown-optional-fields",
+ "unknown-optional-fields"
+ );
+
+ #[test]
+ fn phase1_fixture_parity_rejects_unsupported_required_version() {
+ let error = reduce_phase1_fixture(include_str!(
+ "../../../../protocol/fixtures/phase-01/unsupported-required-version.json"
+ ))
+ .expect_err("PRP v2 fixture must fail closed");
+ assert!(error
+ .to_string()
+ .contains("unsupported required protocolVersion 2"));
+ }
+
+ #[test]
+ fn phase1_fixture_parity_rejects_unsupported_fixture_version() {
+ let fixture = include_str!("../../../../protocol/fixtures/phase-01/happy-path.json")
+ .replace("\"fixtureVersion\": 1", "\"fixtureVersion\": 2");
+ let error =
+ reduce_phase1_fixture(&fixture).expect_err("fixture version 2 must fail closed");
+ assert!(error
+ .to_string()
+ .contains("unsupported required fixtureVersion 2"));
+ }
+
+ #[test]
+ fn phase1_fixture_parity_rejects_a_mismatched_declared_result() {
+ let mut fixture: Value = serde_json::from_str(include_str!(
+ "../../../../protocol/fixtures/phase-01/happy-path.json"
+ ))
+ .expect("fixture should parse");
+ fixture["result"]["summary"] = Value::String("Contradictory result".to_owned());
+ let error = reduce_phase1_fixture(&fixture.to_string())
+ .expect_err("mismatched result must fail closed");
+ assert!(error
+ .to_string()
+ .contains("fixture result must match the run.result.proposed event payload"));
+ }
+}
diff --git a/packages/paperclip-runner/scripts/check-browser-tokens.mjs b/packages/paperclip-runner/scripts/check-browser-tokens.mjs
new file mode 100644
index 00000000000..5e39abedf50
--- /dev/null
+++ b/packages/paperclip-runner/scripts/check-browser-tokens.mjs
@@ -0,0 +1,67 @@
+import { readFile, readdir } from "node:fs/promises";
+import { dirname, extname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const sourceRoot = resolve(packageRoot, "devtools/browser/src");
+
+async function sourceFiles(directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const nested = await Promise.all(
+ entries.map((entry) => {
+ const path = resolve(directory, entry.name);
+ return entry.isDirectory() ? sourceFiles(path) : [path];
+ }),
+ );
+ return nested.flat();
+}
+
+const componentRules = [
+ ["hex color", /#[0-9a-f]{3,8}\b/i],
+ ["raw pixel value", /\b\d+(?:\.\d+)?px\b/],
+ ["inline style", /\bstyle\s*=\s*\{/],
+ ["raw font size", /\bfont(?:-size|Size)\s*[:=]/],
+ ["arbitrary Tailwind value", /\b[a-z-]+-\[[^\]]+\]/],
+];
+const violations = [];
+
+for (const path of await sourceFiles(sourceRoot)) {
+ if (![".ts", ".tsx"].includes(extname(path))) {
+ continue;
+ }
+ const source = await readFile(path, "utf8");
+ for (const [label, pattern] of componentRules) {
+ if (pattern.test(source)) {
+ violations.push(`${path.slice(packageRoot.length + 1)}: ${label}`);
+ }
+ }
+}
+
+const styles = await readFile(resolve(sourceRoot, "styles.css"), "utf8");
+for (const token of [
+ "--background",
+ "--foreground",
+ "--card",
+ "--primary",
+ "--muted",
+ "--border",
+ "--ring",
+ "--font-sans",
+ "--font-mono",
+ "--space-1",
+ "--radius-sm",
+ "--motion-fast",
+]) {
+ if (!styles.includes(`${token}:`)) {
+ violations.push(`devtools/browser/src/styles.css: missing ${token}`);
+ }
+}
+
+if (violations.length > 0) {
+ process.stderr.write(`Standalone browser token check failed:\n- ${violations.join("\n- ")}\n`);
+ process.exitCode = 1;
+} else {
+ process.stdout.write(
+ "Standalone browser token check passed; component visual values stay in styles.css.\n",
+ );
+}
diff --git a/packages/paperclip-runner/scripts/generate-phase1-goldens.mjs b/packages/paperclip-runner/scripts/generate-phase1-goldens.mjs
new file mode 100644
index 00000000000..756d3f7abf9
--- /dev/null
+++ b/packages/paperclip-runner/scripts/generate-phase1-goldens.mjs
@@ -0,0 +1,59 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ parsePrpFixtureText,
+ phase1ParitySummary,
+ reducePrpFixture,
+} from "../dist/index.js";
+
+const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const fixtureDirectory = resolve(packageRoot, "protocol/fixtures/phase-01");
+const goldenDirectory = resolve(fixtureDirectory, "golden");
+const fixtureNames = [
+ "happy-path",
+ "failed-run",
+ "interrupted-run",
+ "duplicate-event",
+ "source-gap",
+ "unknown-optional-fields",
+];
+const check = process.argv.includes("--check");
+const stale = [];
+
+await mkdir(goldenDirectory, { recursive: true });
+for (const fixtureName of fixtureNames) {
+ const source = await readFile(resolve(fixtureDirectory, `${fixtureName}.json`), "utf8");
+ const validation = parsePrpFixtureText(source);
+ if (!validation.ok) {
+ throw new Error(
+ `${fixtureName}.json failed validation: ${validation.issues.map((issue) => issue.message).join("; ")}`,
+ );
+ }
+ const snapshot = reducePrpFixture(validation.fixture);
+ const outputs = [
+ ["snapshot", snapshot],
+ ["summary", phase1ParitySummary(snapshot)],
+ ];
+ for (const [kind, value] of outputs) {
+ const path = resolve(goldenDirectory, `${fixtureName}.${kind}.json`);
+ const generated = `${JSON.stringify(value, null, 2)}\n`;
+ if (check) {
+ if ((await readFile(path, "utf8").catch(() => "")) !== generated) {
+ stale.push(`${fixtureName}.${kind}.json`);
+ }
+ } else {
+ await writeFile(path, generated);
+ }
+ }
+}
+
+if (stale.length > 0) {
+ process.stderr.write(`Phase 1 golden files are stale:\n- ${stale.join("\n- ")}\n`);
+ process.exitCode = 1;
+} else if (check) {
+ process.stdout.write("Phase 1 golden snapshots and parity summaries are current.\n");
+} else {
+ process.stdout.write("Generated Phase 1 golden snapshots and parity summaries.\n");
+}
diff --git a/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs b/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs
new file mode 100644
index 00000000000..b1c2420977e
--- /dev/null
+++ b/packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs
@@ -0,0 +1,43 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const schemaDirectory = resolve(packageRoot, "protocol/schemas");
+const outputPath = resolve(packageRoot, "src/protocol/generated/schema-bundle.ts");
+const schemaNames = [
+ "identity",
+ "capabilities",
+ "command",
+ "terminal",
+ "request",
+ "result",
+ "event",
+ "fixture",
+];
+
+const schemas = await Promise.all(
+ schemaNames.map(async (name) => ({
+ name,
+ value: JSON.parse(await readFile(resolve(schemaDirectory, `${name}.schema.json`), "utf8")),
+ })),
+);
+
+const declarations = schemas
+ .map(({ name, value }) => `export const ${name}Schema = ${JSON.stringify(value, null, 2)} as const;`)
+ .join("\n\n");
+const bundle = schemaNames.map((name) => ` ${name}: ${name}Schema,`).join("\n");
+const generated = `// Generated by scripts/generate-protocol-schema-module.mjs. Do not edit.\n\n${declarations}\n\nexport const prpSchemaBundle = {\n${bundle}\n} as const;\n`;
+
+if (process.argv.includes("--check")) {
+ const current = await readFile(outputPath, "utf8").catch(() => "");
+ if (current !== generated) {
+ process.stderr.write("Generated PRP schema module is stale. Run pnpm generate:protocol-types.\n");
+ process.exitCode = 1;
+ } else {
+ process.stdout.write("Generated PRP schema module matches the JSON Schema sources.\n");
+ }
+} else {
+ await writeFile(outputPath, generated);
+ process.stdout.write("Generated src/protocol/generated/schema-bundle.ts.\n");
+}
diff --git a/packages/paperclip-runner/src/cli/phase1-replay.ts b/packages/paperclip-runner/src/cli/phase1-replay.ts
new file mode 100644
index 00000000000..be36dd3ad1c
--- /dev/null
+++ b/packages/paperclip-runner/src/cli/phase1-replay.ts
@@ -0,0 +1,17 @@
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+import { formatPhase1Replay, replayPhase1FixtureText } from "../tracer/phase1-replay.js";
+
+const defaultFixtureUrl = new URL(
+ "../../protocol/fixtures/phase-01/happy-path.json",
+ import.meta.url,
+);
+const input = process.argv[2];
+const fixtureUrl = input === undefined ? defaultFixtureUrl : pathToFileURL(resolve(input));
+const result = replayPhase1FixtureText(await readFile(fixtureUrl, "utf8"));
+process.stdout.write(`${formatPhase1Replay(result)}\n`);
+if (!result.ok) {
+ process.exitCode = 1;
+}
diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts
index a12b6e66352..3412abcef45 100644
--- a/packages/paperclip-runner/src/index.ts
+++ b/packages/paperclip-runner/src/index.ts
@@ -4,4 +4,8 @@ export * from "./contracts/native-session-backend.js";
export * from "./contracts/types.js";
export * from "./mock-core/mock-control-plane-adapter.js";
export * from "./protocol/phase0-fixture.js";
+export * from "./protocol/phase1-contract.js";
+export * from "./protocol/phase1-loader.js";
+export * from "./reducer/session-reducer.js";
export * from "./tracer/phase0-runner.js";
+export * from "./tracer/phase1-replay.js";
diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts
new file mode 100644
index 00000000000..46c62d0e076
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts
@@ -0,0 +1,924 @@
+// Generated by scripts/generate-protocol-schema-module.mjs. Do not edit.
+
+export const identitySchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/identity.schema.json",
+ "title": "PRP identity graph",
+ "type": "object",
+ "required": [
+ "schema",
+ "companyId",
+ "issueId",
+ "runId",
+ "environmentLeaseId",
+ "runnerInstanceId",
+ "normalizedSessionId",
+ "driverSessionId"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.prp.identity.v1"
+ },
+ "companyId": {
+ "$ref": "#/$defs/stableId"
+ },
+ "issueId": {
+ "$ref": "#/$defs/stableId"
+ },
+ "runId": {
+ "$ref": "#/$defs/stableId"
+ },
+ "environmentLeaseId": {
+ "$ref": "#/$defs/stableId"
+ },
+ "runnerInstanceId": {
+ "$ref": "#/$defs/stableId"
+ },
+ "normalizedSessionId": {
+ "$ref": "#/$defs/stableId"
+ },
+ "driverSessionId": {
+ "$ref": "#/$defs/stableId"
+ },
+ "providerSessionId": {
+ "$ref": "#/$defs/stableId"
+ }
+ },
+ "additionalProperties": true,
+ "$defs": {
+ "stableId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160,
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
+ }
+ }
+} as const;
+
+export const capabilitiesSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json",
+ "title": "PRP negotiated capabilities",
+ "type": "object",
+ "required": [
+ "schema",
+ "sessionReusePolicy",
+ "driver",
+ "steer",
+ "interrupt",
+ "resume",
+ "runtimeRequests",
+ "structuredResult",
+ "typedEvents"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.prp.capabilities.v1"
+ },
+ "sessionReusePolicy": {
+ "enum": [
+ "new_per_run",
+ "reuse_per_issue",
+ "reuse_per_workspace"
+ ]
+ },
+ "driver": {
+ "type": "object",
+ "required": [
+ "kind",
+ "version"
+ ],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 80
+ },
+ "version": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 80
+ }
+ },
+ "additionalProperties": true
+ },
+ "steer": {
+ "type": "boolean"
+ },
+ "interrupt": {
+ "type": "boolean"
+ },
+ "resume": {
+ "type": "boolean"
+ },
+ "runtimeRequests": {
+ "type": "boolean"
+ },
+ "structuredResult": {
+ "type": "boolean"
+ },
+ "typedEvents": {
+ "type": "boolean"
+ },
+ "unsupported": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "uniqueItems": true
+ }
+ },
+ "additionalProperties": true
+} as const;
+
+export const commandSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/command.schema.json",
+ "title": "PRP runner command",
+ "type": "object",
+ "required": [
+ "schema",
+ "commandId",
+ "controllerSeq",
+ "type",
+ "issuedAt",
+ "payload"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.prp.command.v1"
+ },
+ "commandId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "controllerSeq": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "type": {
+ "enum": [
+ "run.prepare",
+ "session.open",
+ "turn.start",
+ "turn.steer",
+ "turn.interrupt",
+ "turn.stop",
+ "request.resolve",
+ "interaction.receipt",
+ "session.snapshot",
+ "session.close",
+ "run.cancel",
+ "runner.drain",
+ "runner.shutdown"
+ ]
+ },
+ "issuedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "deadlineAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "precondition": {
+ "type": "object",
+ "properties": {
+ "runnerState": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "runState": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "sessionState": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "activeTurnId": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "additionalProperties": true
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "additionalProperties": true
+} as const;
+
+export const terminalSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json",
+ "title": "PRP run terminal state",
+ "type": "object",
+ "required": [
+ "schema",
+ "turnTerminalState",
+ "runTerminalState",
+ "reportedWorkDisposition"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.prp.terminal.v1"
+ },
+ "turnTerminalState": {
+ "enum": [
+ "completed",
+ "failed",
+ "interrupted",
+ "cancelled"
+ ]
+ },
+ "runTerminalState": {
+ "enum": [
+ "succeeded",
+ "failed",
+ "cancelled"
+ ]
+ },
+ "reportedWorkDisposition": {
+ "enum": [
+ "done",
+ "blocked",
+ "needs_review",
+ "yielded"
+ ]
+ },
+ "workAssessmentId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "statusDecisionId": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": true
+} as const;
+
+export const requestSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/request.schema.json",
+ "title": "PRP request",
+ "oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "schema",
+ "requestKind",
+ "requestId",
+ "type",
+ "status",
+ "prompt"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.runtime_request.v1"
+ },
+ "requestKind": {
+ "const": "runtime"
+ },
+ "requestId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "type": {
+ "enum": [
+ "permission",
+ "input"
+ ]
+ },
+ "status": {
+ "enum": [
+ "pending",
+ "resolved",
+ "expired",
+ "cancelled"
+ ]
+ },
+ "prompt": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 4000
+ },
+ "choices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "key",
+ "label"
+ ],
+ "properties": {
+ "key": {
+ "type": "string",
+ "minLength": 1
+ },
+ "label": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true
+ },
+ {
+ "type": "object",
+ "required": [
+ "schema",
+ "requestKind",
+ "requestId",
+ "kind",
+ "blocking",
+ "payload"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.interaction_request.v1"
+ },
+ "requestKind": {
+ "const": "issue_thread"
+ },
+ "requestId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "kind": {
+ "enum": [
+ "request_confirmation",
+ "request_checkbox_confirmation",
+ "request_item_verdicts",
+ "ask_user_questions",
+ "suggest_tasks"
+ ]
+ },
+ "blocking": {
+ "type": "boolean"
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "additionalProperties": true
+ }
+ ]
+} as const;
+
+export const resultSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/result.schema.json",
+ "title": "PRP structured run result",
+ "type": "object",
+ "required": [
+ "schema",
+ "reportedWorkDisposition",
+ "summary",
+ "completionClaim",
+ "evidence",
+ "verification",
+ "attentionRequests",
+ "artifacts"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.run_result.v1"
+ },
+ "reportedWorkDisposition": {
+ "enum": [
+ "done",
+ "blocked",
+ "needs_review",
+ "yielded"
+ ]
+ },
+ "summary": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 12000
+ },
+ "completionClaim": {
+ "type": "object",
+ "required": [
+ "contractRevision",
+ "objectiveSatisfied",
+ "criteria",
+ "remainingWork"
+ ],
+ "properties": {
+ "contractRevision": {
+ "type": "string",
+ "minLength": 1
+ },
+ "objectiveSatisfied": {
+ "type": "boolean"
+ },
+ "criteria": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "criterionId",
+ "status",
+ "evidenceRefs"
+ ],
+ "properties": {
+ "criterionId": {
+ "type": "string",
+ "minLength": 1
+ },
+ "status": {
+ "enum": [
+ "satisfied",
+ "not_satisfied",
+ "unknown"
+ ]
+ },
+ "evidenceRefs": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "explanation": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "remainingWork": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "description",
+ "blocksCompletion"
+ ],
+ "properties": {
+ "description": {
+ "type": "string",
+ "minLength": 1
+ },
+ "blocksCompletion": {
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": true
+ }
+ }
+ },
+ "additionalProperties": true
+ },
+ "evidence": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "verification": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "commandOrCheck",
+ "status"
+ ],
+ "properties": {
+ "commandOrCheck": {
+ "type": "string",
+ "minLength": 1
+ },
+ "status": {
+ "enum": [
+ "passed",
+ "failed",
+ "not_run"
+ ]
+ },
+ "detail": {
+ "type": "string"
+ },
+ "artifactRef": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "blocker": {
+ "type": "object",
+ "required": [
+ "reasonCode",
+ "owner",
+ "unblockAction",
+ "scope"
+ ],
+ "properties": {
+ "reasonCode": {
+ "type": "string",
+ "minLength": 1
+ },
+ "owner": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "unblockAction": {
+ "type": "string",
+ "minLength": 1
+ },
+ "scope": {
+ "enum": [
+ "current_track",
+ "task_wide"
+ ]
+ }
+ },
+ "additionalProperties": true
+ },
+ "attentionRequests": {
+ "type": "array",
+ "items": {
+ "type": "object"
+ }
+ },
+ "artifacts": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "kind",
+ "ref"
+ ],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "minLength": 1
+ },
+ "ref": {
+ "type": "string",
+ "minLength": 1
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "continuation": {
+ "type": "object",
+ "required": [
+ "kind",
+ "summary",
+ "idempotencyKey"
+ ],
+ "properties": {
+ "kind": {
+ "enum": [
+ "same_agent",
+ "retry",
+ "delegated_issue",
+ "response_wake",
+ "monitor"
+ ]
+ },
+ "summary": {
+ "type": "string",
+ "minLength": 1
+ },
+ "idempotencyKey": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "reportedWorkDisposition": {
+ "const": "blocked"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "blocker"
+ ]
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "reportedWorkDisposition": {
+ "const": "yielded"
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "continuation"
+ ]
+ }
+ }
+ ],
+ "additionalProperties": true
+} as const;
+
+export const eventSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/event.schema.json",
+ "title": "PRP native event",
+ "type": "object",
+ "required": [
+ "schema",
+ "sourceEventId",
+ "sourceSeq",
+ "sourceInstanceId",
+ "sourceKind",
+ "runId",
+ "eventType",
+ "schemaVersion",
+ "priority",
+ "emittedAt",
+ "payload"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.prp.event.v1"
+ },
+ "sourceEventId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "sourceSeq": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "sourceInstanceId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "sourceKind": {
+ "enum": [
+ "runner",
+ "control_plane"
+ ]
+ },
+ "runId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "normalizedSessionId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "turnId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "itemId": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 160
+ },
+ "eventType": {
+ "enum": [
+ "runner.connected",
+ "runner.reconnected",
+ "runner.disconnected",
+ "runner.draining",
+ "runner.diagnostic",
+ "runtime.phase.changed",
+ "sandbox.metric",
+ "workspace.ready",
+ "harness.starting",
+ "harness.ready",
+ "harness.exited",
+ "harness.diagnostic",
+ "session.starting",
+ "session.started",
+ "session.resuming",
+ "session.resumed",
+ "session.reconciled",
+ "session.closed",
+ "session.failed",
+ "turn.submitted",
+ "turn.accepted",
+ "turn.started",
+ "turn.completed",
+ "turn.failed",
+ "turn.interrupted",
+ "turn.cancelled",
+ "item.started",
+ "item.delta",
+ "item.completed",
+ "item.failed",
+ "mcp_app.discovered",
+ "mcp_app.resource.resolved",
+ "mcp_app.initializing",
+ "mcp_app.ready",
+ "mcp_app.tool_input",
+ "mcp_app.tool_result",
+ "mcp_app.action.requested",
+ "mcp_app.action.resolved",
+ "mcp_app.host_context.changed",
+ "mcp_app.failed",
+ "mcp_app.teardown",
+ "runtime_request.created",
+ "runtime_request.resolved",
+ "runtime_request.expired",
+ "runtime_request.cancelled",
+ "interaction.request.proposed",
+ "interaction.request.materialized",
+ "interaction.request.rejected",
+ "interaction.response.progressed",
+ "interaction.response.resolved",
+ "interaction.response.delivered",
+ "run.result.proposed",
+ "run.result.accepted",
+ "run.result.rejected",
+ "attention.request.proposed",
+ "attention.request.routed",
+ "attention.request.resolved",
+ "attention.request.expired",
+ "attention.request.superseded",
+ "work.assessment.recorded",
+ "issue.status.decision.recorded",
+ "issue.status.decision.applied",
+ "issue.status.decision.rejected",
+ "issue.status.decision.superseded",
+ "run.terminal"
+ ]
+ },
+ "schemaVersion": {
+ "const": 1
+ },
+ "priority": {
+ "enum": [
+ 0,
+ 1,
+ 2
+ ]
+ },
+ "emittedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "observedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "payload": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "debug": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "eventType": {
+ "const": "run.terminal"
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "payload": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/terminal.schema.json"
+ }
+ }
+ }
+ },
+ {
+ "if": {
+ "properties": {
+ "eventType": {
+ "const": "run.result.proposed"
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "payload": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json"
+ }
+ }
+ }
+ }
+ ],
+ "additionalProperties": true
+} as const;
+
+export const fixtureSchema = {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://paperclip.dev/schemas/prp/v1/fixture.schema.json",
+ "title": "PRP scripted replay fixture",
+ "type": "object",
+ "required": [
+ "schema",
+ "fixtureVersion",
+ "protocolVersion",
+ "name",
+ "description",
+ "identity",
+ "capabilities",
+ "commands",
+ "events",
+ "result"
+ ],
+ "properties": {
+ "schema": {
+ "const": "paperclip.prp.fixture.v1"
+ },
+ "fixtureVersion": {
+ "const": 1
+ },
+ "protocolVersion": {
+ "const": 1
+ },
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 120
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1000
+ },
+ "identity": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/identity.schema.json"
+ },
+ "capabilities": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"
+ },
+ "commands": {
+ "type": "array",
+ "items": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/command.schema.json"
+ }
+ },
+ "events": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/event.schema.json"
+ }
+ },
+ "requests": {
+ "type": "array",
+ "items": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/request.schema.json"
+ }
+ },
+ "result": {
+ "$ref": "https://paperclip.dev/schemas/prp/v1/result.schema.json"
+ }
+ },
+ "additionalProperties": true
+} as const;
+
+export const prpSchemaBundle = {
+ identity: identitySchema,
+ capabilities: capabilitiesSchema,
+ command: commandSchema,
+ terminal: terminalSchema,
+ request: requestSchema,
+ result: resultSchema,
+ event: eventSchema,
+ fixture: fixtureSchema,
+} as const;
diff --git a/packages/paperclip-runner/src/protocol/phase1-contract.test.ts b/packages/paperclip-runner/src/protocol/phase1-contract.test.ts
new file mode 100644
index 00000000000..7597fde999e
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol/phase1-contract.test.ts
@@ -0,0 +1,142 @@
+import { readFile } from "node:fs/promises";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ negotiateProtocolVersion,
+ parsePrpFixtureText,
+ PRP_PROTOCOL_VERSION,
+} from "./phase1-contract.js";
+
+const fixtureDirectory = new URL("../../protocol/fixtures/phase-01/", import.meta.url);
+const validFixtures = [
+ "happy-path.json",
+ "failed-run.json",
+ "interrupted-run.json",
+ "duplicate-event.json",
+ "source-gap.json",
+ "unknown-optional-fields.json",
+];
+
+async function readFixture(name = "happy-path.json"): Promise> {
+ return JSON.parse(
+ await readFile(new URL(name, fixtureDirectory), "utf8"),
+ ) as Record;
+}
+
+describe("PRP v1 JSON Schema contract", () => {
+ for (const fixtureName of validFixtures) {
+ it(`validates ${fixtureName}`, async () => {
+ const result = parsePrpFixtureText(
+ await readFile(new URL(fixtureName, fixtureDirectory), "utf8"),
+ );
+ expect(result.ok).toBe(true);
+ });
+ }
+
+ it("preserves unknown optional fields for forward compatibility", async () => {
+ const result = parsePrpFixtureText(
+ await readFile(new URL("unknown-optional-fields.json", fixtureDirectory), "utf8"),
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.fixture.futureFixtureHint).toEqual({
+ producerVersion: "1.1-preview",
+ });
+ expect(result.fixture.events[0]?.futureEnvelopeField).toBe(42);
+ }
+ });
+
+ it("fails closed on an unsupported required protocol version", async () => {
+ const result = parsePrpFixtureText(
+ await readFile(new URL("unsupported-required-version.json", fixtureDirectory), "utf8"),
+ );
+ expect(result).toMatchObject({
+ ok: false,
+ issues: [
+ {
+ code: "unsupported_required_version",
+ path: "/protocolVersion",
+ },
+ ],
+ });
+ });
+
+ it("fails closed on unsupported nested required schema versions", async () => {
+ const fixture = await readFixture();
+ const events = fixture.events as Array>;
+ events[0]!.schemaVersion = 2;
+ expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({
+ ok: false,
+ issues: [
+ {
+ code: "unsupported_required_version",
+ path: "/events/0/schemaVersion",
+ },
+ ],
+ });
+ });
+
+ it("requires the declared result to match the replayed result event", async () => {
+ const fixture = await readFixture();
+ const result = fixture.result as Record;
+ result.summary = "A contradictory expected result.";
+ expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({
+ ok: false,
+ issues: [
+ {
+ code: "binding_mismatch",
+ path: "/result",
+ },
+ ],
+ });
+ });
+
+ it("rejects a duplicate event id carrying different content", async () => {
+ const fixture = await readFixture("duplicate-event.json");
+ const events = fixture.events as Array>;
+ const payload = events[3]!.payload as Record;
+ payload.text = "A mutated duplicate.";
+ expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({
+ ok: false,
+ issues: [
+ {
+ code: "binding_mismatch",
+ path: "/events/3/sourceEventId",
+ },
+ ],
+ });
+ });
+
+ it("requires exactly one unique terminal event", async () => {
+ const fixture = await readFixture();
+ const events = fixture.events as Array>;
+ fixture.events = events.filter((event) => event.eventType !== "run.terminal");
+ expect(parsePrpFixtureText(JSON.stringify(fixture))).toMatchObject({
+ ok: false,
+ issues: [
+ {
+ code: "binding_mismatch",
+ path: "/events",
+ },
+ ],
+ });
+ });
+
+ it("reports invalid JSON without throwing", () => {
+ expect(parsePrpFixtureText("{")).toMatchObject({
+ ok: false,
+ issues: [{ code: "invalid_json", path: "/" }],
+ });
+ });
+
+ it("selects only an overlapping supported protocol version", () => {
+ expect(
+ negotiateProtocolVersion(
+ { min: 1, max: PRP_PROTOCOL_VERSION },
+ { min: 1, max: 2 },
+ ),
+ ).toBe(1);
+ expect(negotiateProtocolVersion({ min: 2, max: 3 }, { min: 1, max: 1 })).toBeNull();
+ });
+});
diff --git a/packages/paperclip-runner/src/protocol/phase1-contract.ts b/packages/paperclip-runner/src/protocol/phase1-contract.ts
new file mode 100644
index 00000000000..ad2431f7c45
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol/phase1-contract.ts
@@ -0,0 +1,271 @@
+import {
+ Ajv2020,
+ type ErrorObject,
+ type ValidateFunction,
+} from "ajv/dist/2020.js";
+import type { FromSchema } from "json-schema-to-ts";
+
+import {
+ capabilitiesSchema,
+ commandSchema,
+ eventSchema,
+ fixtureSchema,
+ identitySchema,
+ prpSchemaBundle,
+ requestSchema,
+ resultSchema,
+ terminalSchema,
+} from "./generated/schema-bundle.js";
+
+export const PRP_PROTOCOL_NAME = "paperclip.runner";
+export const PRP_PROTOCOL_VERSION = 1;
+export const PRP_FIXTURE_SCHEMA = "paperclip.prp.fixture.v1";
+
+type EventReferences = [typeof terminalSchema, typeof resultSchema];
+export type PrpIdentity = FromSchema;
+export type PrpCapabilities = FromSchema;
+export type PrpCommand = FromSchema;
+export type PrpTerminalState = FromSchema;
+export type PrpRequest = FromSchema;
+export type PrpStructuredRunResult = FromSchema;
+export type PrpEvent = FromSchema<
+ typeof eventSchema,
+ { references: EventReferences }
+>;
+/** Runtime-validated composition of the JSON-Schema-derived contract types. */
+export interface PrpFixture {
+ schema: typeof PRP_FIXTURE_SCHEMA;
+ fixtureVersion: 1;
+ protocolVersion: typeof PRP_PROTOCOL_VERSION;
+ name: string;
+ description: string;
+ identity: PrpIdentity;
+ capabilities: PrpCapabilities;
+ commands: PrpCommand[];
+ events: PrpEvent[];
+ requests?: PrpRequest[];
+ result: PrpStructuredRunResult;
+ [key: string]: unknown;
+}
+
+export type ProtocolValidationIssueCode =
+ | "invalid_json"
+ | "schema_validation"
+ | "unsupported_required_version"
+ | "binding_mismatch";
+
+export interface ProtocolValidationIssue {
+ code: ProtocolValidationIssueCode;
+ path: string;
+ message: string;
+}
+
+export type ProtocolValidationResult =
+ | { ok: true; fixture: PrpFixture; issues: [] }
+ | { ok: false; fixture: null; issues: ProtocolValidationIssue[] };
+
+export interface ProtocolVersionRange {
+ min: number;
+ max: number;
+}
+
+const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });
+ajv.addFormat("date-time", {
+ type: "string",
+ validate(value: string) {
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value);
+ },
+});
+for (const schema of Object.values(prpSchemaBundle)) {
+ ajv.addSchema(schema);
+}
+
+const registeredFixtureValidator = ajv.getSchema(fixtureSchema.$id);
+if (registeredFixtureValidator === undefined) {
+ throw new Error("PRP fixture validator was not registered");
+}
+const fixtureValidator = registeredFixtureValidator as ValidateFunction;
+
+function asRecord(value: unknown): Record | null {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function canonicalJson(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalJson).join(",")}]`;
+ }
+ const object = asRecord(value);
+ if (object !== null) {
+ return `{${Object.keys(object)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value) ?? "undefined";
+}
+
+function versionIssues(value: unknown): ProtocolValidationIssue[] {
+ const fixture = asRecord(value);
+ if (fixture === null) {
+ return [];
+ }
+
+ const issues: ProtocolValidationIssue[] = [];
+ for (const [field, supported] of [
+ ["fixtureVersion", 1],
+ ["protocolVersion", PRP_PROTOCOL_VERSION],
+ ] as const) {
+ const actual = fixture[field];
+ if (typeof actual === "number" && actual !== supported) {
+ issues.push({
+ code: "unsupported_required_version",
+ path: `/${field}`,
+ message: `${field} ${actual} is unsupported; this implementation requires ${supported}`,
+ });
+ }
+ }
+
+ if (Array.isArray(fixture.events)) {
+ fixture.events.forEach((entry, index) => {
+ const event = asRecord(entry);
+ const actual = event?.schemaVersion;
+ if (typeof actual === "number" && actual !== 1) {
+ issues.push({
+ code: "unsupported_required_version",
+ path: `/events/${index}/schemaVersion`,
+ message: `event schemaVersion ${actual} is unsupported; this implementation requires 1`,
+ });
+ }
+ });
+ }
+ return issues;
+}
+
+function ajvIssue(error: ErrorObject): ProtocolValidationIssue {
+ return {
+ code: "schema_validation",
+ path: error.instancePath || "/",
+ message: error.message ?? "does not match the PRP schema",
+ };
+}
+
+function bindingIssues(fixture: PrpFixture): ProtocolValidationIssue[] {
+ const issues: ProtocolValidationIssue[] = [];
+ const uniqueEvents = new Map();
+ fixture.events.forEach((event, index) => {
+ if (event.runId !== fixture.identity.runId) {
+ issues.push({
+ code: "binding_mismatch",
+ path: `/events/${index}/runId`,
+ message: "event runId must match identity.runId",
+ });
+ }
+ if (
+ event.normalizedSessionId !== undefined &&
+ event.normalizedSessionId !== fixture.identity.normalizedSessionId
+ ) {
+ issues.push({
+ code: "binding_mismatch",
+ path: `/events/${index}/normalizedSessionId`,
+ message: "event normalizedSessionId must match identity.normalizedSessionId",
+ });
+ }
+ const existing = uniqueEvents.get(event.sourceEventId);
+ if (existing === undefined) {
+ uniqueEvents.set(event.sourceEventId, event);
+ } else if (canonicalJson(existing) !== canonicalJson(event)) {
+ issues.push({
+ code: "binding_mismatch",
+ path: `/events/${index}/sourceEventId`,
+ message: "duplicate sourceEventId deliveries must be byte-equivalent",
+ });
+ }
+ });
+
+ fixture.commands.forEach((command, index) => {
+ if (command.controllerSeq !== index + 1) {
+ issues.push({
+ code: "binding_mismatch",
+ path: `/commands/${index}/controllerSeq`,
+ message: `controllerSeq must be ${index + 1} in a scripted fixture`,
+ });
+ }
+ });
+
+ const distinctEvents = [...uniqueEvents.values()];
+ const proposedResults = distinctEvents.filter(
+ (event) => event.eventType === "run.result.proposed",
+ );
+ if (proposedResults.length !== 1) {
+ issues.push({
+ code: "binding_mismatch",
+ path: "/events",
+ message: "scripted fixtures must contain exactly one unique run.result.proposed event",
+ });
+ } else if (canonicalJson(proposedResults[0]?.payload) !== canonicalJson(fixture.result)) {
+ issues.push({
+ code: "binding_mismatch",
+ path: "/result",
+ message: "fixture result must match the run.result.proposed event payload",
+ });
+ }
+
+ const terminalEvents = distinctEvents.filter(
+ (event) => event.eventType === "run.terminal",
+ );
+ if (terminalEvents.length !== 1) {
+ issues.push({
+ code: "binding_mismatch",
+ path: "/events",
+ message: "scripted fixtures must contain exactly one unique run.terminal event",
+ });
+ }
+ return issues;
+}
+
+export function validatePrpFixture(value: unknown): ProtocolValidationResult {
+ const unsupported = versionIssues(value);
+ if (unsupported.length > 0) {
+ return { ok: false, fixture: null, issues: unsupported };
+ }
+ if (!fixtureValidator(value)) {
+ return {
+ ok: false,
+ fixture: null,
+ issues: (fixtureValidator.errors ?? []).map(ajvIssue),
+ };
+ }
+
+ const bindings = bindingIssues(value);
+ return bindings.length === 0
+ ? { ok: true, fixture: value, issues: [] }
+ : { ok: false, fixture: null, issues: bindings };
+}
+
+export function parsePrpFixtureText(text: string): ProtocolValidationResult {
+ try {
+ return validatePrpFixture(JSON.parse(text) as unknown);
+ } catch (error) {
+ return {
+ ok: false,
+ fixture: null,
+ issues: [
+ {
+ code: "invalid_json",
+ path: "/",
+ message: error instanceof Error ? error.message : "fixture is not valid JSON",
+ },
+ ],
+ };
+ }
+}
+
+export function negotiateProtocolVersion(
+ runner: ProtocolVersionRange,
+ controller: ProtocolVersionRange,
+): number | null {
+ const selected = Math.min(runner.max, controller.max);
+ return selected >= Math.max(runner.min, controller.min) ? selected : null;
+}
diff --git a/packages/paperclip-runner/src/protocol/phase1-loader.ts b/packages/paperclip-runner/src/protocol/phase1-loader.ts
new file mode 100644
index 00000000000..f5bfac54fdd
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol/phase1-loader.ts
@@ -0,0 +1,32 @@
+import { readFile } from "node:fs/promises";
+
+import {
+ parsePrpFixtureText,
+ type ProtocolValidationIssue,
+ type PrpFixture,
+} from "./phase1-contract.js";
+
+export const phase1HappyFixtureUrl = new URL(
+ "../../protocol/fixtures/phase-01/happy-path.json",
+ import.meta.url,
+);
+
+export class PrpFixtureValidationError extends Error {
+ readonly issues: ProtocolValidationIssue[];
+
+ constructor(issues: ProtocolValidationIssue[]) {
+ super(issues.map((issue) => `${issue.path}: ${issue.message}`).join("; "));
+ this.name = "PrpFixtureValidationError";
+ this.issues = issues;
+ }
+}
+
+export async function loadPrpFixture(
+ url: URL = phase1HappyFixtureUrl,
+): Promise {
+ const result = parsePrpFixtureText(await readFile(url, "utf8"));
+ if (!result.ok) {
+ throw new PrpFixtureValidationError(result.issues);
+ }
+ return result.fixture;
+}
diff --git a/packages/paperclip-runner/src/reducer/session-reducer.test.ts b/packages/paperclip-runner/src/reducer/session-reducer.test.ts
new file mode 100644
index 00000000000..8d3c0c7624f
--- /dev/null
+++ b/packages/paperclip-runner/src/reducer/session-reducer.test.ts
@@ -0,0 +1,73 @@
+import { readFile } from "node:fs/promises";
+
+import { describe, expect, it } from "vitest";
+
+import { parsePrpFixtureText, type PrpFixture } from "../protocol/phase1-contract.js";
+import {
+ reducePrpFixture,
+ reduceSessionEvents,
+ type SessionSnapshot,
+} from "./session-reducer.js";
+
+const fixtureDirectory = new URL("../../protocol/fixtures/phase-01/", import.meta.url);
+const fixtureNames = [
+ "happy-path",
+ "failed-run",
+ "interrupted-run",
+ "duplicate-event",
+ "source-gap",
+ "unknown-optional-fields",
+];
+
+async function loadFixture(name: string): Promise {
+ const result = parsePrpFixtureText(
+ await readFile(new URL(`${name}.json`, fixtureDirectory), "utf8"),
+ );
+ if (!result.ok) {
+ throw new Error(result.issues.map((issue) => issue.message).join("; "));
+ }
+ return result.fixture;
+}
+
+async function loadGolden(name: string): Promise {
+ return JSON.parse(
+ await readFile(new URL(`golden/${name}.snapshot.json`, fixtureDirectory), "utf8"),
+ ) as SessionSnapshot;
+}
+
+describe("deterministic PRP session reducer", () => {
+ for (const fixtureName of fixtureNames) {
+ it(`matches the ${fixtureName} golden snapshot`, async () => {
+ expect(reducePrpFixture(await loadFixture(fixtureName))).toEqual(
+ await loadGolden(fixtureName),
+ );
+ });
+ }
+
+ it("is deterministic and idempotent when the same batch is replayed", async () => {
+ const fixture = await loadFixture("happy-path");
+ const first = reducePrpFixture(fixture);
+ expect(reducePrpFixture(fixture)).toEqual(first);
+ expect(reduceSessionEvents(first, fixture.events)).toEqual(first);
+ });
+
+ it("deduplicates at-least-once delivery before projection effects", async () => {
+ const snapshot = reducePrpFixture(await loadFixture("duplicate-event"));
+ expect(snapshot.duplicateEventIds).toEqual(["event_duplicate_03"]);
+ expect(snapshot.items).toHaveLength(1);
+ expect(snapshot.timeline).toHaveLength(6);
+ });
+
+ it("records a source cursor gap without inventing the missing event", async () => {
+ const snapshot = reducePrpFixture(await loadFixture("source-gap"));
+ expect(snapshot.integrity).toBe("gap_detected");
+ expect(snapshot.gaps).toEqual([
+ {
+ sourceKey: "runner:runner_phase1",
+ expected: 3,
+ received: 4,
+ missing: [3],
+ },
+ ]);
+ });
+});
diff --git a/packages/paperclip-runner/src/reducer/session-reducer.ts b/packages/paperclip-runner/src/reducer/session-reducer.ts
new file mode 100644
index 00000000000..16b54225309
--- /dev/null
+++ b/packages/paperclip-runner/src/reducer/session-reducer.ts
@@ -0,0 +1,354 @@
+import type {
+ PrpCapabilities,
+ PrpEvent,
+ PrpFixture,
+ PrpIdentity,
+ PrpStructuredRunResult,
+ PrpTerminalState,
+} from "../protocol/phase1-contract.js";
+
+export interface SessionTimelineEntry {
+ position: number;
+ sourceEventId: string;
+ sourceSeq: number;
+ eventType: PrpEvent["eventType"];
+ emittedAt: string;
+ itemId?: string;
+ summary: string;
+}
+
+export interface SessionItemSnapshot {
+ itemId: string;
+ kind: string;
+ status: "running" | "completed" | "failed";
+ text: string;
+}
+
+export interface SessionRequestSnapshot {
+ requestId: string;
+ requestKind: string;
+ status: string;
+ prompt: string;
+}
+
+export interface SequenceGap {
+ sourceKey: string;
+ expected: number;
+ received: number;
+ missing: number[];
+}
+
+export interface SessionSnapshot {
+ schema: "paperclip.prp.session-snapshot.v1";
+ fixtureName: string;
+ identity: PrpIdentity;
+ capabilities: PrpCapabilities;
+ runPhase: string;
+ sessionState: "not_started" | "running" | "closed" | "failed";
+ turnState:
+ | "not_started"
+ | "running"
+ | "completed"
+ | "failed"
+ | "interrupted"
+ | "cancelled";
+ activeTurnId: string | null;
+ items: SessionItemSnapshot[];
+ requests: SessionRequestSnapshot[];
+ proposedResult: PrpStructuredRunResult | null;
+ terminal: PrpTerminalState | null;
+ timeline: SessionTimelineEntry[];
+ sourceCursors: Record;
+ processedEventIds: string[];
+ duplicateEventIds: string[];
+ outOfOrderEventIds: string[];
+ gaps: SequenceGap[];
+ integrity: "complete" | "gap_detected";
+}
+
+function record(value: unknown): Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function stringValue(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
+
+function sourceKey(event: PrpEvent): string {
+ return `${event.sourceKind}:${event.sourceInstanceId}`;
+}
+
+function timelineSummary(event: PrpEvent): string {
+ const payload = record(event.payload);
+ if (event.eventType === "runtime.phase.changed") {
+ return `Phase changed to ${stringValue(payload.phase, "unknown")}`;
+ }
+ if (event.eventType === "item.started") {
+ return `Started ${stringValue(payload.kind, "item")}`;
+ }
+ if (event.eventType === "item.delta") {
+ return stringValue(payload.text, "Item updated");
+ }
+ if (event.eventType === "item.completed") {
+ return `Completed ${stringValue(payload.kind, "item")}`;
+ }
+ if (event.eventType === "item.failed") {
+ return stringValue(payload.message, "Item failed");
+ }
+ if (event.eventType === "run.result.proposed") {
+ return stringValue(payload.summary, "Structured result proposed");
+ }
+ if (event.eventType === "run.terminal") {
+ return `Run ${stringValue(payload.runTerminalState, "terminal")}`;
+ }
+ return event.eventType.replaceAll(".", " ");
+}
+
+function upsertItem(
+ items: SessionItemSnapshot[],
+ event: PrpEvent,
+ status: SessionItemSnapshot["status"],
+): void {
+ if (event.itemId === undefined) {
+ return;
+ }
+ const payload = record(event.payload);
+ const existing = items.find((item) => item.itemId === event.itemId);
+ const text = stringValue(payload.text);
+ if (existing === undefined) {
+ items.push({
+ itemId: event.itemId,
+ kind: stringValue(payload.kind, "diagnostic"),
+ status,
+ text,
+ });
+ return;
+ }
+ existing.kind = stringValue(payload.kind, existing.kind);
+ existing.status = status;
+ if (event.eventType === "item.delta") {
+ existing.text += text;
+ } else if (text.length > 0) {
+ existing.text = text;
+ }
+}
+
+function applyProjection(snapshot: SessionSnapshot, event: PrpEvent): void {
+ const payload = record(event.payload);
+ switch (event.eventType) {
+ case "runtime.phase.changed":
+ snapshot.runPhase = stringValue(payload.phase, snapshot.runPhase);
+ break;
+ case "session.started":
+ case "session.resumed":
+ snapshot.sessionState = "running";
+ break;
+ case "session.closed":
+ snapshot.sessionState = "closed";
+ break;
+ case "session.failed":
+ snapshot.sessionState = "failed";
+ break;
+ case "turn.started":
+ snapshot.turnState = "running";
+ snapshot.activeTurnId = event.turnId ?? null;
+ break;
+ case "turn.completed":
+ snapshot.turnState = "completed";
+ snapshot.activeTurnId = null;
+ break;
+ case "turn.failed":
+ snapshot.turnState = "failed";
+ snapshot.activeTurnId = null;
+ break;
+ case "turn.interrupted":
+ snapshot.turnState = "interrupted";
+ snapshot.activeTurnId = null;
+ break;
+ case "turn.cancelled":
+ snapshot.turnState = "cancelled";
+ snapshot.activeTurnId = null;
+ break;
+ case "item.started":
+ case "item.delta":
+ upsertItem(snapshot.items, event, "running");
+ break;
+ case "item.completed":
+ upsertItem(snapshot.items, event, "completed");
+ break;
+ case "item.failed":
+ upsertItem(snapshot.items, event, "failed");
+ break;
+ case "runtime_request.created": {
+ const request = record(payload.request ?? payload);
+ const requestId = stringValue(request.requestId);
+ if (
+ requestId.length > 0 &&
+ !snapshot.requests.some((candidate) => candidate.requestId === requestId)
+ ) {
+ snapshot.requests.push({
+ requestId,
+ requestKind: stringValue(request.requestKind, "runtime"),
+ status: stringValue(request.status, "pending"),
+ prompt: stringValue(request.prompt),
+ });
+ }
+ break;
+ }
+ case "runtime_request.resolved":
+ case "runtime_request.expired":
+ case "runtime_request.cancelled": {
+ const requestId = stringValue(payload.requestId);
+ const request = snapshot.requests.find((candidate) => candidate.requestId === requestId);
+ if (request !== undefined) {
+ request.status = event.eventType.split(".").at(-1) ?? request.status;
+ }
+ break;
+ }
+ case "run.result.proposed":
+ snapshot.proposedResult = structuredClone(
+ event.payload as PrpStructuredRunResult,
+ );
+ break;
+ case "run.terminal":
+ snapshot.terminal = structuredClone(event.payload as PrpTerminalState);
+ snapshot.runPhase = "terminal";
+ break;
+ }
+}
+
+function addGap(snapshot: SessionSnapshot, event: PrpEvent, expected: number): void {
+ const key = sourceKey(event);
+ if (
+ snapshot.gaps.some(
+ (gap) => gap.sourceKey === key && gap.expected === expected && gap.received === event.sourceSeq,
+ )
+ ) {
+ return;
+ }
+ snapshot.gaps.push({
+ sourceKey: key,
+ expected,
+ received: event.sourceSeq,
+ missing: Array.from(
+ { length: event.sourceSeq - expected },
+ (_, index) => expected + index,
+ ),
+ });
+}
+
+export function createSessionSnapshot(fixture: PrpFixture): SessionSnapshot {
+ return {
+ schema: "paperclip.prp.session-snapshot.v1",
+ fixtureName: fixture.name,
+ identity: structuredClone(fixture.identity),
+ capabilities: structuredClone(fixture.capabilities),
+ runPhase: "queued",
+ sessionState: "not_started",
+ turnState: "not_started",
+ activeTurnId: null,
+ items: [],
+ requests: [],
+ proposedResult: null,
+ terminal: null,
+ timeline: [],
+ sourceCursors: {},
+ processedEventIds: [],
+ duplicateEventIds: [],
+ outOfOrderEventIds: [],
+ gaps: [],
+ integrity: "complete",
+ };
+}
+
+export function applyPrpEvent(
+ current: SessionSnapshot,
+ event: PrpEvent,
+): SessionSnapshot {
+ if (current.processedEventIds.includes(event.sourceEventId)) {
+ return current;
+ }
+
+ const snapshot = structuredClone(current);
+ const key = sourceKey(event);
+ const cursor = snapshot.sourceCursors[key] ?? 0;
+ const expected = cursor + 1;
+ if (event.sourceSeq <= cursor) {
+ if (!snapshot.outOfOrderEventIds.includes(event.sourceEventId)) {
+ snapshot.outOfOrderEventIds.push(event.sourceEventId);
+ }
+ snapshot.integrity = "gap_detected";
+ return snapshot;
+ }
+ if (event.sourceSeq > expected) {
+ addGap(snapshot, event, expected);
+ }
+
+ snapshot.sourceCursors[key] = event.sourceSeq;
+ snapshot.processedEventIds.push(event.sourceEventId);
+ snapshot.timeline.push({
+ position: snapshot.timeline.length + 1,
+ sourceEventId: event.sourceEventId,
+ sourceSeq: event.sourceSeq,
+ eventType: event.eventType,
+ emittedAt: event.emittedAt,
+ ...(event.itemId === undefined ? {} : { itemId: event.itemId }),
+ summary: timelineSummary(event),
+ });
+ applyProjection(snapshot, event);
+ snapshot.integrity =
+ snapshot.gaps.length > 0 || snapshot.outOfOrderEventIds.length > 0
+ ? "gap_detected"
+ : "complete";
+ return snapshot;
+}
+
+export function reduceSessionEvents(
+ current: SessionSnapshot,
+ events: readonly PrpEvent[],
+): SessionSnapshot {
+ const counts = new Map();
+ for (const event of events) {
+ counts.set(event.sourceEventId, (counts.get(event.sourceEventId) ?? 0) + 1);
+ }
+
+ let snapshot = structuredClone(current);
+ for (const [eventId, count] of counts) {
+ if (count > 1 && !snapshot.duplicateEventIds.includes(eventId)) {
+ snapshot.duplicateEventIds.push(eventId);
+ }
+ }
+ snapshot.duplicateEventIds.sort();
+ for (const event of events) {
+ snapshot = applyPrpEvent(snapshot, event);
+ }
+ return snapshot;
+}
+
+export function reducePrpFixture(fixture: PrpFixture): SessionSnapshot {
+ return reduceSessionEvents(createSessionSnapshot(fixture), fixture.events);
+}
+
+export interface Phase1ParitySummary {
+ runId: string;
+ integrity: SessionSnapshot["integrity"];
+ timelineCount: number;
+ duplicateEventIds: string[];
+ gaps: SequenceGap[];
+ turnTerminalState: string | null;
+ runTerminalState: string | null;
+}
+
+export function phase1ParitySummary(snapshot: SessionSnapshot): Phase1ParitySummary {
+ return {
+ runId: snapshot.identity.runId,
+ integrity: snapshot.integrity,
+ timelineCount: snapshot.timeline.length,
+ duplicateEventIds: [...snapshot.duplicateEventIds],
+ gaps: structuredClone(snapshot.gaps),
+ turnTerminalState: snapshot.terminal?.turnTerminalState ?? null,
+ runTerminalState: snapshot.terminal?.runTerminalState ?? null,
+ };
+}
diff --git a/packages/paperclip-runner/src/tracer/phase1-replay.test.ts b/packages/paperclip-runner/src/tracer/phase1-replay.test.ts
new file mode 100644
index 00000000000..0d2c94c0c98
--- /dev/null
+++ b/packages/paperclip-runner/src/tracer/phase1-replay.test.ts
@@ -0,0 +1,23 @@
+import { readFile } from "node:fs/promises";
+
+import { describe, expect, it } from "vitest";
+
+import { formatPhase1Replay, replayPhase1FixtureText } from "./phase1-replay.js";
+
+describe("Phase 1 replay tracer", () => {
+ it("formats the validated reducer snapshot for CLI consumers", async () => {
+ const source = await readFile(
+ new URL("../../protocol/fixtures/phase-01/happy-path.json", import.meta.url),
+ "utf8",
+ );
+ const result = replayPhase1FixtureText(source);
+ expect(result.ok).toBe(true);
+ expect(JSON.parse(formatPhase1Replay(result))).toMatchObject({
+ ok: true,
+ snapshot: {
+ integrity: "complete",
+ terminal: { runTerminalState: "succeeded" },
+ },
+ });
+ });
+});
diff --git a/packages/paperclip-runner/src/tracer/phase1-replay.ts b/packages/paperclip-runner/src/tracer/phase1-replay.ts
new file mode 100644
index 00000000000..d2eafdd4fbf
--- /dev/null
+++ b/packages/paperclip-runner/src/tracer/phase1-replay.ts
@@ -0,0 +1,21 @@
+import {
+ parsePrpFixtureText,
+ type ProtocolValidationIssue,
+} from "../protocol/phase1-contract.js";
+import { reducePrpFixture, type SessionSnapshot } from "../reducer/session-reducer.js";
+
+export type Phase1ReplayResult =
+ | { ok: true; snapshot: SessionSnapshot; issues: [] }
+ | { ok: false; snapshot: null; issues: ProtocolValidationIssue[] };
+
+export function replayPhase1FixtureText(text: string): Phase1ReplayResult {
+ const validation = parsePrpFixtureText(text);
+ if (!validation.ok) {
+ return { ok: false, snapshot: null, issues: validation.issues };
+ }
+ return { ok: true, snapshot: reducePrpFixture(validation.fixture), issues: [] };
+}
+
+export function formatPhase1Replay(result: Phase1ReplayResult): string {
+ return JSON.stringify(result, null, 2);
+}
diff --git a/packages/paperclip-runner/tsconfig.browser.json b/packages/paperclip-runner/tsconfig.browser.json
new file mode 100644
index 00000000000..81f66f96486
--- /dev/null
+++ b/packages/paperclip-runner/tsconfig.browser.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "rootDir": ".",
+ "types": ["vite/client"]
+ },
+ "include": ["devtools/browser/src/**/*.ts", "devtools/browser/src/**/*.tsx"]
+}
diff --git a/packages/paperclip-runner/vite.config.ts b/packages/paperclip-runner/vite.config.ts
new file mode 100644
index 00000000000..49a825967e7
--- /dev/null
+++ b/packages/paperclip-runner/vite.config.ts
@@ -0,0 +1,20 @@
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+const packageRoot = fileURLToPath(new URL(".", import.meta.url));
+
+export default defineConfig({
+ root: resolve(packageRoot, "devtools/browser"),
+ plugins: [react()],
+ build: {
+ outDir: resolve(packageRoot, "dist-browser"),
+ emptyOutDir: true,
+ // The standalone devtool targets current evergreen browsers. Keeping the
+ // emitted syntax modern also avoids asking Vite to downlevel Ajv's runtime
+ // validator, which recent esbuild releases intentionally do not support.
+ target: "esnext",
+ },
+});