import { execFile } from "node:child_process"; import type { ToolResultEvent, ToolResultEventResult } from "@earendil-works/pi-coding-agent"; import { resolve } from "node:path"; // Harness roadmap T4 (R1): ODD slices delivery at about 400 authored changed // lines, but the model had to estimate the count. After each parent commit the // runtime reports it from Git, so the slicing decision reads a number. export const SLICE_LINES = 400; // Generated files that do not count as authored lines. const GENERATED = [/(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|composer\.lock|Gemfile\.lock)$/, /\.min\.(js|css)$/]; // `git [-C dir | -c key=value]... commit` at the start of a segment, after any variable assignments. const COMMIT = /^\s*(\w+=\S*\s+)*git(\s+-[Cc]\s+\S+)*\s+commit(\s|$)/; /** A commit is a git invocation that starts a shell segment; text that only mentions one (echo, a message) is not. */ export function isGitCommitCommand(command: string): boolean { return command.split(/[;&|(\n]/).some((segment) => COMMIT.test(segment)); } /** Shell segments joined by `;`, `&&`, `||`, or a newline. */ export const SHELL_SEGMENT_SEPARATOR = /;|&&|\|\||\n/; const unquote = (value: string) => value.replace(/^["']|["']$/g, ""); /** The directory after a shell segment: `cd ` resolved against `base`, else `base`. */ export function directoryAfterSegment(segment: string, base: string): string { const cdDir = /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*$/.exec(segment)?.[1]; return cdDir ? resolve(base, unquote(cdDir)) : base; } /** The directory the commit runs in: `git -C ` under every `cd ` segment before the commit (joined by `;`, `&&`, `||`, or a newline), else the session cwd. */ export function commitTargetDirectory(command: string, cwd: string): string { const gitDir = /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)\s+(?:-[Cc]\s+\S+\s+)*commit\b/.exec(command)?.[1]; let base = cwd; for (const segment of command.split(SHELL_SEGMENT_SEPARATOR)) { if (COMMIT.test(segment)) break; base = directoryAfterSegment(segment, base); } return gitDir ? resolve(base, unquote(gitDir)) : base; } type Git = (args: string[], cwd: string) => Promise; // Asynchronous, so the parent stays responsive while git answers. const runGit: Git = (args, cwd) => new Promise((resolve) => { execFile("git", args, { cwd, encoding: "utf8", timeout: 2_000, windowsHide: true }, (error, stdout) => resolve(error ? undefined : stdout.trim())); }); async function defaultBranch(cwd: string, git: Git): Promise { const remote = await git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"], cwd); if (remote) return remote; for (const name of ["main", "master"]) { if (await git(["rev-parse", "--verify", "-q", `refs/heads/${name}`], cwd) !== undefined) return name; } return undefined; } /** One line for the parent after a commit on a feature branch, or undefined when there is no slice to report. */ export async function commitSliceReport(cwd: string, git: Git = runGit): Promise { const branch = await git(["symbolic-ref", "-q", "--short", "HEAD"], cwd); const target = branch === undefined ? undefined : await defaultBranch(cwd, git); if (!branch || !target || branch === target || `origin/${branch}` === target) return undefined; const base = await git(["merge-base", "HEAD", target], cwd); const commits = base ? Number(await git(["rev-list", "--count", `${base}..HEAD`], cwd)) : 0; if (!base || !(commits > 0)) return undefined; // A failed or timed-out diff is not an empty one: report nothing rather than 0. // `-M` counts a move by what it changed; `-z` keeps paths unquoted for the generated-file filter. const numstat = await git(["diff", "--numstat", "-z", "-M", base, "HEAD"], cwd); if (numstat === undefined) return undefined; let lines = 0; const fields = numstat.split("\0"); for (let i = 0; i < fields.length; i++) { const [added, deleted, inline] = fields[i].split("\t"); // A rename leaves the inline path empty and is followed by its old and new paths. const path = inline === "" ? fields[i += 2] : inline; if (!path || added === "-" || GENERATED.some((pattern) => pattern.test(path))) continue; lines += Number(added) + Number(deleted); } const head = `Commit slice: ${lines} authored changed lines in ${commits} commit${commits === 1 ? "" : "s"} on ${branch} since ${target} (lock files excluded).`; return lines > SLICE_LINES ? `${head} Past the ~${SLICE_LINES}-line slice: apply the feature's delivery strategy before the next commit (record the slice boundary in the feature document).` : `${head} ${SLICE_LINES - lines} lines left before the ~${SLICE_LINES}-line slice.`; } /** * tool_result patch for a successful parent `git commit`: the slice line appended, structured content kept. * Only foreground `bash`: a background job's result arrives before its commit exists. */ export async function commitSliceToolResult(event: Pick, cwd: string, git: Git = runGit): Promise { const command = (event.input as { command?: unknown } | undefined)?.command; if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string" || !isGitCommitCommand(command)) return undefined; const report = await commitSliceReport(commitTargetDirectory(command, cwd), git); if (!report) return undefined; return { content: [...event.content, { type: "text", text: `\n${report}` }], ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }), }; }