feat: multi-work agent #12

Merged
StanPonomarev merged 5 commits from feat/multi-work into main 2026-09-18 10:28:03 +02:00
9 changed files with 291 additions and 38 deletions
Showing only changes of commit 9494936160 - Show all commits
+14 -3
View File
@@ -63,13 +63,24 @@ subagent({
agent: "coordinate", agent: "coordinate",
description: "Implement API change", description: "Implement API change",
prompt: "Goal, scope, constraints, and acceptance checks...", prompt: "Goal, scope, constraints, and acceptance checks...",
worktree: { base: "feature/source" }, worktree: {
base: "main",
branch: "fix/login-timeout",
label: "Fix login timeout",
},
background: true, background: true,
}); });
``` ```
`base` identifies a locally available branch or remote-tracking branch. The tool `base` identifies a locally available branch or remote-tracking branch. Optional
resolves its commit and creates a new unique `multi-work/<uuid>` branch with Herdr. `branch` chooses the exact new Git branch and optional `label` sets the Herdr workspace
display label through `herdr worktree create --label`. Both must be nonempty after
trimming. Prefer concise, descriptive human-readable names. Existing branch validation
and collision checks apply, and the tool never overwrites a branch. When omitted, the
tool retains the previous behavior: a unique `multi-work/<uuid>` branch and Herdr's
default workspace label. Naming fields are creation-only; resumes accept only `sessionID`.
The tool
resolves the base commit and creates the branch with Herdr.
It does not fetch refs or copy uncommitted root changes. Herdr determines the It does not fetch refs or copy uncommitted root changes. Herdr determines the
checkout path; the tool parses and verifies the returned checkout/workspace. checkout path; the tool parses and verifies the returned checkout/workspace.
Creation explicitly preserves focus with `--no-focus`. Creation explicitly preserves focus with `--no-focus`.
+10 -4
View File
@@ -15,6 +15,7 @@ const root = join(temp, "root"),
destination = join(temp, "child"), destination = join(temp, "child"),
bin = join(temp, "bin"), bin = join(temp, "bin"),
config = join(temp, "config"); config = join(temp, "config");
const herdrCreation = join(temp, "herdr-creation.txt");
for (const path of [root, bin, config, join(temp, "plugin")]) await mkdir(path); for (const path of [root, bin, config, join(temp, "plugin")]) await mkdir(path);
const signal = AbortSignal.timeout(120_000); const signal = AbortSignal.timeout(120_000);
if ((await command([binary, "--version"], temp, signal)) !== "opencode v2.0.4") if ((await command([binary, "--version"], temp, signal)) !== "opencode v2.0.4")
@@ -41,17 +42,20 @@ const inventory = (path: string, id: string) =>
source: { source: {
repo_key: join(root, ".git"), repo_key: join(root, ".git"),
repo_root: root, repo_root: root,
source_checkout_path: path, source_checkout_path: root,
source_workspace_id: id, source_workspace_id: "w1",
}, },
worktrees: [ worktrees: [
{ path, is_linked_worktree: path !== root, is_bare: false, open_workspace_id: id }, { path: root, is_linked_worktree: false, is_bare: false, open_workspace_id: "w1" },
...(path === root
? []
: [{ path, is_linked_worktree: true, is_bare: false, open_workspace_id: id }]),
], ],
}, },
}); });
await writeFile( await writeFile(
join(bin, "herdr"), join(bin, "herdr"),
`#!/bin/sh\ncase "$1 $2" in\n"worktree create") git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`, `#!/bin/sh\ncase "$1 $2" in\n"worktree create") [ "$9" = "--label" ] && [ "\${10}" = "Fix native host" ] || exit 8; printf '%s\\n%s\\n' "$6" "\${10}" > '${herdrCreation}'; git -C '${root}' worktree add -b "$6" '${destination}' "$8" >&2 || exit 1; printf '{"result":{"workspace":{"workspace_id":"w2"},"worktree":{"path":"${destination}","branch":"%s","is_linked_worktree":true}}}' "$6"; exit ;;\nesac\ncase "$1 $2 $4" in\n"workspace list ") echo '{"result":{"workspaces":[{"workspace_id":"w1"}]}}' ;;\n"worktree list w1") echo '${inventory(root, "w1")}' ;;\n"worktree list w2") echo '${inventory(destination, "w2")}' ;;\n*) exit 9 ;;\nesac\n`,
); );
await chmod(join(bin, "herdr"), 0o755); await chmod(join(bin, "herdr"), 0o755);
const built = await Bun.build({ const built = await Bun.build({
@@ -190,6 +194,8 @@ try {
.parse(JSON.parse(z.string().parse(result.output))); .parse(JSON.parse(z.string().parse(result.output)));
if (!output.content.includes("FIXTURE_COMPLETE")) if (!output.content.includes("FIXTURE_COMPLETE"))
throw new Error("Missing native child result"); throw new Error("Missing native child result");
if ((await readFile(herdrCreation, "utf8")) !== "fix/native-host\nFix native host\n")
throw new Error("Custom Herdr branch/label mismatch");
const child = await client.session.get({ sessionID: output.metadata.sessionID }); const child = await client.session.get({ sessionID: output.metadata.sessionID });
if (child.parentID !== parent.id || child.location.directory !== destination) if (child.parentID !== parent.id || child.location.directory !== destination)
throw new Error("Child ancestry/placement mismatch"); throw new Error("Child ancestry/placement mismatch");
+21 -6
View File
@@ -109,7 +109,13 @@ export class Herdr {
return matches.length === 1 ? matches[0] : undefined; return matches.length === 1 ? matches[0] : undefined;
} }
async create(root: HerdrRoot, base: string, branch: string, signal: AbortSignal) { async create(
root: HerdrRoot,
base: string,
branch: string,
signal: AbortSignal,
label?: string,
) {
const current = await this.root(root.directory, signal); const current = await this.root(root.directory, signal);
if (!current || current.workspaceID !== root.workspaceID || current.repo !== root.repo) if (!current || current.workspaceID !== root.workspaceID || current.repo !== root.repo)
throw new Error("Herdr root workspace is no longer eligible"); throw new Error("Herdr root workspace is no longer eligible");
@@ -150,6 +156,7 @@ export class Herdr {
branch, branch,
"--base", "--base",
commit, commit,
...(label === undefined ? [] : ["--label", label]),
"--no-focus", "--no-focus",
], ],
root.directory, root.directory,
@@ -185,7 +192,7 @@ export class Herdr {
throw new Error( throw new Error(
"Created checkout does not match the requested repository and base", "Created checkout does not match the requested repository and base",
); );
await this.verify(directory, workspaceID, root.repo, branch, signal); await this.verify(directory, workspaceID, root, branch, signal);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Retained Herdr workspace ${workspaceID}, checkout ${directory}, branch ${branch}`, `Retained Herdr workspace ${workspaceID}, checkout ${directory}, branch ${branch}`,
@@ -198,7 +205,7 @@ export class Herdr {
async verify( async verify(
directory: string, directory: string,
workspaceID: string, workspaceID: string,
repo: string, root: HerdrRoot,
branch: string, branch: string,
signal: AbortSignal, signal: AbortSignal,
) { ) {
@@ -214,12 +221,20 @@ export class Herdr {
const checkout = value.result.worktrees.find( const checkout = value.result.worktrees.find(
(item) => item.open_workspace_id === workspaceID && item.is_linked_worktree, (item) => item.open_workspace_id === workspaceID && item.is_linked_worktree,
); );
const sourceCheckout = value.result.worktrees.find(
(item) =>
item.open_workspace_id === value.result.source.source_workspace_id &&
!item.is_linked_worktree &&
!item.is_bare,
);
if ( if (
!checkout || !checkout ||
value.result.source.source_workspace_id !== workspaceID || !sourceCheckout ||
(await realpath(value.result.source.source_checkout_path)) !== directory || value.result.source.source_workspace_id !== root.workspaceID ||
(await realpath(value.result.source.source_checkout_path)) !== root.directory ||
(await realpath(sourceCheckout.path)) !== root.directory ||
(await realpath(checkout.path)) !== directory || (await realpath(checkout.path)) !== directory ||
(await realpath(value.result.source.repo_key)) !== repo (await realpath(value.result.source.repo_key)) !== root.repo
) )
throw new Error("Bound Herdr checkout is no longer available"); throw new Error("Bound Herdr checkout is no longer available");
if ( if (
+16 -5
View File
@@ -28,7 +28,11 @@ const inputSchema = z
sessionID: z.string().optional(), sessionID: z.string().optional(),
background: z.boolean().optional(), background: z.boolean().optional(),
worktree: z worktree: z
.object({ base: z.string().trim().min(1) }) .object({
base: z.string().trim().min(1),
branch: z.string().trim().min(1).optional(),
label: z.string().trim().min(1).optional(),
})
.strict() .strict()
.optional(), .optional(),
}) })
@@ -180,7 +184,7 @@ export const installMultiWork = (
agent: { type: "string", enum: ["fast-lo", "coordinate"] }, agent: { type: "string", enum: ["fast-lo", "coordinate"] },
}; };
tool.description = tool.description =
"Delegate to fast-lo locally, or coordinate in a new Herdr worktree using worktree: {base}. Continue an owned coordinate child with sessionID and no worktree argument. Native background and results apply."; "Delegate to fast-lo locally, or coordinate in a new Herdr worktree using worktree: {base, branch?, label?}. Prefer descriptive human-readable branch and label values, for example {base:'main',branch:'fix/login-timeout',label:'Fix login timeout'}. Continue an owned coordinate child with sessionID and no worktree argument; naming fields are create-only. Native background and results apply.";
} }
}).pipe(Effect.orDie), }).pipe(Effect.orDie),
); );
@@ -275,7 +279,7 @@ export const installMultiWork = (
herdr.verify( herdr.verify(
binding.directory, binding.directory,
binding.workspaceID, binding.workspaceID,
binding.root.repo, binding.root,
binding.branch, binding.branch,
signal, signal,
), ),
@@ -319,7 +323,8 @@ export const installMultiWork = (
throw new Error( throw new Error(
"Native executor returned an unexpected child", "Native executor returned an unexpected child",
); );
const branch = `multi-work/${randomUUID()}`; const branch =
worktree.branch ?? `multi-work/${randomUUID()}`;
yield* context.storage.set(callKey, { yield* context.storage.set(callKey, {
child: id, child: id,
branch, branch,
@@ -327,7 +332,13 @@ export const installMultiWork = (
}); });
yield* call.progress({ ...metadata, branch }); yield* call.progress({ ...metadata, branch });
const destination = yield* Effect.tryPromise((signal) => const destination = yield* Effect.tryPromise((signal) =>
herdr.create(root, worktree.base, branch, signal), herdr.create(
root,
worktree.base,
branch,
signal,
worktree.label,
),
); );
const binding = { const binding = {
owner: call.sessionID, owner: call.sessionID,
+6 -4
View File
@@ -16,12 +16,14 @@ export const descriptions = {
export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout. export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout.
Read, plan, track todos and delegate; never implement or execute shell commands yourself. Read, plan, track todos and delegate; never implement or execute shell commands yourself.
Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate
for work in a new Herdr worktree, supplying worktree: {base: "source-branch"}, description, for work in a new Herdr worktree, supplying descriptive human-readable names such as
and a complete prompt with scope, constraints and acceptance checks. The deterministic tool worktree: {base: "main", branch: "fix/login-timeout", label: "Fix login timeout"}, plus
creates a unique branch from that locally available source branch, moves the coordinate child description and a complete prompt with scope, constraints and acceptance checks. The tool
uses the requested branch and Herdr workspace label; when omitted, it creates the prior unique
multi-work/<uuid> branch and lets Herdr choose its default label. It moves the coordinate child
before its task begins, and preserves your root session. Dirty root changes are not copied. before its task begins, and preserves your root session. Dirty root changes are not copied.
Resume an owned coordinate child using its sessionID and no worktree argument. Never switch Resume an owned coordinate child using its sessionID and no worktree argument. Never switch
its agent or location. Only fast-lo and coordinate are permitted. Workers are leaves. its agent or location; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves.
Parallelize independent work with disjoint ownership; serialize dependencies. Native background Parallelize independent work with disjoint ownership; serialize dependencies. Native background
delegation returns immediately and notifies you when complete; do not poll or duplicate work. delegation returns immediately and notifies you when complete; do not poll or duplicate work.
Reconcile results and report checkout, branch and child IDs. Retained resources on failure Reconcile results and report checkout, branch and child IDs. Retained resources on failure
+13 -3
View File
@@ -59,16 +59,26 @@ const scenario = async () => {
source: { source: {
repo_key: join(root, ".git"), repo_key: join(root, ".git"),
repo_root: root, repo_root: root,
source_checkout_path: path, source_checkout_path: root,
source_workspace_id: id, source_workspace_id: "w1",
}, },
worktrees: [ worktrees: [
{
path: root,
is_linked_worktree: false,
is_bare: false,
open_workspace_id: "w1",
},
...(path === root
? []
: [
{ {
path, path,
is_linked_worktree: path !== root, is_linked_worktree: true,
is_bare: false, is_bare: false,
open_workspace_id: id, open_workspace_id: id,
}, },
]),
], ],
}, },
}); });
+7 -1
View File
@@ -76,7 +76,13 @@ export default Plugin.define({
background: input.background, background: input.background,
...(input.child ...(input.child
? { sessionID: input.child } ? { sessionID: input.child }
: { worktree: { base: "main" } }), : {
worktree: {
base: "main",
branch: "fix/native-host",
label: "Fix native host",
},
}),
}, },
{ {
sessionID: Session.ID.make(input.parent), sessionID: Session.ID.make(input.parent),
+105 -8
View File
@@ -38,6 +38,12 @@ async function fixture() {
let linked = false; let linked = false;
let duplicate = false; let duplicate = false;
let branch = ""; let branch = "";
let label: string | undefined;
let sourceWorkspaceIDOverride: string | undefined;
let sourcePathOverride: string | undefined;
let childWorkspaceIDOverride: string | undefined;
let childPathOverride: string | undefined;
let repoOverride: string | undefined;
const run = async (args: string[], cwd: string, signal: AbortSignal) => { const run = async (args: string[], cwd: string, signal: AbortSignal) => {
calls.push(args); calls.push(args);
if (args[0] !== "herdr") return command(args, cwd, signal); if (args[0] !== "herdr") return command(args, cwd, signal);
@@ -52,21 +58,33 @@ async function fixture() {
if (args[2] === "list") { if (args[2] === "list") {
const id = args[4]!; const id = args[4]!;
const path = id === "w2" ? destination : root; const path = id === "w2" ? destination : root;
const sourceWorkspaceID = sourceWorkspaceIDOverride ?? (id === "w2" ? "w1" : id);
const sourcePath = sourcePathOverride ?? root;
return JSON.stringify({ return JSON.stringify({
result: { result: {
source: { source: {
repo_key: join(root, ".git"), repo_key: repoOverride ?? join(root, ".git"),
repo_root: root, repo_root: root,
source_checkout_path: path, source_checkout_path: sourcePath,
source_workspace_id: id, source_workspace_id: sourceWorkspaceID,
}, },
worktrees: [ worktrees: [
{ {
path, path: sourcePath,
is_linked_worktree: id === "w2" || linked, is_linked_worktree: linked,
is_bare: false, is_bare: false,
open_workspace_id: id, open_workspace_id: sourceWorkspaceID,
}, },
...(id === "w2"
? [
{
path: childPathOverride ?? path,
is_linked_worktree: true,
is_bare: false,
open_workspace_id: childWorkspaceIDOverride ?? id,
},
]
: []),
], ],
}, },
}); });
@@ -75,6 +93,8 @@ async function fixture() {
expect(args.slice(3, 5)).toEqual(["--cwd", root]); expect(args.slice(3, 5)).toEqual(["--cwd", root]);
expect(args.at(-1)).toBe("--no-focus"); expect(args.at(-1)).toBe("--no-focus");
branch = args[args.indexOf("--branch") + 1]!; branch = args[args.indexOf("--branch") + 1]!;
const labelIndex = args.indexOf("--label");
label = labelIndex === -1 ? undefined : args[labelIndex + 1];
const base = args[args.indexOf("--base") + 1]!; const base = args[args.indexOf("--base") + 1]!;
// Real Git worktree behavior, but only in an isolated temporary repository. // Real Git worktree behavior, but only in an isolated temporary repository.
await command(["git", "worktree", "add", "-b", branch, destination, base], root, signal); await command(["git", "worktree", "add", "-b", branch, destination, base], root, signal);
@@ -90,6 +110,9 @@ async function fixture() {
destination, destination,
signal, signal,
calls, calls,
get label() {
return label;
},
herdr: new Herdr(run), herdr: new Herdr(run),
close() { close() {
open = false; open = false;
@@ -100,10 +123,23 @@ async function fixture() {
duplicate() { duplicate() {
duplicate = true; duplicate = true;
}, },
changeInventory(change: {
sourceWorkspaceID?: string | undefined;
sourcePath?: string | undefined;
childWorkspaceID?: string | undefined;
childPath?: string | undefined;
repo?: string | undefined;
}) {
sourceWorkspaceIDOverride = change.sourceWorkspaceID;
sourcePathOverride = change.sourcePath;
childWorkspaceIDOverride = change.childWorkspaceID;
childPathOverride = change.childPath;
repoOverride = change.repo;
},
}; };
} }
test("live inventory recognizes an unannotated root and creates a unique branch from another local branch", async () => { test("create verifies a linked workspace whose inventory source remains the parent workspace", async () => {
const f = await fixture(); const f = await fixture();
const root = await f.herdr.root(f.root, f.signal); const root = await f.herdr.root(f.root, f.signal);
expect(root?.workspaceID).toBe("w1"); expect(root?.workspaceID).toBe("w1");
@@ -114,12 +150,73 @@ test("live inventory recognizes an unannotated root and creates a unique branch
await f.herdr.verify( await f.herdr.verify(
destination.directory, destination.directory,
destination.workspaceID, destination.workspaceID,
root!.repo, root!,
destination.branch, destination.branch,
f.signal, f.signal,
); );
}); });
test("create passes an optional workspace label to Herdr", async () => {
const f = await fixture();
const root = (await f.herdr.root(f.root, f.signal))!;
const destination = await f.herdr.create(
root,
"main",
"fix/login-timeout",
f.signal,
"Fix login timeout",
);
expect(destination.branch).toBe("fix/login-timeout");
expect(f.label).toBe("Fix login timeout");
const creation = f.calls.find((args) => args[0] === "herdr" && args[2] === "create")!;
expect(creation.slice(creation.indexOf("--label"), creation.indexOf("--label") + 2)).toEqual([
"--label",
"Fix login timeout",
]);
});
test("create rejects an existing destination branch before invoking Herdr", async () => {
const f = await fixture();
const root = (await f.herdr.root(f.root, f.signal))!;
await expect(
f.herdr.create(root, "main", "invalid branch", f.signal, "Invalid"),
).rejects.toThrow();
await expect(f.herdr.create(root, "main", "other", f.signal, "Other")).rejects.toThrow(
"Destination branch already exists: other",
);
expect(f.calls.filter((args) => args[0] === "herdr" && args[2] === "create")).toHaveLength(0);
});
test("verify rejects changed parent, child and repository identities", async () => {
for (const change of [
{ sourceWorkspaceID: "w9" },
{ sourcePath: "child" },
{ childWorkspaceID: "w9" },
{ childPath: "root" },
{ repo: "child" },
]) {
const f = await fixture();
const root = (await f.herdr.root(f.root, f.signal))!;
const destination = await f.herdr.create(root, "main", "multi-work/fixture", f.signal);
f.changeInventory({
sourceWorkspaceID: change.sourceWorkspaceID,
sourcePath: change.sourcePath === "child" ? f.destination : undefined,
childWorkspaceID: change.childWorkspaceID,
childPath: change.childPath === "root" ? f.root : undefined,
repo: change.repo === "child" ? f.destination : undefined,
});
await expect(
f.herdr.verify(
destination.directory,
destination.workspaceID,
root,
destination.branch,
f.signal,
),
).rejects.toThrow("Bound Herdr checkout is no longer available");
}
});
test("closed, linked and ambiguous workspaces fail root gating", async () => { test("closed, linked and ambiguous workspaces fail root gating", async () => {
for (const mode of ["close", "link", "duplicate"] as const) { for (const mode of ["close", "link", "duplicate"] as const) {
const f = await fixture(); const f = await fixture();
+97 -2
View File
@@ -18,14 +18,26 @@ class FixtureHerdr extends Herdr {
verified = 0; verified = 0;
delayCreation = false; delayCreation = false;
creationAborted = false; creationAborted = false;
createdBase: string | undefined;
createdBranch: string | undefined;
createdLabel: string | undefined;
creationStarted = Promise.withResolvers<void>(); creationStarted = Promise.withResolvers<void>();
override async root(directory: string) { override async root(directory: string) {
return this.eligible && directory === "/root" return this.eligible && directory === "/root"
? { directory, workspaceID: "w1", repo: "/root/.git" } ? { directory, workspaceID: "w1", repo: "/root/.git" }
: undefined; : undefined;
} }
override async create(_root: HerdrRoot, _base: string, branch: string, signal: AbortSignal) { override async create(
_root: HerdrRoot,
base: string,
branch: string,
signal: AbortSignal,
label?: string,
) {
this.creations++; this.creations++;
this.createdBase = base;
this.createdBranch = branch;
this.createdLabel = label;
if (!this.eligible) throw new Error("root closed"); if (!this.eligible) throw new Error("root closed");
if (this.delayCreation) { if (this.delayCreation) {
this.creationStarted.resolve(); this.creationStarted.resolve();
@@ -295,7 +307,13 @@ const request = z.object({
agent: z.string(), agent: z.string(),
description: z.string(), description: z.string(),
prompt: z.string(), prompt: z.string(),
worktree: z.object({ base: z.string() }).optional(), worktree: z
.object({
base: z.string(),
branch: z.string().optional(),
label: z.string().optional(),
})
.optional(),
sessionID: z.string().optional(), sessionID: z.string().optional(),
background: z.boolean().optional(), background: z.boolean().optional(),
}); });
@@ -348,6 +366,83 @@ test("local fast-lo uses native delegation without a worktree", async () => {
); );
}); });
test("custom branch and workspace label pass through delegation", async () => {
const f = fixture();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
const result = yield* f.execute({
agent: "coordinate",
description: "Fix login timeout",
prompt: "work",
worktree: {
base: "main",
branch: "fix/login-timeout",
label: "Fix login timeout",
},
});
expect(f.herdr.createdBase).toBe("main");
expect(f.herdr.createdBranch).toBe("fix/login-timeout");
expect(f.herdr.createdLabel).toBe("Fix login timeout");
expect(result.metadata?.branch).toBe("fix/login-timeout");
}),
),
);
});
test("omitted naming retains generated branch and no explicit label", async () => {
const f = fixture();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
yield* f.invoke();
expect(f.herdr.createdBranch).toMatch(/^multi-work\/[0-9a-f-]{36}$/);
expect(f.herdr.createdLabel).toBeUndefined();
}),
),
);
});
test("rejects empty naming fields and naming on resume", async () => {
const f = fixture();
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* f.install;
for (const worktree of [
{ base: "main", branch: " " },
{ base: "main", label: " " },
])
expect(
(yield* Effect.exit(
f.execute({
agent: "coordinate",
description: "invalid",
prompt: "work",
worktree,
}),
))._tag,
).toBe("Failure");
yield* f.invoke();
expect(
(yield* Effect.exit(
f.execute({
agent: "coordinate",
description: "resume",
prompt: "work",
sessionID: "ses_child1",
worktree: { base: "main", branch: "fix/other" },
}),
))._tag,
).toBe("Failure");
expect(f.herdr.creations).toBe(1);
}),
),
);
});
test("root loss, session permission denial and missing destination fail before prompting", async () => { test("root loss, session permission denial and missing destination fail before prompting", async () => {
for (const failure of ["root", "permission", "ask", "destination", "project"] as const) { for (const failure of ["root", "permission", "ask", "destination", "project"] as const) {
const f = fixture(); const f = fixture();