From baa5237e2e5388d43f92eacbf0fe2c5b7d2403c5 Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 00:04:42 +0300 Subject: [PATCH 1/3] feat: add replay-backed flight recorder --- extension/src/agent/driver.ts | 9 + extension/src/agent/replay-driver.ts | 133 ++++++++ extension/src/agent/types.ts | 163 ++++++++++ extension/src/recorder/events.ts | 250 +++++++++++++++ extension/src/recorder/store.ts | 185 +++++++++++ .../fixtures/v0.3/agent-replay/succeeded.json | 163 ++++++++++ extension/test/flight-recorder.test.ts | 286 ++++++++++++++++++ 7 files changed, 1189 insertions(+) create mode 100644 extension/src/agent/driver.ts create mode 100644 extension/src/agent/replay-driver.ts create mode 100644 extension/src/agent/types.ts create mode 100644 extension/src/recorder/events.ts create mode 100644 extension/src/recorder/store.ts create mode 100644 extension/test/fixtures/v0.3/agent-replay/succeeded.json create mode 100644 extension/test/flight-recorder.test.ts diff --git a/extension/src/agent/driver.ts b/extension/src/agent/driver.ts new file mode 100644 index 0000000..b76ed44 --- /dev/null +++ b/extension/src/agent/driver.ts @@ -0,0 +1,9 @@ +import type { RunEnvelope } from "../recorder/events"; +import type { AgentRun, AgentTask, AgentWorkspace } from "./types"; + +export interface AgentDriver { + start(task: AgentTask, workspace: AgentWorkspace): Promise; + followUp(runId: string, message: string): Promise; + cancel(runId: string): Promise; + events(runId: string): AsyncIterable; +} diff --git a/extension/src/agent/replay-driver.ts b/extension/src/agent/replay-driver.ts new file mode 100644 index 0000000..17e183c --- /dev/null +++ b/extension/src/agent/replay-driver.ts @@ -0,0 +1,133 @@ +import { canonicalJson } from "../rnd/canonical"; +import { + assertRunSequence, + type RunEnvelope, +} from "../recorder/events"; +import type { RunRecorder, TaskStore } from "../recorder/store"; +import type { AgentDriver } from "./driver"; +import { + assertAgentRun, + assertAgentTask, + assertAgentWorkspace, + assertObjectShape, + assertRecord, + assertSchemaVersion, + assertToken, + taskIntentHash, + type AgentRun, + type AgentTask, + type AgentWorkspace, + type SchemaVersion, +} from "./types"; + +export interface ReplayTranscript { + schemaVersion: SchemaVersion; + driverId: string; + task: AgentTask; + run: AgentRun; + events: RunEnvelope[]; +} + +export class ReplayDriver implements AgentDriver { + private readonly transcript: ReplayTranscript; + private readonly started = new Set(); + + constructor( + transcript: ReplayTranscript, + private readonly recorder: RunRecorder, + private readonly tasks: TaskStore, + ) { + assertReplayTranscript(transcript); + this.transcript = structuredClone(transcript); + } + + async start(task: AgentTask, workspace: AgentWorkspace): Promise { + assertAgentTask(task); + assertAgentWorkspace(workspace); + const expected = this.transcript; + if (canonicalJson(task) !== canonicalJson(expected.task)) throw new Error("Replay task does not match the checked transcript"); + if (workspace.projectId !== expected.run.projectId || workspace.baseRevision !== expected.run.baseRevision) { + throw new Error("Replay workspace does not match the checked transcript"); + } + + const intentHash = await this.tasks.put(workspace.projectId, task); + const started = expected.events[0]!.event; + if (started.type !== "task.started" || started.intentHash !== intentHash) { + throw new Error("Replay task intent hash does not match task.started"); + } + + const existing = await collect(this.recorder.read(expected.run.runId)); + if (existing.length === 0) { + for (const event of expected.events) await this.recorder.append(event); + } else if (canonicalJson(existing) !== canonicalJson(expected.events)) { + throw new Error("Existing Flight Recorder data does not match the checked replay transcript"); + } + + this.started.add(expected.run.runId); + return structuredClone(expected.run); + } + + async followUp(runId: string, _message: string): Promise { + this.assertKnown(runId); + throw new Error("Replay driver is read-only and cannot accept follow-up messages"); + } + + async cancel(runId: string): Promise { + this.assertKnown(runId); + throw new Error("Replay run is already finished"); + } + + async *events(runId: string): AsyncIterable { + this.assertKnown(runId); + yield* this.recorder.read(runId); + } + + private assertKnown(runId: string): void { + assertToken(runId, "runId"); + if (!this.started.has(runId)) throw new Error(`Unknown replay run: ${runId}`); + } +} + +export function assertReplayTranscript(value: unknown): asserts value is ReplayTranscript { + assertRecord(value, "replay transcript"); + assertObjectShape(value, ["schemaVersion", "driverId", "task", "run", "events"], [], "replay transcript"); + assertSchemaVersion(value.schemaVersion); + assertToken(value.driverId, "driverId"); + if (value.driverId !== "pureflow.replay.v1") throw new Error("Unsupported replay driverId"); + assertAgentTask(value.task); + assertAgentRun(value.run); + if (!Array.isArray(value.events) || value.events.length < 2) throw new Error("Replay transcript requires events"); + assertRunSequence(value.events as RunEnvelope[]); + + const transcript = value as unknown as ReplayTranscript; + const first = transcript.events[0]!; + const terminal = transcript.events.at(-1)!; + if ( + first.projectId !== transcript.run.projectId || + first.runId !== transcript.run.runId || + transcript.run.driverId !== transcript.driverId || + transcript.run.taskId !== transcript.task.taskId + ) { + throw new Error("Replay transcript identity mismatch"); + } + if (first.event.type !== "task.started") throw new Error("Replay transcript must start with task.started"); + if (first.event.taskId !== transcript.task.taskId || first.event.baseRevision !== transcript.run.baseRevision) { + throw new Error("Replay task.started mismatch"); + } + if (first.event.intentHash !== taskIntentHash(transcript.task)) throw new Error("Replay task intent hash mismatch"); + if (terminal.event.type !== "run.finished" || terminal.event.status !== transcript.run.status) { + throw new Error("Replay terminal status mismatch"); + } + if (terminal.event.targetRevision !== transcript.run.targetRevision) { + throw new Error("Replay target revision mismatch"); + } + if (first.at !== transcript.run.startedAt || terminal.at !== transcript.run.finishedAt) { + throw new Error("Replay run timestamps do not match its boundary events"); + } +} + +async function collect(iterable: AsyncIterable): Promise { + const events: RunEnvelope[] = []; + for await (const event of iterable) events.push(event); + return events; +} diff --git a/extension/src/agent/types.ts b/extension/src/agent/types.ts new file mode 100644 index 0000000..0c3a928 --- /dev/null +++ b/extension/src/agent/types.ts @@ -0,0 +1,163 @@ +import { canonicalHash, assertExactKeys } from "../rnd/canonical"; + +export type SchemaVersion = 1; +export type AgentRunStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled"; + +export interface AgentTask { + schemaVersion: SchemaVersion; + taskId: string; + intent: { + summary: string; + acceptance: string[]; + }; + requestedAt: string; +} + +export interface AgentWorkspace { + schemaVersion: SchemaVersion; + projectId: string; + worktreeId: string; + baseRevision: string; + trusted: boolean; + localHandle: string; +} + +export interface AgentRun { + schemaVersion: SchemaVersion; + runId: string; + taskId: string; + projectId: string; + driverId: string; + baseRevision: string; + status: AgentRunStatus; + startedAt?: string; + finishedAt?: string; + targetRevision?: string; +} + +export function assertAgentTask(value: unknown): asserts value is AgentTask { + assertRecord(value, "agent task"); + assertExactKeys(value, ["schemaVersion", "taskId", "intent", "requestedAt"], "agent task"); + assertSchemaVersion(value.schemaVersion); + assertToken(value.taskId, "taskId"); + assertIsoTime(value.requestedAt, "requestedAt"); + assertRecord(value.intent, "task intent"); + assertExactKeys(value.intent, ["summary", "acceptance"], "task intent"); + assertBoundedText(value.intent.summary, 4096, "task summary"); + if (!Array.isArray(value.intent.acceptance) || value.intent.acceptance.length > 32) { + throw new Error("Task acceptance must contain at most 32 items"); + } + for (const item of value.intent.acceptance) assertBoundedText(item, 1024, "acceptance item"); +} + +export function assertAgentWorkspace(value: unknown): asserts value is AgentWorkspace { + assertRecord(value, "agent workspace"); + assertExactKeys( + value, + ["schemaVersion", "projectId", "worktreeId", "baseRevision", "trusted", "localHandle"], + "agent workspace", + ); + assertSchemaVersion(value.schemaVersion); + assertToken(value.projectId, "projectId"); + assertToken(value.worktreeId, "worktreeId"); + assertGitOid(value.baseRevision, "baseRevision"); + if (typeof value.trusted !== "boolean") throw new Error("trusted must be boolean"); + assertBoundedText(value.localHandle, 4096, "localHandle"); +} + +export function assertAgentRun(value: unknown): asserts value is AgentRun { + assertRecord(value, "agent run"); + assertObjectShape( + value, + ["schemaVersion", "runId", "taskId", "projectId", "driverId", "baseRevision", "status"], + ["startedAt", "finishedAt", "targetRevision"], + "agent run", + ); + assertSchemaVersion(value.schemaVersion); + assertToken(value.runId, "runId"); + assertToken(value.taskId, "taskId"); + assertToken(value.projectId, "projectId"); + assertToken(value.driverId, "driverId"); + assertGitOid(value.baseRevision, "baseRevision"); + if (!["queued", "running", "succeeded", "failed", "cancelled"].includes(String(value.status))) { + throw new Error("Unknown agent run status"); + } + + if (value.startedAt !== undefined) assertIsoTime(value.startedAt, "startedAt"); + if (value.finishedAt !== undefined) assertIsoTime(value.finishedAt, "finishedAt"); + if (value.targetRevision !== undefined) assertGitOid(value.targetRevision, "targetRevision"); + + if (value.status === "running" && value.startedAt === undefined) { + throw new Error("A running agent run requires startedAt"); + } + if (["succeeded", "failed", "cancelled"].includes(value.status as string)) { + if (value.startedAt === undefined || value.finishedAt === undefined) { + throw new Error("A terminal agent run requires startedAt and finishedAt"); + } + } + if (value.status === "succeeded" && value.targetRevision === undefined) { + throw new Error("A succeeded agent run requires targetRevision"); + } + if (value.status === "queued" && (value.startedAt !== undefined || value.finishedAt !== undefined)) { + throw new Error("A queued agent run cannot have timestamps"); + } +} + +export function taskIntentHash(task: AgentTask): string { + assertAgentTask(task); + return canonicalHash("task-intent", task); +} + +export function assertSchemaVersion(value: unknown): asserts value is SchemaVersion { + if (value !== 1) throw new Error("schemaVersion must be 1"); +} + +export function assertToken(value: unknown, label: string, maxBytes = 128): asserts value is string { + if (typeof value !== "string" || !/^[A-Za-z0-9._@+-]+$/.test(value) || Buffer.byteLength(value) > maxBytes) { + throw new Error(`${label} must be a bounded opaque identifier`); + } +} + +export function assertIsoTime(value: unknown, label: string): asserts value is string { + if ( + typeof value !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) || + Number.isNaN(Date.parse(value)) + ) { + throw new Error(`${label} must be UTC ISO-8601 with milliseconds`); + } +} + +export function assertGitOid(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value)) { + throw new Error(`${label} must be a full lowercase Git object ID`); + } +} + +export function assertBoundedText(value: unknown, maxBytes: number, label: string): asserts value is string { + if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > maxBytes) { + throw new Error(`${label} exceeds ${maxBytes} UTF-8 bytes`); + } +} + +export function assertRecord(value: unknown, label: string): asserts value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + throw new Error(`${label} must be a plain object`); + } +} + +export function assertObjectShape( + value: Record, + required: readonly string[], + optional: readonly string[], + label: string, +): void { + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + if ( + required.some((key) => !Object.prototype.hasOwnProperty.call(value, key)) || + keys.some((key) => typeof key !== "string" || !allowed.has(key)) + ) { + throw new Error(`${label} contains unknown or missing fields`); + } +} diff --git a/extension/src/recorder/events.ts b/extension/src/recorder/events.ts new file mode 100644 index 0000000..5e8fb6e --- /dev/null +++ b/extension/src/recorder/events.ts @@ -0,0 +1,250 @@ +import { assertExactKeys, assertRelPath, assertSha256, canonicalJson, compareUtf8 } from "../rnd/canonical"; +import { + assertBoundedText, + assertGitOid, + assertIsoTime, + assertObjectShape, + assertRecord, + assertSchemaVersion, + assertToken, + type SchemaVersion, +} from "../agent/types"; + +export type EvidenceKind = "diff" | "command-output" | "test-output" | "trace" | "plan" | "artifact"; +export type EvidenceVisibility = "controller" | "participant" | "oracle"; + +export interface EvidenceRef { + id: string; + kind: EvidenceKind; + sha256: string; + storedBytes: number; + originalBytes: number; + truncated: boolean; + redactions: Array<{ ruleId: string; count: number }>; + mediaType: string; + visibility: EvidenceVisibility; +} + +export type RunEvent = + | { type: "task.started"; taskId: string; baseRevision: string; intentHash: string } + | { type: "plan.recorded"; plan: EvidenceRef } + | { type: "file.changed"; path: string; beforeSha256: string; afterSha256: string; diff: EvidenceRef } + | { type: "command.started"; executionId: string; commandId: string } + | { + type: "command.finished"; + executionId: string; + commandId: string; + exitCode: number | null; + timedOut: boolean; + cancelled: boolean; + output: EvidenceRef; + } + | { + type: "test.finished"; + executionId: string; + commandId: string; + testId: string; + status: "passed" | "failed" | "skipped"; + output: EvidenceRef; + } + | { type: "run.finished"; status: "succeeded" | "failed" | "cancelled"; targetRevision?: string }; + +export interface RunEnvelope { + schemaVersion: SchemaVersion; + projectId: string; + runId: string; + seq: number; + at: string; + event: RunEvent; +} + +export function serializeRunEnvelope(value: RunEnvelope): string { + assertRunEnvelope(value); + return canonicalJson(value); +} + +export function parseRunEnvelope(value: string): RunEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error("Flight Recorder line is not valid JSON"); + } + assertRunEnvelope(parsed); + if (canonicalJson(parsed) !== value) throw new Error("Flight Recorder line is not canonical JSON"); + return parsed; +} + +export function assertRunEnvelope(value: unknown): asserts value is RunEnvelope { + assertRecord(value, "Flight Recorder envelope"); + assertExactKeys(value, ["schemaVersion", "projectId", "runId", "seq", "at", "event"], "Flight Recorder envelope"); + assertSchemaVersion(value.schemaVersion); + assertToken(value.projectId, "projectId"); + assertToken(value.runId, "runId"); + if (!Number.isSafeInteger(value.seq) || (value.seq as number) < 1) throw new Error("Flight Recorder seq must be positive"); + assertIsoTime(value.at, "Flight Recorder timestamp"); + assertRunEvent(value.event); +} + +export function assertRunSequence(events: readonly RunEnvelope[]): void { + const executions = new Map(); + let projectId: string | undefined; + let runId: string | undefined; + let terminal = false; + + events.forEach((envelope, index) => { + assertRunEnvelope(envelope); + const expected = index + 1; + if (envelope.seq !== expected) throw new Error(`Flight Recorder sequence expected ${expected}, received ${envelope.seq}`); + if (terminal) throw new Error("Flight Recorder event appears after terminal run.finished"); + + projectId ??= envelope.projectId; + runId ??= envelope.runId; + if (envelope.projectId !== projectId || envelope.runId !== runId) { + throw new Error("Flight Recorder run/project identity changed within a sequence"); + } + if (index === 0 && envelope.event.type !== "task.started") throw new Error("task.started must be first"); + if (index > 0 && envelope.event.type === "task.started") throw new Error("task.started may appear only once"); + + const event = envelope.event; + if (event.type === "command.started") { + if (executions.has(event.executionId)) throw new Error(`Duplicate executionId: ${event.executionId}`); + executions.set(event.executionId, { commandId: event.commandId, closed: false }); + } else if (event.type === "command.finished" || event.type === "test.finished") { + const execution = executions.get(event.executionId); + if (!execution) throw new Error(`Unknown executionId: ${event.executionId}`); + if (execution.commandId !== event.commandId) throw new Error(`Mismatched commandId for ${event.executionId}`); + if (execution.closed) throw new Error(`Execution ${event.executionId} is already closed`); + if (event.type === "command.finished") execution.closed = true; + } else if (event.type === "run.finished") { + if ([...executions.values()].some((execution) => !execution.closed)) { + throw new Error("run.finished cannot close a Flight Recorder with open executions"); + } + terminal = true; + } + }); +} + +export function eventEvidenceRefs(envelope: RunEnvelope): EvidenceRef[] { + switch (envelope.event.type) { + case "plan.recorded": return [envelope.event.plan]; + case "file.changed": return [envelope.event.diff]; + case "command.finished": + case "test.finished": return [envelope.event.output]; + default: return []; + } +} + +function assertRunEvent(value: unknown): asserts value is RunEvent { + assertRecord(value, "Flight Recorder event"); + if (typeof value.type !== "string") throw new Error("Flight Recorder event type is required"); + + switch (value.type) { + case "task.started": + assertExactKeys(value, ["type", "taskId", "baseRevision", "intentHash"], value.type); + assertToken(value.taskId, "taskId"); + assertGitOid(value.baseRevision, "baseRevision"); + assertSha256Field(value.intentHash, "intentHash"); + return; + case "plan.recorded": + assertExactKeys(value, ["type", "plan"], value.type); + assertEvidenceRef(value.plan, "plan"); + return; + case "file.changed": + assertExactKeys(value, ["type", "path", "beforeSha256", "afterSha256", "diff"], value.type); + if (typeof value.path !== "string") throw new Error("file.changed path must be a string"); + assertRelPath(value.path); + assertSha256Field(value.beforeSha256, "beforeSha256"); + assertSha256Field(value.afterSha256, "afterSha256"); + assertEvidenceRef(value.diff, "diff"); + return; + case "command.started": + assertExactKeys(value, ["type", "executionId", "commandId"], value.type); + assertToken(value.executionId, "executionId"); + assertToken(value.commandId, "commandId"); + return; + case "command.finished": + assertExactKeys( + value, + ["type", "executionId", "commandId", "exitCode", "timedOut", "cancelled", "output"], + value.type, + ); + assertToken(value.executionId, "executionId"); + assertToken(value.commandId, "commandId"); + if (value.exitCode !== null && !Number.isSafeInteger(value.exitCode)) throw new Error("exitCode must be an integer or null"); + if (typeof value.timedOut !== "boolean" || typeof value.cancelled !== "boolean") { + throw new Error("Command terminal flags must be boolean"); + } + if ((value.timedOut || value.cancelled) && value.exitCode !== null) { + throw new Error("Timed out or cancelled commands cannot claim an exit code"); + } + assertEvidenceRef(value.output, "command-output"); + return; + case "test.finished": + assertExactKeys(value, ["type", "executionId", "commandId", "testId", "status", "output"], value.type); + assertToken(value.executionId, "executionId"); + assertToken(value.commandId, "commandId"); + assertToken(value.testId, "testId"); + if (!["passed", "failed", "skipped"].includes(String(value.status))) throw new Error("Unknown test status"); + assertEvidenceRef(value.output, "test-output"); + return; + case "run.finished": + assertObjectShape(value, ["type", "status"], ["targetRevision"], value.type); + if (!["succeeded", "failed", "cancelled"].includes(String(value.status))) throw new Error("Unknown run status"); + if (value.targetRevision !== undefined) assertGitOid(value.targetRevision, "targetRevision"); + if (value.status === "succeeded" && value.targetRevision === undefined) { + throw new Error("A succeeded run.finished requires targetRevision"); + } + return; + default: + throw new Error(`Unknown Flight Recorder event type: ${value.type}`); + } +} + +function assertEvidenceRef(value: unknown, expectedKind: EvidenceKind): asserts value is EvidenceRef { + assertRecord(value, "evidence ref"); + assertExactKeys( + value, + ["id", "kind", "sha256", "storedBytes", "originalBytes", "truncated", "redactions", "mediaType", "visibility"], + "evidence ref", + ); + assertToken(value.id, "evidenceId"); + if (value.kind !== expectedKind) throw new Error(`Evidence kind must be ${expectedKind}`); + assertSha256Field(value.sha256, "evidence sha256"); + if ( + !Number.isSafeInteger(value.storedBytes) || + !Number.isSafeInteger(value.originalBytes) || + (value.storedBytes as number) < 0 || + (value.originalBytes as number) < (value.storedBytes as number) || + (value.storedBytes as number) > 1024 * 1024 + ) { + throw new Error("Evidence byte counts are invalid"); + } + if (typeof value.truncated !== "boolean") throw new Error("Evidence truncated must be boolean"); + if (!Array.isArray(value.redactions)) throw new Error("Evidence redactions must be an array"); + const redactions = value.redactions as unknown[]; + const ids: string[] = []; + for (const item of redactions) { + assertRecord(item, "redaction summary"); + assertExactKeys(item, ["ruleId", "count"], "redaction summary"); + assertToken(item.ruleId, "redaction ruleId"); + if (!Number.isSafeInteger(item.count) || (item.count as number) < 1) throw new Error("Redaction count must be positive"); + ids.push(item.ruleId); + } + const sorted = [...ids].sort(compareUtf8); + if (ids.some((id, index) => id !== sorted[index] || (index > 0 && id === sorted[index - 1]))) { + throw new Error("Evidence redactions must be sorted and unique"); + } + assertBoundedText(value.mediaType, 128, "mediaType"); + if (!/^[A-Za-z0-9][A-Za-z0-9.+-]*\/[A-Za-z0-9][A-Za-z0-9.+-]*$/.test(value.mediaType)) { + throw new Error("Invalid evidence mediaType"); + } + if (!["controller", "participant", "oracle"].includes(String(value.visibility))) { + throw new Error("Unknown evidence visibility"); + } +} + +function assertSha256Field(value: unknown, label: string): asserts value is string { + if (typeof value !== "string") throw new Error(`${label} must be a string`); + assertSha256(value, label); +} diff --git a/extension/src/recorder/store.ts b/extension/src/recorder/store.ts new file mode 100644 index 0000000..504deba --- /dev/null +++ b/extension/src/recorder/store.ts @@ -0,0 +1,185 @@ +import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve, sep } from "node:path"; +import { assertRelPath, canonicalJson } from "../rnd/canonical"; +import { assertAgentTask, assertToken, taskIntentHash, type AgentTask } from "../agent/types"; +import { + assertRunEnvelope, + assertRunSequence, + eventEvidenceRefs, + parseRunEnvelope, + serializeRunEnvelope, + type RunEnvelope, + type EvidenceRef, +} from "./events"; + +export interface LocalTextStorage { + readText(path: string): Promise; + writeText(path: string, value: string): Promise; + appendText(path: string, value: string): Promise; + removeTree(prefix: string): Promise; +} + +export interface RecorderEvidenceAuthority { + owns(projectId: string, ref: EvidenceRef): Promise; +} + +export interface RunRecorder { + append(event: RunEnvelope): Promise; + read(runId: string, afterSeq?: number): AsyncIterable; + lastSeq(runId: string): Promise; +} + +export interface TaskStore { + put(projectId: string, task: AgentTask): Promise; + get(projectId: string, taskId: string): Promise; + removeProject(projectId: string): Promise; +} + +export class LocalRunRecorder implements RunRecorder { + private readonly pending = new Map>(); + + constructor( + private readonly storage: LocalTextStorage, + private readonly evidence: RecorderEvidenceAuthority, + ) {} + + async append(event: RunEnvelope): Promise { + assertRunEnvelope(event); + const previous = this.pending.get(event.runId) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(() => this.appendUnlocked(event)); + this.pending.set(event.runId, current); + try { + await current; + } finally { + if (this.pending.get(event.runId) === current) this.pending.delete(event.runId); + } + } + + async *read(runId: string, afterSeq = 0): AsyncIterable { + assertToken(runId, "runId"); + if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) throw new Error("afterSeq must be a non-negative integer"); + const events = await this.load(runId); + await this.assertEvidenceOwnership(events); + for (const event of events) { + if (event.seq > afterSeq) yield structuredClone(event); + } + } + + async lastSeq(runId: string): Promise { + assertToken(runId, "runId"); + return (await this.load(runId)).at(-1)?.seq ?? 0; + } + + private async appendUnlocked(event: RunEnvelope): Promise { + const existing = await this.load(event.runId); + await this.assertEvidenceOwnership([event]); + assertRunSequence([...existing, event]); + await this.storage.appendText(runPath(event.runId), `${serializeRunEnvelope(event)}\n`); + } + + private async load(runId: string): Promise { + const raw = await this.storage.readText(runPath(runId)); + if (raw === undefined || raw === "") return []; + if (!raw.endsWith("\n")) throw new Error("Flight Recorder JSONL is not append-complete"); + const events = raw.slice(0, -1).split("\n").map(parseRunEnvelope); + assertRunSequence(events); + return events; + } + + private async assertEvidenceOwnership(events: readonly RunEnvelope[]): Promise { + for (const event of events) { + for (const ref of eventEvidenceRefs(event)) { + if (!(await this.evidence.owns(event.projectId, ref))) { + throw new Error(`Evidence ${ref.id} does not belong to project ${event.projectId}`); + } + } + } + } +} + +export class LocalTaskStore implements TaskStore { + constructor(private readonly storage: LocalTextStorage) {} + + async put(projectId: string, task: AgentTask): Promise { + assertToken(projectId, "projectId"); + assertAgentTask(task); + const path = taskPath(projectId, task.taskId); + const serialized = canonicalJson(task); + const existing = await this.storage.readText(path); + if (existing !== undefined && existing !== serialized) throw new Error(`Task ${task.taskId} already exists with different intent`); + if (existing === undefined) await this.storage.writeText(path, serialized); + return taskIntentHash(task); + } + + async get(projectId: string, taskId: string): Promise { + assertToken(projectId, "projectId"); + assertToken(taskId, "taskId"); + const raw = await this.storage.readText(taskPath(projectId, taskId)); + if (raw === undefined) return undefined; + let task: unknown; + try { + task = JSON.parse(raw); + } catch { + throw new Error("Stored task is not valid JSON"); + } + assertAgentTask(task); + if (canonicalJson(task) !== raw) throw new Error("Stored task is not canonical JSON"); + return task; + } + + async removeProject(projectId: string): Promise { + assertToken(projectId, "projectId"); + await this.storage.removeTree(`tasks/${projectId}`); + } +} + +export class NodeLocalTextStorage implements LocalTextStorage { + private readonly root: string; + + constructor(root: string) { + this.root = resolve(root); + } + + async readText(path: string): Promise { + try { + return await readFile(this.file(path), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + async writeText(path: string, value: string): Promise { + const file = this.file(path); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, value, { encoding: "utf8", flag: "wx" }); + } + + async appendText(path: string, value: string): Promise { + const file = this.file(path); + await mkdir(dirname(file), { recursive: true }); + await appendFile(file, value, "utf8"); + } + + async removeTree(prefix: string): Promise { + await rm(this.file(prefix), { recursive: true, force: true }); + } + + private file(path: string): string { + assertRelPath(path); + const file = resolve(this.root, path); + if (!file.startsWith(`${this.root}${sep}`)) throw new Error("Local storage path escapes its root"); + return file; + } +} + +function runPath(runId: string): string { + assertToken(runId, "runId"); + return `recorder/${runId}.jsonl`; +} + +function taskPath(projectId: string, taskId: string): string { + assertToken(projectId, "projectId"); + assertToken(taskId, "taskId"); + return `tasks/${projectId}/${taskId}.json`; +} diff --git a/extension/test/fixtures/v0.3/agent-replay/succeeded.json b/extension/test/fixtures/v0.3/agent-replay/succeeded.json new file mode 100644 index 0000000..b642004 --- /dev/null +++ b/extension/test/fixtures/v0.3/agent-replay/succeeded.json @@ -0,0 +1,163 @@ +{ + "schemaVersion": 1, + "driverId": "pureflow.replay.v1", + "task": { + "schemaVersion": 1, + "taskId": "task_tenant_cache", + "intent": { + "summary": "Add tenant-scoped cache keys", + "acceptance": [ + "cache keys isolate identical IDs across tenants", + "the declared controller check passes" + ] + }, + "requestedAt": "2026-07-31T20:00:00.000Z" + }, + "run": { + "schemaVersion": 1, + "runId": "run_tenant_cache", + "taskId": "task_tenant_cache", + "projectId": "project_r0_fixture", + "driverId": "pureflow.replay.v1", + "baseRevision": "dbb7f641fc6215cbeefa8a100645e1caa5b3d0a7", + "status": "succeeded", + "startedAt": "2026-07-31T20:00:01.000Z", + "finishedAt": "2026-07-31T20:00:06.000Z", + "targetRevision": "449bc18f0e0cb635f36e726affc42a7ae1df0211" + }, + "events": [ + { + "schemaVersion": 1, + "projectId": "project_r0_fixture", + "runId": "run_tenant_cache", + "seq": 1, + "at": "2026-07-31T20:00:01.000Z", + "event": { + "type": "task.started", + "taskId": "task_tenant_cache", + "baseRevision": "dbb7f641fc6215cbeefa8a100645e1caa5b3d0a7", + "intentHash": "630908dfe4286f1b22a28f531f36bfb01ed28743f722cbbff2ef19b0c476960c" + } + }, + { + "schemaVersion": 1, + "projectId": "project_r0_fixture", + "runId": "run_tenant_cache", + "seq": 2, + "at": "2026-07-31T20:00:02.000Z", + "event": { + "type": "plan.recorded", + "plan": { + "id": "evidence_plan_1", + "kind": "plan", + "sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "storedBytes": 64, + "originalBytes": 64, + "truncated": false, + "redactions": [], + "mediaType": "text/markdown", + "visibility": "controller" + } + } + }, + { + "schemaVersion": 1, + "projectId": "project_r0_fixture", + "runId": "run_tenant_cache", + "seq": 3, + "at": "2026-07-31T20:00:03.000Z", + "event": { + "type": "command.started", + "executionId": "execution_fixture_check_1", + "commandId": "fixture.tenant-cache.check" + } + }, + { + "schemaVersion": 1, + "projectId": "project_r0_fixture", + "runId": "run_tenant_cache", + "seq": 4, + "at": "2026-07-31T20:00:04.000Z", + "event": { + "type": "test.finished", + "executionId": "execution_fixture_check_1", + "commandId": "fixture.tenant-cache.check", + "testId": "tenant-isolation", + "status": "passed", + "output": { + "id": "evidence_test_1", + "kind": "test-output", + "sha256": "2222222222222222222222222222222222222222222222222222222222222222", + "storedBytes": 48, + "originalBytes": 48, + "truncated": false, + "redactions": [], + "mediaType": "application/json", + "visibility": "controller" + } + } + }, + { + "schemaVersion": 1, + "projectId": "project_r0_fixture", + "runId": "run_tenant_cache", + "seq": 5, + "at": "2026-07-31T20:00:05.000Z", + "event": { + "type": "command.finished", + "executionId": "execution_fixture_check_1", + "commandId": "fixture.tenant-cache.check", + "exitCode": 0, + "timedOut": false, + "cancelled": false, + "output": { + "id": "evidence_command_1", + "kind": "command-output", + "sha256": "3333333333333333333333333333333333333333333333333333333333333333", + "storedBytes": 96, + "originalBytes": 96, + "truncated": false, + "redactions": [], + "mediaType": "text/plain", + "visibility": "controller" + } + } + }, + { + "schemaVersion": 1, + "projectId": "project_r0_fixture", + "runId": "run_tenant_cache", + "seq": 6, + "at": "2026-07-31T20:00:05.500Z", + "event": { + "type": "file.changed", + "path": "src/cache-key.ts", + "beforeSha256": "4444444444444444444444444444444444444444444444444444444444444444", + "afterSha256": "5555555555555555555555555555555555555555555555555555555555555555", + "diff": { + "id": "evidence_diff_1", + "kind": "diff", + "sha256": "6666666666666666666666666666666666666666666666666666666666666666", + "storedBytes": 128, + "originalBytes": 128, + "truncated": false, + "redactions": [], + "mediaType": "text/x-diff", + "visibility": "controller" + } + } + }, + { + "schemaVersion": 1, + "projectId": "project_r0_fixture", + "runId": "run_tenant_cache", + "seq": 7, + "at": "2026-07-31T20:00:06.000Z", + "event": { + "type": "run.finished", + "status": "succeeded", + "targetRevision": "449bc18f0e0cb635f36e726affc42a7ae1df0211" + } + } + ] +} diff --git a/extension/test/flight-recorder.test.ts b/extension/test/flight-recorder.test.ts new file mode 100644 index 0000000..e79c45c --- /dev/null +++ b/extension/test/flight-recorder.test.ts @@ -0,0 +1,286 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { taskIntentHash, type AgentWorkspace } from "../src/agent/types"; +import { ReplayDriver, type ReplayTranscript } from "../src/agent/replay-driver"; +import { + parseRunEnvelope, + serializeRunEnvelope, + type RunEnvelope, + type EvidenceRef, +} from "../src/recorder/events"; +import { + LocalRunRecorder, + LocalTaskStore, + NodeLocalTextStorage, + type RecorderEvidenceAuthority, + type LocalTextStorage, +} from "../src/recorder/store"; + +const fixturePath = resolve(import.meta.dirname, "fixtures/v0.3/agent-replay/succeeded.json"); + +describe("R1 replay driver and Flight Recorder", () => { + it("replays the same normalized sequence without persisting local handles or secrets", async () => { + const transcript = await loadTranscript(); + const { driver, storage } = setup(transcript); + const workspace = fixtureWorkspace(); + workspace.localHandle = "C:\\Users\\goose\\secret-project|OPENAI_API_KEY=known-secret-fixture"; + + const first = await driver.start(transcript.task, workspace); + const firstEvents = await collect(driver.events(first.runId)); + const second = await driver.start(transcript.task, workspace); + const secondEvents = await collect(driver.events(second.runId)); + + expect(first).toEqual(transcript.run); + expect(secondEvents).toEqual(firstEvents); + expect(firstEvents).toEqual(transcript.events); + expect(storage.dump()).not.toContain(workspace.localHandle); + expect(storage.dump()).not.toContain("OPENAI_API_KEY=known-secret-fixture"); + }); + + it("round-trips canonical event serialization and matches the task-intent golden hash", async () => { + const transcript = await loadTranscript(); + const first = transcript.events[0]!; + + expect(parseRunEnvelope(serializeRunEnvelope(first))).toEqual(first); + expect(taskIntentHash(transcript.task)).toBe("630908dfe4286f1b22a28f531f36bfb01ed28743f722cbbff2ef19b0c476960c"); + expect(first.event).toMatchObject({ type: "task.started", intentHash: taskIntentHash(transcript.task) }); + }); + + it("rejects gaps, duplicate sequences, and events after run.finished", async () => { + const transcript = await loadTranscript(); + const gap = structuredClone(transcript); + gap.events[1]!.seq = 3; + await expect(start(gap)).rejects.toThrow("sequence"); + + const duplicate = structuredClone(transcript); + duplicate.events[1]!.seq = 1; + await expect(start(duplicate)).rejects.toThrow("sequence"); + + const late = structuredClone(transcript); + late.events.push({ + ...late.events[5]!, + seq: 8, + at: "2026-07-31T20:00:07.000Z", + }); + await expect(start(late)).rejects.toThrow("terminal"); + }); + + it("rejects duplicate execution IDs and mismatched command IDs", async () => { + const transcript = await loadTranscript(); + const duplicate = structuredClone(transcript); + duplicate.events.splice(3, 0, { + ...duplicate.events[2]!, + seq: 4, + at: "2026-07-31T20:00:03.500Z", + }); + duplicate.events.slice(4).forEach((event, index) => { event.seq = index + 5; }); + await expect(start(duplicate)).rejects.toThrow("executionId"); + + const mismatch = structuredClone(transcript); + const test = mismatch.events[3]!.event; + if (test.type !== "test.finished") throw new Error("Fixture drift"); + test.commandId = "fixture.other.check"; + await expect(start(mismatch)).rejects.toThrow("commandId"); + }); + + it("rejects cross-project evidence and non-canonical stored lines", async () => { + const transcript = await loadTranscript(); + const crossProject = structuredClone(transcript); + const plan = crossProject.events[1]!.event; + if (plan.type !== "plan.recorded") throw new Error("Fixture drift"); + plan.plan.id = "evidence_other_project"; + await expect(start(crossProject)).rejects.toThrow("project"); + + const storage = new MemoryStorage(); + const { authority } = evidenceAuthority(transcript, false); + const store = new LocalRunRecorder(storage, authority); + await storage.appendText("recorder/run_tenant_cache.jsonl", `${JSON.stringify(transcript.events[0])}\n`); + await expect(collect(store.read("run_tenant_cache"))).rejects.toThrow("canonical"); + }); + + it("rejects path, identity, redaction, and evidence-size violations before storage", async () => { + const transcript = await loadTranscript(); + + const absolute = structuredClone(transcript); + const changed = absolute.events[5]!.event; + if (changed.type !== "file.changed") throw new Error("Fixture drift"); + changed.path = "C:/Users/goose/project/src/cache.ts"; + expect(() => setup(absolute)).toThrow("relative"); + + const identity = structuredClone(transcript); + identity.events[2]!.projectId = "project_other"; + expect(() => setup(identity)).toThrow("identity"); + + const redactions = structuredClone(transcript); + const command = redactions.events[4]!.event; + if (command.type !== "command.finished") throw new Error("Fixture drift"); + command.output.redactions = [ + { ruleId: "z_rule", count: 1 }, + { ruleId: "a_rule", count: 1 }, + ]; + expect(() => setup(redactions)).toThrow("sorted"); + + const oversized = structuredClone(transcript); + const plan = oversized.events[1]!.event; + if (plan.type !== "plan.recorded") throw new Error("Fixture drift"); + plan.plan.storedBytes = 1024 * 1024 + 1; + plan.plan.originalBytes = plan.plan.storedBytes; + expect(() => setup(oversized)).toThrow("byte counts"); + }); + + it("represents failed and cancelled runs without inventing a target revision", async () => { + const transcript = await loadTranscript(); + + for (const status of ["failed", "cancelled"] as const) { + const variant = terminalVariant(transcript, status); + const run = await start(variant); + expect(run.status).toBe(status); + expect(run.targetRevision).toBeUndefined(); + expect(variant.events.at(-1)?.event).toEqual({ type: "run.finished", status }); + } + }); + + it("keeps replay control honest", async () => { + const transcript = await loadTranscript(); + const { driver } = setup(transcript); + await driver.start(transcript.task, fixtureWorkspace()); + + await expect(driver.followUp(transcript.run.runId, "change the answer")).rejects.toThrow("read-only"); + await expect(driver.cancel(transcript.run.runId)).rejects.toThrow("already finished"); + await expect(driver.events("run_unknown")[Symbol.asyncIterator]().next()).rejects.toThrow("Unknown replay run"); + }); + + it("reopens the append-only Flight Recorder from extension-owned filesystem storage", async () => { + const root = await mkdtemp(join(tmpdir(), "pureflow-r1-store-")); + try { + const transcript = await loadTranscript(); + const storage = new NodeLocalTextStorage(root); + const { authority } = evidenceAuthority(transcript); + const driver = new ReplayDriver( + transcript, + new LocalRunRecorder(storage, authority), + new LocalTaskStore(storage), + ); + await driver.start(transcript.task, fixtureWorkspace()); + + const reopened = new LocalRunRecorder(new NodeLocalTextStorage(root), authority); + expect(await collect(reopened.read(transcript.run.runId))).toEqual(transcript.events); + expect(await reopened.lastSeq(transcript.run.runId)).toBe(transcript.events.length); + expect(await collect(reopened.read(transcript.run.runId, 5))).toEqual(transcript.events.slice(5)); + + const tasks = new LocalTaskStore(new NodeLocalTextStorage(root)); + expect(await tasks.get(transcript.run.projectId, transcript.task.taskId)).toEqual(transcript.task); + await tasks.removeProject(transcript.run.projectId); + expect(await tasks.get(transcript.run.projectId, transcript.task.taskId)).toBeUndefined(); + expect(await collect(reopened.read(transcript.run.runId))).toEqual(transcript.events); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects unknown transcript fields and unbounded task intent", async () => { + const transcript = await loadTranscript(); + const injected = structuredClone(transcript) as ReplayTranscript & { vendorPayload?: string }; + injected.vendorPayload = "hidden reasoning"; + expect(() => setup(injected)).toThrow("unknown"); + + const oversized = structuredClone(transcript); + oversized.task.intent.summary = "x".repeat(4097); + expect(() => setup(oversized)).toThrow("4096"); + }); +}); + +async function loadTranscript(): Promise { + return JSON.parse(await readFile(fixturePath, "utf8")) as ReplayTranscript; +} + +function setup(transcript: ReplayTranscript) { + const storage = new MemoryStorage(); + const { authority } = evidenceAuthority(transcript); + const recorder = new LocalRunRecorder(storage, authority); + const tasks = new LocalTaskStore(storage); + const driver = new ReplayDriver(transcript, recorder, tasks); + return { driver, storage }; +} + +async function start(transcript: ReplayTranscript) { + const { driver } = setup(transcript); + return driver.start(transcript.task, fixtureWorkspace()); +} + +function fixtureWorkspace(): AgentWorkspace { + return { + schemaVersion: 1, + projectId: "project_r0_fixture", + worktreeId: "worktree_fixture_1", + baseRevision: "dbb7f641fc6215cbeefa8a100645e1caa5b3d0a7", + trusted: true, + localHandle: "fixture://tenant-cache-key", + }; +} + +function terminalVariant(transcript: ReplayTranscript, status: "failed" | "cancelled"): ReplayTranscript { + const variant = structuredClone(transcript); + variant.run.status = status; + delete variant.run.targetRevision; + const terminal = variant.events.at(-1)!; + terminal.event = { type: "run.finished", status }; + return variant; +} + +function evidenceAuthority(transcript: ReplayTranscript, includeOther = true) { + const owners = new Map(); + for (const envelope of transcript.events) { + for (const ref of evidenceRefs(envelope)) owners.set(ref.id, transcript.run.projectId); + } + if (includeOther) owners.set("evidence_other_project", "project_other"); + + const authority: RecorderEvidenceAuthority = { + owns: async (projectId, ref) => owners.get(ref.id) === projectId, + }; + return { authority }; +} + +function evidenceRefs(envelope: RunEnvelope): EvidenceRef[] { + switch (envelope.event.type) { + case "plan.recorded": return [envelope.event.plan]; + case "file.changed": return [envelope.event.diff]; + case "command.finished": + case "test.finished": return [envelope.event.output]; + default: return []; + } +} + +async function collect(iterable: AsyncIterable): Promise { + const values: RunEnvelope[] = []; + for await (const value of iterable) values.push(value); + return values; +} + +class MemoryStorage implements LocalTextStorage { + private readonly files = new Map(); + + async readText(path: string): Promise { + return this.files.get(path); + } + + async writeText(path: string, value: string): Promise { + this.files.set(path, value); + } + + async appendText(path: string, value: string): Promise { + this.files.set(path, `${this.files.get(path) ?? ""}${value}`); + } + + async removeTree(prefix: string): Promise { + for (const path of this.files.keys()) { + if (path === prefix || path.startsWith(`${prefix}/`)) this.files.delete(path); + } + } + + dump(): string { + return [...this.files.values()].join("\n"); + } +} From bfb5cec57bb73211c11b1345a4855bbae05213af Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 00:04:46 +0300 Subject: [PATCH 2/3] docs: name flight recorder and select live adapter --- docs/BUILD_LOG.md | 11 ++++ docs/PROJECT_STATE.md | 12 ++-- docs/v0.3/ADR-001-DUAL-CONTROL.md | 10 +-- docs/v0.3/ADR-004-EPISTEMIC-CHECKPOINTS.md | 2 +- docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md | 51 ++++++++++++++++ docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md | 61 +++++++++++++++++++ docs/v0.3/AGENT_EXECUTION.md | 18 +++--- docs/v0.3/CONCEPTS.md | 4 +- docs/v0.3/CONTRACTS.md | 22 +++---- docs/v0.3/README.md | 12 ++-- docs/v0.3/RESEARCH.md | 14 +++++ prd.md | 8 +-- 12 files changed, 183 insertions(+), 42 deletions(-) create mode 100644 docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md create mode 100644 docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index 10d2838..7bc1161 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -2,6 +2,17 @@ This is a concise chronological record of material implementation work and runtime evidence. It is not a substitute for Git history; it captures intent, verification, and blockers that a commit alone may not explain. +## 2026-07-31 — R1 replay and Flight Recorder candidate + +- Implemented the exact schema-v1 `AgentTask`, `AgentWorkspace`, `AgentRun`, `AgentDriver`, `RunEvent`, `RunEnvelope`, `RunRecorder`, and `TaskStore` boundaries behind a checked, credential-free replay driver. +- Added canonical JSONL serialization, strict sequence and terminal ordering, execution identity checks, project-scoped evidence ownership, bounded evidence metadata, task-intent hashing, opaque local-handle omission, range reads, and extension-owned filesystem persistence. +- Added a checked seven-event fixture transcript and ten R1 tests. The tests reject sequence gaps/duplicates, late events, command identity mismatches, cross-project evidence, absolute paths, identity drift, noncanonical lines, unsorted redactions, oversized stored evidence, unknown fields, and unbounded task intent. Failed and cancelled runs do not invent a target revision. +- Renamed the raw event layer from `Chronicle` to **Flight Recorder** before publication because OpenAI now uses Chronicle for screen-derived Codex memory. ADR-005 distinguishes the raw controller ledger from a possible participant-facing Flight Log. +- Compared current official Codex App Server, ACP, Cline SDK, OpenCode server, and Claude Managed Agents interfaces. ADR-006 selects exact-version Codex App Server over local stdio for the first live spike and ACP as the next portability boundary; no generic agent loop or vendor event union enters the domain contract. +- Local Windows evidence: `npm run check`, all 37 extension tests, production build, and VSIX packaging passed. The packaged Microsoft Store Codex executable was discoverable but returned `Access denied` from the repository shell, so no live adapter success is claimed; its future preflight must require an accessible exact-version CLI and fail closed otherwise. + +Evidence: `extension/src/agent/`, `extension/src/recorder/`, `extension/test/flight-recorder.test.ts`, `docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md`, `docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md`, and local command output on 2026-07-31. Protected cross-platform CI and merge are pending. + ## 2026-07-31 — R0 deterministic fixture accepted - Added the dependency-free `tenant-cache-key` fixture with fixed base, target, and mutated trees; controller-owned harness, oracle, mutation, and known repair; fixed SHA-1 Git identity, timestamps, branch, LF policy, and golden revisions/hashes. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index a1aaa39..932d690 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -2,7 +2,7 @@ Last updated: 2026-07-31 -## Current branch milestone — R0 deterministic substrate complete +## Current branch milestone — R1 replay and Flight Recorder candidate Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -15,7 +15,9 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - R0a now implements RFC 8785 canonical JSON, domain-separated SHA-256, UTF-8 path ordering, tree/candidate/manifest hashes, the versioned fixture types, and fail-closed manifest validation. Ten R0a tests pass on this Windows checkout, bringing the extension suite to 23/23. - R0b now has a committed dependency-free `tenant-cache-key` fixture, deterministic Git factory, fixed base/target revisions and state-tree hashes, controller-owned mutation/repair/harness/oracle blobs, and a separately downloaded hash-pinned Node `v22.17.0` runtime. On this Windows checkout, base and target checks pass, the mutation fails the declared tenant-isolation check, the known repair returns the exact target tree to green, and cleanup preserves the source repository snapshot. - The full local Windows extension suite passes 27/27 with `npm run check`, build, and VSIX packaging. Protected PR #10 run `30663623200` independently reproduced the exact fixture/runtime behavior on Linux and Windows; all five required checks passed, so R0 acceptance is complete. -- `TrustedFixtureRunner`, `AgentDriver`, Chronicle, semantic extractor, Experience Compiler, Takeover Twin, Evidence Judge, Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. +- Short-lived branch `codex/r1-flight-recorder` now implements the schema-v1 `AgentDriver` boundary, checked replay driver, canonical task-intent storage, and append-only local Flight Recorder behind injected storage and evidence-ownership interfaces. Ten R1 tests cover deterministic replay, canonical round trips, sequence/execution/identity violations, cross-project evidence, path and size bounds, failed/cancelled honesty, persistence, range reads, and secret/local-handle omission. +- The full local Windows extension suite passes 37/37 with `npm run check`; the production bundle and VSIX package also pass. Cross-platform CI and protected integration are still pending, so R1 is a candidate rather than an accepted branch milestone. +- The semantic extractor, Experience Compiler, Takeover Twin lifecycle, Evidence Judge, Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. `TrustedFixtureRunner` exists for the closed R0 fixture substrate. - No skill-retention or speed metric has been measured. Values in the PRD are predeclared R&D targets. - A new implementation audit found five R0 ambiguities: candidate-diff identity, pre-store fixture blobs, runtime identity, check IDs, and Git object format. The normative contract closes them with structured diffs, catalog-owned blobs, standalone Node `v22.17.0`, declared test IDs, and SHA-1 Git initialization; R0a/R0b now implement and verify that complete substrate. - A guarded Jules dispatcher and PR policy are defined as a finite R0→R4 queue. They create at most one session after a successful preflight, stop after merged R4, remain inert unless dispatch is explicitly enabled, and keep plan approval on by default. Merges remain manual because the current project tests are not an independent immutable verifier. Full scheduled continuation still requires the dispatcher workflow to be reviewed into the default branch. @@ -112,7 +114,7 @@ The repository contains no verified evidence that the owner submitted the final | Input | Impact | Resolution | | --- | --- | --- | -| First external agent adapter is not selected | Live Chronicle integration cannot start, but replay-based R&D can proceed | Run R1 replay first, compare current supported agent APIs, then record the choice in an ADR | +| The first live adapter is selected but no accessible Codex CLI is configured for this checkout | ADR-006 selects Codex App Server over local stdio, but the Microsoft Store packaged executable discovered here returns `Access denied` when launched from the repository shell | Keep replay R&D independent; the live spike must preflight a separately accessible, exact-version user-installed Codex CLI and fail closed when unavailable | | Untrusted-code sandbox backend is not selected | R7 corpus and human pilots cannot execute third-party or arbitrary participant code; R0–R4.5 can validate only finite reviewed fixture states, controller-owned repair, and catalog probes | After the fixture slice, select and verify a Windows-capable backend in a separate ADR; never fall back to direct execution | | Technical patch corpus is not assembled | Automatic episode-generation rate cannot be measured | Collect at least 30 consented or open-source test-backed TypeScript patches for R7 | | Human participants are not recruited | Takeover and delayed-transfer claims cannot be tested | Complete the technical gate, then recruit for the preregistered pilot | @@ -133,8 +135,8 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Merge protected PR #10 into `codex/shadow-cockpit-rnd` after the final documentation commit repeats the five required checks. -2. Implement R1–R3 behind stable contracts: replay AgentDriver plus Chronicle, change-evidence extractor, and safe Takeover Twin lifecycle. R1 should record whether real agent near-miss replay has enough observable checkpoint evidence to justify a versioned contract proposal. +1. Publish the R1 short-lived branch, pass the five protected Linux/Windows checks, merge it into `codex/shadow-cockpit-rnd`, and delete the head. +2. Implement R2 and R3 behind the accepted contracts: deterministic change-evidence extraction and the safe Takeover Twin lifecycle. 3. Integrate R4: one compiled recovery episode and deterministic Evidence Judge. 4. Pass R4.5: one bounded, catalog-only Explain-to-Break Pulse with replay/error fail-closed tests. 5. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. diff --git a/docs/v0.3/ADR-001-DUAL-CONTROL.md b/docs/v0.3/ADR-001-DUAL-CONTROL.md index 17510df..6f668cf 100644 --- a/docs/v0.3/ADR-001-DUAL-CONTROL.md +++ b/docs/v0.3/ADR-001-DUAL-CONTROL.md @@ -50,7 +50,7 @@ flowchart TB end subgraph RP["Readiness Plane"] - C["Run Chronicle"] --> G["Semantic Change Graph"] + C["Flight Recorder"] --> G["Semantic Change Graph"] G --> X["Experience Compiler"] R["Readiness Store"] --> X X --> T["Takeover Twin"] @@ -76,7 +76,7 @@ The normative `AgentTask`, `AgentWorkspace`, `AgentRun`, `AgentDriver`, event-or The initial spike supports one driver. Multi-agent routing is not required to validate the readiness mechanism. -### 2. Run Chronicle +### 2. Flight Recorder An append-only local event log records only observable facts: @@ -88,7 +88,7 @@ An append-only local event log records only observable facts: - explicit agent plans or decisions when emitted; - merge and rollback events. -The Chronicle must not fabricate rationale or depend on private model reasoning traces. +The Flight Recorder must not fabricate rationale or depend on private model reasoning traces. Raw terminal output, absolute paths, secrets, and unbounded repository content are not automatically persisted. @@ -199,14 +199,14 @@ After recovery episodes validate the mechanism, the next router experiment is sp - PureFlow is no longer positioned as “AI that waits to be asked.” Autonomous agents are a first-class build plane. - Mentor explanations and Focus Reps become legacy v0.1 capabilities, not the v0.3 thesis. - No new quiz, comprehension score, or manual coding gate should be implemented unless it is part of a complete control episode. -- The first code milestone is an agent adapter plus Chronicle and Takeover Twin spike, not UI polish. +- The first code milestone is an agent adapter plus Flight Recorder and Takeover Twin spike, not UI polish. - Product claims must use `target`, `hypothesis`, or `pilot result` until delayed transfer is measured. - `Shadow Workspace` must not be used as a PureFlow name because Cursor already owns that term in this category. ## Action items 1. Implement the smallest observable `AgentDriver` for one existing runtime. -2. Capture a normalized Chronicle for at least ten representative test-backed patches. +2. Capture a normalized Flight Recorder stream for at least ten representative test-backed patches. 3. Build a deterministic standalone sanitized twin that applies one change-derived mutation and exposes a failing test without sharing source Git objects. 4. Validate that the Evidence Judge reproduces expert-labeled outcomes. 5. Run the technical and human pilots in [`EXPERIMENTS.md`](EXPERIMENTS.md). diff --git a/docs/v0.3/ADR-004-EPISTEMIC-CHECKPOINTS.md b/docs/v0.3/ADR-004-EPISTEMIC-CHECKPOINTS.md index f79318e..9f450b3 100644 --- a/docs/v0.3/ADR-004-EPISTEMIC-CHECKPOINTS.md +++ b/docs/v0.3/ADR-004-EPISTEMIC-CHECKPOINTS.md @@ -112,7 +112,7 @@ PureFlow may later add a local **Flight Log**, but it must not equate activity w ### Costs and risks -- useful trigger precision requires a Chronicle and stable evidence model; +- useful trigger precision requires a Flight Recorder and stable evidence model; - model-generated falsifiers can be invalid and must be rejected before execution; - even good pulses can become annoying, so attention budget and opt-out rate are release metrics; - achievements can distort behavior and remain out of the technical MVP. diff --git a/docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md b/docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md new file mode 100644 index 0000000..e532cc1 --- /dev/null +++ b/docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md @@ -0,0 +1,51 @@ +# ADR-005: Name the Observable Run Ledger Flight Recorder + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Decision owners:** PureFlow project + +## Context + +The original v0.3 documents called PureFlow's normalized, append-only agent-run ledger `Chronicle`. OpenAI now uses [Chronicle](https://learn.chatgpt.com/docs/customization/chronicle) for an opt-in Codex feature that derives memories from recent screen context. That product has different data, privacy, and lifecycle semantics from PureFlow's project-local run evidence. + +Keeping the name would create avoidable ambiguity in user copy, integration code, issue searches, and security discussions. This is also the cheapest point to correct it: R0 is merged, while R1 has not published a wire format or persisted user data. + +## Decision + +The product component is named **Flight Recorder**. + +- Documentation uses `Flight Recorder` for the controller-local observable run ledger. +- TypeScript uses the compact domain names `RunEnvelope`, `RunEvent`, and `RunRecorder`. +- Extension-owned JSONL is stored under the `recorder/` namespace. +- Vendor adapters emit normalized `RunEnvelope` values and never expose their native event unions downstream. +- The optional future `Flight Log` from ADR-004 remains a participant-facing summary of verified control episodes. It is not the raw Flight Recorder. + +The schema remains version `1`. No persisted or public field contained the old component name, and no R1 data has shipped. This ADR changes terminology and code symbols, not the event meaning. + +## Consequences + +### Benefits + +- the name expresses the autopilot analogy: normal automation continues while bounded evidence exists for diagnosis and takeover; +- it avoids collision with Codex screen-memory features; +- `RunEnvelope` and `RunRecorder` remain vendor-neutral and concise in code; +- raw operational evidence stays visibly separate from any user-facing readiness history. + +### Costs + +- existing R&D documents and the uncommitted R1 spike must be renamed together; +- future migrations must still version any actual schema or storage change, even if the product label remains stable. + +## Rejected alternatives + +### Keep Chronicle and clarify it in prose + +Rejected. The ambiguity would recur in every integration and support conversation. + +### Flight Log for both layers + +Rejected. A raw append-only event source and a curated participant history have different visibility and retention rules. + +### Event Store + +Rejected as the product name. It is technically accurate but loses the control-and-recovery metaphor that differentiates the architecture. diff --git a/docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md b/docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md new file mode 100644 index 0000000..9c4bc48 --- /dev/null +++ b/docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md @@ -0,0 +1,61 @@ +# ADR-006: Use Codex App Server for the First Live Agent Adapter + +- **Status:** Accepted for a narrow live spike +- **Date:** 2026-07-31 +- **Decision owners:** PureFlow project + +## Context + +R1 first proves the vendor-neutral `AgentDriver` and Flight Recorder with a checked replay transcript. The next decision is which existing agent runtime should supply the first live event stream. PureFlow must not rebuild file editing, command execution, approval handling, conversation state, or cancellation from scratch. + +The comparison used current official interfaces rather than CLI screen scraping: + +| Candidate | Useful documented surface | Main limitation for the first spike | +| --- | --- | --- | +| [Codex App Server](https://learn.chatgpt.com/docs/app-server) | JSONL-over-stdio rich-client protocol, version-matched generated schemas, thread/turn lifecycle, authoritative item completion, file changes, commands, tests through command evidence, steering, interruption, and approvals | The app-server command and parts of the protocol remain experimental and must be pinned and isolated behind an adapter | +| [Agent Client Protocol](https://agentclientprotocol.com/updates) | Stable multi-agent client/agent interoperability direction with session lifecycle, tool calls, plans, permissions, and usage updates | A protocol, not an autonomous runtime; an ACP agent still has to be selected and observed | +| [Cline SDK](https://docs.cline.bot/sdk/overview) | Embeddable open-source agent harness with [runtime and tool events](https://docs.cline.bot/sdk/events) | A larger runtime dependency and product surface than required to validate one adapter | +| [OpenCode server and SDK](https://opencode.ai/docs/server/) | Headless HTTP server, OpenAPI document, typed SDK, and server-sent events | The embedded v2 SDK is not the stable public boundary; HTTP lifecycle adds integration surface for a local VSCodium spike | +| [Claude Managed Agents](https://platform.claude.com/docs/en/managed-agents/events-and-streaming) | Persisted session event stream, steering, interruption, tool-use events, and multi-agent threads | Hosted beta infrastructure rather than the local IDE runtime boundary PureFlow needs first | + +## Decision + +Use **Codex App Server over local stdio** for the first live `AgentDriver` spike. + +This is a selection for integration learning, not an exclusive product dependency. It wins the first slot because the documented boundary already powers rich clients such as the Codex VS Code extension and exposes the exact observable units R1 needs: `turn/started`, `item/started`, authoritative `item/completed`, `commandExecution`, `fileChange`, `turn/diff/updated`, approvals, and `turn/completed` or interruption. + +The adapter will: + +1. launch a user-installed, exact Codex version with `codex app-server` over stdio; +2. generate or verify TypeScript/JSON schemas for that exact version during adapter development; +3. map only allowlisted lifecycle, command, file-change, test, and terminal facts into schema-v1 `RunEnvelope` events; +4. issue PureFlow execution IDs and command-registry IDs rather than persisting vendor IDs as domain authority; +5. convert bounded, redacted output and diffs into project-owned `EvidenceRef` values before appending events; +6. treat `item/completed` and `turn/completed` as authoritative, including declined, interrupted, and failed states; +7. keep raw reasoning, absolute `cwd` values, environment values, authentication state, and unbounded output outside the Flight Recorder; +8. fail closed when the installed protocol does not match the adapter's tested schema. + +The first spike stays local and explicit. It does not expose a WebSocket listener, migrate the user's agent configuration, or silently start Codex when the user selected another provider. + +## Portability path + +ACP is the preferred second adapter boundary after the Codex spike proves the normalization contract. It can broaden runtime choice without making ACP events the PureFlow domain model. Cline and OpenCode remain concrete fallback runtimes if the App Server does not expose sufficient stable evidence for a real near-miss. Claude Managed Agents remains a later hosted-adapter experiment. + +## Gate + +The live adapter is accepted only if a fixture task can produce the same normalized facts as the checked replay transcript without leaking a vendor event, absolute path, credential, or raw reasoning block. A protocol mismatch, missing terminal evidence, or ambiguous file state is an `unsupported` or failed run, never a fabricated success. + +## Consequences + +### Benefits + +- PureFlow reuses a real agent runtime instead of building a generic agent loop; +- the stdio process boundary is narrow and local; +- generated schemas make protocol drift testable; +- the same Flight Recorder contract remains available to later providers. + +### Costs and risks + +- an experimental upstream surface can change, so the adapter needs compatibility fixtures and a hard version gate; +- Codex native events contain more information than PureFlow may retain, so normalization and redaction are mandatory before storage; +- this decision validates one integration path, not cross-provider portability. diff --git a/docs/v0.3/AGENT_EXECUTION.md b/docs/v0.3/AGENT_EXECUTION.md index e948692..f54dc1a 100644 --- a/docs/v0.3/AGENT_EXECUTION.md +++ b/docs/v0.3/AGENT_EXECUTION.md @@ -22,7 +22,7 @@ Use a small sequential pipeline for the first vertical slice. Parallel agent swa ```text observable agent run -→ normalized Chronicle +→ normalized Flight Recorder → one test-backed seam → disposable Takeover Twin → deterministic judge @@ -41,7 +41,7 @@ New v0.3 runtime code should live under focused modules: ```text extension/src/ agent/ agent-driver contract and first adapter - chronicle/ normalized event model and local event store + recorder/ normalized event model and local event store change/ changed-symbol and evidence extraction experience/ candidate selection and manifest compiler pulse/ post-R4 claim, capsule, and catalog-probe contracts @@ -53,7 +53,7 @@ extension/src/ extension/test/ fixtures/v0.3/ tiny deterministic Git repositories or fixture builders agent.test.ts - chronicle.test.ts + flight-recorder.test.ts experience.test.ts twin.test.ts judge.test.ts @@ -92,7 +92,7 @@ Event-driven Control Pulses, Side Coach calls, streaks, achievements, and any le Do not build the UI or agent adapter if a deterministic disposable Git fixture cannot be made reliable on Windows. -## Workstream R1 — AgentDriver and Chronicle +## Workstream R1 — AgentDriver and Flight Recorder **Goal:** capture observable evidence from one real or simulated coding-agent run behind a stable adapter. @@ -101,8 +101,8 @@ Do not build the UI or agent adapter if a deterministic disposable Git fixture c - `extension/src/agent/types.ts` - `extension/src/agent/driver.ts` - `extension/src/agent/replay-driver.ts` -- `extension/src/chronicle/events.ts` -- `extension/src/chronicle/store.ts` +- `extension/src/recorder/events.ts` +- `extension/src/recorder/store.ts` - corresponding tests ### Tasks @@ -223,7 +223,7 @@ If the vertical slice requires a generalized knowledge graph before it can ident ### Acceptance -- the compiler creates the expected fixture episode from Chronicle plus change evidence; +- the compiler creates the expected fixture episode from Flight Recorder data plus change evidence; - user-facing serialization contains none of the source run/revision, setup, judge, oracle, or hidden-answer fields; - `git show`, reflog, remotes, object enumeration, and environment inspection inside the twin cannot recover the target commit or production path; - the declared `mutated` state fails, the controller-applied known repair reproduces `target.treeHash` and passes, and every non-declared candidate tree is rejected without execution; @@ -389,7 +389,7 @@ Do not call PureFlow a skill-retention product until the controlled delayed-tran ```mermaid flowchart LR - R0["R0 Fixture"] --> R1["R1 Driver + Chronicle"] + R0["R0 Fixture"] --> R1["R1 Driver + Flight Recorder"] R0 --> R2["R2 Change evidence"] R0 --> R3["R3 Twin lifecycle"] R1 --> R4["R4 Compiler + Judge"] @@ -412,7 +412,7 @@ R1, R2, and R3 may run in parallel only after R0 contracts are committed. R4 is The mechanism is ready for the corpus audit when all of this is true: -1. a replayed agent run produces a versioned, ordered, normalized Chronicle with bounded evidence refs; +1. a replayed agent run produces a versioned, ordered, normalized Flight Recorder stream with bounded evidence refs; 2. a test-backed changed boundary is identified; 3. PureFlow creates a sanitized standalone twin with no source Git objects, production path, oracle, or hidden answer; 4. the participant-facing payload and materialized twin contain no target answer, source revision, oracle, or production path; diff --git a/docs/v0.3/CONCEPTS.md b/docs/v0.3/CONCEPTS.md index 029e634..4c7ff8b 100644 --- a/docs/v0.3/CONCEPTS.md +++ b/docs/v0.3/CONCEPTS.md @@ -177,11 +177,11 @@ The next concepts change where the experience comes from and what the human cont ### Agent near-miss replay -Prefer an agent's real failed hypothesis, red test, rollback, or abandoned implementation branch over a synthetic mutation when the Chronicle contains one. PureFlow rewinds to the first observable divergence, escrows the later repair, and asks the developer to choose evidence and recover the checkpoint. +Prefer an agent's real failed hypothesis, red test, rollback, or abandoned implementation branch over a synthetic mutation when the Flight Recorder contains one. PureFlow rewinds to the first observable divergence, escrows the later repair, and asks the developer to choose evidence and recover the checkpoint. This is more project-authentic than a generic seeded bug and makes routine agent self-repair produce operator practice as a second output. It also reduces mutation-pattern gaming. The risk is selection bias: clean first-pass runs produce no near miss, and a failed agent path may be irrelevant rather than instructive. Synthetic change-derived mutations remain the fallback. -**Status:** promote to an R1 Chronicle and R7 corpus hypothesis. Adding intermediate checkpoint revisions requires a versioned Chronicle contract change before implementation. +**Status:** promote to an R1 Flight Recorder and R7 corpus hypothesis. Adding intermediate checkpoint revisions requires a versioned Flight Recorder contract change before implementation. ### Evidence escrow diff --git a/docs/v0.3/CONTRACTS.md b/docs/v0.3/CONTRACTS.md index 4f7e013..cecf1db 100644 --- a/docs/v0.3/CONTRACTS.md +++ b/docs/v0.3/CONTRACTS.md @@ -60,7 +60,7 @@ Invariants: - timestamps are UTC ISO-8601 with milliseconds; - task summaries are at most 4 KiB; each acceptance item is at most 1 KiB and there are at most 32; - an individual stored evidence blob is at most 1 MiB in the R&D slice; larger command output records original size and explicit truncation/redaction metadata; -- Chronicle events contain references and hashes, not raw source files or terminal streams. +- Flight Recorder events contain references and hashes, not raw source files or terminal streams. ### Canonical hashing @@ -133,7 +133,7 @@ interface AgentDriver { start(task: AgentTask, workspace: AgentWorkspace): Promise; followUp(runId: RunId, message: string): Promise; cancel(runId: RunId): Promise; - events(runId: RunId): AsyncIterable; + events(runId: RunId): AsyncIterable; } interface TaskStore { @@ -143,7 +143,7 @@ interface TaskStore { } ``` -`localHandle` is an opaque runtime lookup into an extension-owned workspace registry. It must never be serialized into the Chronicle, webview messages, research export, or logs. Vendor-specific events end at the adapter; downstream modules consume only the normalized Chronicle. +`localHandle` is an opaque runtime lookup into an extension-owned workspace registry. It must never be serialized into the Flight Recorder, webview messages, research export, or logs. Vendor-specific events end at the adapter; downstream modules consume only normalized Flight Recorder events. `trusted` records the state at agent start for audit. It is not an authorization token: the command runner rechecks live `vscode.workspace.isTrusted` immediately before every non-fixture execution and cancels if trust was revoked. @@ -151,10 +151,10 @@ An adapter may record an explicit plan emitted by an agent. It must never reques `TaskStore.put` validates the limits above and returns `canonicalHash("task-intent", task)`; `task.started.intentHash` must equal that value. The full bounded intent stays controller-local and is never included in a participant manifest by default. -## 3. Chronicle envelope and ordering +## 3. Flight Recorder envelope and ordering ```ts -interface ChronicleEnvelope { +interface RunEnvelope { schemaVersion: SchemaVersion; projectId: ProjectId; runId: RunId; @@ -210,9 +210,9 @@ Ordering rules: - wall-clock timestamps are descriptive; `seq` is authoritative for order. ```ts -interface ChronicleStore { - append(event: ChronicleEnvelope): Promise; - read(runId: RunId, afterSeq?: number): AsyncIterable; +interface RunRecorder { + append(event: RunEnvelope): Promise; + read(runId: RunId, afterSeq?: number): AsyncIterable; lastSeq(runId: RunId): Promise; } @@ -865,9 +865,9 @@ Only a delayed adjacent task can create `VerifiedReadiness`. Immediate `predicte | Component | May read | May emit | Must not access | | --- | --- | --- | --- | -| Agent adapter | vendor events, local workspace handle | normalized Chronicle | readiness state, hidden oracle | -| Chronicle | envelopes, evidence refs | append-only run history | hidden answer, participant UI | -| Change extractor | controller Git revisions, Chronicle refs | candidate seams | readiness mutation | +| Agent adapter | vendor events, local workspace handle | normalized Flight Recorder stream | readiness state, hidden oracle | +| Flight Recorder | envelopes, evidence refs | append-only run history | hidden answer, participant UI | +| Change extractor | controller Git revisions, Flight Recorder refs | candidate seams | readiness mutation | | Experience Compiler | candidate seams, capability evidence | `InternalExperience` | participant process | | Snapshot Store / Twin Manager | controller source revision during create; sanitized snapshot afterward | verified snapshot and isolated twin handle | hidden answer, oracle, unrelated host files | | Cockpit | `ParticipantExperience`, visible evidence | participant actions | internal revisions, judge spec, hidden answer | diff --git a/docs/v0.3/README.md b/docs/v0.3/README.md index abbc87a..2f305c4 100644 --- a/docs/v0.3/README.md +++ b/docs/v0.3/README.md @@ -11,10 +11,12 @@ PureFlow v0.3 asks whether an AI IDE can keep autonomous coding fast while behav 5. [`ADR-001-DUAL-CONTROL.md`](ADR-001-DUAL-CONTROL.md) — system decision. 6. [`ADR-002-EXECUTION-PHASES.md`](ADR-002-EXECUTION-PHASES.md) — trusted-fixture slice versus the untrusted-code sandbox gate. 7. [`ADR-004-EPISTEMIC-CHECKPOINTS.md`](ADR-004-EPISTEMIC-CHECKPOINTS.md) — event-driven Control Pulses, Explain-to-Break, Side Coach, and honest motivation mechanics. -8. [`CONTRACTS.md`](CONTRACTS.md) — normative schemas, hidden-answer isolation, oracle integrity, and command boundary. -9. [`EXPERIMENTS.md`](EXPERIMENTS.md) — hypotheses, metrics, and kill criteria. -10. [`AGENT_EXECUTION.md`](AGENT_EXECUTION.md) — ordered implementation workstreams and acceptance gates. -11. [`JULES_LOOP.md`](JULES_LOOP.md) — guarded server-side execution queue for the audited R0–R4 slice. +8. [`ADR-005-FLIGHT-RECORDER-NAME.md`](ADR-005-FLIGHT-RECORDER-NAME.md) — collision-free name and raw-ledger boundary. +9. [`ADR-006-CODEX-APP-SERVER-ADAPTER.md`](ADR-006-CODEX-APP-SERVER-ADAPTER.md) — first live runtime choice and portability path. +10. [`CONTRACTS.md`](CONTRACTS.md) — normative schemas, hidden-answer isolation, oracle integrity, and command boundary. +11. [`EXPERIMENTS.md`](EXPERIMENTS.md) — hypotheses, metrics, and kill criteria. +12. [`AGENT_EXECUTION.md`](AGENT_EXECUTION.md) — ordered implementation workstreams and acceptance gates. +13. [`JULES_LOOP.md`](JULES_LOOP.md) — guarded server-side execution queue for the audited R0–R4 slice. ## Current truth @@ -28,7 +30,7 @@ PureFlow v0.3 asks whether an AI IDE can keep autonomous coding fast while behav ```text production agent run -→ observable Chronicle +→ observable Flight Recorder → high-value changed seam → disposable Takeover Twin → human prediction / diagnosis / intervention / recovery diff --git a/docs/v0.3/RESEARCH.md b/docs/v0.3/RESEARCH.md index 38e6157..236e78d 100644 --- a/docs/v0.3/RESEARCH.md +++ b/docs/v0.3/RESEARCH.md @@ -172,6 +172,20 @@ Atrophy is especially useful as a product boundary. PureFlow may borrow spaced r An observational natural experiment on GitHub contribution streaks, [“How Gamification Affects Software Developers”](https://arxiv.org/abs/2006.02371), found that removing the streak counter changed contribution behavior. This supports streaks as a strong engagement lever and warns that developers will optimize the displayed proxy. PureFlow therefore limits any streak to weekly participation and keeps it separate from capability evidence. +### Integration substrate refresh — 2026-07-31 + +The first adapter should reuse an agent runtime, but its event model must not become PureFlow's architecture. + +- [Codex App Server](https://learn.chatgpt.com/docs/app-server) is the strongest first local rich-client boundary: it documents JSONL-over-stdio, exact-version schema generation, thread and turn control, authoritative command/file-change item completion, approvals, steering, and interruption. +- [Agent Client Protocol](https://agentclientprotocol.com/updates) is the strongest second portability boundary. Its current lifecycle and [typed session updates](https://agentclientprotocol.github.io/typescript-sdk/types/SessionUpdate.html) cover tools, plans, permissions, and usage, but ACP is a protocol rather than an agent runtime. +- The [Cline SDK](https://docs.cline.bot/sdk/overview) exposes the same open-source harness used by its IDE and CLI plus [runtime/tool events](https://docs.cline.bot/sdk/events). It is a credible fallback runtime if a narrower App Server adapter cannot yield enough evidence. +- [OpenCode's server](https://opencode.ai/docs/server/) exposes an OpenAPI document and an event stream with a typed SDK. Its embedded v2 SDK is not yet the stable public boundary, so it remains a later adapter candidate. +- [Claude Managed Agents](https://platform.claude.com/docs/en/managed-agents/events-and-streaming) has a rich persisted event stream, steering, interruption, tool calls, and subagent threads, but it is hosted beta infrastructure rather than a local IDE integration surface. + +This comparison leads to ADR-006: spike Codex App Server first over local stdio, then test ACP as the portability layer. Every adapter normalizes into the same Flight Recorder contract and drops raw reasoning, absolute paths, credentials, and vendor-only fields before storage. + +The research refresh also found a naming collision. OpenAI now uses [Chronicle](https://learn.chatgpt.com/docs/customization/chronicle) for screen-derived Codex memory. ADR-005 therefore renames PureFlow's observable run ledger to **Flight Recorder** before R1 data or a public wire format ships. + ## 5. Adjacent precedent: operational drills Reliability engineering already treats human response as something to exercise: diff --git a/prd.md b/prd.md index bd5ab64..5ba8331 100644 --- a/prd.md +++ b/prd.md @@ -78,7 +78,7 @@ They want the AI to do the volume work and spend a small, deliberate attention b The developer describes an outcome. One or more coding agents plan, edit, run commands, test, and repair in isolated worktrees. -### 2. Chronicle +### 2. Flight Recorder PureFlow records a bounded local event stream: task intent, agent decisions that were made explicit, file changes, commands, test results, traces, and merge state. It does not require storing hidden model reasoning. @@ -127,7 +127,7 @@ flowchart LR I["Developer intent"] --> B["Build plane"] B --> A["Agent swarm"] A --> P["Tested patch"] - A --> C["Run chronicle"] + A --> C["Flight Recorder"] P --> C C --> S["Semantic change graph"] S --> E["Experience Compiler"] @@ -178,7 +178,7 @@ States decay with time and meaningful code changes. The UI shows evidence and ag ## Line-level accountability -The Chronicle groups generated lines into supported semantic units. Each unit may link to: +The Flight Recorder groups generated lines into supported semantic units. Each unit may link to: - the user intent or requirement it serves; - the invariant or public behavior it changes; @@ -275,7 +275,7 @@ The R&D architecture is: - VSCodium plus a bundled extension for the product surface; - adapters over existing coding-agent runtimes for the build plane; -- PureFlow-owned Chronicle, Experience Compiler, Takeover Twin, Evidence Judge, Readiness Map, and Autonomy Router. +- PureFlow-owned Flight Recorder, Experience Compiler, Takeover Twin, Evidence Judge, Readiness Map, and Autonomy Router. This keeps the differentiating layer portable and avoids tying the thesis to a commercial editor fork. A deeper editor fork is justified only if a validated control experience requires unsupported workbench primitives. From a76d67e4dffc11d82985f8201e4dea8e196ebcfb Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 00:09:04 +0300 Subject: [PATCH 3/3] docs: record protected R1 acceptance --- docs/BUILD_LOG.md | 6 +++--- docs/PROJECT_STATE.md | 19 +++++++++---------- docs/v0.3/README.md | 2 +- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index 7bc1161..38eeb0c 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -2,16 +2,16 @@ This is a concise chronological record of material implementation work and runtime evidence. It is not a substitute for Git history; it captures intent, verification, and blockers that a commit alone may not explain. -## 2026-07-31 — R1 replay and Flight Recorder candidate +## 2026-07-31 — R1 replay and Flight Recorder accepted - Implemented the exact schema-v1 `AgentTask`, `AgentWorkspace`, `AgentRun`, `AgentDriver`, `RunEvent`, `RunEnvelope`, `RunRecorder`, and `TaskStore` boundaries behind a checked, credential-free replay driver. - Added canonical JSONL serialization, strict sequence and terminal ordering, execution identity checks, project-scoped evidence ownership, bounded evidence metadata, task-intent hashing, opaque local-handle omission, range reads, and extension-owned filesystem persistence. - Added a checked seven-event fixture transcript and ten R1 tests. The tests reject sequence gaps/duplicates, late events, command identity mismatches, cross-project evidence, absolute paths, identity drift, noncanonical lines, unsorted redactions, oversized stored evidence, unknown fields, and unbounded task intent. Failed and cancelled runs do not invent a target revision. - Renamed the raw event layer from `Chronicle` to **Flight Recorder** before publication because OpenAI now uses Chronicle for screen-derived Codex memory. ADR-005 distinguishes the raw controller ledger from a possible participant-facing Flight Log. - Compared current official Codex App Server, ACP, Cline SDK, OpenCode server, and Claude Managed Agents interfaces. ADR-006 selects exact-version Codex App Server over local stdio for the first live spike and ACP as the next portability boundary; no generic agent loop or vendor event union enters the domain contract. -- Local Windows evidence: `npm run check`, all 37 extension tests, production build, and VSIX packaging passed. The packaged Microsoft Store Codex executable was discoverable but returned `Access denied` from the repository shell, so no live adapter success is claimed; its future preflight must require an accessible exact-version CLI and fail closed otherwise. +- Local Windows evidence: `npm run check`, all 37 extension tests, production build, and VSIX packaging passed. Protected PR #11 run `30665384997` then passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`. The packaged Microsoft Store Codex executable was discoverable but returned `Access denied` from the repository shell, so no live adapter success is claimed; its future preflight must require an accessible exact-version CLI and fail closed otherwise. -Evidence: `extension/src/agent/`, `extension/src/recorder/`, `extension/test/flight-recorder.test.ts`, `docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md`, `docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md`, and local command output on 2026-07-31. Protected cross-platform CI and merge are pending. +Evidence: `extension/src/agent/`, `extension/src/recorder/`, `extension/test/flight-recorder.test.ts`, `docs/v0.3/ADR-005-FLIGHT-RECORDER-NAME.md`, `docs/v0.3/ADR-006-CODEX-APP-SERVER-ADAPTER.md`, local command output on 2026-07-31, and protected GitHub Actions run `30665384997` on PR #11. ## 2026-07-31 — R0 deterministic fixture accepted diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 932d690..55ea916 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -2,7 +2,7 @@ Last updated: 2026-07-31 -## Current branch milestone — R1 replay and Flight Recorder candidate +## Current branch milestone — R1 replay and Flight Recorder complete Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -15,8 +15,8 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - R0a now implements RFC 8785 canonical JSON, domain-separated SHA-256, UTF-8 path ordering, tree/candidate/manifest hashes, the versioned fixture types, and fail-closed manifest validation. Ten R0a tests pass on this Windows checkout, bringing the extension suite to 23/23. - R0b now has a committed dependency-free `tenant-cache-key` fixture, deterministic Git factory, fixed base/target revisions and state-tree hashes, controller-owned mutation/repair/harness/oracle blobs, and a separately downloaded hash-pinned Node `v22.17.0` runtime. On this Windows checkout, base and target checks pass, the mutation fails the declared tenant-isolation check, the known repair returns the exact target tree to green, and cleanup preserves the source repository snapshot. - The full local Windows extension suite passes 27/27 with `npm run check`, build, and VSIX packaging. Protected PR #10 run `30663623200` independently reproduced the exact fixture/runtime behavior on Linux and Windows; all five required checks passed, so R0 acceptance is complete. -- Short-lived branch `codex/r1-flight-recorder` now implements the schema-v1 `AgentDriver` boundary, checked replay driver, canonical task-intent storage, and append-only local Flight Recorder behind injected storage and evidence-ownership interfaces. Ten R1 tests cover deterministic replay, canonical round trips, sequence/execution/identity violations, cross-project evidence, path and size bounds, failed/cancelled honesty, persistence, range reads, and secret/local-handle omission. -- The full local Windows extension suite passes 37/37 with `npm run check`; the production bundle and VSIX package also pass. Cross-platform CI and protected integration are still pending, so R1 is a candidate rather than an accepted branch milestone. +- R1 implements the schema-v1 `AgentDriver` boundary, checked replay driver, canonical task-intent storage, and append-only local Flight Recorder behind injected storage and evidence-ownership interfaces. Ten R1 tests cover deterministic replay, canonical round trips, sequence/execution/identity violations, cross-project evidence, path and size bounds, failed/cancelled honesty, persistence, range reads, and secret/local-handle omission. +- The full local Windows extension suite passes 37/37 with `npm run check`; the production bundle and VSIX package also pass. Protected PR #11 run `30665384997` independently passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R1 acceptance is complete. - The semantic extractor, Experience Compiler, Takeover Twin lifecycle, Evidence Judge, Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. `TrustedFixtureRunner` exists for the closed R0 fixture substrate. - No skill-retention or speed metric has been measured. Values in the PRD are predeclared R&D targets. - A new implementation audit found five R0 ambiguities: candidate-diff identity, pre-store fixture blobs, runtime identity, check IDs, and Git object format. The normative contract closes them with structured diffs, catalog-owned blobs, standalone Node `v22.17.0`, declared test IDs, and SHA-1 Git initialization; R0a/R0b now implement and verify that complete substrate. @@ -135,13 +135,12 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Publish the R1 short-lived branch, pass the five protected Linux/Windows checks, merge it into `codex/shadow-cockpit-rnd`, and delete the head. -2. Implement R2 and R3 behind the accepted contracts: deterministic change-evidence extraction and the safe Takeover Twin lifecycle. -3. Integrate R4: one compiled recovery episode and deterministic Evidence Judge. -4. Pass R4.5: one bounded, catalog-only Explain-to-Break Pulse with replay/error fail-closed tests. -5. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. -6. Add the local readiness ledger and minimal cockpit only after the vertical slice is reliable. -7. Run the preregistered delayed-transfer pilot before making any skill-retention claim. +1. Implement R2 and R3 behind the accepted contracts: deterministic change-evidence extraction and the safe Takeover Twin lifecycle. +2. Integrate R4: one compiled recovery episode and deterministic Evidence Judge. +3. Pass R4.5: one bounded, catalog-only Explain-to-Break Pulse with replay/error fail-closed tests. +4. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. +5. Add the local readiness ledger and minimal cockpit only after the vertical slice is reliable. +6. Run the preregistered delayed-transfer pilot before making any skill-retention claim. ## Recent milestone commits diff --git a/docs/v0.3/README.md b/docs/v0.3/README.md index 2f305c4..a20375f 100644 --- a/docs/v0.3/README.md +++ b/docs/v0.3/README.md @@ -21,7 +21,7 @@ PureFlow v0.3 asks whether an AI IDE can keep autonomous coding fast while behav ## Current truth - The released v0.1 VSCodium IDE exists and remains the runtime baseline. -- The v0.3 Dual-Control product runtime is not implemented. R0 is complete: R0a provides canonical hashing and fail-closed fixture-contract validation, while R0b provides the deterministic fixture, standalone hash-pinned runtime, external oracle, mutation, and exact repair. Protected PR #10 run `30663623200` reproduced the golden behavior on Linux and Windows. +- The complete v0.3 Dual-Control product runtime is not implemented. R0 provides canonical hashing, fail-closed fixture contracts, the deterministic fixture, standalone hash-pinned runtime, external oracle, mutation, and exact repair. R1 adds the checked replay `AgentDriver` and append-only Flight Recorder. Protected PR #11 run `30665384997` passed the required Linux and Windows checks. - No retention, takeover, productivity, or usability target has been measured. - The first valid build is one test-backed vertical slice, not a full Cursor clone. - R0–R4 may execute only finite, repository-owned fixture states. Arbitrary participant or corpus code remains blocked until ADR-003 selects and runtime-verifies a real sandbox backend.