import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { rm, mkdir, writeFile } from "fs/promises"; import path from "path"; import { runTodoSubcommand } from "./todoCli"; const isWindows = process.platform === "win32"; const TEST_ROOT = isWindows ? path.join(process.env.TEMP || "C:\\Temp", "todocli-test-" + process.pid) : "/tmp/todocli-test-" + process.pid; function captureStdout(): { text: () => string; restore: () => void } { let buf = ""; const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { buf += String(chunk); return true; }); return { text: () => buf, restore: () => spy.mockRestore() }; } async function run(...args: string[]): Promise<{ code: number; out: string }> { const cap = captureStdout(); try { const code = await runTodoSubcommand([...args, "--root", TEST_ROOT]); return { code, out: cap.text() }; } finally { cap.restore(); } } describe("ay todo CLI", () => { beforeEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); }); afterEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); }); it("new joins ALL positional words into the summary — an unquoted multi-word summary is not silently truncated to its first word (codex-review Important)", async () => { const a = await run("new", "write", "the", "spec", "--kind", "doc"); expect(a.code).toBe(0); expect(a.out).toContain("created T1"); const got = await run("get", "T1"); expect(got.out).toContain("T1 [drafting] write the spec"); }); it("--root=val and --format=val (equals form, native to yargs) work the same as the space form; an invalid --format value fails clearly", async () => { const cap = captureStdout(); try { const code = await runTodoSubcommand(["new", "x", "--kind", "code", `--root=${TEST_ROOT}`]); expect(code).toBe(0); } finally { cap.restore(); } const cap2 = captureStdout(); try { await runTodoSubcommand(["get", "T1", "--root", TEST_ROOT, "--format=json"]); expect(JSON.parse(cap2.text())._id).toBe("T1"); } finally { cap2.restore(); } await expect( runTodoSubcommand(["get", "T1", "--root", TEST_ROOT, "--format", "yaml"]), ).rejects.toThrow(); }); it("--format/--root work regardless of position — NOT just at the end of the command (codex-review round-5 Important: a prior design broke ordinary invocations like this one)", async () => { // --format BEFORE --owner (neither is trailing) — a real, entirely // ordinary way to type this command. await run("new", "a", "--kind", "code", "--owner", "alice"); const a = await run("ls", "--format", "json", "--owner", "alice"); expect(a.code).toBe(0); expect(JSON.parse(a.out)).toHaveLength(1); // --root itself not at the end either (a verb-specific flag follows it) const cap = captureStdout(); try { const code = await runTodoSubcommand([ "ls", "--root", TEST_ROOT, "--owner", "alice", "--format", "json", ]); expect(code).toBe(0); expect(JSON.parse(cap.text())).toHaveLength(1); } finally { cap.restore(); } }); it("--root cannot be the empty string (codex-review round-4 nitpick)", async () => { await expect(runTodoSubcommand(["ls", "--root", ""])).rejects.toThrow( /--root must not be empty/, ); }); it("new creates a task with kind/tier/owner/tags/deps and rejects a missing summary", async () => { const a = await run( "new", "write the spec", "--kind", "doc", "--owner", "writer", "--tag", "proj-x", ); expect(a.code).toBe(0); expect(a.out).toContain("created T1"); expect(a.out).toContain("kind: doc"); expect(a.out).toContain("owner: writer"); const b = await run( "new", "ship it", "--kind", "code", "--tier", "shipped-done", "--dep", "T1", ); expect(b.code).toBe(0); expect(b.out).toContain("created T2"); expect(b.out).toContain("tier:shipped-done"); expect(b.out).toContain("blockedBy: T1"); await expect(run("new", "", "--kind", "doc")).rejects.toThrow(/usage: ay todo new/); await expect(run("new", "x")).rejects.toThrow(/--kind is required/); await expect(run("new", "x", "--kind", "bogus")).rejects.toThrow(/unknown kind/); }); it("ls lists and filters; get shows one task or fails cleanly on an unknown id", async () => { await run("new", "a", "--kind", "code", "--owner", "alice", "--tag", "proj-x"); await run("new", "b", "--kind", "doc"); const all = await run("ls"); expect(all.out).toContain("T1"); expect(all.out).toContain("T2"); const filtered = await run("ls", "--owner", "alice"); expect(filtered.out).toContain("T1"); expect(filtered.out).not.toContain("T2"); const got = await run("get", "T1"); expect(got.code).toBe(0); expect(got.out).toContain("T1 [doing] a"); await expect(run("get", "T99")).rejects.toThrow(/no such task/); }); it("--format json emits parseable JSON for ls/get", async () => { await run("new", "a", "--kind", "code"); const cap = captureStdout(); await runTodoSubcommand(["get", "T1", "--root", TEST_ROOT, "--format", "json"]); cap.restore(); const parsed = JSON.parse(cap.text()); expect(parsed._id).toBe("T1"); expect(parsed.kind).toBe("code"); }); it("transition moves state directly across an ungated edge and fails naming the missing gate otherwise", async () => { await run("new", "a", "--kind", "code", "--owner", "worker"); const moved = await run("transition", "T1", "merged"); expect(moved.out).toContain("transitioned T1 -> merged"); await run("transition", "T1", "shipped"); await run("transition", "T1", "verifying"); await expect(run("transition", "T1", "done")).rejects.toThrow(/requires gate "verify-green"/); }); it("approve satisfies a manual gate as a DIFFERENT validator, then transition succeeds; self-approval is refused end-to-end via the CLI", async () => { await run("new", "a", "--kind", "doc", "--owner", "worker"); await run("transition", "T1", "review"); await expect(run("approve", "T1", "human-approved", "worker")).rejects.toThrow( /independent verification required/, ); const approved = await run("approve", "T1", "human-approved", "reviewer", "--note", "lgtm"); expect(approved.out).toContain('approved "human-approved" on T1 (validator: reviewer)'); const done = await run("transition", "T1", "done"); expect(done.out).toContain("transitioned T1 -> done"); }); it("verify runs a registered gate end-to-end (only exercisable by a project that calls store.registerGate — the bare CLI has none registered, so it reports that honestly)", async () => { await run("new", "a", "--kind", "code", "--owner", "worker"); await run("transition", "T1", "merged"); await run("transition", "T1", "shipped"); await run("transition", "T1", "verifying"); await expect(run("verify", "T1")).rejects.toThrow(/no registered gate found/); }); it("block/unblock round-trip every typed shape", async () => { await run("new", "a", "--kind", "code"); const human = await run( "block", "T1", "--type", "blocked-by-human", "--who", "alice", "--question", "canary or beta?", ); expect(human.out).toContain("waiting on alice: canary or beta?"); const unblocked = await run("unblock", "T1"); expect(unblocked.out).toContain("unblocked T1"); await run("new", "b", "--kind", "code"); await run("block", "T2", "--type", "blocked-by-task", "--task", "T1"); await run("new", "c", "--kind", "code"); await run("block", "T3", "--type", "blocked-by-external", "--signal", "release-pipeline"); await run("new", "d", "--kind", "code"); const agentBlocked = await run( "block", "T4", "--type", "waiting-on-agent", "--agent", "abc123", ); expect(agentBlocked.out).toContain("waiting on agent abc123"); await expect(run("block", "T1", "--type", "blocked-by-human")).rejects.toThrow(/--who/); await expect(run("block", "T1")).rejects.toThrow(/usage: ay todo block/); }); it("block --type blocked-by-human --action-link produces an action-shaped ask (A7)", async () => { await run("new", "e", "--kind", "code"); const action = await run( "block", "T1", "--type", "blocked-by-human", "--who", "alice", "--action-link", "https://example/oauth", ); expect(action.out).toContain("alice"); expect(action.out).toContain("https://example/oauth"); const got = await run("get", "T1", "--format", "json"); expect(JSON.parse(got.out).block).toEqual({ type: "blocked-by-human", who: "alice", actionLink: "https://example/oauth", }); }); it("block --type blocked-by-human refuses --options together with --action-link (mutually exclusive ask shapes, codex-review round-9 Important)", async () => { await run("new", "f", "--kind", "code"); await expect( run( "block", "T1", "--type", "blocked-by-human", "--who", "alice", "--options", "a", "b", "--action-link", "https://example/oauth", ), ).rejects.toThrow(/mutually exclusive/); }); it("block --action-link refuses a non-http(s) URL scheme (javascript:/data: would be an XSS vector when /ask renders it into an , codex-review round-10 Important)", async () => { await run("new", "g", "--kind", "code"); await expect( run( "block", "T1", "--type", "blocked-by-human", "--who", "alice", "--action-link", "javascript:alert(1)", ), ).rejects.toThrow(/must be a valid http/); await expect( run( "block", "T1", "--type", "blocked-by-human", "--who", "alice", "--action-link", "data:text/html,x", ), ).rejects.toThrow(/must be a valid http/); const ok = await run( "block", "T1", "--type", "blocked-by-human", "--who", "alice", "--action-link", "https://example/oauth", ); expect(ok.out).toContain("https://example/oauth"); }); it("block --action-link stores the URL PARSER's normalized href, not the raw input — a value with embedded control characters that WHATWG URL parsing tolerates/strips must never persist those bytes verbatim, since describeBlock() later renders this as terminal/log text (codex-review round-16 Important)", async () => { await run("new", "h", "--kind", "code"); const withTab = await run( "block", "T1", "--type", "blocked-by-human", "--who", "alice", "--action-link", "https://example/oauth\tinjected", ); const got = JSON.parse((await run("get", "T1", "--format", "json")).out); // whatever the parser accepted, it must be exactly what's stored — never // the raw argv string re-used after validation passed on a DIFFERENT // (parser-normalized) value expect(got.block.actionLink).toBe(new URL("https://example/oauth\tinjected").href); expect(got.block.actionLink).not.toContain("\t"); expect(withTab.out).not.toContain("\t"); }); it("dep add/rm, tree, and digest render real output and surface cycles as a clean error", async () => { await run("new", "a", "--kind", "code"); await run("new", "b", "--kind", "code"); const added = await run("dep", "add", "T2", "T1"); expect(added.out).toContain("added dep T1 on T2"); await expect(run("dep", "add", "T1", "T2")).rejects.toThrow(/cycle/); const tree = await run("tree"); expect(tree.out).toContain("T2"); expect(tree.out).toContain("└─ T1"); const digest = await run("digest"); expect(digest.out).toContain("(untagged)"); const removed = await run("dep", "rm", "T2", "T1"); expect(removed.out).toContain("removed dep T1 on T2"); }); it("tree --format json emits the actual nested structure, not the human text (codex-review Important)", async () => { await run("new", "a", "--kind", "code", "--owner", "cto"); // The production default deliberately assigns new work to the calling // agent. This structure assertion is about tree shape, not caller // identity, so opt out explicitly to keep it hermetic under an ay agent. await run("new", "b", "--kind", "code", "--owner", "none"); await run("dep", "add", "T2", "T1"); const cap = captureStdout(); await runTodoSubcommand(["tree", "--root", TEST_ROOT, "--format", "json"]); cap.restore(); const parsed = JSON.parse(cap.text()); expect(parsed).toEqual([ { id: "T2", state: "doing", summary: "b", children: [{ id: "T1", state: "doing", summary: "a", owner: "cto", children: [] }], }, ]); }); it("digest --format json includes the unblocked list", async () => { await run("new", "a", "--kind", "code"); await run("transition", "T1", "merged"); // no gate registered by the bare CLI, but transition to a terminal-ish // state isn't required to prove the unblocked computation wires through — // create a second task depending on a THIRD, separately-completed one via // the human kind (reaches done with no registered gate at all) await run("new", "h", "--kind", "human"); await run("block", "T2", "--type", "blocked-by-human", "--who", "x"); await run("approve", "T2", "human-replied", "x"); await run("transition", "T2", "decided"); await run("transition", "T2", "done"); await run("new", "waits-on-h", "--kind", "code", "--dep", "T2"); const cap = captureStdout(); await runTodoSubcommand(["digest", "--root", TEST_ROOT, "--format", "json"]); cap.restore(); const parsed = JSON.parse(cap.text()); expect(parsed.unblocked).toEqual(["T3"]); }); it("dep rejects a malformed invocation (missing blockerId, or a verb that isn't add|rm) via yargs' own positional validation", async () => { await run("new", "a", "--kind", "code"); await run("new", "b", "--kind", "code"); await expect(run("dep", "add", "T2")).rejects.toThrow(/not enough non-option arguments/i); await expect(run("dep", "bogus", "T2", "T1")).rejects.toThrow(/invalid values/i); }); it("dep surfaces a non-cycle store error (unknown task id) by rethrowing it, not swallowing it as a cycle", async () => { await run("new", "a", "--kind", "code"); // T99 doesn't exist: store.addDep throws a plain Error, NOT CycleError, so // the CLI must rethrow (the catch only special-cases CycleError). await expect(run("dep", "add", "T1", "T99")).rejects.toThrow(/T99/); }); it("get renders the free-form description block when a task has one", async () => { await run("new", "a", "--kind", "doc", "--description", "the long form details"); const got = await run("get", "T1"); expect(got.out).toContain("the long form details"); }); it("an unknown verb fails with a clear error from yargs' own command-tree validation", async () => { await expect(run("bogus")).rejects.toThrow(/unknown argument: bogus/i); }); it("no verb at all prints help instead of throwing — a bare `ay todo` is a question, not a typo", async () => { const { out } = await run(); expect(out).toContain("ay todo"); // Every verb listed, so the bare invocation is genuinely discoverable. for (const verb of ["new", "ls", "claim", "reconcile", "digest"]) { expect(out).toMatch(new RegExp(`\\b${verb}\\b`)); } }); it("new --acceptance-criteria stores it, and get renders it; set-criteria updates it later (Milestone 1.5)", async () => { const created = await run( "new", "ship", "the", "feature", "--kind", "code", "--acceptance-criteria", "all tests pass and the feature flag is off by default", ); expect(created.out).toContain( "acceptanceCriteria: all tests pass and the feature flag is off by default", ); const updated = await run( "set-criteria", "T1", "all", "tests", "pass", "and", "docs", "are", "updated", ); expect(updated.out).toContain("set acceptance criteria on T1"); const got = await run("get", "T1"); expect(got.out).toContain("acceptanceCriteria: all tests pass and docs are updated"); }); it("approve() snapshots acceptanceCriteria into evidence, rendered in get's output", async () => { await run( "new", "x", "--kind", "doc", "--owner", "worker", "--acceptance-criteria", "reviewed by a human", ); await run("transition", "T1", "review"); await run("approve", "T1", "human-approved", "reviewer"); const got = await run("get", "T1"); expect(got.out).toContain("[criteria: reviewed by a human]"); }); it("--help resolves cleanly (exit 0, no throw) — a real yargs command tree auto-generates the verb listing (operator feedback); yargs' own help writer bypasses the stdout mock in this harness, so content is verified manually rather than asserted here", async () => { const cap = captureStdout(); let code: number | undefined; try { code = await runTodoSubcommand(["--help", "--root", TEST_ROOT]); } finally { cap.restore(); } expect(code).toBe(0); }); it("a per-verb --help (e.g. `ay todo ls --help`) also resolves cleanly", async () => { const cap = captureStdout(); let code: number | undefined; try { code = await runTodoSubcommand(["ls", "--help", "--root", TEST_ROOT]); } finally { cap.restore(); } expect(code).toBe(0); }); }); describe("ay todo reconcile", () => { const AGENT_HOME = isWindows ? path.join(process.env.TEMP || "C:\\Temp", "todocli-agenthome-" + process.pid) : "/tmp/todocli-agenthome-" + process.pid; let prevHome: string | undefined; beforeEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); await rm(AGENT_HOME, { recursive: true, force: true }); prevHome = process.env.AGENT_YES_HOME; process.env.AGENT_YES_HOME = AGENT_HOME; await mkdir(AGENT_HOME, { recursive: true }); }); afterEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); await rm(AGENT_HOME, { recursive: true, force: true }); if (prevHome === undefined) delete process.env.AGENT_YES_HOME; else process.env.AGENT_YES_HOME = prevHome; }); async function seedGlobalPids(records: object[]): Promise { await writeFile( path.join(AGENT_HOME, "pids.jsonl"), records.map((r) => JSON.stringify(r)).join("\n") + "\n", ); } it("orphans a task whose owner is a known, exited agent, and reports the reassignment candidates", async () => { await seedGlobalPids([ { pid: 111, cli: "claude", prompt: null, cwd: "/x", log_file: null, status: "exited", exit_code: 0, exit_reason: null, started_at: 0, agent_id: "dead-agent", }, { pid: 222, cli: "claude", prompt: null, cwd: "/x", log_file: null, status: "idle", exit_code: null, exit_reason: null, started_at: 0, agent_id: "idle-agent", }, ]); await run("new", "do the thing", "--kind", "code", "--owner", "dead-agent"); const result = await run("reconcile"); expect(result.code).toBe(0); expect(result.out).toContain("orphaned T1"); expect(result.out).toContain("idle-agent"); const got = await run("get", "T1"); expect(got.out).toContain("[orphaned]"); }); it("reports notify-unblocked on EVERY reconcile call while the task stays unblocked — there is no real delivery channel yet, so persisting 'already notified' would retire the signal with nobody ever receiving it (codex-review round-7 Important)", async () => { await seedGlobalPids([]); // `human` kind's decided->done edge is ungated, so it's the simplest way // to get a real blocker into `done` without a registered gate. await run("new", "human-blocker", "--kind", "human"); // T1 await run("approve", "T1", "human-replied", "someone"); await run("transition", "T1", "decided"); await run("transition", "T1", "done"); await run("new", "waiter", "--kind", "code", "--owner", "worker", "--dep", "T1"); // T2 const first = await run("reconcile"); expect(first.out).toContain("T2 is now unblocked"); const second = await run("reconcile"); expect(second.out).toContain("T2 is now unblocked"); // still reported, not silently retired }); }); describe("ay todo cross-agent ownership", () => { const AGENT_HOME = isWindows ? path.join(process.env.TEMP || "C:\\Temp", "todocli-xagent-" + process.pid) : "/tmp/todocli-xagent-" + process.pid; let prevHome: string | undefined; let prevPid: string | undefined; const agent = (over: object) => ({ pid: 0, cli: "claude", prompt: null, cwd: "/x", log_file: null, status: "active", exit_code: null, exit_reason: null, started_at: 0, ...over, }); async function seedGlobalPids(records: object[]): Promise { await writeFile( path.join(AGENT_HOME, "pids.jsonl"), records.map((r) => JSON.stringify(r)).join("\n") + "\n", ); } /** Make this process look like the agent registered with `wrapper_pid`. */ function beAgent(wrapperPid: number | undefined): void { if (wrapperPid === undefined) delete process.env.AGENT_YES_PID; else process.env.AGENT_YES_PID = String(wrapperPid); } beforeEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); await rm(AGENT_HOME, { recursive: true, force: true }); prevHome = process.env.AGENT_YES_HOME; prevPid = process.env.AGENT_YES_PID; process.env.AGENT_YES_HOME = AGENT_HOME; await mkdir(AGENT_HOME, { recursive: true }); await seedGlobalPids([ agent({ pid: 11, wrapper_pid: 10, agent_id: "lane-a", status: "active" }), agent({ pid: 21, wrapper_pid: 20, agent_id: "lane-b", status: "idle" }), agent({ pid: 31, wrapper_pid: 30, agent_id: "lane-dead", status: "exited", exit_code: 0 }), ]); }); afterEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); await rm(AGENT_HOME, { recursive: true, force: true }); if (prevHome === undefined) delete process.env.AGENT_YES_HOME; else process.env.AGENT_YES_HOME = prevHome; if (prevPid === undefined) delete process.env.AGENT_YES_PID; else process.env.AGENT_YES_PID = prevPid; }); it("new assigns to the calling agent by default, and --owner me resolves to the same id", async () => { beAgent(10); const created = await run("new", "mine", "--kind", "code"); expect(created.out).toContain("owner: lane-a"); const explicit = await run("new", "also mine", "--kind", "code", "--owner", "me"); expect(explicit.out).toContain("owner: lane-a"); }); it("new from a human shell stays unowned — the pre-existing behavior for anyone without an agent id", async () => { beAgent(undefined); const created = await run("new", "unowned", "--kind", "code"); expect(created.out).not.toContain("owner:"); }); it("--owner none opts out of the default assignment even inside an agent", async () => { beAgent(10); const created = await run("new", "deliberately unowned", "--kind", "code", "--owner", "none"); expect(created.out).not.toContain("owner:"); }); it("--owner me outside an agent fails loudly instead of inventing an identifier", async () => { beAgent(undefined); // Storing a hostname/pid here would look like an agent assignment while // matching nothing in the agent index — permanently outside orphan // recovery. Better to refuse. await expect(run("new", "x", "--kind", "code", "--owner", "me")).rejects.toThrow( /no registered agent id/i, ); }); it("ls --owner me filters to the calling agent's own tasks", async () => { beAgent(10); await run("new", "mine", "--kind", "code"); beAgent(20); await run("new", "theirs", "--kind", "code"); beAgent(10); const mine = await run("ls", "--owner", "me"); expect(mine.out).toContain("mine"); expect(mine.out).not.toContain("theirs"); }); it("ls annotates each owner with that agent's liveness, so a dead owner is visible without cross-referencing `ay ls`", async () => { beAgent(undefined); await run("new", "stalled", "--kind", "code", "--owner", "lane-dead"); await run("new", "human work", "--kind", "code", "--owner", "alice"); const listed = await run("ls"); expect(listed.out).toContain("lane-dead(exited)"); // A human owner has no registry entry; "unknown" there would read as // "might be dead", which is wrong — so it prints bare. expect(listed.out).toContain("alice"); expect(listed.out).not.toContain("alice("); }); it("--format json keeps `owner` verbatim and reports liveness in its own field", async () => { beAgent(undefined); await run("new", "stalled", "--kind", "code", "--owner", "lane-dead"); const listed = await run("ls", "--format", "json"); const [task] = JSON.parse(listed.out); expect(task.owner).toBe("lane-dead"); // NOT "lane-dead(exited)" — filters still work expect(task.ownerLiveness).toBe("exited"); }); it("claim takes over a task owned by an exited agent", async () => { beAgent(undefined); await run("new", "stalled", "--kind", "code", "--owner", "lane-dead"); beAgent(20); const claimed = await run("claim", "T1"); expect(claimed.out).toContain("claimed T1 → lane-b"); }); it("claim refuses to steal from a still-running agent unless forced", async () => { beAgent(10); await run("new", "in flight", "--kind", "code"); beAgent(20); await expect(run("claim", "T1")).rejects.toThrow(/owned by lane-a, an agent that is still/i); const forced = await run("claim", "T1", "--force"); expect(forced.out).toContain("claimed T1 → lane-b"); }); it("claim takes an unowned task without ceremony", async () => { beAgent(undefined); await run("new", "free", "--kind", "code", "--owner", "none"); beAgent(10); const claimed = await run("claim", "T1"); expect(claimed.out).toContain("claimed T1 → lane-a"); }); // The lost-update race itself (two claimers whose reads interleave) is // guarded inside the store and tested there — `setOwner`'s expectedOwner // check in todoStore.spec.ts — since it is not reachable through two // sequential CLI calls: the liveness refusal above fires first. it("ls surfaces BOTH parties of an ask with their liveness — asker in OWNER, answerer in WAITING-ON", async () => { beAgent(10); await run("new", "why is the build red?", "--kind", "question"); await run("block", "T1", "--type", "waiting-on-agent", "--agent", "lane-dead"); const listed = await run("ls"); expect(listed.out).toContain("WAITING-ON"); expect(listed.out).toContain("lane-a(active)"); // the asker expect(listed.out).toContain("lane-dead(exited)"); // the party that owes an answer }); it("the WAITING-ON column stays hidden when nothing is waiting on an agent", async () => { beAgent(10); await run("new", "ordinary work", "--kind", "code"); expect((await run("ls")).out).not.toContain("WAITING-ON"); }); it("--format json reports the waited-on agent's liveness in its own field, and omits it entirely when nothing is waited on", async () => { beAgent(10); await run("new", "q", "--kind", "question"); await run("new", "plain", "--kind", "code"); await run("block", "T1", "--type", "waiting-on-agent", "--agent", "lane-dead"); const [q, plain] = JSON.parse((await run("ls", "--format", "json")).out); expect(q.waitingOnLiveness).toBe("exited"); // Absent, not "unknown": a consumer must be able to tell "waiting on // nobody" apart from "waiting on someone not in the registry". expect("waitingOnLiveness" in plain).toBe(false); }); it("get annotates the block line with whether that agent is still alive", async () => { beAgent(10); await run("new", "q", "--kind", "question"); await run("block", "T1", "--type", "waiting-on-agent", "--agent", "lane-dead"); expect((await run("get", "T1")).out).toContain("[lane-dead is exited]"); }); it("claim --owner assigns to a named agent, not only to self", async () => { beAgent(10); await run("new", "delegated", "--kind", "code", "--owner", "none"); const claimed = await run("claim", "T1", "--owner", "lane-b"); expect(claimed.out).toContain("claimed T1 → lane-b"); }); });