From 7bb83fbe388a7683758a504a7c20d1f06522aceb Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:15:40 +0800 Subject: [PATCH] feat(cli): add a --handoff command emitting a redacted handoff brief for a parallel agent's leased workspace (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators could provision, observe, integrate, and cancel parallel workspaces, but when a parallel agent finished there was no structured handoff describing what that specific agent accomplished — they had to inspect the lease branch by hand before deciding to integrate or cancel. Add collectWorktreeHandoff plus an opt-in --handoff command (by --task-identity/--agent-identity) that reports the lease branch, the commits the agent made since the base, the changed paths, and the clean/dirty state, bounded and redacted. Strictly read-only; an absent lease is an absent handoff, not an error. --- src/index.ts | 43 ++++++ src/worktree-lease.ts | 134 +++++++++++++++++++ tests/integration/result-handoff.test.ts | 114 ++++++++++++++++ tests/unit/result-handoff.test.ts | 161 +++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 tests/integration/result-handoff.test.ts create mode 100644 tests/unit/result-handoff.test.ts diff --git a/src/index.ts b/src/index.ts index c34eeac..ac64fc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { @@ -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 ", "Stable agent identity for a leased worktree (with --create-worktree/--clean-worktree)") .option("--worktree-root ", "Directory where leased worktrees live (default /.oh-my-cli/worktrees)") .option("--command-policy ", "Evaluate one shell command against the offline command policy and exit") @@ -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 \n"); + process.exit(2); + } + if (!opts.agentIdentity) { + process.stderr.write("Error: --handoff requires --agent-identity \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 diff --git a/src/worktree-lease.ts b/src/worktree-lease.ts index d8de3b3..d1f2527 100644 --- a/src/worktree-lease.ts +++ b/src/worktree-lease.ts @@ -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. */ @@ -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; } @@ -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. */ @@ -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[] = []; diff --git a/tests/integration/result-handoff.test.ts b/tests/integration/result-handoff.test.ts new file mode 100644 index 0000000..e79a3d1 --- /dev/null +++ b/tests/integration/result-handoff.test.ts @@ -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, + 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; + + 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 { + 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"); + }); +}); diff --git a/tests/unit/result-handoff.test.ts b/tests/unit/result-handoff.test.ts new file mode 100644 index 0000000..ed985a4 --- /dev/null +++ b/tests/unit/result-handoff.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + createWorktreeLease, + collectWorktreeHandoff, + formatWorktreeHandoff, + WORKTREE_HANDOFF_SCHEMA, + WORKTREE_HANDOFF_VERSION, +} from "../../src/worktree-lease.js"; + +const tmpDirs: string[] = []; +afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); +}); + +function git(cwd: string, ...args: string[]): void { + execFileSync("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "ignore"] }); +} + +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-handoff-")); + 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"); + // Mirror the product repo: the lease worktree root is git-ignored so carving + // worktrees does not dirty the parent. + 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; +} + +function createLease(repo: string, task: string, agent: string): string { + const result = createWorktreeLease({ repo, taskIdentity: task, agentIdentity: agent }); + if (!result.ok) throw new Error(`create failed: ${result.reason}`); + return result.lease.worktreePath; +} + +describe("collectWorktreeHandoff", () => { + it("reports an absent handoff when no lease exists for the identity", () => { + const repo = makeRepo(); + const handoff = collectWorktreeHandoff({ repo, taskIdentity: "nope", agentIdentity: "nada" }); + expect(handoff.schema).toBe(WORKTREE_HANDOFF_SCHEMA); + expect(handoff.v).toBe(WORKTREE_HANDOFF_VERSION); + expect(handoff.present).toBe(false); + expect(handoff.commits).toEqual([]); + expect(handoff.changedPaths).toEqual([]); + }); + + it("reports the branch, head, commits, and changed paths for a lease that made a commit", () => { + const repo = makeRepo(); + const wtPath = 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 handoff = collectWorktreeHandoff({ repo, taskIdentity: "task-1", agentIdentity: "agent-1" }); + expect(handoff.present).toBe(true); + expect(handoff.branch).toMatch(/^lease\/wt-/); + expect(handoff.head).toMatch(/^[0-9a-f]{12}$/); + expect(handoff.dirty).toBe(false); + expect(handoff.commits.map((c) => c.subject)).toContain("agent commit"); + expect(handoff.changedPaths).toContain("b.txt"); + }); + + it("reports a dirty state for a lease with uncommitted work", () => { + const repo = makeRepo(); + const wtPath = createLease(repo, "task-dirty", "agent-1"); + fs.writeFileSync(path.join(wtPath, "c.txt"), "uncommitted\n"); + + const handoff = collectWorktreeHandoff({ repo, taskIdentity: "task-dirty", agentIdentity: "agent-1" }); + expect(handoff.present).toBe(true); + expect(handoff.dirty).toBe(true); + }); + + it("redacts secret-shaped commit subjects", () => { + const repo = makeRepo(); + const wtPath = createLease(repo, "task-secret", "agent-1"); + fs.writeFileSync(path.join(wtPath, "d.txt"), "x\n"); + git(wtPath, "add", "d.txt"); + git(wtPath, "commit", "-q", "-m", "add sk-aaaaaaaaaaaaaaaaaaaa token"); + + const handoff = collectWorktreeHandoff({ repo, taskIdentity: "task-secret", agentIdentity: "agent-1" }); + const subject = handoff.commits.map((c) => c.subject).join(" "); + expect(subject).not.toContain("sk-aaaaaaaaaaaaaaaaaaaa"); + expect(subject).toContain("[REDACTED]"); + }); + + it("does not mutate the worktree it inspects", () => { + const repo = makeRepo(); + const wtPath = createLease(repo, "task-ro", "agent-1"); + fs.writeFileSync(path.join(wtPath, "b.txt"), "x\n"); + git(wtPath, "add", "b.txt"); + git(wtPath, "commit", "-q", "-m", "c"); + const headBefore = execFileSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + + collectWorktreeHandoff({ repo, taskIdentity: "task-ro", agentIdentity: "agent-1" }); + const headAfter = execFileSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + expect(headAfter).toBe(headBefore); + }); + + it("throws when identities are missing", () => { + const repo = makeRepo(); + expect(() => collectWorktreeHandoff({ repo, taskIdentity: "", agentIdentity: "" })).toThrow(/task-identity/); + }); + + it("throws on a non-repository target", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-handoff-norepo-")); + tmpDirs.push(dir); + expect(() => collectWorktreeHandoff({ repo: dir, taskIdentity: "t", agentIdentity: "a" })).toThrow(/not a git repository/); + }); +}); + +describe("formatWorktreeHandoff", () => { + it("renders an absent handoff", () => { + const text = formatWorktreeHandoff({ + schema: WORKTREE_HANDOFF_SCHEMA, + v: WORKTREE_HANDOFF_VERSION, + leaseId: "abc123", + branch: "lease/wt-abc123", + worktreePath: "~/repo/.oh-my-cli/worktrees/abc123", + present: false, + head: "", + dirty: false, + commits: [], + truncatedCommits: 0, + changedPaths: [], + truncatedPaths: 0, + }); + expect(text).toContain("Worktree handoff"); + expect(text).toContain("status: absent (no such lease)"); + }); + + it("renders a present handoff with commits and changed paths", () => { + const text = formatWorktreeHandoff({ + schema: WORKTREE_HANDOFF_SCHEMA, + v: WORKTREE_HANDOFF_VERSION, + leaseId: "abc123", + branch: "lease/wt-abc123", + worktreePath: "~/repo/.oh-my-cli/worktrees/abc123", + present: true, + head: "1234567890ab", + dirty: false, + commits: [{ sha: "1234567890ab", subject: "agent commit" }], + truncatedCommits: 0, + changedPaths: ["b.txt"], + truncatedPaths: 0, + }); + expect(text).toContain("head: 1234567890ab"); + expect(text).toContain("state: clean"); + expect(text).toContain("commits: 1"); + expect(text).toContain("agent commit"); + expect(text).toContain("changed paths: 1"); + expect(text).toContain("b.txt"); + }); +});