import { execFileSync, type ExecFileSyncOptions } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync } from "node:fs"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { gunzipSync, gzipSync } from "node:zlib"; const REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const; export type ReviewLens = (typeof REVIEW_LENS)[number]; const CANDIDATE_GIT_TIMEOUT_MS = 10_000; const CANDIDATE_GIT_TIMEOUT_MAX_MS = 120_000; const CANDIDATE_GIT_TIMEOUT_ENV = "GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS"; const CANDIDATE_GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; // Candidate views may materialize full repository trees. Large repositories can // raise this bounded deadline without creating an unbounded child process. function resolveCandidateGitTimeoutMs(environment: NodeJS.ProcessEnv = process.env): number { const value = environment[CANDIDATE_GIT_TIMEOUT_ENV]; if (value === undefined || !/^[1-9]\d*$/.test(value)) return CANDIDATE_GIT_TIMEOUT_MS; const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed <= CANDIDATE_GIT_TIMEOUT_MAX_MS ? parsed : CANDIDATE_GIT_TIMEOUT_MS; } function isCandidateGitTimeoutMs(value: number): boolean { return Number.isSafeInteger(value) && value > 0 && value <= CANDIDATE_GIT_TIMEOUT_MAX_MS; } const CANDIDATE_VIEW_DIAGNOSTIC_PHASE = "candidate-view"; const CANDIDATE_VIEW_GIT_FAILURE_CATEGORY = { TIMEOUT: "timeout", OUTPUT_LIMIT: "output-limit", GIT_FAILURE: "git-failure", } as const; export type CandidateViewGitFailureCategory = (typeof CANDIDATE_VIEW_GIT_FAILURE_CATEGORY)[keyof typeof CANDIDATE_VIEW_GIT_FAILURE_CATEGORY]; const CANDIDATE_GIT_SUBCOMMAND = { ADD: "add", CHECKOUT_INDEX: "checkout-index", DIFF: "diff", FOR_EACH_REF: "for-each-ref", LOG: "log", LS_FILES: "ls-files", LS_TREE: "ls-tree", READ_TREE: "read-tree", REV_PARSE: "rev-parse", WORKTREE: "worktree", WRITE_TREE: "write-tree", OTHER: "other", } as const; type CandidateGitSubcommand = (typeof CANDIDATE_GIT_SUBCOMMAND)[keyof typeof CANDIDATE_GIT_SUBCOMMAND]; export type CandidateGitExecutor = (file: string, arguments_: readonly string[], options: ExecFileSyncOptions) => string | Buffer; const defaultCandidateGitExecutor: CandidateGitExecutor = (file, arguments_, options) => execFileSync(file, arguments_, options); const CONTROLLER_CANDIDATE_VIEW_HEADING = "## Controller-owned candidate view"; const MAX_SUBAGENT_TASK_LENGTH = 16_384; const MAX_SUBAGENT_CONTEXT_LENGTH = 4_096; const MAX_CANDIDATE_CONTEXT_LENGTH = 4_096; const MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES = 1024 * 1024; const MAX_CANDIDATE_SCOPE_PAGE_BYTES = 16 * 1024; const MAX_CANDIDATE_SCOPE_PAGE_ENTRIES = 128; const CANDIDATE_CONTEXT_MANIFEST = { VERSION: 1, } as const; const CANDIDATE_CONTEXT_MODE = { REGULAR: "100644", EXECUTABLE: "100755", SYMLINK: "120000", GITLINK: "160000", DELETED: "deleted", } as const; export type CandidateContextMode = (typeof CANDIDATE_CONTEXT_MODE)[keyof typeof CANDIDATE_CONTEXT_MODE]; const SUBAGENT_RUN_KEYS = new Set(["agent", "agents", "task", "context", "mode"]); interface CandidateTreeEntry { path: string; mode: string; objectId: string; } interface CandidateViewEntry extends CandidateTreeEntry { contentHash: string; } interface CandidateGitlink extends CandidateTreeEntry { mode: "160000"; } interface ParsedCandidateTree { entries: CandidateTreeEntry[]; gitlinks: CandidateGitlink[]; } interface CandidateViewScope { paths: readonly string[]; modes: Readonly>; gitlinks: Readonly>; deletedPaths: readonly string[]; } interface CandidateViewRecord { token: string; root: string; parent: string; contributorRoot: string; commonDir: string; baseCommit: string; baseTree: string; candidateTree: string; committedOnly: boolean; entries: readonly CandidateViewEntry[]; gitlinks: readonly CandidateGitlink[]; scope: CandidateViewScope; lineageId?: string; selectedLenses?: readonly ReviewLens[]; gitExecutor: CandidateGitExecutor; } export interface CandidateView { token: string; root: string; baseCommit: string; baseTree: string; candidateTree: string; committedOnly: boolean; paths: readonly string[]; modes: Readonly>; gitlinks: Readonly>; deletedPaths: readonly string[]; verify(): void; cleanup(): void; } export interface CandidateContextManifest { version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION; scopeByMode: Readonly>; gitlinks: Readonly>; } export interface DecodedCandidateContextManifest { manifest: CandidateContextManifest; bytes: Buffer; sha256: string; } export interface CandidateContextPageEntry { path: string; mode: CandidateContextMode; gitlinkObjectId?: string; } export interface CandidateContextPage { version: typeof CANDIDATE_CONTEXT_MANIFEST.VERSION; sha256: string; cursor: number; totalPaths: number; entries: readonly CandidateContextPageEntry[]; nextCursor?: number; } export interface FrozenCandidateProjection { contributorRoot: string; baseCommit: string; baseTree: string; candidateTree: string; committedOnly: boolean; paths: readonly string[]; modes: Readonly>; gitlinks: Readonly>; deletedPaths: readonly string[]; } export interface CreateCandidateViewRequest { contributorRoot: string; baseRef?: string; committedOnly?: boolean; replayKey?: string; } export interface BindCandidateViewRequest { token: string; lineageId: string; selectedLenses: readonly string[]; } export interface AuthoritativeReviewingCandidateState { lineageId: string; contributorRoot: string; baseCommit: string; baseTree: string; candidateTree: string; committedOnly?: boolean; paths: readonly string[]; modes: Readonly>; gitlinks?: Readonly>; deletedPaths: readonly string[]; selectedLenses: readonly string[]; } export interface NativeCandidateProjectionDescriptor { baseTree: string; currentCandidateTree: string; paths: readonly string[]; intendedUntracked: readonly string[]; projection: "workspace" | "staged"; // Optional so Phase 3 stays additive: existing callers keep the legacy // sorted-path behavior until the v2 switchover supplies a manifest. manifest?: readonly ChangedPathEntry[]; // The provider artifact-subject's `changed_path_manifest_sha256` claim. // Production callers verify this field across all collect inputs and their // artifact subjects; hand-built descriptors may still use Pi's local digest // as a self-consistency check. manifestSha256?: string; // Set only by the production status adapter after it has checked every // provider-issued collect input and artifact subject for one identical hash. // Provider canonicalization is intentionally not reimplemented here. providerManifestHashVerified?: true; } export interface CandidateViewDiagnostic { phase: typeof CANDIDATE_VIEW_DIAGNOSTIC_PHASE; category: CandidateViewGitFailureCategory; git_subcommand: CandidateGitSubcommand; timeout_ms: number; max_buffer_bytes: number; message: string; } export class CandidateViewError extends Error { readonly reason: string; readonly diagnostics?: CandidateViewDiagnostic; constructor(message: string, reason = "candidate-view-invalid", diagnostics?: CandidateViewDiagnostic) { super(message); this.name = "CandidateViewError"; this.reason = reason; this.diagnostics = diagnostics === undefined ? undefined : sanitizeCandidateViewDiagnostic(diagnostics); } } function candidateGitSubcommand(arguments_: readonly string[]): CandidateGitSubcommand { switch (arguments_[0]) { case CANDIDATE_GIT_SUBCOMMAND.ADD: return CANDIDATE_GIT_SUBCOMMAND.ADD; case CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX: return CANDIDATE_GIT_SUBCOMMAND.CHECKOUT_INDEX; case CANDIDATE_GIT_SUBCOMMAND.DIFF: return CANDIDATE_GIT_SUBCOMMAND.DIFF; case CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF: return CANDIDATE_GIT_SUBCOMMAND.FOR_EACH_REF; case CANDIDATE_GIT_SUBCOMMAND.LOG: return CANDIDATE_GIT_SUBCOMMAND.LOG; case CANDIDATE_GIT_SUBCOMMAND.LS_FILES: return CANDIDATE_GIT_SUBCOMMAND.LS_FILES; case CANDIDATE_GIT_SUBCOMMAND.LS_TREE: return CANDIDATE_GIT_SUBCOMMAND.LS_TREE; case CANDIDATE_GIT_SUBCOMMAND.READ_TREE: return CANDIDATE_GIT_SUBCOMMAND.READ_TREE; case CANDIDATE_GIT_SUBCOMMAND.REV_PARSE: return CANDIDATE_GIT_SUBCOMMAND.REV_PARSE; case CANDIDATE_GIT_SUBCOMMAND.WORKTREE: return CANDIDATE_GIT_SUBCOMMAND.WORKTREE; case CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE: return CANDIDATE_GIT_SUBCOMMAND.WRITE_TREE; default: return CANDIDATE_GIT_SUBCOMMAND.OTHER; } } function candidateGitDiagnosticMessage(category: CandidateViewGitFailureCategory, subcommand: CandidateGitSubcommand, timeoutMs: number): string { if (category === CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT) return `candidate-view Git command ${subcommand} timed out after ${timeoutMs}ms; inspect the candidate state before any new START`; if (category === CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT) return `candidate-view Git command ${subcommand} exceeded the ${CANDIDATE_GIT_MAX_BUFFER_BYTES}-byte output limit; inspect the candidate state before any new START`; return `candidate-view Git command ${subcommand} failed; inspect the candidate state before any new START`; } function candidateGitDiagnostic(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewDiagnostic { const git_subcommand = candidateGitSubcommand(arguments_); return Object.freeze({ phase: CANDIDATE_VIEW_DIAGNOSTIC_PHASE, category, git_subcommand, timeout_ms: timeoutMs, max_buffer_bytes: CANDIDATE_GIT_MAX_BUFFER_BYTES, message: candidateGitDiagnosticMessage(category, git_subcommand, timeoutMs), }); } function sanitizeCandidateViewDiagnostic(diagnostics: CandidateViewDiagnostic): CandidateViewDiagnostic | undefined { const { phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message } = diagnostics; if ( phase !== CANDIDATE_VIEW_DIAGNOSTIC_PHASE || !Object.values(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY).includes(category) || !Object.values(CANDIDATE_GIT_SUBCOMMAND).includes(git_subcommand) || !isCandidateGitTimeoutMs(timeout_ms) || max_buffer_bytes !== CANDIDATE_GIT_MAX_BUFFER_BYTES || message !== candidateGitDiagnosticMessage(category, git_subcommand, timeout_ms) ) return undefined; return Object.freeze({ phase, category, git_subcommand, timeout_ms, max_buffer_bytes, message }); } function candidateGitFailure(category: CandidateViewGitFailureCategory, arguments_: readonly string[], timeoutMs: number): CandidateViewError { const diagnostics = candidateGitDiagnostic(category, arguments_, timeoutMs); return new CandidateViewError(diagnostics.message, `candidate-view-${category}`, diagnostics); } function candidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, encoding: "utf8" | "buffer", executor: CandidateGitExecutor): string | Buffer { const timeoutMs = resolveCandidateGitTimeoutMs(env); try { return executor("git", arguments_, { cwd, encoding, env, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES, windowsHide: true, }); } catch (error) { const detail = error as NodeJS.ErrnoException & { killed?: boolean }; if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs); if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs); throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs); } } function git(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv = process.env, executor: CandidateGitExecutor = defaultCandidateGitExecutor): string { return (candidateGit(cwd, arguments_, env, "utf8", executor) as string).trim(); } function isWithin(parent: string, path: string): boolean { const value = relative(parent, path); return value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !isAbsolute(value); } function isSafeCandidatePath(path: string): boolean { return path.length > 0 && !isAbsolute(path) && !path.includes("\\") && !/[\u0000-\u001f\u007f]/.test(path) && path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); } function isMaterializedCandidateMode(mode: string): boolean { return mode === "100644" || mode === "100755" || mode === "120000"; } function isCanonicalObjectId(objectId: string): boolean { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId); } function gitlinkMapsEqual(left: Readonly>, right: Readonly>): boolean { const entries = Object.entries(left); return entries.length === Object.keys(right).length && entries.every(([path, objectId]) => right[path] === objectId); } function decodeCanonicalPath(value: Buffer): string { const path = value.toString("utf8"); if (!Buffer.from(path, "utf8").equals(value) || !isSafeCandidatePath(path)) { throw new CandidateViewError("candidate tree contains an unsafe or noncanonical path"); } return path; } function assertSafeSymlinkTarget(root: string, entryPath: string, value: Buffer): void { const target = value.toString("utf8"); if ( !Buffer.from(target, "utf8").equals(value) || target.length === 0 || isAbsolute(target) || /^[A-Za-z]:\//.test(target) || target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target) || target.split("/").some((segment) => segment.length === 0 || segment === ".") ) { throw new CandidateViewError("candidate view symlink target is unsafe"); } const resolvedTarget = resolve(dirname(join(root, entryPath)), target); const metadata = join(root, ".git"); if (!isWithin(root, resolvedTarget) || resolvedTarget === metadata || isWithin(metadata, resolvedTarget)) { throw new CandidateViewError("candidate view symlink target escapes its frozen root or enters metadata"); } } function splitNulTerminated(raw: Buffer, errorMessage: string): Buffer[] { if (raw.length === 0) return []; if (raw.at(-1) !== 0) throw new CandidateViewError(errorMessage); const tokens: Buffer[] = []; let start = 0; for (let index = 0; index < raw.length; index += 1) { if (raw[index] === 0) { tokens.push(raw.subarray(start, index)); start = index + 1; } } return tokens; } function parseTree(cwd: string, tree: string, executor: CandidateGitExecutor): ParsedCandidateTree { const raw = candidateGit(cwd, ["ls-tree", "-r", "-z", tree], process.env, "buffer", executor) as Buffer; const entries: CandidateTreeEntry[] = []; const gitlinks: CandidateGitlink[] = []; const paths = new Set(); for (const row of splitNulTerminated(raw, "candidate tree output is not NUL-terminated")) { const separator = row.indexOf(0x09); if (separator < 0) throw new CandidateViewError("candidate tree contains an unsafe entry"); const match = /^(100644|100755|120000) blob ([0-9a-f]{40}|[0-9a-f]{64})$|^(160000) commit ([0-9a-f]{40}|[0-9a-f]{64})$/.exec(row.subarray(0, separator).toString("ascii")); const path = decodeCanonicalPath(row.subarray(separator + 1)); if (!match || paths.has(path)) throw new CandidateViewError("candidate tree contains an unsafe entry"); paths.add(path); const mode = match[1] ?? match[3]!; const objectId = match[2] ?? match[4]!; if (mode === "160000") gitlinks.push({ path, mode, objectId }); else if (isMaterializedCandidateMode(mode)) entries.push({ path, mode, objectId }); } const compare = (left: CandidateTreeEntry, right: CandidateTreeEntry): number => left.path.localeCompare(right.path); entries.sort(compare); gitlinks.sort(compare); return { entries, gitlinks }; } function gitPathTokens(cwd: string, arguments_: readonly string[], executor: CandidateGitExecutor): Buffer[] { const raw = candidateGit(cwd, arguments_, process.env, "buffer", executor) as Buffer; return splitNulTerminated(raw, "candidate scope Git output is not NUL-terminated"); } // One manifest entry per changed path, carrying the state contract v2 ships in // `changed_path_manifest`. `deriveChangedScope` cannot produce this: it runs // `--name-status`, which reports a status and a path but never an old mode, so // a mode-only or type change is invisible to it. `--raw` carries both modes and // both blob ids, which is what makes `modeOnly` decidable at all. export interface ChangedPathEntry { readonly path: string; readonly status: string; readonly oldMode: string; readonly newMode: string; readonly deleted: boolean; readonly typeChanged: boolean; readonly modeOnly: boolean; } export function deriveChangedPathManifest(cwd: string, baseTree: string, candidateTree: string, executor: CandidateGitExecutor = defaultCandidateGitExecutor): readonly ChangedPathEntry[] { const tokens = gitPathTokens(cwd, ["diff", "--raw", "-z", "--abbrev=40", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor); const entries: ChangedPathEntry[] = []; for (let index = 0; index < tokens.length;) { const header = tokens[index++]?.toString("ascii"); if (header === undefined) break; // `: ` const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{7,64}) ([0-9a-f]{7,64}) ([AMDT]|R[0-9]{3})$/.exec(header); if (match === null) throw new CandidateViewError("candidate manifest Git output contains an unsafe raw header", "manifest-derivation-invalid"); const [, oldMode, newMode, oldSha, newSha, status] = match; const firstPath = tokens[index++]; if (firstPath === undefined) throw new CandidateViewError("candidate manifest Git output is incomplete", "manifest-derivation-invalid"); // A rename emits both the old and the new path; the new one is the scope. const path = status.startsWith("R") ? decodeCanonicalPath(tokens[index++] ?? firstPath) : decodeCanonicalPath(firstPath); entries.push(Object.freeze({ path, status: status.startsWith("R") ? "A" : status, oldMode, newMode, deleted: status === "D", typeChanged: status === "T", // Identical blob on both sides with different modes is the case the // sorted-path comparison could never see. modeOnly: oldSha === newSha && oldMode !== newMode, })); } return Object.freeze([...entries].sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))); } // Pi's own canonical digest of a changed-path manifest: sorted by path, with // the wire (snake_case) field names the v2 `changed_path` schema uses. This // is deliberately NOT an attempt to reproduce the provider's undocumented // `changed_path_manifest_sha256` canonicalization byte-for-byte (design.md's // open question). It is Pi's own self-consistency check: does the manifest a // descriptor carries digest to the same value the descriptor claims for it? // A caller (or a corrupted transport) that supplies a manifest and a claimed // digest that disagree with each other is caught here, independent of and // before any comparison against live Git content. export function digestChangedPathManifest(manifest: readonly ChangedPathEntry[]): string { const canonical = [...manifest] .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)) .map((entry) => ({ path: entry.path, status: entry.status, old_mode: entry.oldMode, new_mode: entry.newMode, deleted: entry.deleted, type_changed: entry.typeChanged, mode_only: entry.modeOnly, })); return `sha256:${createHash("sha256").update(JSON.stringify(canonical)).digest("hex")}`; } function assertManifestMatchesGit(descriptor: NativeCandidateProjectionDescriptor, derived: readonly ChangedPathEntry[]): void { const claimed = descriptor.manifest; if (claimed === undefined) return; // Self-consistency: does the manifest digest to what the subject claims // for it? Checked before any Git comparison, same as input-divergence. if (descriptor.manifestSha256 !== undefined && descriptor.providerManifestHashVerified !== true && digestChangedPathManifest(claimed) !== descriptor.manifestSha256) { throw new CandidateViewError("native manifest does not digest to its own artifact-subject claim", "manifest-subject-drift"); } // First: is the provider's own input self-consistent? A manifest that does // not describe the descriptor's paths is not a drift observation, it is a // malformed input, and saying so separately keeps the diagnosis honest. const claimedPaths = [...claimed.map((entry) => entry.path)].sort(); if (JSON.stringify(claimedPaths) !== JSON.stringify([...descriptor.paths].sort())) { throw new CandidateViewError("native manifest does not describe the same paths as its own projection", "manifest-input-divergence"); } const derivedByPath = new Map(derived.map((entry) => [entry.path, entry])); if (JSON.stringify(claimedPaths) !== JSON.stringify(derived.map((entry) => entry.path))) { throw new CandidateViewError("native manifest paths do not match Git content", "manifest-path-set-drift"); } for (const entry of claimed) { const actual = derivedByPath.get(entry.path); if (actual === undefined) throw new CandidateViewError("native manifest paths do not match Git content", "manifest-path-set-drift"); if (entry.status !== actual.status || entry.deleted !== actual.deleted) { throw new CandidateViewError(`native manifest status for ${entry.path} does not match Git content`, "manifest-status-drift"); } if (entry.oldMode !== actual.oldMode || entry.newMode !== actual.newMode || entry.modeOnly !== actual.modeOnly || entry.typeChanged !== actual.typeChanged) { throw new CandidateViewError(`native manifest mode state for ${entry.path} does not match Git content`, "manifest-mode-drift"); } } } function deriveChangedScope(cwd: string, baseTree: string, candidateTree: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): CandidateViewScope { const present = new Map(entries.map((entry) => [entry.path, entry])); const paths = new Set(); const deleted = new Set(); const tokens = gitPathTokens(cwd, ["diff", "--name-status", "-z", "--no-ext-diff", "--find-renames=100%", baseTree, candidateTree], executor); for (let index = 0; index < tokens.length;) { const status = tokens[index++]?.toString("ascii"); if (status === undefined || !/^(?:[AMDT]|R[0-9]{3})$/.test(status)) throw new CandidateViewError("candidate scope Git output contains an unsafe status"); const oldPath = tokens[index++]; if (oldPath === undefined) throw new CandidateViewError("candidate scope Git output is incomplete"); const firstPath = decodeCanonicalPath(oldPath); const path = status.startsWith("R") ? (() => { const newPath = tokens[index++]; if (newPath === undefined) throw new CandidateViewError("candidate scope rename output is incomplete"); return decodeCanonicalPath(newPath); })() : firstPath; if (paths.has(path) || deleted.has(path)) throw new CandidateViewError("candidate scope Git output contains duplicate paths"); if (status === "D") { if (present.has(path)) throw new CandidateViewError("candidate scope deletion is present in the candidate tree"); deleted.add(path); } else { if (!present.has(path)) throw new CandidateViewError("candidate scope path is absent from the candidate tree"); paths.add(path); } } const presentPaths = [...paths].sort(); const deletedPaths = [...deleted].sort(); const allPaths = [...presentPaths, ...deletedPaths].sort(); return { paths: allPaths, modes: Object.fromEntries(presentPaths.map((path) => [path, present.get(path)!.mode])), gitlinks: Object.fromEntries(presentPaths.flatMap((path) => { const entry = present.get(path)!; return entry.mode === "160000" ? [[path, entry.objectId]] : []; })), deletedPaths, }; } function entryContentHash(root: string, entry: CandidateTreeEntry): string { const path = join(root, entry.path); const item = lstatSync(path); if (entry.mode === "120000") { if (!item.isSymbolicLink()) throw new CandidateViewError("candidate view symlink does not match its frozen tree"); const target = readlinkSync(path, "buffer"); const bytes = Buffer.isBuffer(target) ? target : Buffer.from(target); assertSafeSymlinkTarget(root, entry.path, bytes); return createHash("sha256").update(bytes).digest("hex"); } if (!item.isFile() || item.isSymbolicLink()) throw new CandidateViewError("candidate view entry does not match its frozen tree"); return createHash("sha256").update(readFileSync(path)).digest("hex"); } function candidateDirectories(root: string, entries: readonly CandidateViewEntry[]): string[] { const directories = new Set([root]); for (const entry of entries) { for (let path = dirname(join(root, entry.path)); isWithin(root, path) || path === root; path = dirname(path)) { directories.add(path); if (path === root) break; } } return [...directories].sort((left, right) => right.length - left.length); } function makeReadonly(root: string, entries: readonly CandidateViewEntry[]): void { for (const entry of entries) { if (entry.mode !== "120000") chmodSync(join(root, entry.path), entry.mode === "100755" ? 0o555 : 0o444); } const gitFile = join(root, ".git"); const metadata = lstatSync(gitFile); if (!metadata.isFile() || metadata.isSymbolicLink()) throw new CandidateViewError("candidate worktree metadata is unsafe"); chmodSync(gitFile, 0o444); for (const directory of candidateDirectories(root, entries)) chmodSync(directory, 0o555); } function makeWritableForCleanup(path: string): void { const entry = lstatSync(path, { throwIfNoEntry: false }); if (!entry || entry.isSymbolicLink()) return; if (entry.isDirectory()) { for (const child of readdirSync(path)) makeWritableForCleanup(join(path, child)); chmodSync(path, 0o755); return; } chmodSync(path, 0o644); } function candidateViewParent(commonDir: string): string { const parent = join(commonDir, "gentle-ai", "candidate-views"); mkdirSync(parent, { recursive: true, mode: 0o700 }); const stat = lstatSync(parent); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CandidateViewError("candidate view parent is unsafe"); return realpathSync(parent); } export interface ResolvedCandidateBase { commit: string; tree: string; } function isFullCommitId(selector: string): boolean { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(selector); } function explicitBaseRefCandidates(cwd: string, selector: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): string[] { if (selector === "HEAD" || isFullCommitId(selector)) return [selector]; const refs = new Set(git(cwd, ["for-each-ref", "--format=%(refname)"], env, executor).split("\n").filter((ref) => ref.length > 0)); const candidates = selector.startsWith("refs/") ? [selector] : [ `refs/${selector}`, `refs/tags/${selector}`, `refs/heads/${selector}`, `refs/remotes/${selector}`, `refs/remotes/${selector}/HEAD`, ]; return [...new Set(candidates)].filter((candidate) => refs.has(candidate)); } // Runs a probe command that may exit nonzero as an expected signal (absent // ref, detached HEAD). Returns the exit status and trimmed stdout. Timeout, // output-limit, and unexpected Git failures propagate as sanitized // CandidateViewError diagnostics, same as candidateGit. function probeCandidateGit(cwd: string, arguments_: readonly string[], env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): { status: number; stdout: string } { const timeoutMs = resolveCandidateGitTimeoutMs(env); try { const stdout = executor("git", arguments_, { cwd, encoding: "utf8", env, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, maxBuffer: CANDIDATE_GIT_MAX_BUFFER_BYTES, windowsHide: true, }) as string; return { status: 0, stdout: stdout.trim() }; } catch (error) { const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer }; if (detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.OUTPUT_LIMIT, arguments_, timeoutMs); if (detail.code === "ETIMEDOUT" || detail.killed === true) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.TIMEOUT, arguments_, timeoutMs); if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" }; throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, arguments_, timeoutMs); } } // An unborn repository's HEAD is a symbolic ref to a branch with no commits. // `symbolic-ref --quiet HEAD` exits nonzero for a detached HEAD (not unborn). // `rev-parse --verify --quiet ` distinguishes a valid unborn (status 1, // ref absent) from a broken symbolic ref (exit 0, ref OID text exists even // when the object is missing). Any other status (128, etc.) signals // corruption or an I/O failure, so it fails closed instead of masquerading as // an unborn repository. function isUnbornSymbolicHead(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): boolean { const symbolic = probeCandidateGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], env, executor); if (symbolic.status !== 0) return false; const refProbeArguments = ["rev-parse", "--verify", "--quiet", symbolic.stdout]; const refProbe = probeCandidateGit(cwd, refProbeArguments, env, executor); if (refProbe.status === 1) return true; if (refProbe.status === 0) return false; throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, refProbeArguments, resolveCandidateGitTimeoutMs(env)); } // Derives Git's repository-native empty tree without hardcoding the SHA-1 id, // so a sha256 repository derives its own empty-tree object id. `mktree` with // ignored stdin reads empty input and writes the empty tree object. function resolveEmptyTree(cwd: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): string { return git(cwd, ["mktree"], env, executor); } function resolveCandidateBase(cwd: string, baseRef: string | undefined, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): ResolvedCandidateBase { const selector = baseRef ?? "HEAD"; // An unborn repository has a symbolic HEAD pointing at a branch with no // commits yet. Its review base is Git's repository-native empty tree, not a // missing or malformed commit. Only the default/HEAD selector is entitled to // the empty-tree base; a detached HEAD over a missing commit stays fail-closed. if (selector === "HEAD" && isUnbornSymbolicHead(cwd, env, executor)) { return { commit: "HEAD", tree: resolveEmptyTree(cwd, env, executor) }; } try { if (baseRef !== undefined) { const candidates = explicitBaseRefCandidates(cwd, selector, env, executor); if (candidates.length > 1) throw new CandidateViewError("candidate base reference is ambiguous", "base-ref-ambiguous"); if (candidates.length === 0) throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable"); } const firstCommit = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${selector}^{commit}`], env, executor); const tree = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${firstCommit}^{tree}`], env, executor); const confirmedCommit = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${selector}^{commit}`], env, executor); if (firstCommit !== confirmedCommit) throw new CandidateViewError("candidate base reference moved during resolution", "base-ref-moved"); const confirmedTree = git(cwd, ["rev-parse", "--verify", "--end-of-options", `${confirmedCommit}^{tree}`], env, executor); if (tree !== confirmedTree) throw new CandidateViewError("candidate base tree changed during resolution", "base-ref-moved"); return { commit: confirmedCommit, tree: confirmedTree }; } catch (error) { if (error instanceof CandidateViewError && (error.diagnostics !== undefined || error.reason === "base-ref-ambiguous" || error.reason === "base-ref-moved" || error.reason === "base-ref-unresolvable")) throw error; throw new CandidateViewError("candidate base reference is unresolvable", "base-ref-unresolvable"); } } function resolveCandidateBaseTree(cwd: string, baseTree: string, executor: CandidateGitExecutor): ResolvedCandidateBase { const row = git(cwd, ["log", "--format=%H%x09%T", "HEAD"], process.env, executor) .split("\n") .find((entry) => entry.endsWith(`\t${baseTree}`)); if (row === undefined) throw new CandidateViewError("native projection base is not reachable from HEAD"); const base = resolveCandidateBase(cwd, row.slice(0, row.indexOf("\t")), process.env, executor); if (base.tree !== baseTree) throw new CandidateViewError("native projection base tree is inconsistent"); return base; } export function resolveCanonicalCandidateBase(contributorRoot: string, baseRef: string): ResolvedCandidateBase { return resolveCandidateBase(realpathSync(contributorRoot), baseRef, process.env, defaultCandidateGitExecutor); } function checkoutMaterializedEntries(root: string, entries: readonly CandidateTreeEntry[], executor: CandidateGitExecutor): void { let batch: string[] = []; let bytes = 0; const flush = (): void => { if (batch.length === 0) return; git(root, ["checkout-index", "-f", "--", ...batch], process.env, executor); batch = []; bytes = 0; }; for (const entry of entries) { const size = Buffer.byteLength(entry.path, "utf8") + 1; if (batch.length > 0 && bytes + size > 16_384) flush(); batch.push(entry.path); bytes += size; } flush(); } // Creates an unborn worktree (symbolic HEAD pointing at a branch with no // commits, no ref written, no phantom commit). Git 2.42+ supports --orphan // directly; older Git lacks the flag and reports an unsupported-option usage // error (exit status 129). Only that exact status triggers the fallback: a // temporary empty-tree commit seeds a detached --no-checkout worktree, then // a symbolic-ref rewrite makes HEAD unborn. The temporary commit is never // referenced by any ref and is GC-able, so it is not a phantom commit. The // fallback uses a deterministic author/committer identity and timestamp in a // copied environment so it does not depend on user.name/user.email or the // ambient date. Contributor HEAD, branch, refs, and index are never touched. function addUnbornWorktree(cwd: string, root: string, branch: string, env: NodeJS.ProcessEnv, executor: CandidateGitExecutor): void { const primary = probeCandidateGit(cwd, ["worktree", "add", "--orphan", "-b", branch, root], env, executor); if (primary.status === 0) return; // Only the unsupported-option usage status (129, pre-2.42 Git lacking --orphan) // triggers the fallback. Every other status propagates as a git-failure. if (primary.status !== 129 || existsSync(root)) throw candidateGitFailure(CANDIDATE_VIEW_GIT_FAILURE_CATEGORY.GIT_FAILURE, ["worktree", "add", "--orphan", "-b", branch, root], resolveCandidateGitTimeoutMs(env)); const fallbackEnv = { ...env, GIT_AUTHOR_NAME: "gentle-ai-candidate", GIT_AUTHOR_EMAIL: "gentle-ai-candidate@example.invalid", GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", GIT_COMMITTER_NAME: "gentle-ai-candidate", GIT_COMMITTER_EMAIL: "gentle-ai-candidate@example.invalid", GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z", }; const emptyTree = git(cwd, ["mktree"], fallbackEnv, executor); const tempCommit = git(cwd, ["commit-tree", "-m", "gentle-ai-candidate", emptyTree], fallbackEnv, executor); git(cwd, ["worktree", "add", "--no-checkout", "--detach", root, tempCommit], env, executor); git(root, ["symbolic-ref", "HEAD", `refs/heads/${branch}`], env, executor); } function materializeCandidateView(request: CreateCandidateViewRequest, executor: CandidateGitExecutor): CandidateViewRecord { const contributorRoot = realpathSync(request.contributorRoot); if (!lstatSync(contributorRoot).isDirectory()) throw new CandidateViewError("contributor root is not a directory"); if (request.committedOnly === true && request.baseRef === undefined) throw new CandidateViewError("committed-only candidate views require an explicit base reference", "committed-only-base-required"); const commonDir = resolve(contributorRoot, git(contributorRoot, ["rev-parse", "--git-common-dir"], process.env, executor)); const canonicalCommonDir = realpathSync(commonDir); const base = resolveCandidateBase(contributorRoot, request.baseRef, process.env, executor); const committedOnly = request.committedOnly === true; const candidateCommit = committedOnly ? resolveCandidateBase(contributorRoot, "HEAD", process.env, executor) : base; const parent = candidateViewParent(canonicalCommonDir); const index = mkdtempSync(join(tmpdir(), "gentle-ai-candidate-index-")); const indexPath = join(index, "index"); const environment = { ...process.env, GIT_INDEX_FILE: indexPath }; try { const baseCommit = base.commit; const unborn = baseCommit === "HEAD"; // For an unborn repository the base tree is Git's empty tree, so seed the // private candidate index from `--empty` instead of a non-existent commit. if (unborn) git(contributorRoot, ["read-tree", "--empty"], environment, executor); else git(contributorRoot, ["read-tree", candidateCommit.commit], environment, executor); if (!committedOnly) git(contributorRoot, ["add", "-A"], environment, executor); const candidateTree = git(contributorRoot, ["write-tree"], environment, executor); const root = join(parent, randomUUID()); // The worktree is created under the same try/catch cleanup boundary as // the read-tree materialization that follows. addUnbornWorktree's // fallback path can register a worktree with `worktree add` and then // fail on a later step (for example `symbolic-ref`); moving creation // here ensures any such partial registration is removed by the catch // below instead of leaking a registered/admin worktree and directory. try { // An unborn repository has no commit to detach a worktree at. addUnbornWorktree // creates an orphan worktree (unborn branch, no commit, no ref) to host the // materialized candidate tree without a phantom commit, with a fallback for // Git versions older than 2.42 that do not support --orphan. if (unborn) addUnbornWorktree(contributorRoot, root, `gentle-ai-candidate-${randomUUID()}`, process.env, executor); else git(contributorRoot, ["worktree", "add", "--detach", "--no-checkout", root, candidateCommit.commit], process.env, executor); git(root, ["read-tree", candidateTree], process.env, executor); const tree = parseTree(root, candidateTree, executor); checkoutMaterializedEntries(root, tree.entries, executor); const entries = tree.entries.map((entry) => ({ ...entry, contentHash: entryContentHash(root, entry) })); const scope = deriveChangedScope(contributorRoot, base.tree, candidateTree, [...tree.entries, ...tree.gitlinks], executor); for (const gitlink of tree.gitlinks) if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view materialized a metadata-only gitlink"); makeReadonly(root, entries); return { token: basename(root), root: realpathSync(root), parent, contributorRoot, commonDir: canonicalCommonDir, baseCommit, baseTree: base.tree, candidateTree, committedOnly, entries, gitlinks: tree.gitlinks, scope, gitExecutor: executor }; } catch (error) { try { git(contributorRoot, ["worktree", "remove", "--force", root], process.env, executor); } catch { rmSync(root, { recursive: true, force: true }); } throw error; } } finally { rmSync(index, { recursive: true, force: true }); } } function assertRecordSafe(record: CandidateViewRecord): void { const root = record.root; if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved"); const rootStat = lstatSync(root); if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || realpathSync(root) !== root) throw new CandidateViewError("candidate view root is unsafe"); for (const directory of candidateDirectories(root, record.entries)) { const metadata = lstatSync(directory); if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o222) !== 0) throw new CandidateViewError("candidate view directory is unsafe or writable"); } const gitFile = lstatSync(join(root, ".git")); if (!gitFile.isFile() || gitFile.isSymbolicLink() || (gitFile.mode & 0o222) !== 0) throw new CandidateViewError("candidate worktree metadata is unsafe or writable"); if (gitPathTokens(root, ["ls-files", "--others", "--exclude-standard", "-z"], record.gitExecutor).length !== 0) throw new CandidateViewError("candidate view contains injected untracked entries"); const tree = git(root, ["write-tree"], process.env, record.gitExecutor); if (tree !== record.candidateTree) throw new CandidateViewError("candidate view index no longer matches its frozen tree"); for (const gitlink of record.gitlinks) { if (!isSafeCandidatePath(gitlink.path) || gitlink.mode !== "160000" || !isCanonicalObjectId(gitlink.objectId)) throw new CandidateViewError("candidate view gitlink metadata is unsafe"); if (lstatSync(join(root, gitlink.path), { throwIfNoEntry: false })) throw new CandidateViewError("candidate view contains materialized gitlink contents"); } for (const entry of record.entries) { if (!isSafeCandidatePath(entry.path)) throw new CandidateViewError("candidate view entry path is unsafe"); const path = join(root, entry.path); if (!isWithin(root, path)) throw new CandidateViewError("candidate view entry is missing or moved"); const item = lstatSync(path, { throwIfNoEntry: false }); if (!item) throw new CandidateViewError("candidate view entry is missing or moved"); if (entry.mode === "120000") { if (!item.isSymbolicLink()) throw new CandidateViewError("candidate view symlink is unsafe or changed"); } else if (!item.isFile() || item.isSymbolicLink() || (item.mode & 0o222) !== 0 || ((item.mode & 0o111) !== (entry.mode === "100755" ? 0o111 : 0))) { throw new CandidateViewError("candidate view entry is unsafe, writable, or has a changed mode"); } const actualHash = entryContentHash(root, entry); if (actualHash !== entry.contentHash) throw new CandidateViewError("candidate view content no longer matches its frozen tree"); } } export class CandidateViewRegistry { private readonly records = new Map(); private readonly gitExecutor: CandidateGitExecutor; constructor(gitExecutor: CandidateGitExecutor = defaultCandidateGitExecutor) { this.gitExecutor = gitExecutor; } private readonly lineages = new Map(); private readonly projections = new Map(); private readonly replays = new Map(); private current: { lineageId: string; token: string } | undefined; // The last dispatch-binding hydration that was attempted and failed. A // swallowed hydration failure is its own defect (field report 2026-08-16): // without it the later dispatch refusal claims no binding was ever // available instead of naming the attempt and its typed cause. private lastHydrationFailure: { lineageId: string; reason: string; message: string } | undefined; create(request: CreateCandidateViewRequest): CandidateView { return this.createOrReuse(request); } createOrReuse(request: CreateCandidateViewRequest): CandidateView { const token = request.replayKey === undefined ? undefined : this.replays.get(request.replayKey); const existing = token === undefined ? undefined : this.records.get(token); if (existing) { assertRecordSafe(existing); return this.expose(existing); } const record = materializeCandidateView(request, this.gitExecutor); this.records.set(record.token, record); if (request.replayKey !== undefined) this.replays.set(request.replayKey, record.token); return this.expose(record); } bind(request: BindCandidateViewRequest): void { this.bindCurrent(request); } bindCurrent(request: BindCandidateViewRequest): void { const selectedLenses = this.validateSelectedLenses(request.selectedLenses); this.bindRecord(request.token, request.lineageId, selectedLenses); this.current = { lineageId: request.lineageId, token: request.token }; } retain(token: string, lineageId: string): void { this.bindRecord(token, lineageId, []); this.current = { lineageId, token }; } restoreCurrentFromNativeStart(request: BindCandidateViewRequest): void { if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established"); const record = this.records.get(request.token); if (!record || record.lineageId !== undefined) throw new CandidateViewError("native reviewing candidate view is missing or already bound", "authoritative-current-match-missing"); assertRecordSafe(record); this.assertCurrentBindingMatchesLiveCandidate(record); this.bindCurrent(request); } hasCurrentBinding(): boolean { return this.current !== undefined; } restoreCurrentFromAuthoritativeReviewingStates( contributorRoot: string, states: readonly AuthoritativeReviewingCandidateState[], ): void { if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established"); if (states.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing"); if (states.length !== 1) throw new CandidateViewError("multiple authoritative reviewing lineages exactly match the live candidate", "authoritative-current-match-ambiguous"); const live = materializeCandidateView({ contributorRoot, baseRef: states[0]!.baseCommit, committedOnly: states[0]!.committedOnly === true }, this.gitExecutor); try { const matches = states.filter((state) => this.matchesAuthoritativeState(live, state)); if (matches.length === 0) throw new CandidateViewError("no authoritative reviewing lineage exactly matches the live candidate", "authoritative-current-match-missing"); if (matches.length !== 1) throw new CandidateViewError("multiple authoritative reviewing lineages exactly match the live candidate", "authoritative-current-match-ambiguous"); const state = matches[0]!; const selectedLenses = this.validateSelectedLenses(state.selectedLenses); this.records.set(live.token, live); this.bindRecord(live.token, state.lineageId, selectedLenses); this.current = { lineageId: state.lineageId, token: live.token }; } catch (error) { if (!this.records.has(live.token)) this.remove(live); throw error; } } createCorrected(lineageId: string, contributorRoot: string, replayKey: string): CandidateView { const projection = this.resolveProjection(lineageId, contributorRoot); const existingToken = this.replays.get(replayKey); const existing = existingToken === undefined ? undefined : this.records.get(existingToken); if (existing) { if (existing.lineageId !== undefined) throw new CandidateViewError("corrected candidate replay is no longer pending"); assertRecordSafe(existing); return this.expose(existing); } const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor); try { if (record.baseCommit !== projection.baseCommit || record.baseTree !== projection.baseTree) throw new CandidateViewError("corrected candidate base does not match the frozen genesis base"); if (!record.scope.paths.every((path) => projection.paths.includes(path))) throw new CandidateViewError("corrected candidate scope escapes the frozen genesis paths"); this.records.set(record.token, record); this.replays.set(replayKey, record.token); return this.expose(record); } catch (error) { this.remove(record); throw error; } } promoteCorrected(lineageId: string, token: string): void { const replacement = this.records.get(token); const projection = this.projections.get(lineageId); const currentToken = this.lineages.get(lineageId); const current = currentToken === undefined ? undefined : this.records.get(currentToken); if (!replacement || replacement.lineageId !== undefined || !projection || (currentToken !== undefined && (!current || current.lineageId !== lineageId))) { throw new CandidateViewError("corrected candidate replacement is missing or ambiguous"); } if (this.current !== undefined && this.current.lineageId !== lineageId) { throw new CandidateViewError("corrected candidate replacement conflicts with the current lineage binding"); } assertRecordSafe(replacement); if (current) assertRecordSafe(current); if ( replacement.contributorRoot !== projection.contributorRoot || replacement.baseCommit !== projection.baseCommit || replacement.baseTree !== projection.baseTree || replacement.committedOnly !== projection.committedOnly || !replacement.scope.paths.every((path) => projection.paths.includes(path)) ) { throw new CandidateViewError("corrected candidate replacement does not preserve its frozen lineage projection"); } replacement.lineageId = lineageId; replacement.selectedLenses = []; this.lineages.set(lineageId, token); for (const [key, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(key); this.projections.set(lineageId, { contributorRoot: replacement.contributorRoot, baseCommit: replacement.baseCommit, baseTree: replacement.baseTree, candidateTree: replacement.candidateTree, committedOnly: replacement.committedOnly, paths: replacement.scope.paths, modes: replacement.scope.modes, gitlinks: replacement.scope.gitlinks, deletedPaths: replacement.scope.deletedPaths, }); this.current = { lineageId, token }; if (current) { this.remove(current); this.forget(current); } } private validateSelectedLenses(lenses: readonly string[]): ReviewLens[] { const selectedLenses = lenses.filter((lens): lens is ReviewLens => (REVIEW_LENS as readonly string[]).includes(lens)); if (selectedLenses.length !== lenses.length || selectedLenses.length === 0) throw new CandidateViewError("candidate view has no valid selected review lenses"); return selectedLenses; } private matchesAuthoritativeState(record: CandidateViewRecord, state: AuthoritativeReviewingCandidateState): boolean { try { return realpathSync(state.contributorRoot) === record.contributorRoot && state.baseCommit === record.baseCommit && state.baseTree === record.baseTree && state.candidateTree === record.candidateTree && (state.committedOnly ?? false) === record.committedOnly && JSON.stringify(state.paths) === JSON.stringify(record.scope.paths) && JSON.stringify(state.modes) === JSON.stringify(record.scope.modes) && gitlinkMapsEqual(state.gitlinks ?? {}, record.scope.gitlinks) && JSON.stringify(state.deletedPaths) === JSON.stringify(record.scope.deletedPaths); } catch { return false; } } private bindRecord(token: string, lineageId: string, selectedLenses: readonly ReviewLens[]): void { const record = this.records.get(token); if (!record || record.lineageId !== undefined || this.lineages.has(lineageId)) throw new CandidateViewError("candidate view lineage binding is missing or ambiguous"); assertRecordSafe(record); record.lineageId = lineageId; record.selectedLenses = selectedLenses; this.lineages.set(lineageId, record.token); this.projections.set(lineageId, { contributorRoot: record.contributorRoot, baseCommit: record.baseCommit, baseTree: record.baseTree, candidateTree: record.candidateTree, committedOnly: record.committedOnly, paths: record.scope.paths, modes: record.scope.modes, gitlinks: record.scope.gitlinks, deletedPaths: record.scope.deletedPaths, }); for (const [key, pendingToken] of this.replays) if (pendingToken === token) this.replays.delete(key); } hasProjection(lineageId: string): boolean { return this.projections.has(lineageId); } restoreProjection(lineageId: string, contributorRoot: string, baseCommit: string, baseTree: string, candidateTree: string, paths: readonly string[]): void { const root = realpathSync(contributorRoot); const base = resolveCandidateBase(root, baseCommit, process.env, this.gitExecutor); if (!lineageId || this.projections.has(lineageId) || base.commit !== baseCommit || base.tree !== baseTree || !isFullCommitId(candidateTree) || paths.some((path) => !isSafeCandidatePath(path)) || new Set(paths).size !== paths.length) throw new CandidateViewError("frozen correction projection is invalid or already restored"); this.projections.set(lineageId, { contributorRoot: root, baseCommit, baseTree, candidateTree, committedOnly: false, paths: [...paths], modes: {}, gitlinks: {}, deletedPaths: [] }); } restoreProjectionFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): void { const root = realpathSync(contributorRoot); if (!lineageId || this.projections.has(lineageId) || !isFullCommitId(descriptor.baseTree) || !isFullCommitId(descriptor.currentCandidateTree)) throw new CandidateViewError("native frozen projection is invalid or already restored"); if (descriptor.paths.some((path) => !isSafeCandidatePath(path)) || new Set(descriptor.paths).size !== descriptor.paths.length) throw new CandidateViewError("native frozen projection paths are invalid"); if (descriptor.intendedUntracked.some((path) => !descriptor.paths.includes(path)) || new Set(descriptor.intendedUntracked).size !== descriptor.intendedUntracked.length) throw new CandidateViewError("native intended-untracked projection is invalid"); const head = resolveCandidateBase(root, "HEAD", process.env, this.gitExecutor); const base = head.tree === descriptor.baseTree ? head : resolveCandidateBaseTree(root, descriptor.baseTree, this.gitExecutor); const committedOnly = head.tree === descriptor.currentCandidateTree && base.tree !== head.tree; if (!committedOnly && head.tree !== descriptor.baseTree) throw new CandidateViewError("native projection base no longer matches HEAD"); // Native `staged` covers both a committed HEAD range and the exact current // index over HEAD. Re-derive the latter from Git instead of trusting the // label; this also rejects dirty-inclusive snapshots mislabeled as staged. const stagedIndex = !committedOnly && descriptor.baseTree === head.tree && git(root, ["write-tree"], process.env, this.gitExecutor) === descriptor.currentCandidateTree; if ( (descriptor.projection === "staged" && !committedOnly && !stagedIndex) || (descriptor.projection === "workspace" && committedOnly) ) { throw new CandidateViewError("native projection commit-state does not match its declared projection kind", "projection-kind-drift"); } const tree = parseTree(root, descriptor.currentCandidateTree, this.gitExecutor); const scope = deriveChangedScope(root, descriptor.baseTree, descriptor.currentCandidateTree, [...tree.entries, ...tree.gitlinks], this.gitExecutor); // A manifest SUBSUMES the sorted-path comparison rather than stacking on // top of it: it checks the same path set plus the mode, status, and // type state the path set cannot express, and it names which of those // drifted. Descriptors without a manifest keep the legacy check. if (descriptor.manifest !== undefined) { assertManifestMatchesGit(descriptor, deriveChangedPathManifest(root, descriptor.baseTree, descriptor.currentCandidateTree, this.gitExecutor)); } else if (JSON.stringify(scope.paths) !== JSON.stringify([...descriptor.paths].sort())) { throw new CandidateViewError("native projection paths do not match Git content"); } this.projections.set(lineageId, { contributorRoot: root, baseCommit: base.commit, baseTree: descriptor.baseTree, candidateTree: descriptor.currentCandidateTree, committedOnly, paths: scope.paths, modes: scope.modes, gitlinks: scope.gitlinks, deletedPaths: scope.deletedPaths, }); } /** * Re-derives this lineage's FINALIZE binding from the provider's own * projection, replacing a binding this session is still holding. * * Field defect (Engram #12547): once a bounded correction is admitted, the * candidate identity legitimately moves, and the provider issues its * finalize transition for that corrected target. A session that started the * review still holds the START-time immutable reviewer view, so comparing * it against the corrected projection reads as drift and no receipt is ever * minted — while a fresh process, which restores from the native descriptor, * finalizes the very same lineage successfully. This makes the in-session * path behave like that already-correct fresh-process path. * * This is a re-derivation, not a relaxation: the replacement is * materialized from Git and must match the provider descriptor exactly * (base tree, projection kind, changed-path manifest), and the caller still * asserts the binding afterwards. The immutable reviewer view is retired * here on purpose — the lenses that consumed it finished before the * correction. */ rebindForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView { const staleToken = this.lineages.get(lineageId); const stale = staleToken === undefined ? undefined : this.records.get(staleToken); this.projections.delete(lineageId); if (stale !== undefined) { this.remove(stale); this.forget(stale); } return this.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor); } restoreForFinalizeFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor): CandidateView { this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor); const projection = this.resolveProjection(lineageId, contributorRoot); const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor); try { if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) { throw new CandidateViewError("live candidate does not match the native frozen projection"); } this.records.set(record.token, record); this.bindRecord(record.token, lineageId, []); return this.expose(record); } catch (error) { this.projections.delete(lineageId); this.remove(record); throw error; } } /** * Mirrors the START-time dispatch registration for a lineage this * controller never started (live defect 2026-08-16: a successor created * by native `review recover` exists only in native authority). The * authoritative STATUS descriptor supplies the frozen projection; the * live candidate is re-materialized and must match it exactly before the * dispatch-facing current binding is established with the provider-named * pending lenses. */ restoreCurrentForDispatchFromNative(lineageId: string, contributorRoot: string, descriptor: NativeCandidateProjectionDescriptor, selectedLenses: readonly string[]): void { if (this.current !== undefined) throw new CandidateViewError("candidate view already has a current lineage binding", "current-binding-already-established"); try { const lenses = this.validateSelectedLenses(selectedLenses); this.restoreProjectionFromNative(lineageId, contributorRoot, descriptor); const projection = this.resolveProjection(lineageId, contributorRoot); const record = materializeCandidateView({ contributorRoot, baseRef: projection.baseCommit, committedOnly: projection.committedOnly }, this.gitExecutor); try { if (record.baseTree !== projection.baseTree || record.candidateTree !== projection.candidateTree || JSON.stringify(record.scope.paths) !== JSON.stringify(projection.paths)) { throw new CandidateViewError("live candidate does not match the native frozen projection"); } this.records.set(record.token, record); this.bindRecord(record.token, lineageId, lenses); this.current = { lineageId, token: record.token }; } catch (error) { this.projections.delete(lineageId); this.records.delete(record.token); this.remove(record); throw error; } this.lastHydrationFailure = undefined; } catch (error) { this.lastHydrationFailure = { lineageId, reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid", message: error instanceof Error ? error.message : String(error), }; throw error; } } resolveProjection(lineageId: string, contributorRoot: string): FrozenCandidateProjection { const projection = this.projections.get(lineageId); if (!projection || realpathSync(contributorRoot) !== projection.contributorRoot) { throw new CandidateViewError("candidate projection is missing, ambiguous, or belongs to a different contributor root"); } return projection; } resolveForLens(lineageId: string, lens: string): CandidateView { const token = this.lineages.get(lineageId); const record = token === undefined ? undefined : this.records.get(token); if (!record || record.lineageId !== lineageId || !record.selectedLenses?.includes(lens as ReviewLens)) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected"); assertRecordSafe(record); return this.expose(record); } currentLineageId(): string { if (this.current === undefined) { const failure = this.lastHydrationFailure; if (failure !== undefined) { throw new CandidateViewError( `review subagent dispatch has no current controller-owned candidate view lineage binding: hydration for lineage ${failure.lineageId} was attempted from authoritative native status and failed (${failure.reason}): ${failure.message}`, "current-binding-hydration-failed", ); } throw new CandidateViewError("review subagent dispatch has no current controller-owned candidate view lineage binding", "current-binding-missing"); } return this.current.lineageId; } /** The last failed dispatch-binding hydration, for controller envelopes. */ lastDispatchHydrationFailure(): Readonly<{ lineageId: string; reason: string; message: string }> | undefined { return this.lastHydrationFailure; } resolveCurrentForLens(lens: string): CandidateView { return this.resolveCurrentForLenses([lens])[0]!; } resolveCurrentForLenses(lenses: readonly string[]): CandidateView[] { const lineageId = this.currentLineageId(); const token = this.current?.token; const record = token === undefined ? undefined : this.records.get(token); if (!record || record.lineageId !== lineageId || this.lineages.get(lineageId) !== token) throw new CandidateViewError("review subagent dispatch current lineage binding is stale or ambiguous", "current-binding-stale"); assertRecordSafe(record); this.assertCurrentBindingMatchesLiveCandidate(record); if (!lenses.every((lens) => record.selectedLenses?.includes(lens as ReviewLens))) throw new CandidateViewError("candidate view context is missing, ambiguous, stale, or lens-unselected", "current-binding-lens-unselected"); return lenses.map(() => this.expose(record)); } private assertCurrentBindingMatchesLiveCandidate(record: CandidateViewRecord): void { const live = materializeCandidateView({ contributorRoot: record.contributorRoot, baseRef: record.baseCommit, committedOnly: record.committedOnly }, this.gitExecutor); try { if ( live.baseCommit !== record.baseCommit || live.baseTree !== record.baseTree || live.candidateTree !== record.candidateTree || live.committedOnly !== record.committedOnly || JSON.stringify(live.scope.paths) !== JSON.stringify(record.scope.paths) || JSON.stringify(live.scope.modes) !== JSON.stringify(record.scope.modes) || !gitlinkMapsEqual(live.scope.gitlinks, record.scope.gitlinks) || JSON.stringify(live.scope.deletedPaths) !== JSON.stringify(record.scope.deletedPaths) ) throw new CandidateViewError("live candidate no longer matches the current controller-owned lineage binding", "current-binding-live-candidate-drift"); } finally { this.remove(live); } } resolveForFinalize(lineageId: string): CandidateView { const token = this.lineages.get(lineageId); const record = token === undefined ? undefined : this.records.get(token); if (!record || record.lineageId !== lineageId) throw new CandidateViewError("candidate view context is missing or ambiguous for FINALIZE"); assertRecordSafe(record); return this.expose(record); } cleanup(token: string): void { const record = this.records.get(token); if (!record) return; this.remove(record); this.forget(record); } cleanupTerminal(lineageId: string, state: string): void { if (state !== "approved" && state !== "escalated") return; const token = this.lineages.get(lineageId); if (token) this.cleanup(token); if (state === "escalated") this.projections.delete(lineageId); } private remove(record: CandidateViewRecord): void { if (!isWithin(record.parent, record.root)) throw new CandidateViewError("candidate view cleanup escaped its owned parent"); try { makeWritableForCleanup(record.root); } catch {} try { git(record.contributorRoot, ["worktree", "remove", "--force", record.root], process.env, record.gitExecutor); } catch { try { makeWritableForCleanup(record.root); } catch {} rmSync(record.root, { recursive: true, force: true }); } } private forget(record: CandidateViewRecord): void { this.records.delete(record.token); if (record.lineageId && this.lineages.get(record.lineageId) === record.token) this.lineages.delete(record.lineageId); if (this.current?.token === record.token) this.current = undefined; for (const [key, pendingToken] of this.replays) if (pendingToken === record.token) this.replays.delete(key); } consumeProjection(lineageId: string): void { this.projections.delete(lineageId); } private expose(record: CandidateViewRecord): CandidateView { return { token: record.token, root: record.root, baseCommit: record.baseCommit, baseTree: record.baseTree, candidateTree: record.candidateTree, committedOnly: record.committedOnly, paths: record.scope.paths, modes: record.scope.modes, gitlinks: record.scope.gitlinks, deletedPaths: record.scope.deletedPaths, verify: () => assertRecordSafe(record), cleanup: () => this.cleanup(record.token), }; } } interface MutableSubagentRunInput { agent?: unknown; agents?: unknown; task?: unknown; context?: unknown; mode?: unknown; [key: string]: unknown; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isReviewLens(value: string): value is ReviewLens { return (REVIEW_LENS as readonly string[]).includes(value); } function hasCandidateContextConflict(text: string, views: readonly CandidateView[]): boolean { return text.includes(CONTROLLER_CANDIDATE_VIEW_HEADING) || views.some((view) => text.includes(view.root) || text.includes(view.candidateTree)); } function compareCanonicalStrings(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } function canonicalStringMap(value: Readonly>): Record { // Plain objects enumerate integer-like keys numerically; canonical round trips intentionally preserve that ordering. return Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right))); } function candidateScopeByMode(view: CandidateView): Record { const grouped = new Map(); const deletedPaths = new Set(view.deletedPaths); for (const path of view.paths) { const group = deletedPaths.has(path) ? "deleted" : view.modes[path]; if (group === undefined) throw new CandidateViewError("candidate view scope omits a changed path mode"); const paths = grouped.get(group) ?? []; paths.push(path); grouped.set(group, paths); } return Object.fromEntries( [...grouped.entries()] .sort(([left], [right]) => compareCanonicalStrings(left, right)) .map(([mode, paths]) => [mode, paths.sort(compareCanonicalStrings)]), ); } function isCandidateContextMode(value: string): boolean { return (Object.values(CANDIDATE_CONTEXT_MODE) as readonly string[]).includes(value); } function isCanonicalStringArray(value: unknown): value is readonly string[] { return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && isSafeCandidatePath(item)) && value.every((item, index, items) => index === 0 || compareCanonicalStrings(items[index - 1]!, item) < 0); } function hasCanonicalRecordOrder(value: Readonly>): boolean { const normalized = Object.fromEntries(Object.entries(value).sort(([left], [right]) => compareCanonicalStrings(left, right))); return JSON.stringify(value) === JSON.stringify(normalized); } function invalidCandidateContextManifest(message: string): never { throw new CandidateViewError(message, "candidate-context-manifest-invalid"); } function validateCandidateContextManifest(value: unknown, bytes: Buffer): CandidateContextManifest { if (!isRecord(value)) return invalidCandidateContextManifest("candidate context manifest has an invalid structure"); const keys = Object.keys(value); const expectedKeys = ["version", "scopeByMode", "gitlinks"]; if (keys.length !== expectedKeys.length || !expectedKeys.every((key) => keys.includes(key))) { return invalidCandidateContextManifest("candidate context manifest has an invalid structure"); } if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) return invalidCandidateContextManifest("candidate context manifest is not canonical"); if (value.version !== CANDIDATE_CONTEXT_MANIFEST.VERSION || !isRecord(value.scopeByMode) || !isRecord(value.gitlinks)) { return invalidCandidateContextManifest("candidate context manifest has an invalid structure"); } const scopeByMode = value.scopeByMode; if (!hasCanonicalRecordOrder(scopeByMode)) return invalidCandidateContextManifest("candidate context manifest is not canonical"); const scopePaths = new Set(); for (const [mode, paths] of Object.entries(scopeByMode)) { if (!isCandidateContextMode(mode) || !isCanonicalStringArray(paths)) return invalidCandidateContextManifest("candidate context manifest has an invalid scope"); for (const path of paths) { if (scopePaths.has(path)) return invalidCandidateContextManifest("candidate context manifest has duplicate scope paths"); scopePaths.add(path); } } const gitlinks = value.gitlinks; if (!hasCanonicalRecordOrder(gitlinks)) return invalidCandidateContextManifest("candidate context manifest is not canonical"); for (const [path, objectId] of Object.entries(gitlinks)) { if (!isSafeCandidatePath(path) || typeof objectId !== "string" || !isCanonicalObjectId(objectId)) { return invalidCandidateContextManifest("candidate context manifest has an invalid gitlink map"); } } const gitlinkPaths = scopeByMode["160000"]; const canonicalGitlinkPaths = Object.keys(gitlinks).sort(compareCanonicalStrings); if ( (gitlinkPaths === undefined && canonicalGitlinkPaths.length !== 0) || (gitlinkPaths !== undefined && JSON.stringify(canonicalGitlinkPaths) !== JSON.stringify(gitlinkPaths)) ) return invalidCandidateContextManifest("candidate context manifest gitlinks do not match its scope"); const manifest: CandidateContextManifest = { version: CANDIDATE_CONTEXT_MANIFEST.VERSION, scopeByMode: scopeByMode as Readonly>, gitlinks: gitlinks as Readonly>, }; if (!Buffer.from(JSON.stringify(manifest), "utf8").equals(bytes)) { return invalidCandidateContextManifest("candidate context manifest is not canonical"); } return manifest; } export function decodeCandidateContextManifest(encoded: string, sha256: string): DecodedCandidateContextManifest { if (encoded.length > MAX_CANDIDATE_CONTEXT_LENGTH || !/^[A-Za-z0-9_-]+$/.test(encoded) || !/^[0-9a-f]{64}$/.test(sha256)) { throw new CandidateViewError("candidate context manifest encoding is invalid", "candidate-context-manifest-invalid"); } let bytes: Buffer; try { bytes = gunzipSync(Buffer.from(encoded, "base64url"), { maxOutputLength: MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES }); } catch { throw new CandidateViewError("candidate context manifest cannot be decompressed", "candidate-context-manifest-invalid"); } const actualSha256 = createHash("sha256").update(bytes).digest("hex"); if (actualSha256 !== sha256) throw new CandidateViewError("candidate context manifest integrity check failed", "candidate-context-manifest-integrity"); const text = bytes.toString("utf8"); if (!Buffer.from(text, "utf8").equals(bytes)) return invalidCandidateContextManifest("candidate context manifest is not valid UTF-8"); if (gzipSync(bytes, { mtime: 0 }).toString("base64url") !== encoded) { return invalidCandidateContextManifest("candidate context manifest transport is not canonical"); } let value: unknown; try { value = JSON.parse(text); } catch (error) { if (error instanceof CandidateViewError) throw error; return invalidCandidateContextManifest("candidate context manifest is not valid JSON"); } return { manifest: validateCandidateContextManifest(value, bytes), bytes, sha256: actualSha256 }; } export function readCandidateContextManifestPage(encoded: string, sha256: string, cursor = 0): CandidateContextPage { if (!Number.isSafeInteger(cursor) || cursor < 0) throw new CandidateViewError("candidate context manifest cursor is invalid", "candidate-context-cursor-invalid"); const decoded = decodeCandidateContextManifest(encoded, sha256); const entries = Object.entries(decoded.manifest.scopeByMode).flatMap(([mode, paths]) => paths.map((path): CandidateContextPageEntry => ({ path, mode: mode as CandidateContextMode, ...(mode === CANDIDATE_CONTEXT_MODE.GITLINK ? { gitlinkObjectId: decoded.manifest.gitlinks[path]! } : {}), }))); if (cursor > entries.length) throw new CandidateViewError("candidate context manifest cursor exceeds the changed scope", "candidate-context-cursor-invalid"); const pageEntries: CandidateContextPageEntry[] = []; for (let index = cursor; index < entries.length && pageEntries.length < MAX_CANDIDATE_SCOPE_PAGE_ENTRIES; index += 1) { const candidateEntries = [...pageEntries, entries[index]!]; const candidatePage: CandidateContextPage = { version: CANDIDATE_CONTEXT_MANIFEST.VERSION, sha256: decoded.sha256, cursor, totalPaths: entries.length, entries: candidateEntries, ...(cursor + candidateEntries.length < entries.length ? { nextCursor: cursor + candidateEntries.length } : {}), }; if (Buffer.byteLength(JSON.stringify(candidatePage), "utf8") > MAX_CANDIDATE_SCOPE_PAGE_BYTES) { if (pageEntries.length === 0) throw new CandidateViewError("candidate context manifest path exceeds the bounded actor response", "candidate-context-page-too-large"); break; } pageEntries.push(entries[index]!); } return { version: CANDIDATE_CONTEXT_MANIFEST.VERSION, sha256: decoded.sha256, cursor, totalPaths: entries.length, entries: pageEntries, ...(cursor + pageEntries.length < entries.length ? { nextCursor: cursor + pageEntries.length } : {}), }; } function candidateContextPreamble(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string): string { return `\n\n${CONTROLLER_CANDIDATE_VIEW_HEADING}\nController-owned review lineage: \`${lineageId}\`.\nAuthorized review actors: ${agents.join(", ")}.\nRead ONLY the absolute frozen candidate view at \`${view.root}\`.\nFrozen candidate tree: \`${view.candidateTree}\`.\nScope semantics: ${scopeSemantics}`; } function compactCandidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView, scopeSemantics: string, scopeByMode: Record): string { const manifest: CandidateContextManifest = { version: CANDIDATE_CONTEXT_MANIFEST.VERSION, scopeByMode, gitlinks: canonicalStringMap(view.gitlinks), }; const bytes = Buffer.from(JSON.stringify(manifest), "utf8"); if (bytes.length > MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract"); const sha256 = createHash("sha256").update(bytes).digest("hex"); const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url"); const block = `${candidateContextPreamble(lineageId, agents, view, scopeSemantics)}\nFrozen changed scope manifest (gzip+base64url): \`${encoded}\`.\nFrozen changed scope manifest SHA-256: \`${sha256}\`.\nCall \`gentle_review_scope\` with exactly this manifest, SHA-256, and cursor 0; continue with each returned \`nextCursor\` until absent. It is the only authorized scope enumerator: do not infer scope by traversing the candidate or ambient tree. Gitlinks are metadata-only and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`; if (Buffer.byteLength(block, "utf8") > MAX_CANDIDATE_CONTEXT_LENGTH) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract"); return block; } function candidateContextBlock(lineageId: string, agents: readonly ReviewLens[], view: CandidateView): string { const scopeByMode = candidateScopeByMode(view); const scopeSemantics = view.committedOnly ? "Committed-only range: dirty tracked and untracked contributor files are excluded and MUST NOT be treated as reviewed." : "Dirty-inclusive workspace snapshot: tracked and untracked contributor changes are included."; const readableBlock = `${candidateContextPreamble(lineageId, agents, view, scopeSemantics)}\nFrozen changed scope by mode: ${JSON.stringify(scopeByMode)}.\nFrozen metadata-only gitlinks: ${JSON.stringify(view.gitlinks)}. Gitlink paths have no materialized contents and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`; if (Buffer.byteLength(readableBlock, "utf8") <= MAX_CANDIDATE_CONTEXT_LENGTH) return readableBlock; return compactCandidateContextBlock(lineageId, agents, view, scopeSemantics, scopeByMode); } /** * Validates and mutates the actual mutable Pi `subagent_run` tool input before * execution. It deliberately derives all review context from the controller's * in-memory registry rather than user-provided lineage, cwd, paths, or content. */ export function injectReviewCandidateView(input: unknown, candidateViews: CandidateViewRegistry | null): void { if (!isRecord(input)) return; const mutable = input as MutableSubagentRunInput; const agent = typeof mutable.agent === "string" ? mutable.agent : undefined; const rawAgents = mutable.agents; const agents = Array.isArray(rawAgents) && rawAgents.every((value): value is string => typeof value === "string") ? rawAgents : undefined; const requested = [agent, ...(agents ?? [])].filter((value): value is string => value !== undefined); const hasReviewActor = (typeof mutable.agent === "string" && isReviewLens(mutable.agent)) || (typeof rawAgents === "string" && isReviewLens(rawAgents)) || (Array.isArray(rawAgents) && rawAgents.some((value) => typeof value === "string" && isReviewLens(value))); if (!hasReviewActor) return; if (Object.keys(mutable).some((key) => !SUBAGENT_RUN_KEYS.has(key))) throw new CandidateViewError("review subagent dispatch contains an unsupported input field"); if ((agent === undefined) === (agents === undefined) || requested.length === 0 || new Set(requested).size !== requested.length) throw new CandidateViewError("review subagent dispatch must use exactly one non-duplicate agent shape"); if (!requested.every(isReviewLens)) throw new CandidateViewError("review subagent dispatch cannot mix review and non-review agents"); if (typeof mutable.task !== "string" || mutable.task.length === 0 || mutable.task.length > MAX_SUBAGENT_TASK_LENGTH) throw new CandidateViewError("review subagent dispatch task is malformed or exceeds the bounded contract"); if (mutable.context !== undefined && (typeof mutable.context !== "string" || mutable.context.length > MAX_SUBAGENT_CONTEXT_LENGTH)) throw new CandidateViewError("review subagent dispatch context is malformed or exceeds the bounded contract"); if (mutable.mode !== "task") throw new CandidateViewError("review subagent dispatch requires mode task"); if (candidateViews === null) throw new CandidateViewError("review subagent dispatch has no controller-owned candidate view registry"); const reviewAgents = requested as ReviewLens[]; const lineageId = candidateViews.currentLineageId(); const views = candidateViews.resolveCurrentForLenses(reviewAgents); const view = views[0]; if (!view || views.some((candidate) => candidate.root !== view.root || candidate.candidateTree !== view.candidateTree || JSON.stringify(candidate.paths) !== JSON.stringify(view.paths) || JSON.stringify(candidate.modes) !== JSON.stringify(view.modes) || !gitlinkMapsEqual(candidate.gitlinks, view.gitlinks) || JSON.stringify(candidate.deletedPaths) !== JSON.stringify(view.deletedPaths))) { throw new CandidateViewError("review subagent dispatch does not resolve one exact frozen candidate view"); } const userText = `${mutable.task}\n${typeof mutable.context === "string" ? mutable.context : ""}`; if (hasCandidateContextConflict(userText, views)) throw new CandidateViewError("review subagent dispatch contains conflicting candidate-view text"); mutable.task = `${mutable.task}${candidateContextBlock(lineageId, reviewAgents, view)}`; } const defaultRegistry = new CandidateViewRegistry(); export function createCandidateView(request: CreateCandidateViewRequest): CandidateView { return defaultRegistry.create(request); }