import { Effect } from "effect" import { autoArchiveMessage } from "../services/auto-archive" import type { BaseCommandOptions } from "../utils/command" import { TaskService } from "../services/TaskService" import { EpicService } from "../services/EpicService" import { RepositoryService } from "../services/RepositoryService" import { createLoggers } from "../utils/effect" import { parseRepositoryReferences } from "../workbase/repository-reference" import { WorkbaseService } from "../services/WorkbaseService" import { choose } from "../utils/chooser" import { formatTable } from "../utils/table" import { getWorkViews } from "../work-view" import { GraphMutationService } from "../services/GraphMutationService" import { work as startWork, type StartWork } from "./work" import { ReviewService } from "../services/ReviewService" import { buildValidationEvidence, normalizeRecalledContext, } from "../workbase/execution-contract" import { resolveBranchName } from "../workbase/branch-name-command" import { resolveDependencySelector, resolveTaskSelector, splitSelectorArgs, } from "../workbase/item-selector" interface TaskOptions extends BaseCommandOptions { readonly subcommand?: string readonly args: readonly string[] readonly ticketUrl?: string readonly description?: string readonly clearDescription?: boolean readonly clearTicket?: boolean readonly epic?: string readonly repo?: string readonly references?: readonly string[] readonly branch?: string readonly base?: string readonly clearReferences?: boolean readonly prUrl?: string readonly clearPr?: boolean readonly ifRevision?: string readonly noEpic?: boolean readonly multiPhase?: boolean readonly review?: string readonly pullRequest?: string readonly ref?: string readonly json?: boolean readonly statuses?: readonly string[] readonly repositories?: readonly string[] readonly ready?: boolean readonly blocked?: boolean readonly pr?: boolean readonly work?: boolean readonly auto?: boolean readonly noPullRequest?: boolean readonly summary?: string readonly evidenceUrl?: string readonly contextRepo?: string readonly contextBase?: string readonly contextSlug?: string readonly authoritativeSources?: readonly string[] readonly purpose?: string readonly sourcePhase?: string } export interface TaskInteraction { readonly text: (prompt: string) => Effect.Effect readonly select: ( prompt: string, choices: readonly string[], ) => Effect.Effect } const defaultInteraction = ( chooserCommand?: readonly string[], ): TaskInteraction => ({ text: (prompt) => Effect.tryPromise({ try: async () => ( await (await import("../utils/interactive-loader")).loadInteractive() ).promptText(prompt), catch: (cause) => new Error("Failed to read task input", { cause }), }), select: (prompt, choices) => choose( prompt, choices.map((choice, index) => ({ key: String(index), label: choice, value: choice, })), chooserCommand, ), }) export const task = ( options: TaskOptions, interaction?: TaskInteraction, work: StartWork = startWork, ) => Effect.gen(function* () { const tasks = yield* TaskService const epics = yield* EpicService const repositories = yield* RepositoryService const workbase = yield* WorkbaseService const mutations = yield* GraphMutationService const reviews = yield* ReviewService const { log } = createLoggers(options) const cwd = options.cwd ?? process.cwd() switch (options.subcommand) { case "new": { if (options.inputAllowed === false) { return yield* Effect.fail( new Error( "task new requires interactive input; use 'agency task create --repo ' for automation", ), ) } const activeInteraction = interaction ?? defaultInteraction( (yield* workbase.loadConfig(cwd)).config.chooserCommand, ) const id = options.args[0] ?? (yield* activeInteraction.text("Task ID: ")).trim() if (!id) { return yield* Effect.fail(new Error("Task ID is required")) } let ticketUrl = options.ticketUrl?.trim() || null let description = options.description?.trim() || undefined let epic = options.epic let multiPhase = options.multiPhase ?? false let repo = options.repo if (options.ticketUrl === undefined) { ticketUrl = (yield* activeInteraction.text("Ticket URL (optional): ")).trim() || null } if (options.description === undefined) { description = (yield* activeInteraction.text( "Description (optional): ", )).trim() || undefined } if (options.epic === undefined) { const epicRecords = yield* epics.list(cwd) if (epicRecords.length > 0) { const none = "(none)" const selected = yield* activeInteraction.select("Parent epic", [ none, ...epicRecords.map((record) => record.id), ]) if (selected === null) { return yield* Effect.fail(new Error("Task creation cancelled")) } epic = selected === none ? undefined : selected } } if (options.multiPhase === undefined) { const selected = yield* activeInteraction.select("Task type", [ "single-phase", "multi-phase", ]) if (selected === null) { return yield* Effect.fail(new Error("Task creation cancelled")) } multiPhase = selected === "multi-phase" } if (!multiPhase && !repo) { const records = yield* repositories.list(cwd) if (records.length === 0) { return yield* Effect.fail( new Error( "No repositories found; add or link a repository first", ), ) } if (records.length === 1) { repo = records[0]!.alias } else { const selected = yield* activeInteraction.select( "Writable repository", records.map((record) => record.alias), ) if (!selected) { return yield* Effect.fail(new Error("Task creation cancelled")) } repo = selected } } const base = options.base ?? "main" const branch = multiPhase || options.branch ? options.branch : yield* resolveBranchName({ id, taskId: id, ticketUrl, repo: repo!, base, defaultBranch: `task/${id}`, startPath: cwd, }) const record = yield* tasks.create( { id, ticketUrl, description, epic, multiPhase, repo, repos: parseRepositoryReferences(options.references), branch: multiPhase ? undefined : branch, base: multiPhase ? undefined : base, purpose: options.purpose as "investigation" | undefined, }, cwd, ) const { content: _, ...output } = record log( options.json ? JSON.stringify(output, null, 2) : `Created task '${record.id}'`, ) if (options.subcommand === "new" && options.work) { yield* work({ taskId: record.id, auto: options.auto, cwd, inputAllowed: options.inputAllowed, silent: options.silent, verbose: options.verbose, }) } return } case "create": { const id = options.args[0] if (!id) { return yield* Effect.fail(new Error("Task ID is required")) } const multiPhase = options.multiPhase ?? false if ( options.repo && options.contextRepo && options.repo !== options.contextRepo ) { return yield* Effect.fail( new Error( `Recalled repository '${options.contextRepo}' conflicts with --repo '${options.repo}'`, ), ) } if ( options.base && options.contextBase && options.base !== options.contextBase ) { return yield* Effect.fail( new Error( `Recalled base '${options.contextBase}' conflicts with --base '${options.base}'`, ), ) } const repo = options.repo ?? options.contextRepo const base = options.base ?? options.contextBase ?? "main" const recalledContext = yield* Effect.try({ try: () => normalizeRecalledContext({ id, repo, base: multiPhase || options.review ? undefined : base, preferredSlug: options.contextSlug, authoritativeSources: options.authoritativeSources, }), catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)), }) if (!multiPhase && !repo && !options.review) { return yield* Effect.fail( new Error("Writable repository is required for task create"), ) } const review = options.review ? yield* reviews.resolve( options.review, { pullRequest: options.pullRequest, ref: options.ref }, cwd, ) : undefined const ticketUrl = options.ticketUrl?.trim() || null const branch = multiPhase || review || options.branch ? options.branch : yield* resolveBranchName({ id, taskId: id, ticketUrl, repo: repo!, base, defaultBranch: `task/${id}`, startPath: cwd, }) const record = yield* tasks.create( { id, ticketUrl, description: options.description?.trim() || undefined, epic: options.epic, multiPhase, review, repo: review ? undefined : repo, repos: review ? undefined : parseRepositoryReferences(options.references), branch: multiPhase || review ? undefined : branch, base: multiPhase || review ? undefined : base, purpose: options.purpose as "investigation" | undefined, }, cwd, ) const validation = yield* workbase.validate(cwd) const selector = multiPhase ? `task:${record.id}` : `execution-unit:task/${record.id}` const evidence = validation.valid ? yield* buildValidationEvidence({ startPath: cwd, target: selector, documentPath: record.path, documentRevision: record.revision, recalledContext, }) : null const { content: _, ...created } = record const output = { ...created, selector, documentPath: record.path, validation, evidence, recalledContext, } log( options.json ? JSON.stringify(output, null, 2) : `Created task '${record.id}'`, ) return } case "handoff": { const { selectors: [sourceSelector], operands: [id], } = splitSelectorArgs(options.args, 1) if (!id) { return yield* Effect.fail(new Error("New task ID is required")) } if (!options.repo) { return yield* Effect.fail( new Error("Writable repository is required for task handoff"), ) } const source = yield* resolveTaskSelector(sourceSelector, cwd) const ticketUrl = options.ticketUrl?.trim() || null const base = options.base ?? "main" const branch = options.branch ?? (yield* resolveBranchName({ id, taskId: id, ticketUrl, repo: options.repo, base, defaultBranch: `task/${id}`, startPath: source.root, })) const output = yield* tasks.handoff( { sourceTaskId: source.taskId, sourcePhaseId: options.sourcePhase ?? source.phaseId, id, ticketUrl, description: options.description?.trim() || undefined, epic: options.epic, repo: options.repo, repos: parseRepositoryReferences(options.references), branch, base, }, source.root, ) log( options.json ? JSON.stringify(output, null, 2) : `Created implementation task '${id}' from '${output.source.selector}'`, ) return } case "list": { const { taskRows, tasks: taskNodes } = yield* getWorkViews({ cwd, statuses: options.statuses, repositories: options.repositories, ready: options.ready, blocked: options.blocked, pr: options.pr, }) if (options.json) { log( JSON.stringify( taskRows.flatMap((row) => { const node = taskNodes.get(row.key) if (!node?.workspace) return [] const { sha256, ...data } = node.data return [ { id: row.key, path: node.workspace.documentPath, revision: sha256, data, }, ] }), null, 2, ), ) } else { log( formatTable( [ "TASK", "PARENT", "STATUS", "READINESS", "REPOSITORIES", "BRANCH", "PR", "WORKTREE", ], taskRows.map((row) => [ row.id, row.parent, row.status, row.readiness, row.repositories, row.branch, row.pr, row.worktree, ]), ), ) } return } case "show": { const { root, taskId } = yield* resolveTaskSelector( options.args[0], cwd, ) const record = yield* tasks.show(taskId, root) const { content: _, ...output } = record log( options.json ? JSON.stringify(output, null, 2) : record.content.trimEnd(), ) return } case "status": { const { selectors: [selector], operands: [status], } = splitSelectorArgs(options.args, 1) if (!status) { return yield* Effect.fail( new Error("Usage: agency task status [task] "), ) } const { root, taskId: id } = yield* resolveTaskSelector(selector, cwd) const record = yield* tasks.setStatus( id, status, root, options.noPullRequest ? { summary: options.summary ?? "", ...(options.evidenceUrl ? { evidenceUrl: options.evidenceUrl } : {}), } : undefined, ) const { content: _, ...output } = record log( options.json ? JSON.stringify(output, null, 2) : `Marked task '${id}' as ${record.data.status}`, ) const notice = autoArchiveMessage(record.autoArchive) if (!options.json && notice) log(notice) return } case "update": { const { root, taskId: id } = yield* resolveTaskSelector( options.args[0], cwd, ) const output = yield* mutations.updateTask( id, { description: options.clearDescription ? null : options.description, ticketUrl: options.clearTicket ? null : options.ticketUrl, repo: options.repo, repos: options.clearReferences ? null : options.references === undefined ? undefined : parseRepositoryReferences(options.references), branch: options.branch, base: options.base, pr: options.clearPr ? null : options.prUrl, }, root, options.ifRevision, ) log( options.json ? JSON.stringify(output, null, 2) : `Updated task '${id}'`, ) return } case "rename": { const { selectors: [selector], operands: [newId], } = splitSelectorArgs(options.args, 1) if (!newId) { return yield* Effect.fail(new Error("New task ID is required")) } const { root, taskId: id } = yield* resolveTaskSelector(selector, cwd) const output = yield* mutations.renameTask( id, newId, root, options.ifRevision, ) log( options.json ? JSON.stringify(output, null, 2) : `Renamed task '${id}' to '${newId}'`, ) return } case "move": { const { root, taskId: id } = yield* resolveTaskSelector( options.args[0], cwd, ) const output = yield* mutations.moveTask( id, options.noEpic ? null : (options.epic ?? null), root, options.ifRevision, ) log( options.json ? JSON.stringify(output, null, 2) : options.noEpic ? `Removed task '${id}' from its epic` : `Moved task '${id}' to epic '${options.epic}'`, ) return } case "dependency": { const [operation, ...rest] = options.args const { selectors: [selector], operands: [dependency], } = splitSelectorArgs(rest, 1) if ((operation !== "add" && operation !== "remove") || !dependency) { return yield* Effect.fail( new Error( "Usage: agency task dependency [task] ", ), ) } const { root, taskId: id } = yield* resolveTaskSelector(selector, cwd) const dependencyId = yield* resolveDependencySelector( "task", dependency, cwd, ) const output = yield* mutations.mutateTaskDependency( operation, id, dependencyId, root, options.ifRevision, ) log( options.json ? JSON.stringify(output, null, 2) : `${operation === "add" ? "Added" : "Removed"} dependency '${dependencyId}' ${operation === "add" ? "to" : "from"} task '${id}'`, ) return } default: return yield* Effect.fail( new Error( "Subcommand is required. Available: new, create, handoff, list, show, status, update, rename, move, dependency", ), ) } }) export const help = ` Usage: agency task Subcommands: new [id] Create a task with guided input create Create a task without prompting handoff [source] Create implementation work from an investigation list List tasks show [task] Show a task status [task] Set open, working, dropped, or explicit non-PR done update [task] Update task metadata rename [task] Rename a task and update graph references move [task] Move a task with --epic or --no-epic dependency [task] Add or remove a task dependency A [task] selector is a task ID or a path to a task document or directory (or anything inside it). When omitted, the task containing the current directory is used. A handoff source path inside a phase also selects that source phase. Mutation option: --if-revision Require the target's current revision Non-PR completion options (status done only): --no-pull-request Complete without a pull request --summary Required durable outcome summary --evidence-url Optional supporting record Create options: --ticket-url External ticket URL (optional) --description Short description of the task --purpose investigation Generate an investigation-only task --epic Parent epic --repo Writable repository --reference : Read-only repository reference; repeatable --branch Working branch (default: configured resolver or task/) --base Base branch (default: main) --context-repo Recalled repository; must agree with --repo --context-base Recalled base; must agree with --base --context-slug Recalled preferred slug; must equal the task ID --authoritative-source Authoritative input location; repeatable --multi-phase Create a task container for phases --review Create a pinned read-only review task --pull-request Review a GitHub pull request from the alias origin --ref Review a branch or other fetchable origin ref --work Start work on the new task after creating it --auto Pass --auto to work; requires --work Handoff options: --source-phase Use a phase document as the source evidence --repo Writable implementation repository --reference, --branch, --base, --ticket-url, --description, --epic Update options: --ticket-url / --clear-ticket --description / --clear-description --repo Replace the writable repository --reference : / --clear-references --branch Replace the working branch --base Replace the base branch --pr-url / --clear-pr Task creation is noninteractive. Single-phase tasks require --repo or --context-repo; use --multi-phase instead for a task container. Guided input is available only through task new, which fails when --no-input is set or no TTY is available. Options: --json Output results as JSON --no-input Never open interactive task creation --status Filter list by status; repeatable --repository Filter list by repository; repeatable --ready Include only ready tasks --blocked Include only blocked tasks --pr / --no-pr Filter by recorded PR presence `