From 0737bc77cebf5b039b212c5a79bf2b9ad500dfe6 Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 00:26:50 +0300 Subject: [PATCH 1/3] feat: extract test-backed change evidence --- extension/src/change/diff.ts | 184 ++++++++++ extension/src/change/evidence.ts | 335 ++++++++++++++++++ extension/src/change/symbols.ts | 178 ++++++++++ extension/test/change-evidence.test.ts | 305 ++++++++++++++++ .../fixtures/v0.3/agent-replay/succeeded.json | 12 +- 5 files changed, 1008 insertions(+), 6 deletions(-) create mode 100644 extension/src/change/diff.ts create mode 100644 extension/src/change/evidence.ts create mode 100644 extension/src/change/symbols.ts create mode 100644 extension/test/change-evidence.test.ts diff --git a/extension/src/change/diff.ts b/extension/src/change/diff.ts new file mode 100644 index 0000000..7c25c45 --- /dev/null +++ b/extension/src/change/diff.ts @@ -0,0 +1,184 @@ +import { execFile } from "node:child_process"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { assertRelPath } from "../rnd/canonical"; +import { assertGitOid } from "../agent/types"; + +const runFile = promisify(execFile); +const maxSourceBytes = 1024 * 1024; + +export type RevisionChangeStatus = "added" | "modified" | "deleted" | "renamed"; + +export interface RevisionFileChange { + status: RevisionChangeStatus; + beforePath?: string; + afterPath?: string; + beforeText?: string; + afterText?: string; + beforeChangedLines: number[]; + afterChangedLines: number[]; +} + +export interface RevisionDiff { + baseRevision: string; + targetRevision: string; + files: RevisionFileChange[]; +} + +export class GitRevisionDiffReader { + async read(repository: string, baseRevision: string, targetRevision: string): Promise { + assertGitOid(baseRevision, "baseRevision"); + assertGitOid(targetRevision, "targetRevision"); + const root = resolve(repository); + const [base, target] = await Promise.all([ + git(root, ["rev-parse", "--verify", `${baseRevision}^{commit}`]), + git(root, ["rev-parse", "--verify", `${targetRevision}^{commit}`]), + ]); + if (base !== baseRevision || target !== targetRevision) throw new Error("Git revision identity changed during extraction"); + + const status = await git(root, [ + "diff", + "--name-status", + "-z", + "--find-renames=90%", + "--no-ext-diff", + baseRevision, + targetRevision, + "--", + ], false); + const entries = parseNameStatus(status); + if (entries.length > 256) throw new Error("Revision diff exceeds 256 changed files"); + const files: RevisionFileChange[] = []; + + for (const entry of entries) { + const paths = [entry.beforePath, entry.afterPath].filter((path): path is string => path !== undefined); + const patch = await git(root, [ + "diff", + "--unified=0", + "--no-color", + "--no-ext-diff", + "--no-textconv", + baseRevision, + targetRevision, + "--", + ...paths, + ], false); + const lines = parseZeroContextPatch(patch); + const [beforeText, afterText] = await Promise.all([ + entry.beforePath === undefined ? undefined : readRevisionFile(root, baseRevision, entry.beforePath), + entry.afterPath === undefined ? undefined : readRevisionFile(root, targetRevision, entry.afterPath), + ]); + files.push({ ...entry, beforeText, afterText, beforeChangedLines: lines.before, afterChangedLines: lines.after }); + } + + files.sort((left, right) => Buffer.compare( + Buffer.from(left.afterPath ?? left.beforePath!, "utf8"), + Buffer.from(right.afterPath ?? right.beforePath!, "utf8"), + )); + return { baseRevision, targetRevision, files }; + } +} + +export function parseZeroContextPatch(patch: string): { before: number[]; after: number[] } { + const before = new Set(); + const after = new Set(); + let oldLine = 0; + let newLine = 0; + let inHunk = false; + + for (const line of patch.split(/\r?\n/)) { + const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (header) { + oldLine = Number(header[1]); + newLine = Number(header[3]); + inHunk = true; + continue; + } + if (!inHunk) continue; + if (line.startsWith("diff --git ") || line.startsWith("@@ ")) { + inHunk = false; + continue; + } + if (line.startsWith("\\ No newline at end of file")) continue; + if (line.startsWith("-")) { + before.add(oldLine++); + continue; + } + if (line.startsWith("+")) { + after.add(newLine++); + continue; + } + if (line.startsWith(" ")) { + oldLine += 1; + newLine += 1; + } + } + + return { before: [...before].sort((a, b) => a - b), after: [...after].sort((a, b) => a - b) }; +} + +function parseNameStatus(value: string): Array> { + if (value === "") return []; + const tokens = value.split("\0"); + if (tokens.at(-1) === "") tokens.pop(); + const files: Array> = []; + + for (let index = 0; index < tokens.length;) { + const code = tokens[index++]!; + if (/^R\d{1,3}$/.test(code)) { + const beforePath = tokens[index++]; + const afterPath = tokens[index++]; + if (beforePath === undefined || afterPath === undefined) throw new Error("Malformed Git rename status"); + assertBoundedPath(beforePath); + assertBoundedPath(afterPath); + files.push({ status: "renamed", beforePath, afterPath }); + continue; + } + + const path = tokens[index++]; + if (path === undefined) throw new Error("Malformed Git name status"); + assertBoundedPath(path); + if (code === "A") files.push({ status: "added", afterPath: path }); + else if (code === "D") files.push({ status: "deleted", beforePath: path }); + else if (code === "M") files.push({ status: "modified", beforePath: path, afterPath: path }); + else throw new Error(`Unsupported Git change status: ${code}`); + } + return files; +} + +async function readRevisionFile(root: string, revision: string, path: string): Promise { + const value = await git(root, ["show", `${revision}:${path}`], false); + if (Buffer.byteLength(value, "utf8") > maxSourceBytes) throw new Error(`Changed source exceeds ${maxSourceBytes} bytes`); + return value; +} + +async function git(root: string, args: string[], trim = true): Promise { + const { stdout } = await runFile("git", args, { + cwd: root, + encoding: "utf8", + windowsHide: true, + maxBuffer: 4 * 1024 * 1024, + env: gitEnvironment(), + }); + return trim ? stdout.trim() : stdout; +} + +function gitEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_OPTIONAL_LOCKS: "0", + LANG: "C", + LC_ALL: "C", + }; + for (const name of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TMP", "TEMP"]) { + if (process.env[name] !== undefined) env[name] = process.env[name]; + } + return env; +} + +function assertBoundedPath(path: string): void { + assertRelPath(path); + if (Buffer.byteLength(path, "utf8") > 1024) throw new Error("Changed path exceeds 1024 UTF-8 bytes"); +} diff --git a/extension/src/change/evidence.ts b/extension/src/change/evidence.ts new file mode 100644 index 0000000..3c7a151 --- /dev/null +++ b/extension/src/change/evidence.ts @@ -0,0 +1,335 @@ +import { assertBoundedText, assertGitOid, assertSchemaVersion, assertToken, type SchemaVersion } from "../agent/types"; +import { assertRelPath, assertSha256, canonicalHash, canonicalJson, compareUtf8, rawSha256 } from "../rnd/canonical"; +import { assertRunSequence, type EvidenceRef, type RunEnvelope } from "../recorder/events"; +import type { RevisionDiff, RevisionFileChange } from "./diff"; +import { extractChangedSymbols } from "./symbols"; + +export type SemanticUnitKind = "function" | "method" | "class" | "module-boundary"; + +export interface SemanticUnitDraft { + path: string; + symbol: string; + kind: SemanticUnitKind; + changedLineSha256: string[]; +} + +export interface SemanticUnit extends SemanticUnitDraft { + id: string; + attribution: + | { status: "attributed"; intentHash: string; evidence: EvidenceRef[] } + | { status: "unattributed"; gaps: string[] }; +} + +export interface CandidateSeam { + schemaVersion: SchemaVersion; + id: string; + projectId: string; + sourceRunId: string; + baseRevision: string; + targetRevision: string; + unit: SemanticUnit; + linkedChecks: string[]; + evidence: EvidenceRef[]; + gaps: string[]; + factors: { + blastRadius: number | null; + novelty: number | null; + evidenceGap: number | null; + capabilityAgeMs: number | null; + estimatedAttentionMinutes: number; + }; +} + +export type ExtractionReason = + | "unsupported-language" + | "unsupported-syntax" + | "ambiguous-symbol" + | "no-changed-unit" + | "missing-test-link" + | "partial-parse"; + +export interface CheckLink { + path: string; + symbol: string; + checks: string[]; +} + +export interface ChangeEvidenceInput { + schemaVersion: SchemaVersion; + projectId: string; + sourceRunId: string; + baseRevision: string; + targetRevision: string; + diff: RevisionDiff; + links: CheckLink[]; + events: RunEnvelope[]; +} + +interface ExtractionContext { + schemaVersion: SchemaVersion; + projectId: string; + sourceRunId: string; + baseRevision: string; + targetRevision: string; +} + +export type ExtractionResult = ExtractionContext & ( + | { status: "supported"; seams: CandidateSeam[] } + | { status: "partial"; seams: CandidateSeam[]; reasons: ExtractionReason[] } + | { status: "unsupported"; seams: []; reasons: ExtractionReason[] } +); + +export function extractChangeEvidence(input: ChangeEvidenceInput): ExtractionResult { + validateInput(input); + const context: ExtractionContext = { + schemaVersion: input.schemaVersion, + projectId: input.projectId, + sourceRunId: input.sourceRunId, + baseRevision: input.baseRevision, + targetRevision: input.targetRevision, + }; + const reasons = new Set(); + const drafts: SemanticUnitDraft[] = []; + let sawTypeScript = false; + + for (const change of input.diff.files) { + const path = change.afterPath ?? change.beforePath; + if (path === undefined) continue; + const linkedPath = input.links.some((link) => link.path === path || link.path === change.beforePath); + const typeScript = isTypeScript(path); + if (!typeScript && !linkedPath) continue; + sawTypeScript ||= typeScript; + const result = extractChangedSymbols(change); + result.reasons.forEach((reason) => reasons.add(reason)); + drafts.push(...result.units); + } + + if (!sawTypeScript && input.diff.files.length > 0) reasons.add("unsupported-language"); + if (drafts.length === 0 && reasons.size === 0) reasons.add("no-changed-unit"); + + const first = input.events[0]!.event; + if (first.type !== "task.started") throw new Error("Run evidence must start with task.started"); + const fileEvents = input.events.filter((envelope) => envelope.event.type === "file.changed"); + const tests = input.events.filter((envelope) => envelope.event.type === "test.finished"); + const seams: CandidateSeam[] = []; + + for (const draft of uniqueDrafts(drafts)) { + const link = input.links.find((candidate) => candidate.path === draft.path && candidate.symbol === draft.symbol); + if (link === undefined) { + reasons.add("missing-test-link"); + continue; + } + + const change = input.diff.files.find((candidate) => (candidate.afterPath ?? candidate.beforePath) === draft.path); + if (change === undefined) throw new Error(`Missing revision change for ${draft.path}`); + const fileEnvelope = fileEvents.find((envelope) => envelope.event.type === "file.changed" && envelope.event.path === draft.path); + const diffEvidence = fileEnvelope?.event.type === "file.changed" ? fileEnvelope.event.diff : undefined; + if (fileEnvelope?.event.type === "file.changed") verifyFileEvent(change, fileEnvelope.event); + + const testEvidence = tests + .filter((envelope) => envelope.event.type === "test.finished" && link.checks.includes(envelope.event.testId)) + .map((envelope) => envelope.event.type === "test.finished" ? envelope.event.output : undefined) + .filter((value): value is EvidenceRef => value !== undefined); + const evidence = uniqueEvidence([...(diffEvidence === undefined ? [] : [diffEvidence]), ...testEvidence]); + const unitEvidence = diffEvidence === undefined ? [] : [structuredClone(diffEvidence)]; + const unit: SemanticUnit = { + ...structuredClone(draft), + id: unitId(draft), + attribution: diffEvidence === undefined + ? { status: "unattributed", gaps: ["missing-change-evidence"] } + : { status: "attributed", intentHash: first.intentHash, evidence: unitEvidence }, + }; + + const gaps = [ + "blast-radius-unavailable", + "capability-age-unavailable", + "novelty-unavailable", + ]; + if (diffEvidence === undefined) gaps.push("missing-change-evidence"); + for (const check of link.checks) { + if (!tests.some((envelope) => envelope.event.type === "test.finished" && envelope.event.testId === check)) { + gaps.push(`missing-test-evidence:${check}`); + } + } + gaps.sort(compareUtf8); + + const missing = (diffEvidence === undefined ? 1 : 0) + link.checks.filter((check) => + !tests.some((envelope) => envelope.event.type === "test.finished" && envelope.event.testId === check), + ).length; + const evidenceGap = missing / (1 + link.checks.length); + const seamCore = { + schemaVersion: 1 as const, + projectId: input.projectId, + sourceRunId: input.sourceRunId, + baseRevision: input.baseRevision, + targetRevision: input.targetRevision, + unitId: unit.id, + linkedChecks: link.checks, + }; + seams.push({ + schemaVersion: 1, + id: `seam_${canonicalHash("candidate-seam", seamCore)}`, + projectId: input.projectId, + sourceRunId: input.sourceRunId, + baseRevision: input.baseRevision, + targetRevision: input.targetRevision, + unit, + linkedChecks: [...link.checks], + evidence, + gaps, + factors: { + blastRadius: null, + novelty: null, + evidenceGap, + capabilityAgeMs: null, + estimatedAttentionMinutes: 5, + }, + }); + } + + seams.sort((left, right) => compareUtf8(left.unit.path, right.unit.path) || compareUtf8(left.unit.symbol, right.unit.symbol) || compareUtf8(left.id, right.id)); + const sortedReasons = [...reasons].sort(compareUtf8); + if (seams.length === 0) { + if (sortedReasons.length === 0) sortedReasons.push("no-changed-unit"); + return { ...context, status: "unsupported", seams: [], reasons: sortedReasons }; + } + if (sortedReasons.length > 0) return { ...context, status: "partial", seams, reasons: sortedReasons }; + return { ...context, status: "supported", seams }; +} + +function validateInput(input: ChangeEvidenceInput): void { + assertSchemaVersion(input.schemaVersion); + assertToken(input.projectId, "projectId"); + assertToken(input.sourceRunId, "sourceRunId"); + assertGitOid(input.baseRevision, "baseRevision"); + assertGitOid(input.targetRevision, "targetRevision"); + if (input.diff.baseRevision !== input.baseRevision || input.diff.targetRevision !== input.targetRevision) { + throw new Error("Diff revision identity does not match extraction context"); + } + if (input.diff.files.length > 256) throw new Error("Revision diff exceeds 256 changed files"); + let previousPath: string | undefined; + for (const change of input.diff.files) { + validateChange(change); + const path = change.afterPath ?? change.beforePath!; + if (previousPath !== undefined && compareUtf8(previousPath, path) >= 0) { + throw new Error("Revision changes must be sorted by unique relative path"); + } + previousPath = path; + } + assertRunSequence(input.events); + if (input.events.length < 2) throw new Error("Extraction requires terminal run evidence"); + const first = input.events[0]!; + const last = input.events.at(-1)!; + if (first.projectId !== input.projectId || first.runId !== input.sourceRunId) throw new Error("Flight Recorder run identity mismatch"); + if (first.event.type !== "task.started" || first.event.baseRevision !== input.baseRevision) { + throw new Error("Flight Recorder base revision mismatch"); + } + if ( + last.event.type !== "run.finished" || + last.event.status !== "succeeded" || + last.event.targetRevision !== input.targetRevision + ) { + throw new Error("Extraction requires a matching succeeded target revision"); + } + + const keys = new Set(); + for (const link of input.links) { + assertRelPath(link.path); + assertBoundedText(link.symbol, 256, "linked symbol"); + if (link.symbol.length === 0) throw new Error("linked symbol cannot be empty"); + if (link.checks.length === 0) throw new Error("A check link requires at least one check"); + assertSortedTokens(link.checks, "linked checks"); + const key = `${link.path}\0${link.symbol}`; + if (keys.has(key)) throw new Error(`Duplicate check link: ${link.path}:${link.symbol}`); + keys.add(key); + } + const ordered = [...input.links].sort((left, right) => compareUtf8(left.path, right.path) || compareUtf8(left.symbol, right.symbol)); + if (input.links.some((link, index) => link !== ordered[index])) throw new Error("Check links must be canonically ordered"); +} + +function verifyFileEvent(change: RevisionFileChange, event: Extract): void { + if (change.beforeText !== undefined && rawSha256(Buffer.from(change.beforeText, "utf8")) !== event.beforeSha256) { + throw new Error(`Flight Recorder before hash mismatch for ${event.path}`); + } + if (change.afterText !== undefined && rawSha256(Buffer.from(change.afterText, "utf8")) !== event.afterSha256) { + throw new Error(`Flight Recorder after hash mismatch for ${event.path}`); + } +} + +function unitId(draft: SemanticUnitDraft): string { + const value = { + schemaVersion: 1, + path: draft.path, + symbol: draft.symbol, + kind: draft.kind, + changedLineSha256: draft.changedLineSha256, + }; + return `unit_${canonicalHash("semantic-unit", value)}`; +} + +function uniqueDrafts(drafts: SemanticUnitDraft[]): SemanticUnitDraft[] { + const values = new Map(); + for (const draft of drafts) { + assertRelPath(draft.path); + assertBoundedText(draft.symbol, 256, "semantic symbol"); + if (draft.symbol.length === 0) throw new Error("semantic symbol cannot be empty"); + draft.changedLineSha256.forEach((hash) => assertSha256(hash, "changed line hash")); + const key = `${draft.path}\0${draft.symbol}\0${draft.kind}`; + const existing = values.get(key); + values.set(key, { + ...draft, + changedLineSha256: [...new Set([...(existing?.changedLineSha256 ?? []), ...draft.changedLineSha256])].sort(compareUtf8), + }); + } + return [...values.values()].sort((left, right) => compareUtf8(left.path, right.path) || compareUtf8(left.symbol, right.symbol)); +} + +function uniqueEvidence(refs: EvidenceRef[]): EvidenceRef[] { + const values = new Map(); + for (const ref of refs) { + const existing = values.get(ref.id); + if (existing !== undefined && canonicalJson(existing) !== canonicalJson(ref)) { + throw new Error(`Evidence ID ${ref.id} resolves to different records`); + } + values.set(ref.id, structuredClone(ref)); + } + return [...values.values()].sort((left, right) => compareUtf8(left.id, right.id)); +} + +function assertSortedTokens(values: readonly string[], label: string): void { + let previous: string | undefined; + for (const value of values) { + assertToken(value, label); + if (previous !== undefined && compareUtf8(previous, value) >= 0) throw new Error(`${label} must be sorted and unique`); + previous = value; + } +} + +function validateChange(change: RevisionFileChange): void { + if (!["added", "modified", "deleted", "renamed"].includes(change.status)) throw new Error("Unknown revision change status"); + for (const path of [change.beforePath, change.afterPath]) { + if (path === undefined) continue; + assertRelPath(path); + if (Buffer.byteLength(path, "utf8") > 1024) throw new Error("Changed path exceeds 1024 UTF-8 bytes"); + } + const validShape = + (change.status === "added" && change.beforePath === undefined && change.beforeText === undefined && change.afterPath !== undefined && change.afterText !== undefined) || + (change.status === "deleted" && change.beforePath !== undefined && change.beforeText !== undefined && change.afterPath === undefined && change.afterText === undefined) || + (change.status === "modified" && change.beforePath !== undefined && change.beforeText !== undefined && change.afterPath === change.beforePath && change.afterText !== undefined) || + (change.status === "renamed" && change.beforePath !== undefined && change.beforeText !== undefined && change.afterPath !== undefined && change.afterText !== undefined && change.beforePath !== change.afterPath); + if (!validShape) throw new Error(`Invalid ${change.status} revision change shape`); + for (const text of [change.beforeText, change.afterText]) { + if (text !== undefined && Buffer.byteLength(text, "utf8") > 1024 * 1024) throw new Error("Changed source exceeds 1048576 bytes"); + } + for (const [label, lines] of [["before", change.beforeChangedLines], ["after", change.afterChangedLines]] as const) { + let previous = 0; + for (const line of lines) { + if (!Number.isSafeInteger(line) || line < 1 || line <= previous) throw new Error(`${label} changed lines must be sorted unique positive integers`); + previous = line; + } + } +} + +function isTypeScript(path: string): boolean { + return (path.endsWith(".ts") || path.endsWith(".tsx")) && !path.endsWith(".d.ts"); +} diff --git a/extension/src/change/symbols.ts b/extension/src/change/symbols.ts new file mode 100644 index 0000000..11d9325 --- /dev/null +++ b/extension/src/change/symbols.ts @@ -0,0 +1,178 @@ +import ts from "typescript"; +import { assertRelPath, compareUtf8, rawSha256 } from "../rnd/canonical"; +import type { ExtractionReason, SemanticUnitDraft } from "./evidence"; +import type { RevisionFileChange } from "./diff"; + +interface UnitSpan { + symbol: string; + kind: SemanticUnitDraft["kind"]; + startLine: number; + endLine: number; +} + +interface ParsedSide { + text: string; + changed: number[]; + units: UnitSpan[]; +} + +export interface ChangedSymbolResult { + units: SemanticUnitDraft[]; + reasons: ExtractionReason[]; +} + +export function extractChangedSymbols(change: RevisionFileChange): ChangedSymbolResult { + const path = change.afterPath ?? change.beforePath; + if (path === undefined) throw new Error("Changed file requires a path"); + assertRelPath(path); + if (change.beforePath !== undefined) assertRelPath(change.beforePath); + if (change.afterPath !== undefined) assertRelPath(change.afterPath); + assertChangedLines(change.beforeChangedLines, "beforeChangedLines"); + assertChangedLines(change.afterChangedLines, "afterChangedLines"); + + if (!isTypeScript(path)) return { units: [], reasons: ["unsupported-language"] }; + + const before = parseSide(change.beforeText, change.beforeChangedLines, change.beforePath ?? path); + const after = parseSide(change.afterText, change.afterChangedLines, change.afterPath ?? path); + if (before === "invalid" || after === "invalid") return { units: [], reasons: ["unsupported-syntax"] }; + + const selected = new Map(); + addSelected(selected, path, before, change.status === "renamed" && change.beforeChangedLines.length === 0); + addSelected(selected, path, after, change.status === "renamed" && change.afterChangedLines.length === 0); + + if (selected.size === 0 && (change.beforeChangedLines.length > 0 || change.afterChangedLines.length > 0)) { + const hashes = changedHashes(before, after, undefined); + selected.set("module-boundary\0", { + path, + symbol: "", + kind: "module-boundary", + changedLineSha256: hashes, + }); + } + + const units = [...selected.values()]; + units.sort((left, right) => compareUtf8(left.path, right.path) || compareUtf8(left.symbol, right.symbol) || compareUtf8(left.kind, right.kind)); + return { units, reasons: [] }; +} + +function parseSide(text: string | undefined, changed: number[], path: string): ParsedSide | undefined | "invalid" { + if (text === undefined) return undefined; + const kind = path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind); + const diagnostics = (source as ts.SourceFile & { parseDiagnostics?: readonly ts.Diagnostic[] }).parseDiagnostics ?? []; + if (diagnostics.length > 0) return "invalid"; + return { text, changed, units: collectUnits(source) }; +} + +function collectUnits(source: ts.SourceFile): UnitSpan[] { + const units: UnitSpan[] = []; + + const span = (node: ts.Node): Pick => ({ + startLine: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + endLine: source.getLineAndCharacterOfPosition(node.getEnd()).line + 1, + }); + + const visit = (node: ts.Node, className?: string): void => { + if (ts.isFunctionDeclaration(node) && node.name !== undefined) { + units.push({ symbol: node.name.text, kind: "function", ...span(node) }); + return; + } + if (ts.isClassDeclaration(node) && node.name !== undefined) { + const name = node.name.text; + units.push({ symbol: name, kind: "class", ...span(node) }); + node.members.forEach((member) => visit(member, name)); + return; + } + if (className !== undefined && ts.isMethodDeclaration(node) && node.name !== undefined) { + const name = propertyName(node.name); + if (name !== undefined) units.push({ symbol: `${className}.${name}`, kind: "method", ...span(node) }); + return; + } + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) { + if (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) { + units.push({ symbol: node.name.text, kind: "function", ...span(node) }); + return; + } + } + ts.forEachChild(node, (child) => visit(child, className)); + }; + + source.forEachChild((node) => visit(node)); + return units; +} + +function addSelected( + selected: Map, + path: string, + side: ParsedSide | undefined, + selectAll: boolean, +): void { + if (side === undefined) return; + const candidates = side.units.filter((unit) => selectAll || intersects(unit, side.changed)); + const mostSpecific = candidates.filter((candidate) => !candidates.some((other) => + other !== candidate && + other.startLine >= candidate.startLine && + other.endLine <= candidate.endLine && + (other.startLine > candidate.startLine || other.endLine < candidate.endLine), + )); + + for (const unit of mostSpecific) { + const key = `${unit.kind}\0${unit.symbol}`; + const hashes = side.changed + .filter((line) => line >= unit.startLine && line <= unit.endLine) + .map((line) => lineHash(side.text, line)); + const existing = selected.get(key); + selected.set(key, { + path, + symbol: unit.symbol, + kind: unit.kind, + changedLineSha256: sortedUnique([...(existing?.changedLineSha256 ?? []), ...hashes]), + }); + } +} + +function changedHashes( + before: ParsedSide | undefined, + after: ParsedSide | undefined, + span: UnitSpan | undefined, +): string[] { + const hashes: string[] = []; + for (const side of [before, after]) { + if (side === undefined) continue; + for (const line of side.changed) { + if (span === undefined || (line >= span.startLine && line <= span.endLine)) hashes.push(lineHash(side.text, line)); + } + } + return sortedUnique(hashes); +} + +function lineHash(text: string, line: number): string { + const lines = text.split(/\r\n|\n|\r/); + if (line < 1 || line > lines.length) throw new Error(`Changed line ${line} is outside the source file`); + return rawSha256(Buffer.from(lines[line - 1]!, "utf8")); +} + +function intersects(unit: UnitSpan, lines: readonly number[]): boolean { + return lines.some((line) => line >= unit.startLine && line <= unit.endLine); +} + +function propertyName(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; + return undefined; +} + +function assertChangedLines(lines: readonly number[], label: string): void { + let previous = 0; + for (const line of lines) { + if (!Number.isSafeInteger(line) || line < 1 || line <= previous) throw new Error(`${label} must be sorted unique positive integers`); + previous = line; + } +} + +function sortedUnique(values: string[]): string[] { + return [...new Set(values)].sort(compareUtf8); +} + +function isTypeScript(path: string): boolean { + return (path.endsWith(".ts") || path.endsWith(".tsx")) && !path.endsWith(".d.ts"); +} diff --git a/extension/test/change-evidence.test.ts b/extension/test/change-evidence.test.ts new file mode 100644 index 0000000..35249fc --- /dev/null +++ b/extension/test/change-evidence.test.ts @@ -0,0 +1,305 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { GitRevisionDiffReader, parseZeroContextPatch, type RevisionFileChange } from "../src/change/diff"; +import { extractChangeEvidence, type ChangeEvidenceInput } from "../src/change/evidence"; +import { extractChangedSymbols } from "../src/change/symbols"; +import type { RunEnvelope } from "../src/recorder/events"; +import { createTenantCacheKeyFixture, type TenantCacheKeyFixture } from "../src/twin/fixture-factory"; + +const transcriptPath = resolve(import.meta.dirname, "fixtures/v0.3/agent-replay/succeeded.json"); +const roots: string[] = []; +const fixtures = new Map(); + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async (root) => { + const fixture = fixtures.get(root); + if (fixture) { + await fixture.dispose(); + fixtures.delete(root); + } else { + await rm(root, { recursive: true, force: true }); + } + })); +}); + +describe("R2 change evidence", () => { + it("extracts the fixture's test-backed cacheKey seam deterministically", async () => { + const fixture = await createFixture(); + const diff = await new GitRevisionDiffReader().read(fixture.root, fixture.baseRevision, fixture.targetRevision); + const events = await loadEvents(); + const input: ChangeEvidenceInput = { + schemaVersion: 1, + projectId: "project_r0_fixture", + sourceRunId: "run_tenant_cache", + baseRevision: fixture.baseRevision, + targetRevision: fixture.targetRevision, + diff, + links: fixture.manifest.changedSymbols.map(({ path, symbol }) => ({ + path, + symbol, + checks: fixture.manifest.targetChecks, + })), + events, + }; + + const first = extractChangeEvidence(input); + const second = extractChangeEvidence(structuredClone(input)); + + expect(second).toEqual(first); + expect(first.status).toBe("supported"); + if (first.status !== "supported") throw new Error("Fixture extraction unexpectedly failed"); + expect(first.seams).toHaveLength(1); + expect(first.seams[0]).toMatchObject({ + id: "seam_399963ca630c2692340c063eaa0519597f0e41faa726bb117e94c582c2d75633", + projectId: input.projectId, + sourceRunId: input.sourceRunId, + baseRevision: fixture.baseRevision, + targetRevision: fixture.targetRevision, + linkedChecks: ["cache-key.tenant-isolation"], + unit: { + id: "unit_b6d20215f29aeccd3aa086184330527ca6e1073875ff44bb4f6d56214265ecae", + path: "src/cache-key.ts", + symbol: "cacheKey", + kind: "function", + attribution: { status: "attributed", intentHash: events[0]!.event.type === "task.started" ? events[0]!.event.intentHash : "" }, + }, + factors: { + blastRadius: null, + novelty: null, + evidenceGap: 0, + capabilityAgeMs: null, + estimatedAttentionMinutes: 5, + }, + }); + expect(first.seams[0]!.unit.changedLineSha256).toHaveLength(4); + expect(first.seams[0]!.evidence.map(({ id }) => id)).toEqual([ + "evidence_diff_1", + "evidence_test_1", + ]); + expect(first.seams[0]!.gaps).toEqual([ + "blast-radius-unavailable", + "capability-age-unavailable", + "novelty-unavailable", + ]); + }, 30_000); + + it("handles file rename, multiple files, added functions, and deleted functions", () => { + const renamed = change({ + status: "renamed", + beforePath: "src/old.ts", + afterPath: "src/new.ts", + beforeText: "export function stable() { return 1; }\n", + afterText: "export function stable() { return 1; }\n", + }); + expect(extractChangedSymbols(renamed)).toEqual({ + units: [{ path: "src/new.ts", symbol: "stable", kind: "function", changedLineSha256: [] }], + reasons: [], + }); + + const added = change({ + status: "added", + afterPath: "src/added.ts", + afterText: "export function added(value: number) {\n return value + 1;\n}\n", + afterChangedLines: [1, 2, 3], + }); + const deleted = change({ + status: "deleted", + beforePath: "src/deleted.ts", + beforeText: "export function deleted() {\n return false;\n}\n", + beforeChangedLines: [1, 2, 3], + }); + expect(extractChangedSymbols(added).units).toMatchObject([{ path: "src/added.ts", symbol: "added", kind: "function" }]); + expect(extractChangedSymbols(deleted).units).toMatchObject([{ path: "src/deleted.ts", symbol: "deleted", kind: "function" }]); + + const input = syntheticInput([added, deleted], [ + { path: "src/added.ts", symbol: "added", checks: ["added.check"] }, + { path: "src/deleted.ts", symbol: "deleted", checks: ["deleted.check"] }, + ]); + const result = extractChangeEvidence(input); + expect(result.status).toBe("supported"); + expect(result.seams.map(({ unit }) => `${unit.path}:${unit.symbol}`)).toEqual([ + "src/added.ts:added", + "src/deleted.ts:deleted", + ]); + }); + + it("parses zero-context Git hunks without treating headers as source lines", () => { + const patch = [ + "diff --git a/src/a.ts b/src/a.ts", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1,2 +1,3 @@", + "-const a = 1;", + "-const b = 2;", + "+const a = 3;", + "+const b = 4;", + "+const c = 5;", + "@@ -8 +9,0 @@", + "-removed();", + "", + ].join("\n"); + + expect(parseZeroContextPatch(patch)).toEqual({ before: [1, 2, 8], after: [1, 2, 3] }); + }); + + it("selects a class boundary when a changed field is outside its methods", () => { + const classChange = change({ + status: "modified", + beforePath: "src/service.ts", + afterPath: "src/service.ts", + beforeText: "export class Service {\n enabled = false;\n run() { return this.enabled; }\n}\n", + afterText: "export class Service {\n enabled = true;\n run() { return this.enabled; }\n}\n", + beforeChangedLines: [2], + afterChangedLines: [2], + }); + + expect(extractChangedSymbols(classChange).units).toMatchObject([ + { path: "src/service.ts", symbol: "Service", kind: "class" }, + ]); + }); + + it("fails honestly for unsupported syntax and missing test links", () => { + const broken = change({ + status: "modified", + beforePath: "src/broken.ts", + afterPath: "src/broken.ts", + beforeText: "export function okay() { return true; }\n", + afterText: "export function broken(\n", + beforeChangedLines: [1], + afterChangedLines: [1], + }); + const unsupported = extractChangeEvidence(syntheticInput([broken], [])); + expect(unsupported).toMatchObject({ status: "unsupported", seams: [], reasons: ["unsupported-syntax"] }); + + const valid = change({ + status: "modified", + beforePath: "src/unlinked.ts", + afterPath: "src/unlinked.ts", + beforeText: "export function unlinked() { return 1; }\n", + afterText: "export function unlinked() { return 2; }\n", + beforeChangedLines: [1], + afterChangedLines: [1], + }); + expect(extractChangeEvidence(syntheticInput([valid], []))).toMatchObject({ + status: "unsupported", + seams: [], + reasons: ["missing-test-link"], + }); + + const nonTypeScript = change({ + status: "modified", + beforePath: "src/cache.py", + afterPath: "src/cache.py", + beforeText: "def cache(): return 1\n", + afterText: "def cache(): return 2\n", + beforeChangedLines: [1], + afterChangedLines: [1], + }); + expect(extractChangeEvidence(syntheticInput([nonTypeScript], []))).toMatchObject({ + status: "unsupported", + seams: [], + reasons: ["unsupported-language"], + }); + }); + + it("rejects unsafe paths and revision or run identity drift", () => { + const unsafe = change({ + status: "modified", + beforePath: "C:/work/src/cache.ts", + afterPath: "C:/work/src/cache.ts", + beforeText: "export function cache() { return 1; }\n", + afterText: "export function cache() { return 2; }\n", + beforeChangedLines: [1], + afterChangedLines: [1], + }); + expect(() => extractChangedSymbols(unsafe)).toThrow("relative"); + + const valid = change({ + status: "modified", + beforePath: "src/cache.ts", + afterPath: "src/cache.ts", + beforeText: "export function cache() { return 1; }\n", + afterText: "export function cache() { return 2; }\n", + beforeChangedLines: [1], + afterChangedLines: [1], + }); + const input = syntheticInput([valid], [{ path: "src/cache.ts", symbol: "cache", checks: ["cache.check"] }]); + input.diff.targetRevision = "3".repeat(40); + expect(() => extractChangeEvidence(input)).toThrow("revision identity"); + + const runDrift = syntheticInput([valid], [{ path: "src/cache.ts", symbol: "cache", checks: ["cache.check"] }]); + runDrift.events[0]!.runId = "run_other"; + expect(() => extractChangeEvidence(runDrift)).toThrow("identity"); + }); +}); + +async function createFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "pureflow-r0b-r2-")); + roots.push(root); + const fixture = await createTenantCacheKeyFixture(root); + fixtures.set(root, fixture); + return fixture; +} + +async function loadEvents(): Promise { + const transcript = JSON.parse(await readFile(transcriptPath, "utf8")) as { events: RunEnvelope[] }; + return transcript.events; +} + +function change(input: Partial & Pick): RevisionFileChange { + return { + status: input.status, + beforePath: input.beforePath, + afterPath: input.afterPath, + beforeText: input.beforeText, + afterText: input.afterText, + beforeChangedLines: input.beforeChangedLines ?? [], + afterChangedLines: input.afterChangedLines ?? [], + }; +} + +function syntheticInput( + files: RevisionFileChange[], + links: ChangeEvidenceInput["links"], +): ChangeEvidenceInput { + const baseRevision = "1".repeat(40); + const targetRevision = "2".repeat(40); + return { + schemaVersion: 1, + projectId: "project_synthetic", + sourceRunId: "run_synthetic", + baseRevision, + targetRevision, + diff: { baseRevision, targetRevision, files }, + links, + events: boundaryEvents(baseRevision, targetRevision), + }; +} + +function boundaryEvents(baseRevision: string, targetRevision: string): RunEnvelope[] { + return [ + { + schemaVersion: 1, + projectId: "project_synthetic", + runId: "run_synthetic", + seq: 1, + at: "2026-07-31T21:00:00.000Z", + event: { + type: "task.started", + taskId: "task_synthetic", + baseRevision, + intentHash: "a".repeat(64), + }, + }, + { + schemaVersion: 1, + projectId: "project_synthetic", + runId: "run_synthetic", + seq: 2, + at: "2026-07-31T21:00:01.000Z", + event: { type: "run.finished", status: "succeeded", targetRevision }, + }, + ]; +} diff --git a/extension/test/fixtures/v0.3/agent-replay/succeeded.json b/extension/test/fixtures/v0.3/agent-replay/succeeded.json index b642004..2d20048 100644 --- a/extension/test/fixtures/v0.3/agent-replay/succeeded.json +++ b/extension/test/fixtures/v0.3/agent-replay/succeeded.json @@ -69,7 +69,7 @@ "event": { "type": "command.started", "executionId": "execution_fixture_check_1", - "commandId": "fixture.tenant-cache.check" + "commandId": "cache-key.tenant-isolation" } }, { @@ -81,8 +81,8 @@ "event": { "type": "test.finished", "executionId": "execution_fixture_check_1", - "commandId": "fixture.tenant-cache.check", - "testId": "tenant-isolation", + "commandId": "cache-key.tenant-isolation", + "testId": "cache-key.tenant-isolation", "status": "passed", "output": { "id": "evidence_test_1", @@ -106,7 +106,7 @@ "event": { "type": "command.finished", "executionId": "execution_fixture_check_1", - "commandId": "fixture.tenant-cache.check", + "commandId": "cache-key.tenant-isolation", "exitCode": 0, "timedOut": false, "cancelled": false, @@ -132,8 +132,8 @@ "event": { "type": "file.changed", "path": "src/cache-key.ts", - "beforeSha256": "4444444444444444444444444444444444444444444444444444444444444444", - "afterSha256": "5555555555555555555555555555555555555555555555555555555555555555", + "beforeSha256": "2381020b502df0c8933b27fa7993314d582bb917f979b80221afb701f9bd1f95", + "afterSha256": "3dbf02c83ba6d311f5cd2f8b8ef20072d8fdfac720287a5059169d8dd1a3a4e7", "diff": { "id": "evidence_diff_1", "kind": "diff", From 43bb54c58d60b54d6f85064409c52808fff35b90 Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 00:26:56 +0300 Subject: [PATCH 2/3] docs: define deterministic R2 identities --- docs/BUILD_LOG.md | 11 +++++++++++ docs/PROJECT_STATE.md | 19 +++++++++++-------- docs/v0.3/CONTRACTS.md | 15 ++++++++++++++- docs/v0.3/README.md | 2 +- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index 38eeb0c..15f23c8 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 — R2 change-evidence candidate + +- 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. +- Added TypeScript compiler-API extraction for functions, arrow/function variables, classes, methods, and explicit module boundaries. Removed and added logical lines are hashed rather than stored; syntax failures and unsupported languages remain explicit results. +- Added deterministic evidence assembly behind the exact `ExtractionResult`, `SemanticUnit`, and `CandidateSeam` contracts. Fixture-manifest check links are authoritative, matching Flight Recorder before/after hashes and evidence refs are verified, unknown readiness factors stay `null`, and missing evidence becomes a gap rather than a fabricated score. +- Corrected the checked replay transcript's file hashes, command ID, and test ID to the actual R0 fixture identities so R1→R2 integration can be verified rather than merely shaped correctly. +- Added six R2 tests covering the real cache-key revision pair, golden unit/seam IDs, file rename, multi-file changes, added/deleted functions, a class boundary, Git hunk parsing, unsupported syntax/language, missing links, unsafe paths, and revision/run identity drift. +- Local Windows evidence: `npm run check`, all 43 extension tests, production build, and VSIX packaging passed. Cross-platform protected CI and merge are pending. + +Evidence: `extension/src/change/`, `extension/test/change-evidence.test.ts`, the checked agent transcript, `docs/v0.3/CONTRACTS.md`, and local command output on 2026-07-31. + ## 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. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 55ea916..015d053 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 complete +## Current branch milestone — R2 change-evidence candidate Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -17,7 +17,9 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - 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. - 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. +- Short-lived branch `codex/r2-change-evidence` now 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 cross-platform CI is pending. +- 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. - 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. @@ -135,12 +137,13 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -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. +1. Publish the R2 short-lived branch, pass the five protected Linux/Windows checks, merge it into `codex/shadow-cockpit-rnd`, and delete the head. +2. Implement R3 behind the accepted contract: safe Takeover Twin snapshot, lifecycle, catalog, and command boundaries. +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. ## Recent milestone commits diff --git a/docs/v0.3/CONTRACTS.md b/docs/v0.3/CONTRACTS.md index cecf1db..01f2b54 100644 --- a/docs/v0.3/CONTRACTS.md +++ b/docs/v0.3/CONTRACTS.md @@ -71,7 +71,7 @@ Canonical object digests and raw byte hashes use one explicitly separated scheme - `canonicalHash(domain, value)` is lowercase hex SHA-256 over `UTF8("pureflow/v0.3/" + domain + "\n") || UTF8(JCS(value))`; - fields named `sha256` contain lowercase hex raw `SHA-256(bytes)` for the exact stored bytes; Git OIDs retain Git's native object algorithm and are never placed in a `Sha256` field; - a file manifest is sorted by the UTF-8 byte order of normalized `path`, rejects duplicate or case-colliding paths, and hashes `[{ path, mode, sha256 }]` with domain `tree`; -- `FixtureManifest` uses domain `fixture-manifest`, command registry snapshots use `command-registry`, task intents use `task-intent`, participant diffs use `candidate-diff`, command results use `command-result`, judge results use `judge-result`, Control Pulse claims use `control-claim`, internal probes use `control-probe`, probe attempts use `control-probe-attempt`, probe results use `control-probe-result`, capability records use `capability-evidence`, and readiness records use `verified-readiness`; a value omits its own digest field before hashing. +- `FixtureManifest` uses domain `fixture-manifest`, command registry snapshots use `command-registry`, task intents use `task-intent`, semantic units use `semantic-unit`, candidate seams use `candidate-seam`, participant diffs use `candidate-diff`, command results use `command-result`, judge results use `judge-result`, Control Pulse claims use `control-claim`, internal probes use `control-probe`, probe attempts use `control-probe-attempt`, probe results use `control-probe-result`, capability records use `capability-evidence`, and readiness records use `verified-readiness`; a value omits its own digest field before hashing. `candidate-diff` never hashes Git's formatted patch output. It hashes this normalized value, with `changes` sorted by UTF-8 bytes of `path` and duplicate or case-colliding paths rejected: @@ -507,6 +507,19 @@ R2 owns this boundary. It may mark a unit unattributed or a factor unknown; it m Known factor values are normalized to `[0, 1]`; `null` means unavailable, not zero. `estimatedAttentionMinutes` is an integer from 1 to 30 in R&D. Selection records the factor values and deterministic tie-breaker (`CandidateSeam.id` ascending) used for the choice. +The first R2 implementation is deliberately narrow and deterministic: + +- Git changes are read by full verified base/target OID with `--name-status -z`, rename detection, and zero-context patches; paths are normalized workspace-relative values, source files are capped at 1 MiB, and a revision pair is capped at 256 changed files. +- Only `.ts` and `.tsx` implementation files are parsed with the TypeScript compiler API. Declaration files and syntactically invalid sources are unsupported; unrecognized changed code returns an explicit reason rather than an inferred symbol. +- Fixture check linkage is explicit from the committed fixture manifest. R2 never guesses a test from a filename, import, or LLM narrative. +- `changedLineSha256` is the sorted unique list of raw SHA-256 hashes of the exact UTF-8 logical line bytes, excluding the line terminator, for removed base lines and added target lines that intersect the unit. An unchanged file rename may therefore have an empty list. +- `SemanticUnit.id` is `unit_` plus `canonicalHash("semantic-unit", { schemaVersion, path, symbol, kind, changedLineSha256 })`. +- `CandidateSeam.id` is `seam_` plus `canonicalHash("candidate-seam", { schemaVersion, projectId, sourceRunId, baseRevision, targetRevision, unitId, linkedChecks })`; `linkedChecks` is sorted and unique. +- R2 verifies a matching Flight Recorder file event against the exact before/after file bytes. Attribution exists only when that event and `task.started.intentHash` exist; otherwise the unit is explicitly unattributed. +- Until R5 exists, `blastRadius`, `novelty`, and `capabilityAgeMs` are `null`. `evidenceGap` is the fraction of missing observable slots across one file-change reference plus every linked test result. The fixture slice uses a fixed five-minute attention estimate rather than pretending to have a calibrated model. + +Cross-platform golden tests pin the first fixture's semantic-unit and candidate-seam IDs. Any change to these rules requires a schema-version decision rather than silently changing existing identities. + ## 7. Sanitized snapshot boundary ```ts diff --git a/docs/v0.3/README.md b/docs/v0.3/README.md index a20375f..4928db1 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 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. +- 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. R2 adds deterministic Git/TypeScript change-evidence extraction and explicit fixture check linkage; its protected cross-platform acceptance is pending. - 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. From 63f8fd7919a31ba4a65b9dc047fd233378f35f8a Mon Sep 17 00:00:00 2001 From: Error Lover Date: Sat, 1 Aug 2026 00:29:50 +0300 Subject: [PATCH 3/3] docs: record protected R2 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 15f23c8..44817d1 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 — R2 change-evidence candidate +## 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. - Added TypeScript compiler-API extraction for functions, arrow/function variables, classes, methods, and explicit module boundaries. Removed and added logical lines are hashed rather than stored; syntax failures and unsupported languages remain explicit results. - Added deterministic evidence assembly behind the exact `ExtractionResult`, `SemanticUnit`, and `CandidateSeam` contracts. Fixture-manifest check links are authoritative, matching Flight Recorder before/after hashes and evidence refs are verified, unknown readiness factors stay `null`, and missing evidence becomes a gap rather than a fabricated score. - Corrected the checked replay transcript's file hashes, command ID, and test ID to the actual R0 fixture identities so R1→R2 integration can be verified rather than merely shaped correctly. - Added six R2 tests covering the real cache-key revision pair, golden unit/seam IDs, file rename, multi-file changes, added/deleted functions, a class boundary, Git hunk parsing, unsupported syntax/language, missing links, unsafe paths, and revision/run identity drift. -- Local Windows evidence: `npm run check`, all 43 extension tests, production build, and VSIX packaging passed. Cross-platform protected CI and merge are pending. +- Local Windows evidence: `npm run check`, all 43 extension tests, production build, and VSIX packaging passed. Protected PR #12 run `30666648522` then passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`. -Evidence: `extension/src/change/`, `extension/test/change-evidence.test.ts`, the checked agent transcript, `docs/v0.3/CONTRACTS.md`, and local command output on 2026-07-31. +Evidence: `extension/src/change/`, `extension/test/change-evidence.test.ts`, the checked agent transcript, `docs/v0.3/CONTRACTS.md`, local command output on 2026-07-31, and protected GitHub Actions run `30666648522` on PR #12. ## 2026-07-31 — R1 replay and Flight Recorder accepted diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 015d053..d5ee276 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 candidate +## Current branch milestone — R2 change evidence complete Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -17,8 +17,8 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - 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. - 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. -- Short-lived branch `codex/r2-change-evidence` now 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 cross-platform CI is pending. +- 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. - 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. @@ -137,13 +137,12 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Publish the R2 short-lived branch, pass the five protected Linux/Windows checks, merge it into `codex/shadow-cockpit-rnd`, and delete the head. -2. Implement R3 behind the accepted contract: safe Takeover Twin snapshot, lifecycle, catalog, and command boundaries. -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 R3 behind the accepted contract: safe Takeover Twin snapshot, lifecycle, catalog, and command boundaries. +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 4928db1..60fc443 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 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. R2 adds deterministic Git/TypeScript change-evidence extraction and explicit fixture check linkage; its protected cross-platform acceptance is pending. +- 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. R2 adds deterministic Git/TypeScript change-evidence extraction and explicit fixture check linkage; protected PR #12 run `30666648522` 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.