import { Cause, Effect } from "effect" import { dirname, resolve } from "node:path" import type { BaseCommandOptions } from "../utils/command" import { WorktreeService } from "../services/WorktreeService" import { FileSystemService } from "../services/FileSystemService" import { WorkbaseService } from "../services/WorkbaseService" import { EpicService } from "../services/EpicService" import { TaskService } from "../services/TaskService" import { PhaseService } from "../services/PhaseService" import { ReadinessService } from "../services/ReadinessService" import { IntegrationService } from "../services/IntegrationService" import { RuntimeBindingService } from "../services/RuntimeBindingService" import { createLoggers } from "../utils/effect" import { execvp } from "../utils/exec" import { createProgress, type Progress } from "../utils/progress" import { isTerminalStatus } from "../readiness" import { buildWorkTargetChoices, pickWorkTarget, type PickWorkTarget, type WorkTarget, } from "../workbase/work-target" import { locateItem } from "../workbase/item-selector" import { pickWorkbase, resolveWorkbase, type PickWorkbase, } from "../workbase/workbase-choice" import { printableEnvironment, resolveAgentCommand, agentEnvironment, } from "../workbase/agent-command" import { assessValidationEvidence, buildExecutionContract, buildValidationEvidence, normalizeRecalledContext, readValidationEvidence, } from "../workbase/execution-contract" import { ValidationFailedError } from "./validate" import { openCodeDiscoversAncestorConfig, prepareOpenCodeLaunch, } from "../workbase/opencode-launch" export interface WorkOptions extends BaseCommandOptions { readonly directory?: string readonly taskId?: string readonly phaseId?: string readonly epicId?: string readonly opencode?: boolean readonly claude?: boolean readonly agent?: string readonly printCommand?: boolean readonly auto?: boolean /** Additional instructions appended to the generated --auto prompt. */ readonly instructions?: string readonly force?: boolean readonly allowWorkingDependencies?: boolean readonly evidence?: string } export type StartWork = (options: WorkOptions) => ReturnType type LaunchAgent = ( cli: string, args: readonly string[], cwd: string, environment: Readonly>, ) => void const formatCommand = (args: readonly string[]) => args .map((argument) => /^[A-Za-z0-9_./:=+@%-]+$/.test(argument) ? argument : `'${argument.replaceAll("'", `'\\''`)}'`, ) .join(" ") const launchAgent: LaunchAgent = (cli, args, cwd) => { process.chdir(cwd) execvp(cli, [...args]) } const targetNodeId = (target: WorkTarget) => { if (target.kind === "epic") return `epic:${target.epicId}` if (target.kind === "phase") { return `execution-unit:phase/${target.taskId}/${target.phaseId}` } return target.multiPhase ? `task:${target.taskId}` : `execution-unit:task/${target.taskId}` } export const work = ( options: WorkOptions = {}, launch: LaunchAgent = launchAgent, pick: PickWorkTarget = pickWorkTarget, progress: Progress = createProgress(options), pickBase: PickWorkbase = pickWorkbase, ) => Effect.gen(function* () { if (options.allowWorkingDependencies && options.force) { return yield* Effect.fail( new Error("Cannot combine --force with --allow-working-dependencies"), ) } if ( options.allowWorkingDependencies && !options.directory && !options.taskId ) { return yield* Effect.fail( new Error( "--allow-working-dependencies requires an explicit execution-unit target", ), ) } if ( (options.opencode && options.claude) || (options.agent && (options.opencode || options.claude)) ) { return yield* Effect.fail( new Error("Cannot combine --agent, --opencode, and --claude"), ) } const previousEnvironment = { ...process.env } if ( options.epicId && (options.directory || options.taskId || options.phaseId) ) { return yield* Effect.fail( new Error("Cannot combine --epic with a directory, task, or phase ID"), ) } const worktrees = yield* WorktreeService const fs = yield* FileSystemService const workbase = yield* WorkbaseService const epics = yield* EpicService const tasks = yield* TaskService const phases = yield* PhaseService const readiness = yield* ReadinessService const integrations = yield* IntegrationService const { log, verboseLog } = createLoggers(options) const cwd = options.cwd ?? process.cwd() const directoryPath = resolve(cwd, options.directory ?? ".") const isDirectory = yield* fs.isDirectory(directoryPath) const startPath = isDirectory && directoryPath ? directoryPath : cwd const inputAllowed = options.inputAllowed ?? true const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed) if (!root) return yield* integrations.syncRoot(root) const { config } = yield* workbase.loadConfig(root) const globalConfig = yield* workbase.loadGlobalConfig() let target: WorkTarget | null = null if (options.epicId) { const epic = yield* epics.show(options.epicId, root) target = { kind: "epic", epicId: epic.id, path: epic.path } } else if (options.taskId) { const task = yield* tasks.show(options.taskId, root) if (options.phaseId) { const phase = yield* phases.show(task.id, options.phaseId, root) target = { kind: "phase", taskId: task.id, phaseId: phase.id, path: phase.path, status: phase.data.status, } } else { target = { kind: "task", taskId: task.id, path: task.path, multiPhase: "phases" in task.data, status: "phases" in task.data ? undefined : task.data.status, } } } else if (options.directory && !isDirectory) { const task = yield* tasks.show(options.directory, root) target = { kind: "task", taskId: task.id, path: task.path, multiPhase: "phases" in task.data, status: "phases" in task.data ? undefined : task.data.status, } } else if (directoryPath) { const found = locateItem(root, startPath) const location = found?.archived ? null : found if (location?.kind === "epic") { const epic = yield* epics.show(location.epicId, root) target = { kind: "epic", epicId: epic.id, path: epic.path } } else if (location) { const task = yield* tasks.show(location.taskId, root) if (location.kind === "phase") { const phase = yield* phases.show(task.id, location.phaseId, root) target = { kind: "phase", taskId: task.id, phaseId: phase.id, path: phase.path, status: phase.data.status, } } else { target = { kind: "task", taskId: task.id, path: task.path, multiPhase: "phases" in task.data, status: "phases" in task.data ? undefined : task.data.status, } } } } if (!target) { if (options.allowWorkingDependencies) { return yield* Effect.fail( new Error( "--allow-working-dependencies requires an explicit execution-unit target", ), ) } if (!inputAllowed) { return yield* Effect.fail( new Error( "Work target selection requires interactive input; provide a directory, task ID, or --epic ", ), ) } const epicRecords = yield* epics.list(root) const taskRecords = yield* tasks.list(root) const phaseRecords = [] for (const task of taskRecords) { if ("phases" in task.data) { phaseRecords.push(...(yield* phases.list(task.id, root))) } } const allChoices = buildWorkTargetChoices( epicRecords, taskRecords, phaseRecords, ) const workTargetIds = options.force ? null : yield* readiness.getWorkTargetIds(root) const choices = options.force ? allChoices : allChoices.filter((choice) => workTargetIds!.has(targetNodeId(choice.target)), ) if (choices.length === 0) { return yield* Effect.fail( new Error("No ready work targets found in this workbase"), ) } target = yield* pick(choices, config.chooserCommand) if (!target) return } if ( options.allowWorkingDependencies && (target.kind === "epic" || (target.kind === "task" && target.multiPhase)) ) { return yield* Effect.fail( new Error( "--allow-working-dependencies requires an execution-unit target", ), ) } yield* readiness.guardWorkTarget( targetNodeId(target), root, options.force, { allowWorkingDependencies: options.allowWorkingDependencies, }, ) const validationAlreadyPerformed = !options.force const continuing = target.kind !== "epic" && !(target.kind === "task" && target.multiPhase) && (process.env.AGENCY_SESSION_ID !== undefined || (target.status !== undefined && target.status !== "open")) let prompt: string let launchPath: string let writablePath: string | undefined if (target.kind === "epic") { prompt = `Work on the epic. Read ${target.path}.` launchPath = dirname(target.path) } else if (target.kind === "task" && target.multiPhase) { prompt = `Work on the task. Read ${target.path}.` launchPath = dirname(target.path) } else { const taskId = target.taskId const phaseId = target.kind === "phase" ? target.phaseId : undefined progress.start("Preparing workspace...") const workspace = yield* worktrees .materialize(taskId, phaseId, root, { ...options, validationAlreadyPerformed, }) .pipe( Effect.tap(() => Effect.sync(() => progress.succeed("Workspace ready")), ), Effect.tapError(() => Effect.sync(() => progress.fail("Workspace preparation failed")), ), ) const action = continuing ? "Continue" : "Start" prompt = workspace.phasePath ? `${action} the task. Read ${workspace.taskPath} and ${workspace.phasePath}.` : `${action} the task. Read ${workspace.taskPath}.` launchPath = dirname(workspace.phasePath ?? workspace.taskPath) writablePath = workspace.writablePath ?? undefined } prompt = `Agency worker launch target: ${targetNodeId(target)}. ${prompt}` if (options.instructions?.trim()) prompt += `\n\nSupervisor instructions:\n${options.instructions.trim()}` const invocationAgent = options.agent ?? process.env.AGENCY_AGENT ?? (options.claude ? "claude" : options.opencode ? "opencode" : undefined) const selectedAgent = invocationAgent ?? globalConfig.agent const explicitlyRequested = selectedAgent !== undefined const defaultAgents = ["opencode2", "opencode", "pi", "claude"] as const let defaultAgentIndex = 0 let agent: string = selectedAgent ?? defaultAgents[defaultAgentIndex]! const sessionId = process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}` const resume = process.env.AGENCY_SESSION_ID !== undefined || (Boolean(options.auto) && continuing) const variables = { prompt: options.auto ? prompt : "", workbase: root, target: targetNodeId(target), task: target.kind === "epic" ? "" : target.taskId, phase: target.kind === "phase" ? target.phaseId : "", sessionId, } let resolved = resolveAgentCommand( agent, config.agents, variables, resume, options.auto, ) let cli = resolved.argv[0]! let available = yield* fs.runCommand(["which", cli], { captureOutput: true, }) while ( available.exitCode !== 0 && !explicitlyRequested && defaultAgentIndex < defaultAgents.length - 1 ) { agent = defaultAgents[++defaultAgentIndex]! resolved = resolveAgentCommand( agent, config.agents, variables, resume, options.auto, ) cli = resolved.argv[0]! available = yield* fs.runCommand(["which", cli], { captureOutput: true }) } if (available.exitCode !== 0) { return yield* Effect.fail(new Error(`${cli} CLI tool not found`)) } resolved = resolveAgentCommand( agent, config.agents, variables, resume, options.auto, ) cli = resolved.argv[0]! if (writablePath && !config.agents?.[agent]) { if ( agent === "pi" || ((agent === "opencode2" || agent === "opencode") && (yield* openCodeDiscoversAncestorConfig(cli))) ) { launchPath = writablePath } } const environment = { ...resolved.environment, ...agentEnvironment(agent, variables), } if (writablePath) environment.AGENCY_WRITABLE_CHECKOUT = writablePath const managedOpenCodeAuto = options.auto && (agent === "opencode" || agent === "opencode2") && !config.agents?.[agent] if (options.printCommand) { log( JSON.stringify( { cwd: launchPath, argv: resolved.argv, ...(managedOpenCodeAuto ? { startup: "OpenCode V2: submit via API, then attach with --session; V1: use argv", } : {}), environment: printableEnvironment(environment), }, null, 2, ), ) return } if (target.kind === "phase") { const previousStatus = (yield* phases.show( target.taskId, target.phaseId, root, )).data.status if (options.force && isTerminalStatus(previousStatus)) { yield* phases.setStatus(target.taskId, target.phaseId, "open", root) } yield* phases.setStatus(target.taskId, target.phaseId, "working", root) if (options.force && isTerminalStatus(previousStatus)) { const result = { target: `phase/${target.taskId}/${target.phaseId}`, reopened: true, previousStatus, status: "working", } log( options.json ? JSON.stringify(result) : `Reopened ${result.target} from ${previousStatus} as working`, ) } } else if (target.kind === "task" && !target.multiPhase) { const task = yield* tasks.show(target.taskId, root) if ("phases" in task.data) { return yield* Effect.fail( new Error(`Task '${target.taskId}' is not an execution unit`), ) } const previousStatus = task.data.status if (options.force && isTerminalStatus(previousStatus)) { yield* tasks.setStatus(target.taskId, "open", root) } yield* tasks.setStatus(target.taskId, "working", root) if (options.force && isTerminalStatus(previousStatus)) { const result = { target: `task/${target.taskId}`, reopened: true, previousStatus, status: "working", } log( options.json ? JSON.stringify(result) : `Reopened ${result.target} from ${previousStatus} as working`, ) } } const prepared = managedOpenCodeAuto ? yield* prepareOpenCodeLaunch(resolved.argv, launchPath, environment) : { argv: resolved.argv, sessionId: null } const launchArgv = prepared.argv if ( target.kind === "phase" || (target.kind === "task" && !target.multiPhase) ) { // Binding is best-effort bookkeeping: it must never fail, defect, or // noticeably delay the launch. const runtimeBindings = yield* RuntimeBindingService yield* runtimeBindings .recordLaunch({ root, unit: targetNodeId(target), agent, agentSessionId: prepared.sessionId, agencySessionId: sessionId, }) .pipe( Effect.timeout("2 seconds"), Effect.tap((binding) => Effect.sync(() => { if (binding) { verboseLog( `Recorded ${binding.runtime} binding: ${binding.unit} -> ${binding.paneId}`, ) } }), ), Effect.catchCause((cause) => Effect.sync(() => verboseLog( `Unable to record runtime binding: ${Cause.pretty(cause)}`, ), ), ), ) } for (const [key, value] of Object.entries(environment)) { process.env[key] = value } verboseLog( `Launching command: ${formatCommand(launchArgv)} (cwd: ${launchPath})`, ) try { launch(cli, launchArgv, launchPath, environment) } finally { for (const key of Object.keys(environment)) { const previous = previousEnvironment[key] if (previous === undefined) delete process.env[key] else process.env[key] = previous } } }) export const workPrepare = (options: WorkOptions = {}) => Effect.gen(function* () { if (options.allowWorkingDependencies && options.force) { return yield* Effect.fail( new Error("Cannot combine --force with --allow-working-dependencies"), ) } if ( options.allowWorkingDependencies && !options.directory && !options.taskId ) { return yield* Effect.fail( new Error( "--allow-working-dependencies requires an explicit execution-unit target", ), ) } const fs = yield* FileSystemService const workbase = yield* WorkbaseService const tasks = yield* TaskService const phases = yield* PhaseService const worktrees = yield* WorktreeService const readiness = yield* ReadinessService const { log } = createLoggers(options) const cwd = options.cwd ?? process.cwd() const targetPath = options.directory ? resolve(cwd, options.directory) : cwd const isDirectory = yield* fs.isDirectory(targetPath) const targetExists = isDirectory || (yield* fs.exists(targetPath)) const root = yield* workbase.discover(targetExists ? targetPath : cwd) let taskId = options.taskId let phaseId = options.phaseId if (taskId) { const task = yield* tasks.show(taskId, root) taskId = task.id if (phaseId) phaseId = (yield* phases.show(task.id, phaseId, root)).id } else if (options.directory && !targetExists) { const task = yield* tasks.show(options.directory, root) taskId = task.id } else { const location = locateItem(root, targetPath) if (location && !location.archived && location.kind !== "epic") { const task = yield* tasks.show(location.taskId, root) taskId = task.id if (location.kind === "phase") { const phase = yield* phases.show(task.id, location.phaseId, root) phaseId = phase.id } } } if (!taskId) { return yield* Effect.fail( new Error( "Work preparation requires a task ID or a path inside an execution unit", ), ) } const task = yield* tasks.show(taskId, root) const phase = phaseId ? yield* phases.show(taskId, phaseId, root) : undefined if ("phases" in task.data && !phase) { return yield* Effect.fail( new Error(`Task '${taskId}' has multiple phases; phase ID is required`), ) } if (!("phases" in task.data) && phaseId) { return yield* Effect.fail( new Error( `Task '${taskId}' is single-phase and does not accept a phase ID`, ), ) } const target = phase ? `execution-unit:phase/${taskId}/${phase.id}` : `execution-unit:task/${taskId}` const document = phase ?? task const suppliedEvidence = options.evidence ? yield* readValidationEvidence(options.evidence, cwd) : undefined if ( suppliedEvidence?.recalledContext.repo && (!("repo" in document.data) || suppliedEvidence.recalledContext.repo !== document.data.repo) ) { return yield* Effect.fail( new Error( "Recalled repository conflicts with the current execution unit", ), ) } if ( suppliedEvidence?.recalledContext.base && (!("base" in document.data) || suppliedEvidence.recalledContext.base !== document.data.base) ) { return yield* Effect.fail( new Error("Recalled base conflicts with the current execution unit"), ) } const assessment = yield* assessValidationEvidence({ evidence: suppliedEvidence, startPath: root, target, documentPath: document.path, documentRevision: document.revision, }) let validation: unknown = { valid: true, source: "evidence" } if (assessment.disposition.status === "refreshed") { const report = yield* workbase.validate(root) validation = report if (!report.valid && !options.force) { const details = report.issues .map((issue) => `- ${issue.path}: ${issue.message}`) .join("\n") return yield* new ValidationFailedError({ message: `Workbase validation failed with ${report.issues.length} issue${report.issues.length === 1 ? "" : "s"}:\n${details}`, root: report.root, issues: report.issues, }) } } yield* readiness.guardWorkTarget(target, root, options.force, { allowWorkingDependencies: options.allowWorkingDependencies, }) const workspace = yield* worktrees.materialize(taskId, phaseId, root, { ...options, dryRun: options.dryRun, validationAlreadyPerformed: true, }) const recalledContext = suppliedEvidence?.recalledContext ?? normalizeRecalledContext({ id: taskId, repo: "repo" in document.data ? document.data.repo : undefined, base: "base" in document.data ? document.data.base : undefined, }) const evidence = yield* buildValidationEvidence({ startPath: root, target, documentPath: document.path, documentRevision: document.revision, recalledContext, }) const result = { ...workspace, workspace, validation, validationEvidence: { ...assessment.disposition, evidence }, execution: buildExecutionContract({ workbaseRoot: root, target, taskPath: task.path, phasePath: phase?.path, checkoutPath: workspace.writablePath ?? workspace.reviewPath, documentRevision: document.revision, dryRun: options.dryRun === true, allowWorkingDependencies: options.allowWorkingDependencies, }), } if (options.json) { log(JSON.stringify(result, null, 2)) } else { log( `${workspace.dryRun ? "Workspace plan" : "Workspace ready"}: ${workspace.writablePath ?? workspace.reviewPath}`, ) } }) export const help = ` Usage: agency work [ | --epic ] [--agent ] [--auto] agency work prepare [target] [--evidence ] [--force] [--dry-run] [--json] Launch an agent for an epic, task, or phase. With no directory, infer the current item or select one interactively at the workbase root. A positional argument resolves as a directory first, then as a task ID. Outside a workbase, select a registered workbase first. Managed OpenCode launches receive whole-workbase access through Agency's project plugin; Agency context remains authoritative for writes. Automatic agent discovery checks opencode2, opencode, pi, then claude. The prepare subcommand resolves and materializes an execution workspace without launching an agent or changing lifecycle status. Its omitted target defaults to the current execution unit. --dry-run reports planned Git changes without fetching, creating branches, or creating worktrees. It emits revision-bound validation evidence and an idempotent external-orchestrator contract. Evidence is reused only while the target, workbase, configuration, and repository mapping remain unchanged. Dynamic readiness and workspace safety checks always run. With prepare, --force overrides readiness without launching or changing lifecycle status. For an explicit execution-unit target, --allow-working-dependencies admits open work blocked only by dependencies whose status is working. It does not override validation, lifecycle, or workspace safety checks and cannot be combined with --force or interactive target selection. Ready and resumable working targets keep their normal behavior. Preparation includes the opt-in in commands.work; validation evidence does not grant it to later calls. Options: --epic Work on an epic --task Work on a task --phase Work on a phase selected with --task --workbase Select a workbase by ID, name, or path --cwd Resolve context from a specific directory --agent Select a configured agent or built-in preset --auto Send the generated context prompt to the agent --instructions Append instructions to the --auto prompt --print-command Print cwd, argv, and non-secret environment without launch --opencode Require the OpenCode preset --claude Require the Claude Code preset --force Override readiness; launched terminal work is reopened --allow-working-dependencies Allow only working dependency blockers --evidence Validation evidence JSON or a path to JSON (prepare only) --no-input Never open an interactive selector Without interactive input, provide an explicit workbase or cwd and an entity selector. Workbase and target selection otherwise fail. `