Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,11 @@ import {
cleanWorktreeLease,
cancelWorktreeLease,
collectWorktreeGraph,
collectWorktreeHandoff,
formatWorktreeLeaseResult,
formatWorktreeCancelResult,
formatWorktreeGraph,
formatWorktreeHandoff,
} from "./worktree-lease.js";
import { evaluateCommandPolicy, formatCommandPolicyDecision } from "./command-policy.js";
import {
Expand Down Expand Up @@ -380,6 +382,7 @@ program
.option("--cancel-worktree", "Cancel a leased git worktree, preserving committed work and failing closed on uncommitted work, and exit")
.option("--cancel-force", "With --cancel-worktree, discard uncommitted work instead of refusing")
.option("--list-workspaces", "List the leased parallel workspaces (worktrees) with branch and state (read-only, redacted) and exit")
.option("--handoff", "Emit a handoff brief for a leased workspace (by --task-identity/--agent-identity): branch, commits, changed paths, state (read-only, redacted) and exit")
.option("--agent-identity <id>", "Stable agent identity for a leased worktree (with --create-worktree/--clean-worktree)")
.option("--worktree-root <dir>", "Directory where leased worktrees live (default <workspace>/.oh-my-cli/worktrees)")
.option("--command-policy <command>", "Evaluate one shell command against the offline command policy and exit")
Expand Down Expand Up @@ -1411,6 +1414,46 @@ program
process.exit(0);
}

// Handoff mode: a read-only, bounded, redacted handoff brief for one specific
// leased workspace (worktree-lease.ts collectWorktreeHandoff), identified by
// --task-identity/--agent-identity. Never mutates anything. Exits 0 (an
// absent lease is an absent handoff, not an error); exits 2 on a usage error
// (missing identity) or a non-repository target.
if (opts.handoff) {
const format = String(opts.output ?? "text");
if (format !== "text" && format !== "json") {
process.stderr.write(`Error: invalid output format "${format}"\n`);
process.exit(2);
}
if (!opts.taskIdentity) {
process.stderr.write("Error: --handoff requires --task-identity <id>\n");
process.exit(2);
}
if (!opts.agentIdentity) {
process.stderr.write("Error: --handoff requires --agent-identity <id>\n");
process.exit(2);
}
let handoff;
try {
handoff = collectWorktreeHandoff({
repo: opts.workspace,
taskIdentity: String(opts.taskIdentity),
agentIdentity: String(opts.agentIdentity),
worktreeRoot: opts.worktreeRoot,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`${msg}\n`);
process.exit(2);
}
if (format === "json") {
process.stdout.write(JSON.stringify(handoff) + "\n");
} else {
process.stdout.write(formatWorktreeHandoff(handoff) + "\n");
}
process.exit(0);
}

// Leased-worktree mode: create or clean one isolated git worktree per
// mutating delegated agent, offline (no provider config needed). Exits 0
// on success (including idempotent no-ops), 1 on a safety refusal, and 2
Expand Down
134 changes: 134 additions & 0 deletions src/worktree-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export const WORKTREE_LEASE_VERSION = 1;
export const WORKTREE_GRAPH_SCHEMA = "oh-my-cli.worktree-graph";
export const WORKTREE_GRAPH_VERSION = 1;

export const WORKTREE_HANDOFF_SCHEMA = "oh-my-cli.worktree-handoff";
export const WORKTREE_HANDOFF_VERSION = 1;

/** Options shared by lease creation and cleanup. */
export interface WorktreeLeaseOptions {
/** The parent repository workspace the lease is carved from. */
Expand Down Expand Up @@ -147,6 +150,32 @@ export interface WorktreeGraph {
truncated: number;
}

/**
* A bounded, redacted, read-only handoff brief for one specific leased workspace:
* the agent's branch, the commits it made, the paths it changed, and its
* clean/dirty state — for review before integration (#228) or cancellation (#230).
*/
export interface WorktreeHandoff {
schema: typeof WORKTREE_HANDOFF_SCHEMA;
v: typeof WORKTREE_HANDOFF_VERSION;
leaseId: string;
branch: string;
/** Home-collapsed absolute path to the leased worktree. */
worktreePath: string;
/** False when no lease exists for this identity (an absent handoff). */
present: boolean;
/** Abbreviated branch head ("" when absent). */
head: string;
/** True when the worktree has uncommitted changes. */
dirty: boolean;
/** Commits the agent made (branch commits not in the parent HEAD), bounded. */
commits: PreservedCommit[];
truncatedCommits: number;
/** Paths the agent changed (relative to the merge-base), bounded and redacted. */
changedPaths: string[];
truncatedPaths: number;
}

function redact(text: string): string {
return redactSecrets(text).text;
}
Expand Down Expand Up @@ -613,6 +642,80 @@ export function collectWorktreeGraph(opts: { repo: string; worktreeRoot?: string
};
}

// Bounds that keep a handoff brief bounded.
const MAX_HANDOFF_COMMITS = 50;
const MAX_HANDOFF_PATHS = 100;

/**
* Collect a read-only, bounded, redacted handoff brief for one specific leased
* workspace (identified by task+agent identity, reusing the lease derivation).
* Reports the agent's branch, the commits it made (branch commits not in the
* parent HEAD), the paths it changed (relative to the merge-base), and its
* clean/dirty state. Never mutates anything. An absent lease yields present:false
* (not an error). Throws a redacted error on a missing identity or non-repository.
*/
export function collectWorktreeHandoff(opts: WorktreeLeaseOptions): WorktreeHandoff {
const repo = path.resolve(opts.repo);
const task = (opts.taskIdentity ?? "").trim();
const agent = (opts.agentIdentity ?? "").trim();
if (!task || !agent) {
throw new Error("Worktree handoff error: both --task-identity and --agent-identity are required");
}
const commonDir = repoCommonDir(repo);
if (commonDir === null) {
throw new Error("Worktree handoff error: target is not a git repository");
}
const identity = deriveLeaseIdentity({ repoKey: commonDir, taskIdentity: task, agentIdentity: agent });
const worktreeRoot = path.resolve(opts.worktreeRoot ?? defaultWorktreeRoot(commonDir));
const worktreePath = path.join(worktreeRoot, identity.leaseId);

const wtListed = worktreeRegistered(repo, worktreePath);
const branchExists = branchRefExists(repo, identity.branch);
const handoff: WorktreeHandoff = {
schema: WORKTREE_HANDOFF_SCHEMA,
v: WORKTREE_HANDOFF_VERSION,
leaseId: identity.leaseId,
branch: identity.branch,
worktreePath: redactHomePath(worktreePath),
present: wtListed || branchExists,
head: "",
dirty: false,
commits: [],
truncatedCommits: 0,
changedPaths: [],
truncatedPaths: 0,
};
if (!handoff.present) return handoff;

handoff.head = git(repo, ["rev-parse", "--verify", "--quiet", identity.branch]).stdout.trim().slice(0, 12);
if (wtListed) {
handoff.dirty = dirtyCount(worktreePath) > 0;
}

const parentHead = git(repo, ["rev-parse", "HEAD"]).stdout.trim();
if (parentHead && branchExists) {
const log = git(repo, ["log", "--format=%H%x09%s", `${parentHead}..${identity.branch}`]);
const commitLines = log.stdout.split("\n").filter((line) => line.trim() !== "");
handoff.commits = commitLines.slice(0, MAX_HANDOFF_COMMITS).map((line) => {
const tab = line.indexOf("\t");
const sha = tab >= 0 ? line.slice(0, tab) : line;
const subject = tab >= 0 ? line.slice(tab + 1) : "";
return { sha: sha.slice(0, 12), subject: redact(subject) };
});
handoff.truncatedCommits = Math.max(0, commitLines.length - handoff.commits.length);

const diff = git(repo, ["diff", "--name-only", `${parentHead}...${identity.branch}`]);
const paths = diff.stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "");
handoff.changedPaths = paths.slice(0, MAX_HANDOFF_PATHS).map((p) => redactHomePath(p));
handoff.truncatedPaths = Math.max(0, paths.length - handoff.changedPaths.length);
}

return handoff;
}

// --- formatting -------------------------------------------------------------

/** A concise, redacted, human-readable lease result. */
Expand Down Expand Up @@ -671,6 +774,37 @@ export function formatWorktreeGraph(graph: WorktreeGraph): string {
return lines.join("\n");
}

/** A concise, redacted, human-readable handoff brief. */
export function formatWorktreeHandoff(handoff: WorktreeHandoff): string {
const lines: string[] = [];
lines.push(`Worktree handoff (${handoff.schema} v${handoff.v})`);
lines.push("─".repeat(40));
lines.push(`lease: ${handoff.leaseId}`);
lines.push(`branch: ${handoff.branch}`);
lines.push(`worktree: ${handoff.worktreePath}`);
if (!handoff.present) {
lines.push("status: absent (no such lease)");
return lines.join("\n");
}
lines.push(`head: ${handoff.head}`);
lines.push(`state: ${handoff.dirty ? "dirty" : "clean"}`);
lines.push(`commits: ${handoff.commits.length}`);
for (const commit of handoff.commits) {
lines.push(` ${commit.sha} ${commit.subject}`);
}
if (handoff.truncatedCommits > 0) {
lines.push(` … ${handoff.truncatedCommits} more commit(s) beyond the bound`);
}
lines.push(`changed paths: ${handoff.changedPaths.length}`);
for (const path of handoff.changedPaths) {
lines.push(` ${path}`);
}
if (handoff.truncatedPaths > 0) {
lines.push(` … ${handoff.truncatedPaths} more path(s) beyond the bound`);
}
return lines.join("\n");
}

/** A concise, redacted, human-readable cancellation result. */
export function formatWorktreeCancelResult(result: WorktreeCancelResult): string {
const lines: string[] = [];
Expand Down
114 changes: 114 additions & 0 deletions tests/integration/result-handoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execFileSync } from "node:child_process";
import { spawn } from "node:child_process";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";

function runCli(
args: string[],
env: Record<string, string | undefined>,
timeoutMs = 20_000,
): Promise<{ stdout: string; stderr: string; code: number | null }> {
return new Promise((resolve, reject) => {
const cliPath = path.resolve(import.meta.dirname, "../../dist/index.js");
const proc = spawn("node", [cliPath, ...args], {
env: { ...process.env, ...env },
timeout: timeoutMs,
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d) => { stdout += d; });
proc.stderr.on("data", (d) => { stderr += d; });
proc.on("close", (code) => resolve({ stdout, stderr, code }));
proc.on("error", reject);
});
}

function git(cwd: string, ...args: string[]): void {
execFileSync("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "ignore"] });
}

const tmpDirs: string[] = [];
function makeRepo(): string {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-handoff-int-"));
tmpDirs.push(repo);
execFileSync("git", ["init", "-q", "-b", "main", repo], { stdio: ["ignore", "pipe", "ignore"] });
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test");
fs.writeFileSync(path.join(repo, ".gitignore"), ".oh-my-cli/\n");
fs.writeFileSync(path.join(repo, "a.txt"), "base\n");
git(repo, "add", ".gitignore", "a.txt");
git(repo, "commit", "-q", "-m", "base");
return repo;
}

describe("Integration: result handoff (--handoff)", () => {
let homeDir: string;
let env: Record<string, string>;

beforeAll(() => {
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-handoff-int-home-"));
tmpDirs.push(homeDir);
env = { HOME: homeDir, OPENAI_API_KEY: "", OPENAI_BASE_URL: "", OPENAI_MODEL: "" };
});

afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});

async function createLease(repo: string, task: string, agent: string): Promise<string> {
const r = await runCli(
["--create-worktree", "--task-identity", task, "--agent-identity", agent, "--workspace", repo, "--output", "json"],
env,
);
expect(r.code).toBe(0);
const result = JSON.parse(r.stdout);
expect(result.ok).toBe(true);
return result.lease.worktreePath as string;
}

it("emits a handoff brief (JSON) for a lease that made a commit, read-only", async () => {
const repo = makeRepo();
const wtPath = await createLease(repo, "task-1", "agent-1");
fs.writeFileSync(path.join(wtPath, "b.txt"), "agent work\n");
git(wtPath, "add", "b.txt");
git(wtPath, "commit", "-q", "-m", "agent commit");
const headBefore = execFileSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();

const r = await runCli(
["--handoff", "--task-identity", "task-1", "--agent-identity", "agent-1", "--workspace", repo, "--output", "json"],
env,
);
expect(r.code).toBe(0);
const handoff = JSON.parse(r.stdout);
expect(handoff.schema).toBe("oh-my-cli.worktree-handoff");
expect(handoff.present).toBe(true);
expect(handoff.branch).toMatch(/^lease\/wt-/);
expect(handoff.commits.map((c: { subject: string }) => c.subject)).toContain("agent commit");
expect(handoff.changedPaths).toContain("b.txt");
expect(handoff.dirty).toBe(false);

// Read-only: the worktree is unchanged.
const headAfter = execFileSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
expect(headAfter).toBe(headBefore);
});

it("emits an absent handoff (text) for an unknown lease", async () => {
const repo = makeRepo();
const r = await runCli(
["--handoff", "--task-identity", "ghost", "--agent-identity", "ghost", "--workspace", repo],
env,
);
expect(r.code).toBe(0);
expect(r.stdout).toContain("Worktree handoff");
expect(r.stdout).toContain("status: absent (no such lease)");
});

it("exits 2 when --task-identity is missing", async () => {
const repo = makeRepo();
const r = await runCli(["--handoff", "--agent-identity", "a", "--workspace", repo], env);
expect(r.code).toBe(2);
expect(r.stderr).toContain("--task-identity");
});
});
Loading
Loading