import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { assertNoTouch, readNoTouch, readNoTouchAt } from "./no_touch"; export interface GitWorkspaceSnapshot { kind: "git-workspace-v1"; repository_root: string; head: string; dirty_files: Record; /** Single-task Enrollment starts clean and owns the subsequent workspace delta. */ whole_workspace_delivery?: boolean; } function git(root: string, args: string[]): string | null { const diff = args[0] === "diff"; const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args; const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); return result.status === 0 || (diff && result.status === 1) ? result.stdout : null; } function splitNull(value: string): string[] { return value.split("\0").filter((entry) => entry.length > 0); } function comparePaths(left: string, right: string): number { if (left < right) return -1; if (left > right) return 1; return 0; } function isDeliveryAttachment(path: string): boolean { return path === ".imm/audit" || path.startsWith(".imm/audit/"); } function isNonDeliveryPath(path: string): boolean { return isDeliveryAttachment(path) || isRuntimeAuthorityPath(path); } function isOwnPlanningSidecar(path: string, taskId?: string): boolean { return Boolean(taskId) && (path === `docs/plans/${taskId}.intent.json` || path === `docs/plans/archive/${taskId}.intent.json`); } function isOtherTaskSidecar(path: string, taskId?: string): boolean { return /^docs\/plans\/(?:archive\/)?[^/]+\.intent\.json$/.test(path) && !isOwnPlanningSidecar(path, taskId); } /** * Uncommitted work that blocks a snapshot. Under branch delivery another task's * unstaged planning draft is not this task's work, as the envelope tolerated it * before (Spec branch-boundary-r2 D23); staging it makes it delivered. */ function blocksAsUncommitted(path: string, scope: string[], branchDelivery: boolean, taskId?: string): boolean { if (isNonDeliveryPath(path)) return false; return branchDelivery ? !isOtherTaskSidecar(path, taskId) : taskPathMatchesScope(path, scope); } function isPlanningNoise(path: string, taskId?: string): boolean { return path.startsWith("docs/plans/") && !isOtherTaskSidecar(path, taskId) && !isOwnPlanningSidecar(path, taskId); } function enrollmentBaselinePath(root: string): string { return join(root, ".imm/state/enrollment-baseline.json"); } /** * Records the dirt that predates the task. `claimedPaths` are the in-scope * paths the confirmed Enrollment gate listed: they are left out, so later * snapshots treat them as task work instead of withholding them. */ export function writeEnrollmentBaseline(root: string, claimedPaths: readonly string[] = [], wholeWorkspaceDelivery = false): void { const snapshot = captureGitWorkspaceSnapshot(root); if (!snapshot) return; if (wholeWorkspaceDelivery) snapshot.whole_workspace_delivery = true; for (const path of claimedPaths) delete snapshot.dirty_files[path]; const path = enrollmentBaselinePath(root); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(snapshot)}\n`); } function readEnrollmentBaseline(root: string): GitWorkspaceSnapshot | null { const baselinePath = enrollmentBaselinePath(root); if (!existsSync(baselinePath)) return null; try { const baseline = JSON.parse(readFileSync(baselinePath, "utf8")) as GitWorkspaceSnapshot; return isGitWorkspaceSnapshot(baseline) ? baseline : null; } catch { throw new Error("enrollment baseline is unreadable"); } } /** Lanes and clean-start single tasks deliver their entire Enrollment delta. */ export function usesBranchDelivery(root: string, taskId?: string): boolean { if (readEnrollmentBaseline(root)?.whole_workspace_delivery) return true; const branch = git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])?.trim(); if (!branch || !/^imm-lane\/[^/]+\/[^/]+$/.test(branch)) return false; return taskId === undefined || branch.endsWith(`/${taskId}`); } function assertNoEnvelopeEscape( root: string, stagedPaths: readonly string[], scope: string[], taskId?: string, outOfScope?: string[], ): void { if (usesBranchDelivery(root, taskId)) return; // A read-only status collects escaped paths for a warning; frozen snapshots refuse them. const refuse = (paths: string[]) => { if (!outOfScope) throw new Error(`task delivery contains paths outside the authorization envelope: ${paths.join(", ")}`); outOfScope.splice(0, outOfScope.length, ...[...new Set([...outOfScope, ...paths])].sort(comparePaths)); }; const baseline = readEnrollmentBaseline(root); const current = baseline ? captureGitWorkspaceSnapshot(root) : null; if (baseline && !current) throw new Error("enrollment baseline cannot be compared because Git is unavailable"); const escaped = [...new Set(stagedPaths)] .filter((path) => !isNonDeliveryPath(path) && !isOwnPlanningSidecar(path, taskId) && !isPlanningNoise(path, taskId) && !taskPathMatchesScope(path, scope)) .filter((path) => !baseline || baseline.dirty_files[path] !== current?.dirty_files[path]) .sort(comparePaths); if (escaped.length > 0) refuse(escaped); if (!baseline || !current) return; const staged = new Set(stagedPaths); const mixed = [...new Set([...Object.keys(baseline.dirty_files), ...Object.keys(current.dirty_files)])] .filter((path) => !isNonDeliveryPath(path) && !isOwnPlanningSidecar(path, taskId) && !isPlanningNoise(path, taskId) && !taskPathMatchesScope(path, scope)) .filter((path) => !isOtherTaskSidecar(path, taskId) || staged.has(path)) .filter((path) => baseline.dirty_files[path] !== current.dirty_files[path]) .sort(comparePaths); if (mixed.length > 0) refuse(mixed); } function isRuntimeAuthorityPath(path: string): boolean { return ( // Kernel v2 task state, workspace coordination, and authority journals are // runtime-owned and must not change the code/workspace diff identity. // State-layout cutover: .imm/state/ is wholly ignored runtime state; // .imm/audit/ is tracked evidence and must NEVER be excluded here. // The legacy .imm/tasks/.imm/memory/ paths remain excluded until the // one-release migration relocates them (Slice 2 deletes those arms). path === ".imm/workspace.json" || path.startsWith(".imm/tasks/") || path.startsWith(".imm/memory/") || path.startsWith(".imm/state/") || path.startsWith(".imm/authority/") || path.startsWith(".imm/journal") || path === "HANDOFF.md" ); } function fileFingerprint(root: string, relativePath: string): string { const absolutePath = resolve(root, relativePath); if (!existsSync(absolutePath)) return "missing"; const stat = lstatSync(absolutePath); if (stat.isSymbolicLink()) return `symlink:${readlinkSync(absolutePath)}`; if (!stat.isFile()) return `other:${stat.mode}:${stat.size}`; return `file:${stat.mode}:${createHash("sha256") .update(readFileSync(absolutePath)) .digest("hex")}`; } function dirtyPaths(root: string): string[] | null { // --no-renames, like the task-path derivations: rename detection would list // only the destination and hide the source path's deletion. const tracked = git(root, ["diff", "--name-only", "--no-renames", "-z", "HEAD", "--"]); const untracked = git(root, [ "ls-files", "--others", "--exclude-standard", "-z", "--", ]); if (tracked === null || untracked === null) return null; return [...new Set([...splitNull(tracked), ...splitNull(untracked)])] .filter((path) => !isRuntimeAuthorityPath(path)) .sort(comparePaths); } export function captureGitWorkspaceSnapshot( projectRoot: string, ): GitWorkspaceSnapshot | null { const root = realpathSync(resolve(projectRoot)); const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim(); const head = git(root, ["rev-parse", "HEAD"])?.trim(); if ( !repositoryRoot || !head || realpathSync(resolve(repositoryRoot)) !== root ) return null; const paths = dirtyPaths(root); if (!paths) return null; return { kind: "git-workspace-v1", repository_root: root, head, dirty_files: Object.fromEntries( paths.map((path) => [path, fileFingerprint(root, path)]), ), }; } export function workspaceDiffHash(projectRoot: string): string { const snapshot = captureGitWorkspaceSnapshot(projectRoot); if (!snapshot) throw new Error("Cannot derive workspace diff without a Git workspace."); return `sha256:${createHash("sha256") .update(JSON.stringify(snapshot)) .digest("hex")}`; } export interface GitTaskIndexEntry { status: "added" | "modified" | "deleted"; mode: "100644" | "100755" | "120000" | null; oid: string | null; base_mode: "100644" | "100755" | "120000" | null; base_oid: string | null; } export interface GitTaskSnapshot { kind: "git-task-index-v1"; repository_root: string; head: string; scope: string[]; staged_files: Record; } const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; const TASK_GIT_MODES = new Set(["100644", "100755", "120000"] as const); const fatalUtf8 = new TextDecoder("utf-8", { fatal: true }); const portablePathCollator = new Intl.Collator("und", { usage: "search", sensitivity: "base", numeric: false, ignorePunctuation: false, }); let gitTaskSnapshotTestHook: (() => void) | undefined; export function setGitTaskSnapshotTestHook(hook: () => void): () => void { const previous = gitTaskSnapshotTestHook; gitTaskSnapshotTestHook = hook; return () => { if (gitTaskSnapshotTestHook === hook) gitTaskSnapshotTestHook = previous; }; } function gitBytes(root: string, args: string[], env?: NodeJS.ProcessEnv): Buffer { const diff = args[0] === "diff"; const command = diff ? ["diff", "--exit-code", ...args.slice(1)] : args; const result = spawnSync("git", ["--no-optional-locks", "-c", "diff.autoRefreshIndex=false", "-C", root, ...command], { encoding: null, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024, ...(env ? { env } : {}), }); if ((result.status !== 0 && !(diff && result.status === 1)) || !Buffer.isBuffer(result.stdout)) { throw new Error(`Git task snapshot command failed: git ${args.join(" ")}`); } return result.stdout; } function decodeCanonicalGitPath(bytes: Buffer, label: string): string { let value: string; try { value = fatalUtf8.decode(bytes); } catch { throw new Error(`${label} contains invalid UTF-8 path bytes`); } if (!Buffer.from(value, "utf8").equals(bytes)) throw new Error(`${label} path does not round-trip through UTF-8`); if (value.normalize("NFC") !== value) throw new Error(`${label} path is not NFC-normalized: ${value}`); if ( !value || value.includes("\0") || value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:\//.test(value) || value.split("/").some((part) => !part || part === "." || part === "..") ) throw new Error(`${label} is not a canonical project-relative path: ${value}`); return value; } function decodeNullPaths(bytes: Buffer, label: string): string[] { if (bytes.length === 0) return []; if (bytes[bytes.length - 1] !== 0) throw new Error(`${label} is not NUL-terminated`); const paths: string[] = []; let start = 0; for (let index = 0; index < bytes.length; index += 1) { if (bytes[index] !== 0) continue; if (index === start) throw new Error(`${label} contains an empty path`); paths.push(decodeCanonicalGitPath(bytes.subarray(start, index), label)); start = index + 1; } return paths; } function decodeIndexFlaggedPaths(bytes: Buffer, label: string): Array<{ path: string; flag: "assume-unchanged" | "skip-worktree" }> { if (bytes.length === 0) return []; if (bytes[bytes.length - 1] !== 0) throw new Error(`${label} is not NUL-terminated`); const flagged: Array<{ path: string; flag: "assume-unchanged" | "skip-worktree" }> = []; let start = 0; for (let index = 0; index < bytes.length; index += 1) { if (bytes[index] !== 0) continue; const record = bytes.subarray(start, index); if (record.length < 3 || record[1] !== 0x20) throw new Error(`${label} contains a malformed entry`); const tag = String.fromCharCode(record[0]); if (tag === "h" || tag === "S" || tag === "s") { flagged.push({ path: decodeCanonicalGitPath(record.subarray(2), label), flag: tag === "h" ? "assume-unchanged" : "skip-worktree", }); } start = index + 1; } return flagged; } function assertNoScopedIndexFlags(root: string, scope: string[], label: string): void { const flagged = decodeIndexFlaggedPaths(gitBytes(root, ["ls-files", "-v", "-z", "--"]), "Git index flags") .filter(({ path }) => taskPathMatchesScope(path, scope)); if (flagged.length > 0) throw new Error(`${label} contains unsupported index flags: ${flagged.map(({ path, flag }) => `${path} (${flag})`).join(", ")}`); } function assertNoCaseFoldCollisions(paths: string[], label: string): void { const prefixes: string[] = []; for (const path of paths) { let prefix = ""; for (const component of path.split("/")) { prefix = prefix ? `${prefix}/${component}` : component; const prior = prefixes.find( (candidate) => candidate !== prefix && portablePathCollator.compare(candidate, prefix) === 0, ); if (prior !== undefined) throw new Error(`${label} contains a case-fold path collision: ${prior} and ${prefix}`); if (!prefixes.includes(prefix)) prefixes.push(prefix); } } } function assertCanonicalTaskScope(scope: unknown): string[] { if (!Array.isArray(scope) || scope.length === 0) throw new Error("task scope must contain at least one canonical path"); const paths = scope.map((entry, index) => { if (typeof entry !== "string") throw new Error(`task scope entry ${index} must be a string`); return decodeCanonicalGitPath(Buffer.from(entry, "utf8"), `task scope entry ${index}`); }); assertNoCaseFoldCollisions(paths, "task scope"); const canonical = [...new Set(paths)].sort(comparePaths).filter( (path, _index, all) => !all.some( (candidate) => candidate !== path && !candidate.includes("*") && !candidate.includes("?") && path.startsWith(`${candidate}/`), ), ); if (JSON.stringify(canonical) !== JSON.stringify(paths)) throw new Error("task scope must already be canonical, sorted, and non-overlapping"); return paths; } type GitTreeEntry = { mode: GitTaskIndexEntry["mode"]; oid: string; }; function parseGitTreeEntry( bytes: Buffer, path: string, kind: "index" | "HEAD", ): GitTreeEntry | null { if (bytes.length === 0) return null; if (bytes[bytes.length - 1] !== 0) throw new Error(`${kind} entry for ${path} is not NUL-terminated`); const record = bytes.subarray(0, bytes.length - 1); if (record.includes(0)) throw new Error(`${kind} returned multiple entries for ${path}`); const tab = record.indexOf(9); if (tab < 0) throw new Error(`${kind} entry for ${path} is malformed`); const metadata = record.subarray(0, tab).toString("ascii").split(" "); const returnedPath = decodeCanonicalGitPath(record.subarray(tab + 1), `${kind} entry`); if (returnedPath !== path) throw new Error(`${kind} returned an unexpected path: ${returnedPath}`); const mode = metadata[0]; const oid = kind === "index" ? metadata[1] : metadata[2]; const stage = kind === "index" ? metadata[2] : undefined; if (!TASK_GIT_MODES.has(mode as "100644" | "100755" | "120000")) throw new Error(`${kind} entry for ${path} has unsupported mode ${mode}`); if (!oid || !GIT_OBJECT_ID.test(oid) || /^0+$/.test(oid)) throw new Error(`${kind} entry for ${path} has invalid object identity`); if (stage !== undefined && stage !== "0") throw new Error(`index entry for ${path} is not at stage zero`); return { mode: mode as GitTaskIndexEntry["mode"], oid }; } function indexEntry(root: string, path: string): GitTreeEntry | null { return parseGitTreeEntry( gitBytes(root, ["ls-files", "--stage", "-z", "--", path]), path, "index", ); } function headEntry(root: string, head: string, path: string): GitTreeEntry | null { return parseGitTreeEntry( gitBytes(root, ["ls-tree", "-z", head, "--", path]), path, "HEAD", ); } function taskPathMatchesScope(path: string, scope: string[]): boolean { return scope.some((scopePath) => pathMatchesScope(path, scopePath)); } /** * A staged in-scope path that still equals the Enrollment baseline predates the * task and was not listed by the confirmed Enrollment gate. Dropping it silently * let QA attest workspace bytes that the Review revision did not carry, so it is * a hard stop instead: reverting and reapplying yields the same bytes, and only * a fresh Enrollment that lists the path, or starts from a clean scope, recovers. * The task's own planning sidecar is exempt, as it is in dirtyScopePaths: * Enrollment requires it tracked, so it is routinely staged in the baseline. */ function assertNoPreEnrollmentScopeChanges(root: string, scopedStagedPaths: readonly string[], taskId?: string): void { const baseline = readEnrollmentBaseline(root); if (!baseline) return; const current = captureGitWorkspaceSnapshot(root); if (!current) throw new Error("enrollment baseline cannot be compared because Git is unavailable"); const withheld = [...new Set(scopedStagedPaths)] .filter((path) => !isOwnPlanningSidecar(path, taskId)) .filter((path) => baseline.dirty_files[path] !== undefined && baseline.dirty_files[path] === current.dirty_files[path]) .sort(comparePaths); if (withheld.length > 0) throw new Error( `task scope contains staged changes that predate Enrollment and cannot become task work: ${withheld.join(", ")}; stop the task and enroll again so the Enrollment gate lists them, or from a clean scope`, ); } /** Refuse pre-existing single-task work except the exact planning paths. */ export function dirtyWorkspacePaths(root: string, planningPaths: readonly string[]): string[] { const tracked = git(root, ["diff", "--name-only", "--no-renames", "-z", "HEAD", "--"]); const staged = git(root, ["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--"]); const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--"]); if (tracked === null || staged === null || untracked === null) throw new Error("cannot inspect Enrollment workspace"); // Enrollment creates its own store before this check, even in repositories // that have not ignored it yet. Tracked authority changes still refuse. return [...new Set([...splitNull(tracked), ...splitNull(staged), ...splitNull(untracked).filter(path => !path.startsWith(".imm/state/"))])] .filter(path => !planningPaths.includes(path)).sort(comparePaths); } /** Batch disclosure keeps its scope-bound claim of pre-existing changes. */ export function dirtyScopePaths(projectRoot: string, scopeHint: unknown, taskId?: string): string[] { const root = realpathSync(resolve(projectRoot)); const scope = assertCanonicalTaskScope(scopeHint); const paths = dirtyPaths(root); if (!paths) throw new Error("cannot inspect the task scope outside a committed Git workspace"); return paths.filter( (path) => !isNonDeliveryPath(path) && !isOwnPlanningSidecar(path, taskId) && taskPathMatchesScope(path, scope), ); } function taskSnapshotOnce(root: string, scope: string[], taskId?: string, tolerated?: string[], outOfScope?: string[]): GitTaskSnapshot { const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim(); const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim(); if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head)) throw new Error("cannot derive task snapshot outside a committed Git workspace"); if (realpathSync(resolve(repositoryRoot)) !== root) throw new Error("task snapshot repository root does not match the project root"); const sparseCheckout = git(root, ["config", "--bool", "core.sparseCheckout"])?.trim(); const sparseIndex = git(root, ["config", "--bool", "index.sparse"])?.trim(); if (sparseCheckout === "true" || sparseIndex === "true") throw new Error("task snapshot does not support sparse checkout or sparse index"); if (gitBytes(root, ["ls-files", "--unmerged", "-z"]).length > 0) throw new Error("task snapshot does not support unmerged index entries"); const branchDelivery = usesBranchDelivery(root, taskId); const baseHead = branchDelivery ? readEnrollmentBaseline(root)?.head ?? head : head; assertNoScopedIndexFlags(root, branchDelivery ? ["**"] : scope, "task snapshot"); const stagedPaths = decodeNullPaths( gitBytes(root, ["diff", "--cached", "--no-renames", "--name-only", "-z", baseHead, "--"]), "staged task paths", ); const unstagedPaths = decodeNullPaths( gitBytes(root, ["diff", "--no-renames", "--name-only", "-z", "--"]), "unstaged task paths", ); const untrackedPaths = decodeNullPaths( gitBytes(root, ["ls-files", "--others", "--exclude-standard", "-z", "--"]), "untracked task paths", ); assertNoCaseFoldCollisions([...stagedPaths, ...unstagedPaths, ...untrackedPaths], "Git task paths"); assertNoEnvelopeEscape(root, stagedPaths, scope, taskId, outOfScope); const uncommittedInScope = [...new Set([...unstagedPaths, ...untrackedPaths])] .filter((path) => blocksAsUncommitted(path, scope, branchDelivery, taskId)) .sort(comparePaths); if (uncommittedInScope.length > 0) { if (!tolerated) throw new Error(`task scope contains unstaged or untracked changes: ${uncommittedInScope.join(", ")}`); tolerated.splice(0, tolerated.length, ...uncommittedInScope); } const taskPaths = [...new Set(stagedPaths)] .filter((path) => !isNonDeliveryPath(path) && (branchDelivery || taskPathMatchesScope(path, scope))) .sort(comparePaths); assertNoPreEnrollmentScopeChanges(root, taskPaths, taskId); const stagedFiles: Record = {}; for (const path of taskPaths) { const current = indexEntry(root, path); const base = headEntry(root, baseHead, path); if (!current && !base) throw new Error(`task path has no index or HEAD identity: ${path}`); stagedFiles[path] = { status: !base ? "added" : !current ? "deleted" : "modified", mode: current?.mode ?? null, oid: current?.oid ?? null, base_mode: base?.mode ?? null, base_oid: base?.oid ?? null, }; } return { kind: "git-task-index-v1", repository_root: root, head, scope, staged_files: stagedFiles, }; } export function captureGitTaskSnapshot( projectRoot: string, scopeHint: unknown, taskId?: string, tolerated?: string[], outOfScope?: string[], ): GitTaskSnapshot { const requestedRoot = resolve(projectRoot); const requestedStat = lstatSync(requestedRoot); if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory()) throw new Error("task snapshot root must be a real directory"); const root = realpathSync(requestedRoot); const scope = assertCanonicalTaskScope(scopeHint); const before = taskSnapshotOnce(root, scope, taskId, tolerated, outOfScope); gitTaskSnapshotTestHook?.(); const after = taskSnapshotOnce(root, scope, taskId, tolerated, outOfScope); if (JSON.stringify(after) !== JSON.stringify(before)) throw new Error("Git task snapshot changed while being captured"); return before; } export interface GitTaskDiffIdentity { diff_hash: string; changed_paths: string[]; } function hashTaskSnapshot(snapshot: object): string { return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`; } export function taskDiffIdentity( projectRoot: string, scopeHint: unknown, taskId?: string, ): GitTaskDiffIdentity { if (usesBranchDelivery(projectRoot, taskId)) { const baseline = readEnrollmentBaseline(projectRoot); if (!baseline) throw new Error("Lane task diff requires its Enrollment baseline"); return taskRevisionIdentity(projectRoot, scopeHint, baseline.head, taskId); } const snapshot = captureGitTaskSnapshot(projectRoot, scopeHint, taskId); return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: Object.keys(snapshot.staged_files).sort(comparePaths), }; } export function taskDiffHash(projectRoot: string, scopeHint: unknown, taskId?: string): string { return taskDiffIdentity(projectRoot, scopeHint, taskId).diff_hash; } function gitRequired(root: string, args: string[], failure: string): string { const output = git(root, args); if (output === null) throw new Error(failure); return output.trim(); } /** * The v4 scoped revision snapshot: the exact delta between the Enrollment base * commit and the current index, restricted to the TaskIntent mutation envelope. * It deliberately omits the current HEAD so an out-of-scope commit never * invalidates task identity, and it never reads unchanged scope matches. */ export interface GitTaskRevisionSnapshot { kind: "git-task-revision-v1"; repository_root: string; base_head: string; base_tree: string; scope: string[]; changed_paths: Record; } function taskRevisionSnapshotOnce( root: string, scope: string[], baseHead: string, taskId?: string, tolerated?: string[], outOfScope?: string[], ): GitTaskRevisionSnapshot { const repositoryRoot = git(root, ["rev-parse", "--show-toplevel"])?.trim(); const head = git(root, ["rev-parse", "--verify", "HEAD^{commit}"])?.trim(); if (!repositoryRoot || !head || !GIT_OBJECT_ID.test(head)) throw new Error("cannot derive a task revision outside a committed Git workspace"); if (realpathSync(resolve(repositoryRoot)) !== root) throw new Error("task revision repository root does not match the project root"); if (gitRequired(root, ["cat-file", "-t", baseHead], `task revision base is unreadable: ${baseHead}`) !== "commit") throw new Error(`task revision base is not a commit: ${baseHead}`); if (git(root, ["merge-base", "--is-ancestor", baseHead, head]) === null) throw new Error( `task revision base ${baseHead} is no longer an ancestor of HEAD; rewrite the task history or re-enroll`, ); const baseTree = gitRequired(root, ["rev-parse", `${baseHead}^{tree}`], "task revision base tree is unreadable"); if (!GIT_OBJECT_ID.test(baseTree)) throw new Error("task revision base tree has invalid identity"); const sparseCheckout = git(root, ["config", "--bool", "core.sparseCheckout"])?.trim(); const sparseIndex = git(root, ["config", "--bool", "index.sparse"])?.trim(); if (sparseCheckout === "true" || sparseIndex === "true") throw new Error("task revision does not support sparse checkout or sparse index"); if (gitBytes(root, ["ls-files", "--unmerged", "-z"]).length > 0) throw new Error("task revision does not support unmerged index entries"); const branchDelivery = usesBranchDelivery(root, taskId); const delivered = (path: string) => !isNonDeliveryPath(path) && (branchDelivery || taskPathMatchesScope(path, scope)); assertNoScopedIndexFlags(root, branchDelivery ? ["**"] : scope, "task revision"); const stagedPaths = decodeNullPaths( gitBytes(root, ["diff", "--cached", "--no-renames", "--name-only", "-z", baseHead, "--"]), "task revision paths", ); const unstagedPaths = decodeNullPaths( gitBytes(root, ["diff", "--no-renames", "--name-only", "-z", "--"]), "unstaged task revision paths", ); const untrackedPaths = decodeNullPaths( gitBytes(root, ["ls-files", "--others", "--exclude-standard", "-z", "--"]), "untracked task revision paths", ); assertNoEnvelopeEscape(root, stagedPaths, scope, taskId, outOfScope); const scopedStagedPaths = stagedPaths.filter(delivered); assertNoPreEnrollmentScopeChanges(root, scopedStagedPaths, taskId); const scopedUnstagedPaths = unstagedPaths.filter((path) => blocksAsUncommitted(path, scope, branchDelivery, taskId)); const scopedUntrackedPaths = untrackedPaths.filter((path) => blocksAsUncommitted(path, scope, branchDelivery, taskId)); assertNoCaseFoldCollisions( [...scopedStagedPaths, ...scopedUnstagedPaths, ...scopedUntrackedPaths], "Git task revision paths", ); const drift = [...new Set([...scopedUnstagedPaths, ...scopedUntrackedPaths])].sort(comparePaths); if (drift.length > 0) { if (!tolerated) throw new Error(`task scope contains unstaged or untracked changes: ${drift.join(", ")}`); tolerated.splice(0, tolerated.length, ...drift); } const changed = [...new Set(scopedStagedPaths)].sort(comparePaths); const changedPaths: Record = {}; for (const path of changed) { const current = indexEntry(root, path); const base = headEntry(root, baseHead, path); if (!current && !base) throw new Error(`task revision path has no index or base identity: ${path}`); if (current && base && current.oid === base.oid && current.mode === base.mode) throw new Error(`task revision path is not actually changed: ${path}`); changedPaths[path] = { status: !base ? "added" : !current ? "deleted" : "modified", mode: current?.mode ?? null, oid: current?.oid ?? null, base_mode: base?.mode ?? null, base_oid: base?.oid ?? null, }; } return { kind: "git-task-revision-v1", repository_root: root, base_head: baseHead, base_tree: baseTree, scope, changed_paths: changedPaths, }; } export function captureGitTaskRevisionSnapshot( projectRoot: string, scopeHint: unknown, baseHead: unknown, taskId?: string, tolerated?: string[], outOfScope?: string[], ): GitTaskRevisionSnapshot { const requestedRoot = resolve(projectRoot); const requestedStat = lstatSync(requestedRoot); if (requestedStat.isSymbolicLink() || !requestedStat.isDirectory()) throw new Error("task revision root must be a real directory"); const root = realpathSync(requestedRoot); if (typeof baseHead !== "string" || !GIT_OBJECT_ID.test(baseHead.toLowerCase())) throw new Error("task revision base must be a Git commit id"); const scope = assertCanonicalTaskScope(scopeHint); const normalizedBase = baseHead.toLowerCase(); const before = taskRevisionSnapshotOnce(root, scope, normalizedBase, taskId, tolerated, outOfScope); gitTaskSnapshotTestHook?.(); const after = taskRevisionSnapshotOnce(root, scope, normalizedBase, taskId, tolerated, outOfScope); if (JSON.stringify(after) !== JSON.stringify(before)) throw new Error("Git task revision changed while being captured"); return before; } export function taskRevisionIdentity( projectRoot: string, scopeHint: unknown, baseHead: string, taskId?: string, ): GitTaskDiffIdentity { const snapshot = captureGitTaskRevisionSnapshot(projectRoot, scopeHint, baseHead, taskId); return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: Object.keys(snapshot.changed_paths).sort(comparePaths), }; } /** * Structural record shape shared by every TaskRecord contract family, so the * delivery identity selector never needs the full Kernel record type. */ export interface TaskDeliveryRecord { contract: string; task_id: string; intent_snapshot: { scope_hint: unknown }; git_base_head?: string; } /** * The one delivery identity selector (deepen-authority-seams D2): given a root * and a TaskRecord, a v4 record with `git_base_head` yields the scoped revision * identity, a v4 record without it throws, and a pre-v4 record keeps the * index-family identity. Hosts, the CLI, and batch reconfirmation call this * function instead of branching on `record.contract` themselves. */ export function taskDeliveryIdentity( projectRoot: string, record: TaskDeliveryRecord, ): GitTaskDiffIdentity { const base = record.git_base_head ?? readEnrollmentBaseline(projectRoot)?.head ?? "HEAD"; const changed = [...new Set([ ...decodeNullPaths(gitBytes(projectRoot, ["diff", "--cached", "--no-renames", "--name-only", "-z", base, "--"]), "no-touch index paths"), ...decodeNullPaths(gitBytes(projectRoot, ["diff", "--no-renames", "--name-only", "-z", base, "--"]), "no-touch working paths"), ])]; assertNoTouch(changed, [...readNoTouchAt(projectRoot, base), ...readNoTouch(projectRoot)]); if (record.contract === "assurance_kernel/task_record/v4") { if (!record.git_base_head) throw new Error("TaskRecord v4 is missing git_base_head"); return taskRevisionIdentity( projectRoot, record.intent_snapshot.scope_hint, record.git_base_head, record.task_id, ); } return taskDiffIdentity(projectRoot, record.intent_snapshot.scope_hint, record.task_id); } /** * The delivery identity for a read-only status projection. Unstaged or * untracked in-scope paths and out-of-scope staged or dirty paths do not refuse * it: the identity is the in-scope staged one and the paths are returned for a * warning. Nothing may be frozen from this value; every snapshot that is frozen * goes through `taskDeliveryIdentity`, which still refuses those paths. */ export function taskStatusIdentity( projectRoot: string, record: TaskDeliveryRecord, ): GitTaskDiffIdentity & { unstaged_scope_paths: string[]; out_of_scope_paths: string[] } { const unstaged: string[] = []; const outOfScope: string[] = []; const scope = record.intent_snapshot.scope_hint; if (record.contract === "assurance_kernel/task_record/v4") { if (!record.git_base_head) throw new Error("TaskRecord v4 is missing git_base_head"); const snapshot = captureGitTaskRevisionSnapshot(projectRoot, scope, record.git_base_head, record.task_id, unstaged, outOfScope); return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: Object.keys(snapshot.changed_paths).sort(comparePaths), unstaged_scope_paths: unstaged, out_of_scope_paths: outOfScope, }; } const snapshot = captureGitTaskSnapshot(projectRoot, scope, record.task_id, unstaged, outOfScope); return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: Object.keys(snapshot.staged_files).sort(comparePaths), unstaged_scope_paths: unstaged, out_of_scope_paths: outOfScope, }; } /** * The scoped content identity of the working tree: the bytes of every tracked * or untracked file matching the scopes, independent of HEAD, commits, the * index or stashes. Equal bytes keep the identity across any user Git * operation; an absent file contributes nothing, so committing a deletion is * neutral too. */ export function scopedContentIdentity(projectRoot: string, scopes: readonly string[]): string { const root = realpathSync(resolve(projectRoot)); const listed = git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--"]); if (listed === null) throw new Error("cannot derive scoped content identity outside a Git workspace"); const entries = [...new Set(splitNull(listed))] .filter((path) => !isNonDeliveryPath(path) && !isRuntimeAuthorityPath(path) && taskPathMatchesScope(path, [...scopes])) .sort(comparePaths) .map((path) => [path, fileFingerprint(root, path)] as const) .filter(([, fingerprint]) => fingerprint !== "missing"); return hashTaskSnapshot({ kind: "scoped-content-v1", scopes: [...new Set(scopes)].sort(comparePaths), entries }); } /** The same v4 identity for an immutable delivered commit, without touching the index. */ export function taskCommitRevisionIdentity(projectRoot: string, scopeHint: unknown, baseHead: string, commit: string): GitTaskDiffIdentity { const requested = resolve(projectRoot); if (lstatSync(requested).isSymbolicLink()) throw new Error("task commit root must be a real directory"); const root = realpathSync(requested), scope = assertCanonicalTaskScope(scopeHint); if (!GIT_OBJECT_ID.test(baseHead) || !GIT_OBJECT_ID.test(commit)) throw new Error("invalid task commit identity"); if (gitRequired(root, ["cat-file", "-t", baseHead], "unreadable task base") !== "commit" || gitRequired(root, ["cat-file", "-t", commit], "unreadable task commit") !== "commit" || git(root, ["merge-base", "--is-ancestor", baseHead, commit]) === null) throw new Error("invalid task commit ancestry"); const branchDelivery = usesBranchDelivery(root); const paths = decodeNullPaths(gitBytes(root, ["diff", "--no-renames", "--name-only", "-z", baseHead, commit, "--"]), "task commit paths") .filter(path => !isNonDeliveryPath(path) && (branchDelivery || taskPathMatchesScope(path, scope))).sort(comparePaths); assertNoCaseFoldCollisions(paths, "task commit paths"); const changed_paths: Record = {}; for (const path of paths) { const current = headEntry(root, commit, path), base = headEntry(root, baseHead, path); changed_paths[path] = { status: !base ? "added" : !current ? "deleted" : "modified", mode: current?.mode ?? null, oid: current?.oid ?? null, base_mode: base?.mode ?? null, base_oid: base?.oid ?? null }; } const snapshot: GitTaskRevisionSnapshot = { kind: "git-task-revision-v1", repository_root: root, base_head: baseHead, base_tree: gitRequired(root, ["rev-parse", `${baseHead}^{tree}`], "unreadable task base tree"), scope, changed_paths }; return { diff_hash: hashTaskSnapshot(snapshot), changed_paths: paths }; } /** The single v4 freshness identity shared by QA, Review, authorization, and completion. */ export function taskRevisionDiffHash( projectRoot: string, scopeHint: unknown, baseHead: string, taskId?: string, ): string { return taskRevisionIdentity(projectRoot, scopeHint, baseHead, taskId).diff_hash; } function isGitWorkspaceSnapshot(value: unknown): value is GitWorkspaceSnapshot { return ( typeof value === "object" && value !== null && !Array.isArray(value) && (value as Record).kind === "git-workspace-v1" && typeof (value as Record).repository_root === "string" && typeof (value as Record).head === "string" && typeof (value as Record).dirty_files === "object" && (value as Record).dirty_files !== null ); } function canonicalizeClaimedPath(value: string): string { const normalized = value.trim().replace(/\\/g, "/"); if ( !normalized || normalized.includes("\0") || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) ) { throw new Error(`Invalid replacement changed file claim: ${value}`); } const parts: string[] = []; for (const part of normalized.split("/")) { if (!part || part === ".") continue; if (part === "..") { throw new Error(`Invalid replacement changed file claim: ${value}`); } parts.push(part); } if (parts.length === 0) { throw new Error(`Invalid replacement changed file claim: ${value}`); } return parts.join("/"); } export type ReplacementChangedFiles = { changedFiles: string[] | null; carriedFiles: string[]; workspaceSnapshot: GitWorkspaceSnapshot; }; export function replacementChangedFilesSinceSnapshot( projectRoot: string, baseline: unknown, claimedFiles: string[], ): ReplacementChangedFiles | null { const baselineSnapshot = isGitWorkspaceSnapshot(baseline) ? baseline : null; const current = captureGitWorkspaceSnapshot(projectRoot); if (!current) { if (!baselineSnapshot) return null; throw new Error( "Cannot verify execution scope because the Git workspace is unavailable.", ); } if (!baselineSnapshot || current.head !== baselineSnapshot.head) { return { changedFiles: null, carriedFiles: [], workspaceSnapshot: current, }; } if (current.repository_root !== baselineSnapshot.repository_root) { throw new Error("Git workspace root changed after Step activation."); } const currentDirty = new Set(Object.keys(current.dirty_files)); const claimed = new Set(claimedFiles.map(canonicalizeClaimedPath)); const carriedFiles = Object.keys(baselineSnapshot.dirty_files) .filter((path) => !isRuntimeAuthorityPath(path)) .filter((path) => claimed.has(path)) .filter((path) => currentDirty.has(path)) .sort(comparePaths); const changedFiles = [ ...new Set([ ...Object.keys(baselineSnapshot.dirty_files), ...Object.keys(current.dirty_files), ]), ] .filter((path) => !isRuntimeAuthorityPath(path)) .filter( (path) => baselineSnapshot.dirty_files[path] !== current.dirty_files[path], ) .filter((path) => currentDirty.has(path)); return { changedFiles: [...new Set([...changedFiles, ...carriedFiles])].sort( comparePaths, ), carriedFiles, workspaceSnapshot: current, }; } export function changedFilesSinceSnapshot( projectRoot: string, baseline: unknown, ): string[] | null { if (!isGitWorkspaceSnapshot(baseline)) return null; const current = captureGitWorkspaceSnapshot(projectRoot); if (!current) { throw new Error( "Cannot verify execution scope because the Git workspace is unavailable.", ); } if (current.repository_root !== baseline.repository_root) { throw new Error("Git workspace root changed after Step activation."); } if (current.head !== baseline.head) { // Commits after activation invalidate git-delta derivation (the baseline // dirty set cannot be compared against a different tree). Rather than // blocking execution recording entirely — which strands HITL steps that // legitimately commit deploy fixes mid-step — fall back to the same // self-reported provenance path used when no baseline exists. The // claimed files still pass the Step Scope check and independent QA. return null; } return [ ...new Set([ ...Object.keys(baseline.dirty_files), ...Object.keys(current.dirty_files), ]), ] .filter((path) => !isRuntimeAuthorityPath(path)) .filter((path) => baseline.dirty_files[path] !== current.dirty_files[path]) .sort(comparePaths); } function normalizeBoundaryPath(value: string): string { return value .trim() .replace(/^\.\//, "") .replace(/\\/g, "/") .replace(/\/+$/, ""); } function globMatches(path: string, pattern: string): boolean { const memo = new Map(); const match = (pathIndex: number, patternIndex: number): boolean => { const key = `${pathIndex}:${patternIndex}`; const cached = memo.get(key); if (cached !== undefined) return cached; let result: boolean; if (patternIndex === pattern.length) result = pathIndex === path.length; else if (pattern[patternIndex] === "*") { const recursive = pattern[patternIndex + 1] === "*"; const nextPatternIndex = patternIndex + (recursive ? 2 : 1); result = match(pathIndex, nextPatternIndex); if (!result && pathIndex < path.length) { result = (recursive || path[pathIndex] !== "/") && match(pathIndex + 1, patternIndex); } } else if (pathIndex === path.length) result = false; else if (pattern[patternIndex] === "?") { result = path[pathIndex] !== "/" && match(pathIndex + 1, patternIndex + 1); } else { result = path[pathIndex] === pattern[patternIndex] && match(pathIndex + 1, patternIndex + 1); } memo.set(key, result); return result; }; return match(0, 0); } export function pathMatchesScope(path: string, scopePath: string): boolean { const normalizedPath = normalizeBoundaryPath(path); const normalizedScope = normalizeBoundaryPath(scopePath); if (!normalizedPath || !normalizedScope) return false; if (normalizedScope.includes("*") || normalizedScope.includes("?")) { return globMatches(normalizedPath, normalizedScope); } return ( normalizedPath === normalizedScope || normalizedPath.startsWith(`${normalizedScope}/`) ); } const TEST_FILE_REFERENCE = /[\w./-]*\.(?:test|spec)\.[cm]?[jt]sx?\b/g; /** * `bun test` treats its arguments as filters rather than paths, so a command * naming three files where one was renamed away runs the other two and still * exits 0. Left unchecked the evidence records a pass whose coverage silently * shrank. Verification runs after the work is done, so a cited test file is * expected to exist by the time evidence is recorded. * * Only passing checks are held to this. A failure must stay recordable — that * is how a missing test file gets reported in the first place, since a command * naming nothing else exits non-zero — and it cannot manufacture false * confidence because QA refuses to pass on failed evidence. */ export function assertCheckCommandsCiteExistingTests( checks: unknown, root: string, ): void { if (!Array.isArray(checks)) return; const missing = new Set(); for (const check of checks) { if (typeof check !== "object" || check === null || Array.isArray(check)) continue; const entry = check as Record; if (entry.kind === "manual" || entry.status !== "passed") continue; const command = entry.command; if (typeof command !== "string") continue; // A command that moves elsewhere first resolves its paths against that // directory, not the project root, so the check cannot be trusted. if (/(^|\s|&|;)cd\s/.test(command)) continue; for (const [candidate] of command.matchAll(TEST_FILE_REFERENCE)) { // A glob is resolved by the shell or the runner, which reports its own // miss; only a literal path can be confirmed here. if (!candidate.includes("/") || /[*?[\]]/.test(candidate)) continue; if (!existsSync(resolve(root, candidate))) missing.add(candidate); } } if (missing.size > 0) { throw new Error( `Evidence cites test files that do not exist: ${[...missing].join(", ")}. A renamed or deleted test leaves the command passing on a smaller set than it names.`, ); } } function gitBlobId(body: Buffer, length: number): string { const hash = createHash(length === 64 ? "sha256" : "sha1"); hash.update(`blob ${body.length}\0`); hash.update(body); return hash.digest("hex"); } /** * In-scope tracked paths whose working-directory content differs from `tree`. * Bytes are compared directly, never through the index stat cache, so a file a * command rewrote with identical content is not a difference. Paths outside the * scope are the user's environment and are not read. */ // A hosting process that carries one of these would compare another repository // or index instead of the task's working directory. const GIT_REPOSITORY_BINDINGS = [ "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_PREFIX", ]; export function inScopeTrackedDrift(projectRoot: string, tree: string, scopeHint: unknown, deliveryPaths: readonly string[] = []): string[] { const root = resolve(projectRoot); const env = { ...process.env }; for (const name of GIT_REPOSITORY_BINDINGS) delete env[name]; if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(tree)) throw new Error("frozen tree has invalid identity"); const scope = Array.isArray(scopeHint) ? scopeHint.filter((entry): entry is string => typeof entry === "string") : []; const inScope = (path: string) => !isNonDeliveryPath(path) && taskPathMatchesScope(path, scope); const expected = new Map(); for (const entry of gitBytes(root, ["ls-tree", "-r", "-z", tree], env).toString("utf8").split("\0")) { const tab = entry.indexOf("\t"); if (tab < 0) continue; const [mode, , oid] = entry.slice(0, tab).split(" "); const path = entry.slice(tab + 1); if (mode && oid && inScope(path)) expected.set(path, { mode, oid }); } const tracked = splitNull(gitBytes(root, ["ls-files", "-z"], env).toString("utf8")).filter(inScope); const regular: string[] = []; const drift = new Set(); // Deleted delivery paths are absent from both the frozen tree and the index. for (const path of new Set([...expected.keys(), ...tracked, ...deliveryPaths.filter(inScope)])) { const want = expected.get(path); let stat; try { stat = lstatSync(join(root, path)); } catch { stat = null; } if (!want || !stat) { if (want || stat) drift.add(path); continue; } // A submodule is pinned by the index, not by working-directory bytes. if (want.mode === "160000") continue; if (stat.isSymbolicLink()) { if (want.mode !== "120000" || gitBlobId(Buffer.from(readlinkSync(join(root, path))), want.oid.length) !== want.oid) drift.add(path); } else if (!stat.isFile() || want.mode === "120000" || (want.mode === "100755") !== ((stat.mode & 0o100) !== 0)) drift.add(path); else regular.push(path); } if (regular.length) { // `--stdin-paths` applies the same clean filters as `git add` would. const hashed = spawnSync("git", ["--no-optional-locks", "-C", root, "hash-object", "--stdin-paths"], { input: `${regular.join("\n")}\n`, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024, env, }); const ids = hashed.status === 0 ? hashed.stdout.split("\n").filter(Boolean) : []; if (ids.length !== regular.length) throw new Error("Git task snapshot command failed: git hash-object"); regular.forEach((path, index) => { if (ids[index] !== expected.get(path)!.oid) drift.add(path); }); } return [...drift].sort(comparePaths); }