From f7102d8168a162b60f08de3a2af0b3e29c4b2f7d Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 00:58:00 +0300 Subject: [PATCH 1/3] feat: build isolated takeover twin --- docs/BUILD_LOG.md | 10 + docs/PROJECT_STATE.md | 8 +- docs/v0.3/AGENT_EXECUTION.md | 5 +- .../v0.3/tenant-cache-key/base/.gitattributes | 0 .../tenant-cache-key/base/src/cache-key.ts | 0 .../tenant-cache-key/controller/harness.mjs | 0 .../controller/mutation.patch | 0 .../tenant-cache-key/controller/oracle.json | 0 .../tenant-cache-key/controller/repair.patch | 0 .../tenant-cache-key/mutated/.gitattributes | 0 .../tenant-cache-key/mutated/src/cache-key.ts | 0 .../tenant-cache-key/target/.gitattributes | 0 .../tenant-cache-key/target/src/cache-key.ts | 0 extension/src/recorder/events.ts | 2 +- extension/src/twin/catalog.ts | 100 +++++ extension/src/twin/commands.ts | 325 ++++++++++++++++ extension/src/twin/fixture-factory.ts | 49 ++- extension/src/twin/fixture-runtime.ts | 11 +- extension/src/twin/manager.ts | 134 +++++++ extension/src/twin/snapshot.ts | 354 ++++++++++++++++++ extension/src/twin/types.ts | 84 +++++ extension/test/twin.test.ts | 319 ++++++++++++++++ 22 files changed, 1392 insertions(+), 9 deletions(-) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/base/.gitattributes (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/controller/harness.mjs (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/controller/mutation.patch (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/controller/oracle.json (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/controller/repair.patch (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/target/.gitattributes (100%) rename extension/{test => }/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts (100%) create mode 100644 extension/src/twin/catalog.ts create mode 100644 extension/src/twin/commands.ts create mode 100644 extension/src/twin/manager.ts create mode 100644 extension/src/twin/snapshot.ts create mode 100644 extension/src/twin/types.ts create mode 100644 extension/test/twin.test.ts diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index 44817d1..c842735 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -2,6 +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 — R3 Takeover Twin local candidate + +- Implemented a controller-owned `SnapshotStore` that starts from the verified fixture target, applies the catalog mutation, creates a sanitized standalone one-commit participant repository, and re-verifies tree hashes, commit identity, ownership, remotes, reflogs, and Git alternates before materialization. +- Added an opaque Twin Manager with separate uniquely named twin/evaluation directories and explicit `preparing → ready → running → completed/failed → cleaning → cleaned` lifecycle. Cleanup resolves and checks both exact controller-owned paths before recursive removal. +- Added the immutable fixture command registry, extension-owned catalog, hash-pinned `fixture-node` resolution, bounded command evidence, tombstoned execution IDs, independent cancellation, scrubbed child environment, and full process-tree termination. Every non-fixture request, caller manifest/runtime/path, unknown command, and undeclared tree state fails before execution. +- Moved the committed fixture from the excluded test tree into packaged extension assets after a VSIX audit found it would otherwise be absent at runtime. The package now contains all three candidate states plus controller-only mutation, repair, harness, and oracle assets. +- Seven R3 tests pass locally on Windows, including a real descendant-process kill in a path with spaces. The complete extension suite passes 50/50; `npm run check`, production build, and VSIX packaging pass. Protected Linux/Windows CI has not yet accepted this candidate. + +Evidence: `extension/src/twin/{types,catalog,snapshot,manager,commands}.ts`, `extension/fixtures/v0.3/tenant-cache-key/`, `extension/test/twin.test.ts`, and local command/package output on 2026-07-31. + ## 2026-07-31 — R2 change evidence accepted - Added a bounded read-only Git revision reader that verifies full base/target commit IDs, parses NUL-delimited name status plus zero-context hunks, detects renames, caps source and revision size, and keeps every emitted path workspace-relative. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index d5ee276..4ec812e 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -2,7 +2,7 @@ Last updated: 2026-07-31 -## Current branch milestone — R2 change evidence complete +## Current branch milestone — R3 Takeover Twin candidate complete Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -19,7 +19,9 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - 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. - R2 implements bounded Git revision diffs, zero-context changed-line extraction, TypeScript compiler-API symbol resolution, explicit fixture check linkage, Flight Recorder hash/attribution validation, and exact `ExtractionResult` / `SemanticUnit` / `CandidateSeam` outputs. It fails to `partial` or `unsupported` for missing links, syntax failures, unsupported languages, or incomplete coverage instead of inventing an invariant. - Six R2 tests cover the real cache-key fixture, cross-run determinism and golden IDs, file rename, multi-file changes, added/deleted functions, class boundaries, Git hunk parsing, unsupported syntax/language, missing checks, unsafe paths, and revision/run drift. The full local Windows extension suite passes 43/43 with TypeScript, build, and VSIX packaging. Protected PR #12 run `30666648522` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R2 acceptance is complete. -- The 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. +- R3 now implements the fixture-only sanitized snapshot store, standalone one-commit participant repository, opaque Twin Manager, immutable command-registry snapshots, extension-owned trusted catalog, and `TrustedFixtureRunner`. The runner accepts only the pinned manifest, exact declared state/tree, catalog command, hash-verified standalone Node runtime, and opaque twin handle; non-fixture execution remains explicitly unsupported. +- Seven R3 tests cover production file/index/HEAD/ref/remote/worktree invariants, hidden-answer and source-history absence, cross-project ownership, exact-state execution, tamper/unknown-command rejection, concurrent execution IDs, single-run cancellation, immutable command snapshots, Windows paths with spaces, descendant-process termination, and exact cleanup. The full local Windows extension suite passes 50/50; build and VSIX packaging pass, and the runtime fixture assets are present in the VSIX. Protected Linux/Windows acceptance is still pending for the current short-lived R3 branch. +- The Experience Compiler, Evidence Judge, Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. R3 proves only the closed reviewed-fixture boundary; it does not execute arbitrary participant or workspace code. - 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. @@ -137,7 +139,7 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Implement R3 behind the accepted contract: safe Takeover Twin snapshot, lifecycle, catalog, and command boundaries. +1. Obtain protected Linux/Windows acceptance for the R3 Takeover Twin candidate. 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. diff --git a/docs/v0.3/AGENT_EXECUTION.md b/docs/v0.3/AGENT_EXECUTION.md index f54dc1a..ad81d73 100644 --- a/docs/v0.3/AGENT_EXECUTION.md +++ b/docs/v0.3/AGENT_EXECUTION.md @@ -50,8 +50,11 @@ extension/src/ readiness/ local evidence ledger and derived state cockpit/ host-to-webview messages for v0.3 UI +extension/fixtures/v0.3/ + tenant-cache-key/ packaged finite candidate states and controller assets + extension/test/ - fixtures/v0.3/ tiny deterministic Git repositories or fixture builders + fixtures/v0.3/ replay transcripts and other test-only evidence agent.test.ts flight-recorder.test.ts experience.test.ts diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/base/.gitattributes b/extension/fixtures/v0.3/tenant-cache-key/base/.gitattributes similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/base/.gitattributes rename to extension/fixtures/v0.3/tenant-cache-key/base/.gitattributes diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts b/extension/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts rename to extension/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/harness.mjs b/extension/fixtures/v0.3/tenant-cache-key/controller/harness.mjs similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/controller/harness.mjs rename to extension/fixtures/v0.3/tenant-cache-key/controller/harness.mjs diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/mutation.patch b/extension/fixtures/v0.3/tenant-cache-key/controller/mutation.patch similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/controller/mutation.patch rename to extension/fixtures/v0.3/tenant-cache-key/controller/mutation.patch diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/oracle.json b/extension/fixtures/v0.3/tenant-cache-key/controller/oracle.json similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/controller/oracle.json rename to extension/fixtures/v0.3/tenant-cache-key/controller/oracle.json diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/repair.patch b/extension/fixtures/v0.3/tenant-cache-key/controller/repair.patch similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/controller/repair.patch rename to extension/fixtures/v0.3/tenant-cache-key/controller/repair.patch diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes b/extension/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes rename to extension/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts b/extension/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts rename to extension/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/target/.gitattributes b/extension/fixtures/v0.3/tenant-cache-key/target/.gitattributes similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/target/.gitattributes rename to extension/fixtures/v0.3/tenant-cache-key/target/.gitattributes diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts b/extension/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts similarity index 100% rename from extension/test/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts rename to extension/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts diff --git a/extension/src/recorder/events.ts b/extension/src/recorder/events.ts index 5e8fb6e..29ec2e9 100644 --- a/extension/src/recorder/events.ts +++ b/extension/src/recorder/events.ts @@ -201,7 +201,7 @@ function assertRunEvent(value: unknown): asserts value is RunEvent { } } -function assertEvidenceRef(value: unknown, expectedKind: EvidenceKind): asserts value is EvidenceRef { +export function assertEvidenceRef(value: unknown, expectedKind: EvidenceKind): asserts value is EvidenceRef { assertRecord(value, "evidence ref"); assertExactKeys( value, diff --git a/extension/src/twin/catalog.ts b/extension/src/twin/catalog.ts new file mode 100644 index 0000000..e4894e1 --- /dev/null +++ b/extension/src/twin/catalog.ts @@ -0,0 +1,100 @@ +import { readFile, stat } from "node:fs/promises"; +import { rawSha256 } from "../rnd/canonical"; +import { + EXPECTED_TENANT_CACHE_KEY, + loadTenantCacheKeyManifest, + tenantCacheKeyControllerAssets, +} from "./fixture-factory"; +import { fixtureManifestHash, validateFixtureManifest, type FixtureBlobRef } from "./fixture-contract"; +import { openFixtureNodeRuntime, type InstalledFixtureRuntime } from "./fixture-runtime"; +import type { TrustedFixtureRecord } from "./types"; + +export class BuiltinFixtureCatalog { + private record?: Promise; + private runtime?: InstalledFixtureRuntime; + + async open(fixtureId: string, manifestHash: string): Promise { + if ( + fixtureId !== "tenant-cache-key" || + manifestHash !== EXPECTED_TENANT_CACHE_KEY.manifestHash + ) { + return undefined; + } + return structuredClone(await (this.record ??= this.load())); + } + + async openForSnapshot(sourceRevision: string, mutationId: string): Promise { + const record = await this.open("tenant-cache-key", EXPECTED_TENANT_CACHE_KEY.manifestHash); + if ( + !record || + record.manifest.targetRevision !== sourceRevision || + record.manifest.mutation.id !== mutationId + ) { + return undefined; + } + return record; + } + + async resolveNode(record: TrustedFixtureRecord): Promise { + const current = await this.open(record.manifest.fixtureId, record.manifestHash); + if (!current || current.node.executableSha256 !== record.node.executableSha256) { + throw new Error("Trusted fixture runtime record changed after catalog validation"); + } + const runtime = this.runtime; + if (!runtime || runtime.executableSha256 !== record.node.executableSha256) { + throw new Error("Trusted fixture runtime is not open"); + } + return runtime.executablePath; + } + + private async load(): Promise { + const [manifest, runtime] = await Promise.all([ + loadTenantCacheKeyManifest(), + openFixtureNodeRuntime(), + ]); + validateFixtureManifest(manifest, { + handle: runtime.handle, + version: runtime.version, + executableSha256: runtime.executableSha256, + }); + const manifestHash = fixtureManifestHash(manifest); + if (manifestHash !== EXPECTED_TENANT_CACHE_KEY.manifestHash) { + throw new Error("Built-in fixture manifest hash changed"); + } + this.runtime = runtime; + + const handles = tenantCacheKeyControllerAssets(); + const record: TrustedFixtureRecord = { + manifestHash, + manifest, + node: { + handle: "fixture-node", + version: runtime.version, + executableSha256: runtime.executableSha256, + }, + blobs: { + harness: { localHandle: handles.harness, ref: manifest.toolchain.harness }, + oracle: { localHandle: handles.oracle, ref: manifest.toolchain.oracle }, + mutation: { localHandle: handles.mutation, ref: manifest.mutation.changeRef }, + repair: { localHandle: handles.repair, ref: manifest.knownRepair.changeRef }, + }, + }; + + await Promise.all([ + verifyBlob(record.blobs.harness.localHandle, record.blobs.harness.ref), + verifyBlob(record.blobs.oracle.localHandle, record.blobs.oracle.ref), + verifyBlob(record.blobs.mutation.localHandle, record.blobs.mutation.ref), + verifyBlob(record.blobs.repair.localHandle, record.blobs.repair.ref), + ]); + return record; + } +} + +async function verifyBlob(path: string, ref: FixtureBlobRef): Promise { + const info = await stat(path); + if (!info.isFile()) throw new Error(`Fixture blob is not a regular file: ${ref.id}`); + const bytes = await readFile(path); + if (bytes.byteLength !== ref.storedBytes || rawSha256(bytes) !== ref.sha256) { + throw new Error(`Fixture blob does not match its committed reference: ${ref.id}`); + } +} diff --git a/extension/src/twin/commands.ts b/extension/src/twin/commands.ts new file mode 100644 index 0000000..9f8791c --- /dev/null +++ b/extension/src/twin/commands.ts @@ -0,0 +1,325 @@ +import { randomUUID } from "node:crypto"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { join, resolve, sep } from "node:path"; +import { assertToken } from "../agent/types"; +import { canonicalHash, compareUtf8, rawSha256, treeHash } from "../rnd/canonical"; +import type { EvidenceRef } from "../recorder/events"; +import { BuiltinFixtureCatalog } from "./catalog"; +import { TwinManager } from "./manager"; +import { readCandidateTree } from "./snapshot"; +import type { + CommandRegistrySnapshot, + CommandResult, + TrustedFixtureRecord, + TrustedFixtureRequest, +} from "./types"; + +interface StoredOutput { + projectId: string; + ref: EvidenceRef; + content: string; +} + +export class MemoryCommandEvidenceStore { + private readonly values = new Map(); + + async put(projectId: string, content: Buffer, originalBytes: number): Promise { + assertToken(projectId, "projectId"); + const id = randomUUID().replaceAll("-", ""); + const ref: EvidenceRef = { + id, + kind: "command-output", + sha256: rawSha256(content), + storedBytes: content.byteLength, + originalBytes, + truncated: content.byteLength < originalBytes, + redactions: [], + mediaType: "text/plain", + visibility: "controller", + }; + this.values.set(id, { projectId, ref, content: content.toString("utf8") }); + return structuredClone(ref); + } + + async open(projectId: string, ref: EvidenceRef): Promise { + const value = this.values.get(ref.id); + if (!value || value.projectId !== projectId || canonicalHash("evidence-ref", value.ref) !== canonicalHash("evidence-ref", ref)) { + return undefined; + } + return value.content; + } +} + +interface Execution { + request: TrustedFixtureRequest; + cancelled: boolean; + child?: ChildProcess; +} + +export class TrustedFixtureProcessRunner { + private readonly active = new Map(); + private readonly tombstones = new Set(); + + constructor( + private readonly catalog: BuiltinFixtureCatalog, + private readonly twins: TwinManager, + private readonly evidence: MemoryCommandEvidenceStore, + ) {} + + async run(request: TrustedFixtureRequest): Promise { + validateRequest(request); + if (this.active.has(request.executionId) || this.tombstones.has(request.executionId)) { + throw new Error("Execution ID was reused"); + } + const execution: Execution = { request: structuredClone(request), cancelled: false }; + this.active.set(request.executionId, execution); + let began = false; + let lifecycleFailed = false; + + try { + const record = await this.catalog.open(request.fixtureId, request.manifestHash); + if (!record) throw new Error("Trusted fixture catalog entry is unavailable"); + const state = record.manifest.states.find((item) => item.id === request.stateId); + const command = record.manifest.commands.find((item) => item.id === request.commandId); + if (!state || !command || !state.commandIds.includes(command.id)) { + throw new Error("Requested command is not declared for this fixture state"); + } + + const root = this.twins.resolveReadyRoot(request.projectId, request.twinHandle); + if (treeHash(await readCandidateTree(root)) !== state.treeHash) { + throw new Error("Twin tree does not match the declared fixture state"); + } + if (execution.cancelled) return this.cancelledResult(request); + + this.twins.beginExecution(request.projectId, request.twinHandle); + began = true; + const executable = await this.catalog.resolveNode(record); + if (execution.cancelled) return this.cancelledResult(request); + const raw = await execute( + execution, + executable, + [ + "--experimental-strip-types", + record.blobs.harness.localHandle, + ...command.args, + record.blobs.oracle.localHandle, + ], + command.cwd === "." ? root : inside(root, command.cwd), + command.timeoutMs, + command.maxOutputBytes, + ); + lifecycleFailed = raw.timedOut; + const [stdout, stderr] = await Promise.all([ + this.evidence.put(request.projectId, raw.stdout.content, raw.stdout.originalBytes), + this.evidence.put(request.projectId, raw.stderr.content, raw.stderr.originalBytes), + ]); + return { + executionId: request.executionId, + commandId: request.commandId, + exitCode: raw.exitCode, + timedOut: raw.timedOut, + cancelled: raw.cancelled, + stdout, + stderr, + }; + } catch (error) { + if (began) this.twins.finishExecution(request.projectId, request.twinHandle, true); + began = false; + throw error; + } finally { + if (began) this.twins.finishExecution(request.projectId, request.twinHandle, lifecycleFailed); + this.active.delete(request.executionId); + this.tombstones.add(request.executionId); + } + } + + async cancel(executionId: string): Promise { + assertToken(executionId, "executionId"); + const execution = this.active.get(executionId); + if (!execution) throw new Error("Execution ID is not active"); + execution.cancelled = true; + if (execution.child) await terminateProcessTree(execution.child); + } + + async runUnsupported(_request: unknown): Promise { + throw new Error("Non-fixture execution is unsupported until a verified sandbox backend exists"); + } + + private async cancelledResult(request: TrustedFixtureRequest): Promise { + const empty = Buffer.alloc(0); + const [stdout, stderr] = await Promise.all([ + this.evidence.put(request.projectId, empty, 0), + this.evidence.put(request.projectId, empty, 0), + ]); + return { + executionId: request.executionId, + commandId: request.commandId, + exitCode: null, + timedOut: false, + cancelled: true, + stdout, + stderr, + }; + } +} + +export class FixtureCommandRegistry { + private readonly snapshots = new Map(); + + constructor( + private readonly catalog: BuiltinFixtureCatalog, + private readonly fixtureId: string, + private readonly manifestHash: string, + ) {} + + async freeze(projectId: string, commandIds: string[]): Promise { + assertToken(projectId, "projectId"); + const record = await this.catalog.open(this.fixtureId, this.manifestHash); + if (!record) throw new Error("Fixture command catalog is unavailable"); + const unique = new Set(commandIds); + if (unique.size !== commandIds.length) throw new Error("Command registry IDs must be unique"); + const commands = [...unique].sort(compareUtf8).map((id) => { + const command = record.manifest.commands.find((item) => item.id === id); + if (!command) throw new Error(`Unknown fixture command: ${id}`); + return structuredClone(command); + }); + const core = { schemaVersion: 1 as const, projectId, commands }; + const snapshot = { ...core, sha256: canonicalHash("command-registry", core) }; + const previous = this.snapshots.get(`${projectId}/${snapshot.sha256}`); + if (previous && canonicalHash("command-registry-snapshot", previous) !== canonicalHash("command-registry-snapshot", snapshot)) { + throw new Error("Command registry snapshot collision"); + } + this.snapshots.set(`${projectId}/${snapshot.sha256}`, structuredClone(snapshot)); + return structuredClone(snapshot); + } + + async open(projectId: string, sha256: string): Promise { + const value = this.snapshots.get(`${projectId}/${sha256}`); + if (!value) return undefined; + const core = { schemaVersion: value.schemaVersion, projectId: value.projectId, commands: value.commands }; + if (canonicalHash("command-registry", core) !== value.sha256) throw new Error("Command registry integrity failure"); + return structuredClone(value); + } +} + +async function execute( + execution: Execution, + executable: string, + args: string[], + cwd: string, + timeoutMs: number, + maxOutputBytes: number, +): Promise<{ + exitCode: number | null; + timedOut: boolean; + cancelled: boolean; + stdout: Captured; + stderr: Captured; +}> { + return new Promise((resolveRun, reject) => { + const child = spawn(executable, args, { + cwd, + env: minimalEnvironment(execution.request.executionId), + shell: false, + windowsHide: true, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + execution.child = child; + const stdout = capture(maxOutputBytes); + const stderr = capture(maxOutputBytes); + let timedOut = false; + let settled = false; + const timer = setTimeout(() => { + timedOut = true; + void terminateProcessTree(child); + }, timeoutMs); + child.stdout.on("data", stdout.add); + child.stderr.on("data", stderr.add); + child.once("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveRun({ + exitCode: code, + timedOut, + cancelled: execution.cancelled, + stdout: stdout.done(), + stderr: stderr.done(), + }); + }); + }); +} + +interface Captured { + content: Buffer; + originalBytes: number; +} + +function capture(limit: number): { add(chunk: Buffer): void; done(): Captured } { + const chunks: Buffer[] = []; + let stored = 0; + let originalBytes = 0; + return { + add(chunk) { + originalBytes += chunk.byteLength; + if (stored >= limit) return; + const part = chunk.subarray(0, limit - stored); + chunks.push(part); + stored += part.byteLength; + }, + done: () => ({ content: Buffer.concat(chunks), originalBytes }), + }; +} + +export async function terminateProcessTree(child: ChildProcess): Promise { + const pid = child.pid; + if (!pid || !Number.isSafeInteger(pid) || pid < 1) throw new Error("Cannot terminate an unmapped process tree"); + if (process.platform === "win32") { + const systemRoot = process.env.SYSTEMROOT ?? process.env.WINDIR; + if (!systemRoot) throw new Error("Windows process-tree termination requires SystemRoot"); + await new Promise((resolveKill) => { + execFile(join(systemRoot, "System32", "taskkill.exe"), ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + timeout: 10_000, + }, () => resolveKill()); + }); + } else { + try { + process.kill(-pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } +} + +function minimalEnvironment(executionId: string): Record { + const env: Record = { PUREFLOW_EXECUTION_ID: executionId }; + if (process.platform === "win32") { + if (process.env.SYSTEMROOT) env.SYSTEMROOT = process.env.SYSTEMROOT; + if (process.env.WINDIR) env.WINDIR = process.env.WINDIR; + } + return env; +} + +function inside(root: string, rel: string): string { + const value = resolve(root, ...rel.split("/")); + if (!value.startsWith(`${resolve(root)}${sep}`)) throw new Error("Command cwd escapes the twin"); + return value; +} + +function validateRequest(request: TrustedFixtureRequest): void { + assertToken(request.executionId, "executionId"); + assertToken(request.projectId, "projectId"); + assertToken(request.fixtureId, "fixtureId"); + if (!/^[0-9a-f]{64}$/.test(request.manifestHash)) throw new Error("Invalid fixture manifest hash"); + assertToken(request.commandId, "commandId"); + assertToken(request.twinHandle, "twinHandle"); + if (!["base", "target", "mutated"].includes(request.stateId)) throw new Error("Unknown fixture state"); +} diff --git a/extension/src/twin/fixture-factory.ts b/extension/src/twin/fixture-factory.ts index 614af00..18e15f1 100644 --- a/extension/src/twin/fixture-factory.ts +++ b/extension/src/twin/fixture-factory.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; import { rawSha256, treeHash, type TreeFile } from "../rnd/canonical"; import { candidateDiffHash, @@ -15,7 +15,7 @@ import { } from "./fixture-contract"; import { openFixtureNodeRuntime, type InstalledFixtureRuntime } from "./fixture-runtime"; -const assets = fileURLToPath(new URL("../../test/fixtures/v0.3/tenant-cache-key/", import.meta.url)); +const assets = fixtureAssetsRoot(); const gitDate = "2026-01-01T00:00:00.000Z"; export const EXPECTED_TENANT_CACHE_KEY = { @@ -51,6 +51,41 @@ export interface TenantCacheKeyFixture { dispose(): Promise; } +export interface TenantCacheKeyControllerAssets { + harness: string; + oracle: string; + mutation: string; + repair: string; +} + +export async function loadTenantCacheKeyManifest(): Promise { + const manifest = await buildManifest( + EXPECTED_TENANT_CACHE_KEY.baseRevision, + EXPECTED_TENANT_CACHE_KEY.targetRevision, + ); + validateFixtureManifest(manifest); + if (fixtureManifestHash(manifest) !== EXPECTED_TENANT_CACHE_KEY.manifestHash) { + throw new Error("Committed tenant-cache-key fixture does not match its pinned manifest hash"); + } + return manifest; +} + +export function tenantCacheKeyControllerAssets(): TenantCacheKeyControllerAssets { + return { + harness: join(assets, "controller", "harness.mjs"), + oracle: join(assets, "controller", "oracle.json"), + mutation: join(assets, "controller", "mutation.patch"), + repair: join(assets, "controller", "repair.patch"), + }; +} + +export async function materializeTenantCacheKeyState( + state: FixtureState["id"], + destination: string, +): Promise { + await copyTree(join(assets, state), destination); +} + export async function createTenantCacheKeyFixture(destination: string): Promise { const root = verifiedTempRoot(destination); const runtime = await openFixtureNodeRuntime(); @@ -402,3 +437,13 @@ async function removeFixtureRoot(root: string): Promise { verifiedTempRoot(root); await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } + +function fixtureAssetsRoot(): string { + const candidates = [ + resolve(__dirname, "../../fixtures/v0.3/tenant-cache-key"), + resolve(__dirname, "../fixtures/v0.3/tenant-cache-key"), + ]; + const found = candidates.find((candidate) => existsSync(join(candidate, "controller", "harness.mjs"))); + if (!found) throw new Error("Committed tenant-cache-key fixture assets are missing"); + return found; +} diff --git a/extension/src/twin/fixture-runtime.ts b/extension/src/twin/fixture-runtime.ts index 59dae75..08eeb79 100644 --- a/extension/src/twin/fixture-runtime.ts +++ b/extension/src/twin/fixture-runtime.ts @@ -1,13 +1,13 @@ import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; import { readFile, realpath, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { assertExactKeys, assertSha256, rawSha256 } from "../rnd/canonical"; import { R0_NODE_VERSION, type FixtureRuntime } from "./fixture-contract"; const runFile = promisify(execFile); -const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const extensionRoot = findExtensionRoot(); interface Artifact { url: string; @@ -114,3 +114,10 @@ function validateArtifact(artifact: Artifact): void { async function readJson(path: string): Promise { return JSON.parse(await readFile(path, "utf8")) as T; } + +function findExtensionRoot(): string { + const candidates = [resolve(__dirname, "../.."), resolve(__dirname, "..")]; + const found = candidates.find((candidate) => existsSync(join(candidate, "fixture-node-artifacts.json"))); + if (!found) throw new Error("PureFlow extension root does not contain the fixture runtime catalog"); + return found; +} diff --git a/extension/src/twin/manager.ts b/extension/src/twin/manager.ts new file mode 100644 index 0000000..d28fd1b --- /dev/null +++ b/extension/src/twin/manager.ts @@ -0,0 +1,134 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { basename, resolve, sep } from "node:path"; +import { assertToken } from "../agent/types"; +import { FixtureSnapshotStore } from "./snapshot"; +import type { TwinSession } from "./types"; + +interface ManagedTwin extends TwinSession { + root: string; + evaluationRoot: string; + active: number; +} + +export class TwinManager { + private readonly root: string; + private readonly sessions = new Map(); + + constructor(root: string, private readonly snapshots: FixtureSnapshotStore) { + this.root = resolve(root); + } + + async prepare(projectId: string, snapshotId: string): Promise { + assertToken(projectId, "projectId"); + assertToken(snapshotId, "snapshotId"); + if (!(await this.snapshots.get(projectId, snapshotId))) { + throw new Error("Snapshot does not belong to this project"); + } + await mkdir(this.root, { recursive: true }); + const root = await mkdtemp(`${this.root}${sep}pureflow-twin-`); + const evaluationRoot = await mkdtemp(`${this.root}${sep}pureflow-eval-`); + const handle = randomUUID().replaceAll("-", ""); + const session: ManagedTwin = { + handle, + projectId, + snapshotId, + state: "preparing", + createdAt: new Date().toISOString(), + failure: null, + root, + evaluationRoot, + active: 0, + }; + this.sessions.set(handle, session); + + try { + const destination = this.snapshots.reserveDestination(root); + await this.snapshots.materialize(projectId, snapshotId, destination); + session.state = "ready"; + return publicSession(session); + } catch (error) { + session.state = "failed"; + session.failure = error instanceof Error ? error.message : String(error); + await this.removeRoots(session); + throw error; + } + } + + get(projectId: string, handle: string): TwinSession | undefined { + const session = this.sessions.get(handle); + if (!session || session.projectId !== projectId) return undefined; + return publicSession(session); + } + + resolveReadyRoot(projectId: string, handle: string): string { + assertToken(projectId, "projectId"); + assertToken(handle, "twinHandle"); + const session = this.sessions.get(handle); + if (!session || session.projectId !== projectId) throw new Error("Unknown twin handle for project"); + if (!["ready", "running", "completed"].includes(session.state)) { + throw new Error(`Twin is not executable in state ${session.state}`); + } + return this.owned(session.root, "pureflow-twin-"); + } + + beginExecution(projectId: string, handle: string): string { + const root = this.resolveReadyRoot(projectId, handle); + const session = this.sessions.get(handle)!; + session.active += 1; + session.state = "running"; + return root; + } + + finishExecution(projectId: string, handle: string, failed = false): void { + const session = this.sessions.get(handle); + if (!session || session.projectId !== projectId || session.active < 1) { + throw new Error("Twin execution lifecycle is inconsistent"); + } + session.active -= 1; + if (failed) { + session.state = "failed"; + } else if (session.active === 0) { + session.state = "completed"; + } + } + + async cleanup(projectId: string, handle: string, options: { preserveFailed?: boolean } = {}): Promise { + const session = this.sessions.get(handle); + if (!session || session.projectId !== projectId) throw new Error("Unknown twin handle for project"); + if (session.active) throw new Error("Cannot clean a running twin"); + if (session.state === "failed" && options.preserveFailed) return; + if (session.state === "cleaned") return; + session.state = "cleaning"; + await this.removeRoots(session); + session.state = "cleaned"; + } + + private async removeRoots(session: ManagedTwin): Promise { + const twin = this.owned(session.root, "pureflow-twin-"); + const evaluation = this.owned(session.evaluationRoot, "pureflow-eval-"); + await Promise.all([ + rm(twin, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }), + rm(evaluation, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }), + ]); + } + + private owned(path: string, prefix: string): string { + const value = resolve(path); + if (!value.startsWith(`${this.root}${sep}`) || !basename(value).startsWith(prefix)) { + throw new Error("Twin cleanup target is outside the controller root"); + } + return value; + } +} + +function publicSession(session: ManagedTwin): TwinSession { + return { + handle: session.handle, + projectId: session.projectId, + snapshotId: session.snapshotId, + state: session.state, + createdAt: session.createdAt, + failure: session.failure, + }; +} diff --git a/extension/src/twin/snapshot.ts b/extension/src/twin/snapshot.ts new file mode 100644 index 0000000..19273e7 --- /dev/null +++ b/extension/src/twin/snapshot.ts @@ -0,0 +1,354 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { assertToken } from "../agent/types"; +import { assertRelPath, compareUtf8, rawSha256, treeHash, type TreeFile } from "../rnd/canonical"; +import { assertEvidenceRef } from "../recorder/events"; +import { BuiltinFixtureCatalog } from "./catalog"; +import { materializeTenantCacheKeyState } from "./fixture-factory"; +import type { CreateSnapshotInput, SanitizedSnapshot } from "./types"; + +const participantDate = "2026-01-02T00:00:00.000Z"; + +interface SnapshotRecord { + snapshot: SanitizedSnapshot; + root: string; +} + +export class FixtureSnapshotStore { + private readonly root: string; + private readonly records = new Map(); + private readonly destinations = new Map(); + + constructor( + root: string, + private readonly catalog: BuiltinFixtureCatalog, + private readonly options: { now?: () => string; id?: () => string } = {}, + ) { + this.root = resolve(root); + } + + async create(input: CreateSnapshotInput): Promise { + validateInput(input); + const record = await this.catalog.openForSnapshot(input.sourceRevision, input.mutationId); + if (!record) throw new Error("Snapshot source revision or mutation is not in the trusted fixture catalog"); + if (!sameEvidence(input.mutation, record.manifest.mutation.changeRef)) { + throw new Error("Snapshot mutation evidence does not match the trusted catalog"); + } + + const target = record.manifest.states.find((state) => state.id === "target")!; + const mutated = record.manifest.states.find((state) => state.id === "mutated")!; + const allowed = [...input.allowedFiles].sort(compareUtf8); + const declared = target.files.map((file) => file.path); + if (allowed.length !== declared.length || allowed.some((path, index) => path !== declared[index])) { + throw new Error("Snapshot allowed files must equal the declared target fixture tree"); + } + + const id = (this.options.id ?? randomUUID)().replaceAll("-", ""); + assertToken(id, "snapshotId"); + const projectRoot = this.owned(join(this.root, input.projectId)); + const root = this.owned(join(projectRoot, id, "repo")); + if (this.records.has(key(input.projectId, id))) throw new Error("Snapshot ID already exists"); + await mkdir(root, { recursive: true }); + + try { + await materializeTenantCacheKeyState("target", root); + await assertTree(root, target.treeHash, "target"); + await git(root, ["apply", "--whitespace=nowarn", record.blobs.mutation.localHandle]); + const files = await readCandidateTree(root); + const hash = treeHash(files); + if (hash !== mutated.treeHash) throw new Error("Applied mutation does not produce the declared fixture state"); + const participantCommit = await initializeStandaloneRepo(root); + await verifyStandaloneRepo(root, participantCommit, files); + + const snapshot: SanitizedSnapshot = { + schemaVersion: 1, + id, + projectId: input.projectId, + state: "mutated", + treeHash: hash, + participantCommit, + files, + mutationId: input.mutationId, + mutationSha256: input.mutation.sha256, + createdAt: (this.options.now ?? (() => new Date().toISOString()))(), + }; + this.records.set(key(input.projectId, id), { snapshot, root }); + return structuredClone(snapshot); + } catch (error) { + await this.removeOwned(join(projectRoot, id)); + throw error; + } + } + + async get(projectId: string, snapshotId: string): Promise { + assertToken(projectId, "projectId"); + assertToken(snapshotId, "snapshotId"); + const record = this.records.get(key(projectId, snapshotId)); + if (!record) return undefined; + await verifyStandaloneRepo(record.root, record.snapshot.participantCommit, record.snapshot.files); + return structuredClone(record.snapshot); + } + + reserveDestination(destination: string): string { + const root = resolve(destination); + if (!basename(root).startsWith("pureflow-twin-")) { + throw new Error("Twin destination must have a controller-owned prefix"); + } + const handle = randomUUID().replaceAll("-", ""); + this.destinations.set(handle, root); + return handle; + } + + async materialize(projectId: string, snapshotId: string, destinationHandle: string): Promise { + assertToken(destinationHandle, "destinationHandle"); + const record = this.records.get(key(projectId, snapshotId)); + if (!record) throw new Error("Snapshot does not belong to this project"); + const destination = this.destinations.get(destinationHandle); + if (!destination) throw new Error("Unknown or expired destination handle"); + this.destinations.delete(destinationHandle); + await assertEmpty(destination); + await verifyStandaloneRepo(record.root, record.snapshot.participantCommit, record.snapshot.files); + await copyRegularTree(record.root, destination, true); + await verifyStandaloneRepo(destination, record.snapshot.participantCommit, record.snapshot.files); + } + + async remove(projectId: string, snapshotId: string): Promise { + const record = this.records.get(key(projectId, snapshotId)); + if (!record) return; + this.records.delete(key(projectId, snapshotId)); + await this.removeOwned(dirname(record.root)); + } + + async removeProject(projectId: string): Promise { + assertToken(projectId, "projectId"); + for (const [id, record] of [...this.records]) { + if (record.snapshot.projectId === projectId) this.records.delete(id); + } + await this.removeOwned(join(this.root, projectId)); + } + + private owned(path: string): string { + const value = resolve(path); + if (value === this.root || !value.startsWith(`${this.root}${sep}`)) { + throw new Error("Snapshot path escapes its storage root"); + } + return value; + } + + private async removeOwned(path: string): Promise { + const value = this.owned(path); + await rm(value, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } +} + +export async function readCandidateTree(root: string): Promise { + const base = resolve(root); + const files: TreeFile[] = []; + await walk(base); + return files.sort((left, right) => compareUtf8(left.path, right.path)); + + async function walk(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (directory === base && entry.name === ".git") continue; + const path = join(directory, entry.name); + const info = await lstat(path); + if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) { + throw new Error(`Unsupported twin entry: ${relative(base, path)}`); + } + if (info.isDirectory()) { + await walk(path); + continue; + } + if (info.nlink !== 1) throw new Error(`Hard-linked twin file is unsupported: ${relative(base, path)}`); + const rel = relative(base, path).split(sep).join("/"); + assertRelPath(rel); + files.push({ + path: rel, + mode: process.platform !== "win32" && (info.mode & 0o111) ? "100755" : "100644", + sha256: rawSha256(await readFile(path)), + }); + } + } +} + +async function initializeStandaloneRepo(root: string): Promise { + const template = await mkdtemp(join(tmpdir(), "pureflow-empty-git-template-")); + try { + await git(root, ["init", "--object-format=sha1", "--initial-branch=main", `--template=${template}`]); + await git(root, ["config", "core.autocrlf", "false"]); + await git(root, ["config", "core.eol", "lf"]); + await git(root, ["config", "core.logAllRefUpdates", "false"]); + await git(root, ["config", "user.name", "PureFlow Participant"]); + await git(root, ["config", "user.email", "participant@pureflow.invalid"]); + await git(root, ["add", "--all"]); + await git(root, ["-c", "commit.gpgSign=false", "commit", "--no-gpg-sign", "-m", "takeover start"]); + await rm(join(root, ".git", "logs"), { recursive: true, force: true }); + await rm(join(root, ".git", "objects", "info", "alternates"), { force: true }); + return git(root, ["rev-parse", "HEAD"]); + } finally { + await rm(template, { recursive: true, force: true }); + } +} + +export async function verifyStandaloneRepo( + root: string, + participantCommit: string, + files: readonly TreeFile[], +): Promise { + if ((await git(root, ["rev-parse", "HEAD"])) !== participantCommit) { + throw new Error("Participant commit changed"); + } + if ((await git(root, ["rev-list", "--count", "HEAD"])) !== "1") { + throw new Error("Participant repository must contain exactly one commit"); + } + if (await git(root, ["remote"])) throw new Error("Participant repository contains a remote"); + if (await git(root, ["reflog", "show", "--all"])) throw new Error("Participant repository contains a reflog"); + try { + await stat(join(root, ".git", "objects", "info", "alternates")); + throw new Error("Participant repository contains Git alternates"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const actual = await readCandidateTree(root); + if (treeHash(actual) !== treeHash(files)) throw new Error("Participant tree does not match its snapshot"); +} + +async function assertTree(root: string, expected: string, label: string): Promise { + if (treeHash(await readCandidateTree(root)) !== expected) { + throw new Error(`Materialized tree does not match declared ${label} state`); + } +} + +async function copyRegularTree(source: string, destination: string, includeGit: boolean): Promise { + const base = resolve(source); + await walk(base); + + async function walk(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!includeGit && directory === base && entry.name === ".git") continue; + const path = join(directory, entry.name); + const info = await lstat(path); + if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) { + throw new Error(`Unsupported source entry: ${relative(base, path)}`); + } + const rel = relative(base, path); + const target = join(destination, rel); + if (info.isDirectory()) { + await mkdir(target, { recursive: true }); + await walk(path); + } else { + if (info.nlink !== 1) throw new Error(`Hard-linked source file is unsupported: ${rel}`); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, await readFile(path)); + if (process.platform !== "win32") await chmod(target, info.mode & 0o777); + } + } + } +} + +async function assertEmpty(root: string): Promise { + const info = await stat(root); + if (!info.isDirectory() || (await readdir(root)).length !== 0) { + throw new Error("Twin destination must be an empty directory"); + } + await realpath(root); +} + +async function git(cwd: string, args: string[]): Promise { + const result = await run("git", args, cwd, 30_000, gitEnvironment()); + if (result.code !== 0) throw new Error(`Git failed (${args[0]}): ${result.stderr || result.stdout}`); + return result.stdout.trim(); +} + +async function run( + executable: string, + args: string[], + cwd: string, + timeoutMs: number, + env: Record, +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolveRun, reject) => { + const child = spawn(executable, args, { + cwd, + env, + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const timer = setTimeout(() => child.kill(), timeoutMs); + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.once("error", reject); + child.once("close", (code) => { + clearTimeout(timer); + resolveRun({ code, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }); + }); + }); +} + +function gitEnvironment(): Record { + return { + GIT_AUTHOR_NAME: "PureFlow Participant", + GIT_AUTHOR_EMAIL: "participant@pureflow.invalid", + GIT_AUTHOR_DATE: participantDate, + GIT_COMMITTER_NAME: "PureFlow Participant", + GIT_COMMITTER_EMAIL: "participant@pureflow.invalid", + GIT_COMMITTER_DATE: participantDate, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + LANG: "C", + LC_ALL: "C", + PATH: process.env.PATH ?? "", + SYSTEMROOT: process.env.SYSTEMROOT ?? "", + }; +} + +function validateInput(input: CreateSnapshotInput): void { + assertToken(input.projectId, "projectId"); + if (!/^[0-9a-f]{40}$/.test(input.sourceRevision)) throw new Error("Invalid source revision"); + assertToken(input.mutationId, "mutationId"); + assertEvidenceRef(input.mutation, "diff"); + if (input.mutation.kind !== "diff" || input.mutation.visibility !== "controller") { + throw new Error("Snapshot mutation must be controller diff evidence"); + } + if (!Array.isArray(input.allowedFiles) || input.allowedFiles.length === 0) { + throw new Error("Snapshot allowed files must be a non-empty array"); + } + const folded = new Set(); + for (const path of input.allowedFiles) { + assertRelPath(path); + const key = path.toLowerCase(); + if (folded.has(key)) throw new Error("Snapshot allowed files contain duplicates"); + folded.add(key); + } +} + +function sameEvidence(ref: CreateSnapshotInput["mutation"], expected: { id: string; sha256: string; storedBytes: number; mediaType: string }): boolean { + return ref.id === expected.id && + ref.sha256 === expected.sha256 && + ref.storedBytes === expected.storedBytes && + ref.originalBytes === expected.storedBytes && + ref.truncated === false && + ref.redactions.length === 0 && + ref.mediaType === expected.mediaType; +} + +function key(projectId: string, snapshotId: string): string { + return `${projectId}/${snapshotId}`; +} diff --git a/extension/src/twin/types.ts b/extension/src/twin/types.ts new file mode 100644 index 0000000..6635e31 --- /dev/null +++ b/extension/src/twin/types.ts @@ -0,0 +1,84 @@ +import type { EvidenceRef } from "../recorder/events"; +import type { + FixtureBlobRef, + FixtureManifest, + FixtureState, + TrustedFixtureCommand, +} from "./fixture-contract"; + +export type TwinState = "preparing" | "ready" | "running" | "completed" | "failed" | "cleaning" | "cleaned"; + +export interface SnapshotFile { + path: string; + sha256: string; + mode: "100644" | "100755"; +} + +export interface SanitizedSnapshot { + schemaVersion: 1; + id: string; + projectId: string; + state: "mutated"; + treeHash: string; + participantCommit: string; + files: SnapshotFile[]; + mutationId: string; + mutationSha256: string; + createdAt: string; +} + +export interface CreateSnapshotInput { + projectId: string; + sourceRevision: string; + mutationId: string; + mutation: EvidenceRef; + allowedFiles: string[]; +} + +export interface TrustedFixtureRecord { + manifestHash: string; + manifest: FixtureManifest; + node: { handle: "fixture-node"; version: string; executableSha256: string }; + blobs: { + harness: { localHandle: string; ref: FixtureBlobRef }; + oracle: { localHandle: string; ref: FixtureBlobRef }; + mutation: { localHandle: string; ref: FixtureBlobRef }; + repair: { localHandle: string; ref: FixtureBlobRef }; + }; +} + +export interface TrustedFixtureRequest { + executionId: string; + projectId: string; + fixtureId: string; + manifestHash: string; + stateId: FixtureState["id"]; + commandId: string; + twinHandle: string; +} + +export interface CommandResult { + executionId: string; + commandId: string; + exitCode: number | null; + timedOut: boolean; + cancelled: boolean; + stdout: EvidenceRef; + stderr: EvidenceRef; +} + +export interface CommandRegistrySnapshot { + schemaVersion: 1; + projectId: string; + commands: TrustedFixtureCommand[]; + sha256: string; +} + +export interface TwinSession { + handle: string; + projectId: string; + snapshotId: string; + state: TwinState; + createdAt: string; + failure: string | null; +} diff --git a/extension/test/twin.test.ts b/extension/test/twin.test.ts new file mode 100644 index 0000000..7617db4 --- /dev/null +++ b/extension/test/twin.test.ts @@ -0,0 +1,319 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { rawSha256 } from "../src/rnd/canonical"; +import { BuiltinFixtureCatalog } from "../src/twin/catalog"; +import { + FixtureCommandRegistry, + MemoryCommandEvidenceStore, + terminateProcessTree, + TrustedFixtureProcessRunner, +} from "../src/twin/commands"; +import { TwinManager } from "../src/twin/manager"; +import { FixtureSnapshotStore } from "../src/twin/snapshot"; +import { EXPECTED_TENANT_CACHE_KEY } from "../src/twin/fixture-factory"; +import { openFixtureNodeRuntime } from "../src/twin/fixture-runtime"; +import type { EvidenceRef } from "../src/recorder/events"; + +const runFile = promisify(execFile); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true, maxRetries: 3 }))); +}); + +describe("R3 Takeover Twin", () => { + it("creates a sanitized standalone mutation snapshot without changing production", async () => { + const before = await sourceRepoSnapshot(); + const env = await setup(); + const snapshot = await env.snapshots.create(await createInput(env.catalog, "project-a")); + const twin = await env.twins.prepare("project-a", snapshot.id); + + expect(snapshot.treeHash).toBe(EXPECTED_TENANT_CACHE_KEY.mutatedTreeHash); + expect(snapshot.state).toBe("mutated"); + expect(twin.state).toBe("ready"); + + const root = env.twins.resolveReadyRoot("project-a", twin.handle); + expect(await git(root, ["rev-list", "--count", "HEAD"])).toBe("1"); + expect(await git(root, ["rev-parse", "HEAD"])).toBe(snapshot.participantCommit); + expect(await git(root, ["remote"])).toBe(""); + expect(await git(root, ["reflog", "show", "--all"])).toBe(""); + await expect(stat(join(root, ".git", "objects", "info", "alternates"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(repoContains(root, EXPECTED_TENANT_CACHE_KEY.targetRevision)).resolves.toBe(false); + await expect(repoContains(root, repoRoot)).resolves.toBe(false); + await expect(repoContains(root, "return `${tenant}:${id}`;")).resolves.toBe(false); + await expect(repoContains(root, "tenant-a:account-42")).resolves.toBe(false); + await expect(sourceRepoSnapshot()).resolves.toEqual(before); + + await env.twins.cleanup("project-a", twin.handle); + expect(env.twins.get("project-a", twin.handle)?.state).toBe("cleaned"); + await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(sourceRepoSnapshot()).resolves.toEqual(before); + }, 45_000); + + it("runs only an exact declared fixture state and command", async () => { + const env = await setup(); + const record = await openRecord(env.catalog); + const snapshot = await env.snapshots.create(await createInput(env.catalog, "project-a")); + const twin = await env.twins.prepare("project-a", snapshot.id); + + const result = await env.runner.run({ + executionId: "exec-mutated", + projectId: "project-a", + fixtureId: record.manifest.fixtureId, + manifestHash: record.manifestHash, + stateId: "mutated", + commandId: "cache-key.tenant-isolation", + twinHandle: twin.handle, + }); + + expect(result).toMatchObject({ exitCode: 1, timedOut: false, cancelled: false }); + await expect(env.evidence.open("project-a", result.stderr)).resolves.toContain("tenant-a:account-42"); + + const root = env.twins.resolveReadyRoot("project-a", twin.handle); + await writeFile(join(root, "src", "cache-key.ts"), "export const cacheKey = () => 'tampered';\n"); + await expect(env.runner.run({ + executionId: "exec-tampered", + projectId: "project-a", + fixtureId: record.manifest.fixtureId, + manifestHash: record.manifestHash, + stateId: "mutated", + commandId: "cache-key.tenant-isolation", + twinHandle: twin.handle, + })).rejects.toThrow("declared fixture state"); + + await expect(env.runner.run({ + executionId: "exec-unknown", + projectId: "project-a", + fixtureId: record.manifest.fixtureId, + manifestHash: record.manifestHash, + stateId: "mutated", + commandId: "npm-install", + twinHandle: twin.handle, + })).rejects.toThrow("command"); + }, 45_000); + + it("fails closed on caller manifests, paths, runtimes, and non-fixture workspaces", async () => { + const env = await setup(); + const record = await openRecord(env.catalog); + + await expect(env.catalog.open("tenant-cache-key", "0".repeat(64))).resolves.toBeUndefined(); + await expect(env.catalog.open("workspace-project", record.manifestHash)).resolves.toBeUndefined(); + expect(() => env.twins.resolveReadyRoot("project-a", "D:/workspace")).toThrow("opaque"); + await expect(env.runner.runUnsupported({ runner: "sandbox", workspace: repoRoot })).rejects.toThrow("unsupported"); + }); + + it("keeps identical concurrent commands independent and cancellation single-use", async () => { + const env = await setup(); + const record = await openRecord(env.catalog); + const firstSnapshot = await env.snapshots.create(await createInput(env.catalog, "project-a")); + const secondSnapshot = await env.snapshots.create(await createInput(env.catalog, "project-a")); + const [first, second] = await Promise.all([ + env.twins.prepare("project-a", firstSnapshot.id), + env.twins.prepare("project-a", secondSnapshot.id), + ]); + const request = (executionId: string, twinHandle: string) => ({ + executionId, + projectId: "project-a", + fixtureId: record.manifest.fixtureId, + manifestHash: record.manifestHash, + stateId: "mutated" as const, + commandId: "cache-key.tenant-isolation", + twinHandle, + }); + + const cancelled = env.runner.run(request("exec-cancel", first.handle)); + const completed = env.runner.run(request("exec-complete", second.handle)); + await env.runner.cancel("exec-cancel"); + + await expect(cancelled).resolves.toMatchObject({ executionId: "exec-cancel", cancelled: true }); + await expect(completed).resolves.toMatchObject({ executionId: "exec-complete", exitCode: 1, cancelled: false }); + await expect(env.runner.run(request("exec-cancel", second.handle))).rejects.toThrow("reused"); + }, 45_000); + + it("rejects unsafe snapshot inputs and cross-project materialization", async () => { + const env = await setup(); + const input = await createInput(env.catalog, "project-a"); + await expect(env.snapshots.create({ ...input, allowedFiles: ["../secret"] })).rejects.toThrow("path"); + await expect(env.snapshots.create({ ...input, sourceRevision: "0".repeat(40) })).rejects.toThrow("source revision"); + + const snapshot = await env.snapshots.create(input); + await expect(env.twins.prepare("project-b", snapshot.id)).rejects.toThrow("project"); + await expect(env.snapshots.get("project-b", snapshot.id)).resolves.toBeUndefined(); + }, 45_000); + + it("freezes immutable command-registry snapshots", async () => { + const env = await setup(); + const record = await openRecord(env.catalog); + const registry = new FixtureCommandRegistry(env.catalog, record.manifest.fixtureId, record.manifestHash); + const snapshot = await registry.freeze("project-a", ["cache-key.tenant-isolation"]); + + expect(snapshot.sha256).toMatch(/^[0-9a-f]{64}$/); + await expect(registry.open("project-a", snapshot.sha256)).resolves.toEqual(snapshot); + await expect(registry.open("project-b", snapshot.sha256)).resolves.toBeUndefined(); + await expect(registry.freeze("project-a", ["npm-install"])).rejects.toThrow("Unknown fixture command"); + await expect(registry.freeze("project-a", ["cache-key.legacy", "cache-key.legacy"])).rejects.toThrow("unique"); + }); + + it("terminates a descendant process tree in a path with spaces", async () => { + const runtime = await openFixtureNodeRuntime(); + const root = await mkdtemp(join(tmpdir(), "pureflow r3 process tree ")); + roots.push(root); + const ready = join(root, "child-ready.txt"); + const held = join(root, "held file.txt"); + const childScript = [ + "const fs=require('node:fs')", + `const fd=fs.openSync(${JSON.stringify(held)},'w')`, + `fs.writeFileSync(${JSON.stringify(ready)},String(process.pid))`, + "setInterval(()=>fs.fsyncSync(fd),1000)", + ].join(";"); + const parentScript = [ + "const {spawn}=require('node:child_process')", + `spawn(process.execPath,['-e',${JSON.stringify(childScript)}],{stdio:'ignore'})`, + "setInterval(()=>{},1000)", + ].join(";"); + const parent = spawn(runtime.executablePath, ["-e", parentScript], { + stdio: "ignore", + windowsHide: true, + detached: process.platform !== "win32", + }); + + await waitForFile(ready); + const childPid = Number(await readFile(ready, "utf8")); + await terminateProcessTree(parent); + await waitForExit(parent.pid!); + await waitForExit(childPid); + await rm(root, { recursive: true, force: false, maxRetries: 3, retryDelay: 50 }); + roots.splice(roots.indexOf(root), 1); + }, 30_000); +}); + +async function setup() { + const root = await mkdtemp(join(tmpdir(), "pureflow-r3-test-")); + roots.push(root); + const catalog = new BuiltinFixtureCatalog(); + const snapshots = new FixtureSnapshotStore(join(root, "snapshots"), catalog, { + now: () => "2026-01-02T00:00:00.000Z", + }); + const twins = new TwinManager(join(root, "twins"), snapshots); + const evidence = new MemoryCommandEvidenceStore(); + const runner = new TrustedFixtureProcessRunner(catalog, twins, evidence); + return { catalog, snapshots, twins, evidence, runner }; +} + +async function createInput(catalog: BuiltinFixtureCatalog, projectId: string) { + const record = await openRecord(catalog); + const ref = record.manifest.mutation.changeRef; + const mutation: EvidenceRef = { + id: ref.id, + kind: "diff", + sha256: ref.sha256, + storedBytes: ref.storedBytes, + originalBytes: ref.storedBytes, + truncated: false, + redactions: [], + mediaType: ref.mediaType, + visibility: "controller", + }; + return { + projectId, + sourceRevision: record.manifest.targetRevision, + mutationId: record.manifest.mutation.id, + mutation, + allowedFiles: record.manifest.states.find((state) => state.id === "target")!.files.map((file) => file.path), + }; +} + +async function openRecord(catalog: BuiltinFixtureCatalog) { + const record = await catalog.open("tenant-cache-key", EXPECTED_TENANT_CACHE_KEY.manifestHash); + if (!record) throw new Error("Built-in fixture missing"); + return record; +} + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await runFile("git", args, { cwd, encoding: "utf8", windowsHide: true }); + return stdout.trim(); +} + +async function repoContains(root: string, value: string): Promise { + const files: string[] = []; + await walk(root); + for (const file of files) { + if ((await readFile(file)).includes(Buffer.from(value))) return true; + } + return false; + + async function walk(dir: string): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) files.push(path); + } + } +} + +async function sourceRepoSnapshot(): Promise { + const commands = [ + ["status", "--porcelain=v1"], + ["hash-object", ".git/index"], + ["rev-parse", "--symbolic-full-name", "HEAD"], + ["rev-parse", "HEAD"], + ["for-each-ref", "--format=%(refname):%(objectname)"], + ["remote", "-v"], + ["worktree", "list", "--porcelain"], + ]; + const gitState = await Promise.all(commands.map(async (args) => (await runFile("git", args, { + cwd: repoRoot, + encoding: "utf8", + windowsHide: true, + })).stdout.trim())); + const { stdout: listed } = await runFile("git", ["ls-files", "-co", "--exclude-standard", "-z"], { + cwd: repoRoot, + encoding: "utf8", + windowsHide: true, + }); + const hashes = await Promise.all(listed.split("\0").filter(Boolean).map(async (path) => { + try { + return `${path}:${rawSha256(await readFile(join(repoRoot, ...path.split("/"))))}`; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return `${path}:missing`; + if (code === "EISDIR") return `${path}:directory`; + throw error; + } + })); + return [...gitState, ...hashes]; +} + +async function waitForFile(path: string): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + await stat(path); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + throw new Error("Descendant process did not become ready"); +} + +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return; + throw error; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + throw new Error(`Process ${pid} survived tree termination`); +} From e42345c3a0155119611dafb1994efaa30b9bd404 Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 01:03:12 +0300 Subject: [PATCH 2/3] fix: pin packaged fixture line endings --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 6708b8c..2dcdbae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ prd.md text eol=lf docs/v0.3/*.md text eol=lf .github/workflows/*.yml text eol=lf extension/test/fixtures/v0.3/** text eol=lf +extension/fixtures/v0.3/** text eol=lf From 33c8307de5fe6ac51e079884b2bea7196a441a78 Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 01:05:38 +0300 Subject: [PATCH 3/3] docs: record protected R3 acceptance --- docs/BUILD_LOG.md | 4 ++-- docs/PROJECT_STATE.md | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index c842735..859c5fc 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -8,9 +8,9 @@ This is a concise chronological record of material implementation work and runti - Added an opaque Twin Manager with separate uniquely named twin/evaluation directories and explicit `preparing → ready → running → completed/failed → cleaning → cleaned` lifecycle. Cleanup resolves and checks both exact controller-owned paths before recursive removal. - Added the immutable fixture command registry, extension-owned catalog, hash-pinned `fixture-node` resolution, bounded command evidence, tombstoned execution IDs, independent cancellation, scrubbed child environment, and full process-tree termination. Every non-fixture request, caller manifest/runtime/path, unknown command, and undeclared tree state fails before execution. - Moved the committed fixture from the excluded test tree into packaged extension assets after a VSIX audit found it would otherwise be absent at runtime. The package now contains all three candidate states plus controller-only mutation, repair, harness, and oracle assets. -- Seven R3 tests pass locally on Windows, including a real descendant-process kill in a path with spaces. The complete extension suite passes 50/50; `npm run check`, production build, and VSIX packaging pass. Protected Linux/Windows CI has not yet accepted this candidate. +- Seven R3 tests pass locally on Windows, including a real descendant-process kill in a path with spaces. The complete extension suite passes 50/50; `npm run check`, production build, and VSIX packaging pass. The first protected Windows run exposed missing LF policy after the fixture moved from `test/` to packaged assets; `.gitattributes` now pins the new path. Protected PR #13 run `30668675359` then passed all required Linux, Windows, contract, web, and policy checks, so R3 acceptance is complete. -Evidence: `extension/src/twin/{types,catalog,snapshot,manager,commands}.ts`, `extension/fixtures/v0.3/tenant-cache-key/`, `extension/test/twin.test.ts`, and local command/package output on 2026-07-31. +Evidence: `extension/src/twin/{types,catalog,snapshot,manager,commands}.ts`, `extension/fixtures/v0.3/tenant-cache-key/`, `extension/test/twin.test.ts`, local command/package output on 2026-07-31, and protected GitHub Actions run `30668675359` on PR #13. ## 2026-07-31 — R2 change evidence accepted diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 4ec812e..78c19f5 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -2,7 +2,7 @@ Last updated: 2026-07-31 -## Current branch milestone — R3 Takeover Twin candidate complete +## Current branch milestone — R3 Takeover Twin complete Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -20,7 +20,7 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - R2 implements bounded Git revision diffs, zero-context changed-line extraction, TypeScript compiler-API symbol resolution, explicit fixture check linkage, Flight Recorder hash/attribution validation, and exact `ExtractionResult` / `SemanticUnit` / `CandidateSeam` outputs. It fails to `partial` or `unsupported` for missing links, syntax failures, unsupported languages, or incomplete coverage instead of inventing an invariant. - Six R2 tests cover the real cache-key fixture, cross-run determinism and golden IDs, file rename, multi-file changes, added/deleted functions, class boundaries, Git hunk parsing, unsupported syntax/language, missing checks, unsafe paths, and revision/run drift. The full local Windows extension suite passes 43/43 with TypeScript, build, and VSIX packaging. Protected PR #12 run `30666648522` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R2 acceptance is complete. - R3 now implements the fixture-only sanitized snapshot store, standalone one-commit participant repository, opaque Twin Manager, immutable command-registry snapshots, extension-owned trusted catalog, and `TrustedFixtureRunner`. The runner accepts only the pinned manifest, exact declared state/tree, catalog command, hash-verified standalone Node runtime, and opaque twin handle; non-fixture execution remains explicitly unsupported. -- Seven R3 tests cover production file/index/HEAD/ref/remote/worktree invariants, hidden-answer and source-history absence, cross-project ownership, exact-state execution, tamper/unknown-command rejection, concurrent execution IDs, single-run cancellation, immutable command snapshots, Windows paths with spaces, descendant-process termination, and exact cleanup. The full local Windows extension suite passes 50/50; build and VSIX packaging pass, and the runtime fixture assets are present in the VSIX. Protected Linux/Windows acceptance is still pending for the current short-lived R3 branch. +- Seven R3 tests cover production file/index/HEAD/ref/remote/worktree invariants, hidden-answer and source-history absence, cross-project ownership, exact-state execution, tamper/unknown-command rejection, concurrent execution IDs, single-run cancellation, immutable command snapshots, Windows paths with spaces, descendant-process termination, and exact cleanup. The full local Windows extension suite passes 50/50; build and VSIX packaging pass, and the runtime fixture assets are present in the VSIX. Protected PR #13 run `30668675359` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R3 acceptance is complete. - The Experience Compiler, Evidence Judge, Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. R3 proves only the closed reviewed-fixture boundary; it does not execute arbitrary participant or workspace code. - 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. @@ -139,12 +139,11 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Obtain protected Linux/Windows acceptance for the R3 Takeover Twin candidate. -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. +1. Integrate R4: one compiled recovery episode and deterministic Evidence Judge. +2. Pass R4.5: one bounded, catalog-only Explain-to-Break Pulse with replay/error fail-closed tests. +3. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. +4. Add the local readiness ledger and minimal cockpit only after the vertical slice is reliable. +5. Run the preregistered delayed-transfer pilot before making any skill-retention claim. ## Recent milestone commits