// Task lifecycle commands via Local Service RPC (architecture §4 / task-api §3).
// Role claim/submit MUST go through this path — no direct Core mutation.

import type { ServiceClient } from "../service/client.js";
import { isTaskResultId } from "../core/task-model.js";
import { attachOrBootstrapService, type CliAttachOptions } from "./service-attach.js";
import { ensureMountedWorkspace } from "./workspace-context.js";

export type TaskRpcGlobalOptions = {
  workspace?: string;
  cwd?: string;
  dataDir?: string;
  attachOnly?: boolean;
  serviceEntry?: string;
  packageRoot?: string;
  json?: boolean;
  env?: NodeJS.ProcessEnv;
  /** Injected client (tests); skips attach when set. */
  client?: ServiceClient;
};

export type TaskCommandResult = {
  exitCode: number;
  stdout: string;
  stderr: string;
};

/**
 * Run a `tent task <sub>` command. Returns structured result for tests;
 * CLI main prints and sets process.exitCode.
 */
export async function runTaskCommand(
  sub: string,
  args: string[],
  globals: TaskRpcGlobalOptions = {}
): Promise<TaskCommandResult> {
  try {
    const { positionals, flags, repeatable } = parseTaskFlags(args);
    const json = globals.json === true || flags.json === "true";
    if (
      sub === "claim" &&
      (Object.prototype.hasOwnProperty.call(flags, "session") ||
        Object.prototype.hasOwnProperty.call(flags, "session-id"))
    ) {
      return failUsage(
        "tent task claim does not accept --session or --session-id; Session binding is owned by Tent host integration"
      );
    }
    if (
      sub === "accept" &&
      Object.prototype.hasOwnProperty.call(flags, "commits")
    ) {
      return failUsage(
        "tent task accept does not accept --commits; the ready TaskResult is the sole commit source"
      );
    }
    if (sub === "accept" || sub === "reject") {
      const allowed =
        sub === "accept"
          ? new Set(["actor", "by", ...TASK_COMMON_FLAGS])
          : new Set(["actor", "by", "note", "resume", "no-resume", ...TASK_COMMON_FLAGS]);
      const unknown = findUnknownFlag(flags, allowed);
      if (unknown) return failUsage(`Unknown option --${unknown} for task ${sub}`);
      if (positionals.length !== 1 || !isTaskResultId(positionals[0]!)) {
        return failUsage(
          `Usage: tent task ${sub} <resultId> --actor <user|roleId> [--workspace <path>] [--json]`
        );
      }
    }
    const workspaceFlag = flags.workspace || globals.workspace;
    const attachOpts: CliAttachOptions = {
      dataDir: flags["data-dir"] || globals.dataDir,
      attachOnly: globals.attachOnly === true || flags["attach-only"] === "true",
      serviceEntry: flags["service-entry"] || globals.serviceEntry,
      packageRoot: globals.packageRoot,
      env: globals.env,
    };

    const client =
      globals.client ??
      (
        await attachOrBootstrapService(attachOpts)
      ).client;

    const ctx = await ensureMountedWorkspace(client, {
      cwd: globals.cwd,
      workspace: workspaceFlag,
    });
    const workspaceId = ctx.workspaceId;

    switch (sub) {
      case "list": {
        if (positionals.length > 0) {
          return failUsage("Usage: tent task list [--workspace <path>] [--json]");
        }
        const result = await client.taskList(workspaceId);
        return okPrint(result, json, formatTaskList);
      }
      case "get": {
        const taskPath = positionals[0];
        if (!taskPath || positionals.length > 1) {
          return failUsage("Usage: tent task get <taskPath> [--workspace <path>] [--json]");
        }
        const result = await client.taskGet(workspaceId, taskPath);
        return okPrint(result, json, (r) => formatTaskGet(r as { task: TaskLike }));
      }
      case "package": {
        const unknown = findUnknownFlag(flags, TASK_COMMON_FLAGS);
        if (unknown) return failUsage(`Unknown option --${unknown} for task package`);
        const taskPath = positionals[0];
        if (!taskPath || positionals.length > 1) {
          return failUsage("Usage: tent task package <taskPath> [--workspace <path>] [--json]");
        }
        const result = await client.taskPackage(workspaceId, taskPath);
        return okPrint(result, json, (r) =>
          String((r as { taskPackage: string }).taskPackage)
        );
      }
      case "claim": {
        const taskPath = positionals[0];
        const hasRetiredDirectNodeFlags =
          Object.prototype.hasOwnProperty.call(flags, "work-node") ||
          Object.prototype.hasOwnProperty.call(flags, "context-node");
        const hasDirectClaimInput =
          (repeatable["node"]?.length ?? 0) > 0 ||
          Object.prototype.hasOwnProperty.call(flags, "prompt") ||
          Object.prototype.hasOwnProperty.call(flags, "from-task");
        if (hasRetiredDirectNodeFlags) {
          return failUsage(
            "tent task claim: --work-node/--context-node are retired; use --node <nodeId> ..."
          );
        }
        if (taskPath && hasDirectClaimInput) {
          return failUsage(
            "tent task claim: <taskPath> cannot be combined with --node, --prompt, or --from-task"
          );
        }
        if (taskPath) {
          if (positionals.length > 1) {
            return failUsage(
              "Usage: tent task claim <taskPath> [--workspace <path>] [--json]"
            );
          }
          const result = await client.taskClaim(workspaceId, taskPath);
          return okPrint(result, json, (r) => {
            const row = r as { taskPath: string; state?: string; sessionId?: string };
            return (
              `✓ Claimed via service RPC\n` +
              `taskPath: ${row.taskPath}\n` +
              `state: ${row.state ?? "running"}\n` +
              (row.sessionId ? `sessionId: ${row.sessionId}\n` : "")
            );
          });
        }
        if (!hasDirectClaimInput || positionals.length > 0) {
          return failUsage(
            "Usage: tent task claim [--node <nodeId> ...] --prompt <text>|- [--from-task <taskPath>] [--workspace <path>] [--json]"
          );
        }
        const rawNodes = repeatable["node"] ?? [];
        if (rawNodes.some((value) => !String(value ?? "").trim())) {
          return failUsage("tent task claim: every Node value must be a non-empty nodeId");
        }
        const nodeIds = collectTaskNodeIds(rawNodes);
        if (!Object.prototype.hasOwnProperty.call(flags, "prompt")) {
          return failUsage("tent task claim: direct Role claim requires --prompt <text> or --prompt -");
        }
        let prompt = flags.prompt ?? "";
        if (prompt === "-") prompt = await readStdinText();
        if (!prompt.trim()) {
          return failUsage("tent task claim: --prompt must be non-empty");
        }
        const env = globals.env ?? process.env;
        const roleId = String(env.TENT_ROLE_ID ?? "").trim();
        if (!/^rl-[a-z0-9]+$/i.test(roleId)) {
          return failUsage(
            "tent task claim: direct claim requires a canonical durable Role id in TENT_ROLE_ID"
          );
        }
        const sourceSessionId = String(env.TENT_SESSION_ID ?? "").trim();
        const sourceSessionToken = String(env.TENT_SESSION_TOKEN ?? "").trim();
        const nativeSessionContext =
          String(env.TENT_EXTERNAL_SESSION_KEY ?? "").trim() ||
          String(env.CODEX_THREAD_ID ?? "").trim() ||
          String(env.CLAUDE_SESSION_ID ?? "").trim();
        if ((!sourceSessionId || !sourceSessionToken) && !nativeSessionContext) {
          return failUsage(
            "tent task claim: direct claim requires the current trusted Role Session context"
          );
        }
        const sourceTaskPath = String(flags["from-task"] ?? "").trim() || undefined;
        const result = await client.taskClaimDirect(workspaceId, {
          roleId,
          nodeIds,
          prompt,
          ...(sourceTaskPath ? { sourceTaskPath } : {}),
        });
        return okPrint(result, json, (r) => {
          const row = r as { taskPath: string; state?: string; sessionId?: string };
          return (
            `✓ Created and claimed via service RPC\n` +
            `taskPath: ${row.taskPath}\n` +
            `state: ${row.state ?? "running"}\n` +
            (row.sessionId ? `sessionId: ${row.sessionId}\n` : "")
          );
        });
      }
      case "submit": {
        const taskPath = positionals[0];
        if (!taskPath) {
          return failUsage(
            "Usage: tent task submit <taskPath> --report <text>|- [--commits sha,sha] [--decision integrate|request-review] [--workspace <path>] [--json]"
          );
        }
        if (positionals.length > 1) {
          return failUsage(
            "Usage: tent task submit <taskPath> --report <text>|- [--commits sha,sha] [--decision integrate|request-review] [--workspace <path>] [--json]"
          );
        }
        if (!Object.prototype.hasOwnProperty.call(flags, "report")) {
          return failUsage("tent task submit requires --report <text> or --report -");
        }
        let report = flags.report ?? "";
        if (report === "-") report = await readStdinText();
        if (!report.trim()) {
          return failUsage("tent task submit: --report must be non-empty");
        }
        const commits = parseCommitsFlag(flags.commits);
        const result = await client.taskSubmit(workspaceId, taskPath, {
          report,
          commits,
          decision: flags.decision,
        });
        return okPrint(result, json, (r) => {
          const row = r as {
            taskPath: string;
            state?: string;
            autoIntegrated?: boolean;
            result?: { id?: string; status?: string; path?: string };
          };
          return (
            `✓ Submitted via service RPC\n` +
            `taskPath: ${row.taskPath}\n` +
            `state: ${row.state ?? "submitted"}\n` +
            (row.result?.id ? `resultId: ${row.result.id}\n` : "") +
            (row.result?.status ? `resultStatus: ${row.result.status}\n` : "") +
            (row.autoIntegrated != null ? `autoIntegrated: ${row.autoIntegrated}\n` : "")
          );
        });
      }
      case "dispatch": {
        // Public ordinary dispatch (cx-b9bf58):
        //   tent task dispatch --target role:<id>|connection:<connectionId>
        //     [--node <nodeId>…] --prompt <text>|-
        // Agent Connection ids are transient launch selectors and are never Task identity.
        const usage =
          "Usage: tent task dispatch --target role:<roleId>|connection:<connectionId> [--node <nodeId> ...] --prompt <text>|- [--workspace <path>] [--json]";

        const unknownFlag = findUnknownFlag(flags, DISPATCH_FLAGS);
        if (unknownFlag) {
          return failUsage(`Unknown option --${unknownFlag}\n${usage}`);
        }
        if (positionals.length > 0) {
          return failUsage(
            "Public ordinary dispatch no longer accepts positional <nodeId> <role> grammar; " +
              "use --target and optional --node.\n" +
              usage
          );
        }

        const targetRaw = String(flags.target ?? "").trim();
        if (!targetRaw) {
          return failUsage(`--target is required\n${usage}`);
        }
        const targetMatch = /^(role|connection):(.+)$/i.exec(targetRaw);
        if (!targetMatch) {
          return failUsage(
            `--target must be role:<roleId> or connection:<connectionId> (got ${JSON.stringify(targetRaw)})\n` +
              usage
          );
        }
        const targetKind = targetMatch[1]!.toLowerCase() as "role" | "connection";
        const targetId = targetMatch[2]!.trim();
        if (!targetId) {
          return failUsage(
            `--target ${targetKind}: requires a non-empty id\n${usage}`
          );
        }

        const rawNodes = repeatable["node"] ?? [];
        // Every Node occurrence must be non-empty; do not silently drop blanks
        // when another valid Node is present in the same batch.
        for (const value of rawNodes) {
          if (!String(value ?? "").trim()) {
            return failUsage(
              `Every Node value must be a non-empty nodeId (got empty/whitespace)\n${usage}`
            );
          }
        }
        const nodeIds = collectTaskNodeIds(rawNodes);

        if (!Object.prototype.hasOwnProperty.call(flags, "prompt")) {
          return failUsage(`--prompt is required (<text> or -)\n${usage}`);
        }
        let prompt = flags.prompt ?? "";
        if (prompt === "-") prompt = await readStdinText();
        if (!prompt.trim()) {
          return failUsage("tent task dispatch: --prompt must be non-empty");
        }

        // Caller authority comes from the verified environment/current actor.
        // Role caller → requester=that Role (downstream review).
        // User-direct → requester=user.
        // Role downstream work targets the parent Role lane; user-direct work does not.
        const envRole = String(
          (globals.env?.TENT_ROLE_ID ?? globals.env?.TENT_ROLE ?? process.env.TENT_ROLE_ID ?? process.env.TENT_ROLE ?? "") as string
        ).trim();
        const roleCaller = Boolean(envRole && envRole !== "user");
        const requester = roleCaller
          ? ({ kind: "role" as const, id: envRole })
          : ({ kind: "user" as const, id: "user" });
        // Service RPC mirrors the public model:
        // - role target: durable Role handoff, queued, never startSession
        // - connection target: reserve a temporary ACP Session from Settings,
        //   then create the Task already bound to that exact Session.
        // Exact nodeIds[] is the sole public Node selection.
        const common = {
          nodeIds,
          prompt,
          requester,
        };
        const dispatchArgs =
          targetKind === "role"
            ? {
                ...common,
                assigneeRoleId: targetId,
              }
            : {
                ...common,
                connectionId: targetId,
              };

        const result = await client.taskDispatch(workspaceId, dispatchArgs);
        return okPrint(result, json, (r) => formatTaskDispatch(r));
      }
      case "accept": {
        const unknown = findUnknownFlag(
          flags,
          new Set(["actor", "by", ...TASK_COMMON_FLAGS])
        );
        if (unknown) return failUsage(`Unknown option --${unknown} for task accept`);
        const resultId = positionals[0];
        if (!resultId || positionals.length > 1) {
          return failUsage(
            "Usage: tent task accept <resultId> --actor <user|roleId> [--workspace <path>] [--json]"
          );
        }
        const actor = flags.actor || flags.by || process.env.TENT_ROLE;
        if (!actor) return failUsage("tent task accept requires --actor <user|roleId>");
        const result = await client.taskAccept(workspaceId, resultId, actor);
        return okPrint(result, json, (r) => {
          const row = r as {
            taskPath: string;
            state?: string;
          };
          return (
            `✓ Accepted via service RPC\n` +
            `taskPath: ${row.taskPath}\n` +
            `state: ${row.state ?? "accepted"}\n`
          );
        });
      }
      case "bind-output": {
        const unknown = findUnknownFlag(
          flags,
          new Set(["actor", "by", "output-node", ...TASK_COMMON_FLAGS])
        );
        if (unknown) return failUsage(`Unknown option --${unknown} for task bind-output`);
        const resultId = positionals[0];
        if (!resultId || positionals.length > 1 || !isTaskResultId(resultId)) {
          return failUsage(
            "Usage: tent task bind-output <resultId> --output-node <nodeId> [--output-node <nodeId> ...] --actor <user|roleId> [--workspace <path>] [--json]"
          );
        }
        const outputNodeIds = collectTaskNodeIds(repeatable["output-node"]);
        if (outputNodeIds.length === 0) {
          return failUsage("tent task bind-output requires at least one --output-node <nodeId>");
        }
        const actor = flags.actor || flags.by || process.env.TENT_ROLE;
        if (!actor) return failUsage("tent task bind-output requires --actor <user|roleId>");
        const result = await client.taskBindOutput(
          workspaceId,
          resultId,
          outputNodeIds,
          actor
        );
        return okPrint(result, json, (r) => {
          const row = r as {
            taskPath: string;
            resultId: string;
            outputNodeIds: string[];
            changedNodeIds: string[];
          };
          return (
            `✓ Bound accepted Result to Output Node provenance\n` +
            `taskPath: ${row.taskPath}\n` +
            `resultId: ${row.resultId}\n` +
            `outputNodeIds: ${row.outputNodeIds.join(", ")}\n` +
            `changedNodeIds: ${row.changedNodeIds.join(", ") || "(none; already bound)"}\n`
          );
        });
      }
      case "reject": {
        const unknown = findUnknownFlag(
          flags,
          new Set(["actor", "by", "note", "resume", "no-resume", ...TASK_COMMON_FLAGS])
        );
        if (unknown) return failUsage(`Unknown option --${unknown} for task reject`);
        const resultId = positionals[0];
        if (!resultId || positionals.length > 1) {
          return failUsage(
            "Usage: tent task reject <resultId> --actor <user|roleId> [--note <text>] [--resume|--no-resume] [--workspace <path>] [--json]"
          );
        }
        const actor = flags.actor || flags.by || process.env.TENT_ROLE;
        if (!actor) return failUsage("tent task reject requires --actor <user|roleId>");
        const resume =
          flags.resume === "true" ? true : flags["no-resume"] === "true" ? false : undefined;
        const result = await client.taskReject(workspaceId, resultId, actor, {
          note: flags.note,
          resume,
        });
        return okPrint(result, json, (r) => {
          const row = r as { taskPath: string; state?: string };
          return `✓ Rejected via service RPC\ntaskPath: ${row.taskPath}\nstate: ${row.state ?? "?"}\n`;
        });
      }
      case "cancel": {
        const taskPath = positionals[0];
        if (!taskPath || positionals.length > 1) {
          return failUsage("Usage: tent task cancel <taskPath> [--workspace <path>] [--json]");
        }
        const result = await client.taskCancel(workspaceId, taskPath);
        return okPrint(result, json, (r) => {
          const row = r as { taskPath: string; state?: string };
          return `✓ Cancelled via service RPC\ntaskPath: ${row.taskPath}\nstate: ${row.state ?? "interrupted"}\n`;
        });
      }
      case "interrupt": {
        const taskPath = positionals[0];
        if (!taskPath || positionals.length > 1) {
          return failUsage("Usage: tent task interrupt <taskPath> [--workspace <path>] [--json]");
        }
        const result = await client.taskInterrupt(workspaceId, taskPath);
        return okPrint(result, json, (r) => {
          const row = r as { taskPath: string; task?: { state?: string }; state?: string };
          return (
            `✓ Interrupted via service RPC\n` +
            `taskPath: ${row.taskPath}\n` +
            `state: ${row.task?.state ?? row.state ?? "interrupted"}\n`
          );
        });
      }
      case "worktree-reclaim": {
        const action = positionals[0];
        const taskPath = positionals[1];
        const usage =
          "Usage: tent task worktree-reclaim <preview|reconcile> <taskPath> [--workspace <path>] [--json]";
        if (
          (action !== "preview" && action !== "reconcile") ||
          !taskPath ||
          positionals.length > 2
        ) {
          return failUsage(usage);
        }
        const result =
          action === "preview"
            ? await client.taskWorktreeReclaimPreview(workspaceId, taskPath)
            : await client.taskWorktreeReclaimReconcile(
                workspaceId,
                taskPath,
                String(globals.env?.TENT_ROLE ?? process.env.TENT_ROLE ?? "user").trim() ||
                  "user"
              );
        return okPrint(result, json, (r) =>
          formatTaskWorktreeReclaim(r, action)
        );
      }
      case "request-decision": {
        const unknown = findUnknownFlag(
          flags,
          new Set(["question", "options", ...TASK_COMMON_FLAGS])
        );
        if (unknown) return failUsage(`Unknown option --${unknown} for task request-decision`);
        const taskPath = positionals[0];
        if (!taskPath || positionals.length !== 1) {
          return failUsage(
            "Usage: tent task request-decision <taskPath> --question <text>|- [--options id=label,id=label] [--workspace <path>] [--json]"
          );
        }
        if (!Object.prototype.hasOwnProperty.call(flags, "question")) {
          return failUsage("tent task request-decision requires --question <text> or --question -");
        }
        let question = flags.question ?? "";
        if (question === "-") question = await readStdinText();
        if (!question.trim()) {
          return failUsage("tent task request-decision: --question must be non-empty");
        }
        const options = parseDecisionOptionsFlag(flags.options);
        const result = await client.taskRequestDecision(workspaceId, taskPath, {
          question,
          options,
        });
        return okPrint(result, json, (r) => {
          const row = r as {
            taskPath: string;
            state?: string;
            request?: { id?: string; question?: string; status?: string };
          };
          return (
            `✓ Decision Request created via service RPC\n` +
            `taskPath: ${row.taskPath}\n` +
            `state: ${row.state ?? "waiting"}\n` +
            (row.request?.id ? `requestId: ${row.request.id}\n` : "") +
            (row.request?.status ? `requestStatus: ${row.request.status}\n` : "")
          );
        });
      }
      case "decision": {
        // tent task decision list|get|respond|escalate
        const action = positionals[0];
        if (!action || action === "list") {
          const unknown = findUnknownFlag(flags, TASK_COMMON_FLAGS);
          if (unknown) return failUsage(`Unknown option --${unknown} for task decision list`);
          if (positionals.length > 1) {
            return failUsage("Usage: tent task decision list [--workspace <path>] [--json]");
          }
          const result = await client.decisionRequestListPending(workspaceId);
          return okPrint(result, json, (r) => formatDecisionRequestList(r));
        }
        if (action === "get") {
          const unknown = findUnknownFlag(flags, TASK_COMMON_FLAGS);
          if (unknown) return failUsage(`Unknown option --${unknown} for task decision get`);
          const taskPath = positionals[1];
          const requestId = positionals[2];
          if (!taskPath || !requestId || positionals.length !== 3) {
            return failUsage(
              "Usage: tent task decision get <taskPath> <requestId> [--workspace <path>] [--json]"
            );
          }
          const result = await client.decisionRequestGet(workspaceId, taskPath, requestId);
          return okPrint(result, json, (r) => formatDecisionRequestGet(r));
        }
        if (action === "respond") {
          const unknown = findUnknownFlag(
            flags,
            new Set(["option", "text", "deny", ...TASK_COMMON_FLAGS])
          );
          if (unknown) return failUsage(`Unknown option --${unknown} for task decision respond`);
          const requestId = positionals[1];
          if (!requestId || positionals.length !== 2) {
            return failUsage(
              "Usage: tent task decision respond <requestId> (--option <id> | --text <text>|- | --deny) [--workspace <path>] [--json]"
            );
          }
          let text = flags.text;
          if (text === "-") text = await readStdinText();
          const optionId = flags.option;
          const deny = flags.deny === "true" || flags.deny === "1" || flags.deny === "yes";
          const selected = Number(Boolean(optionId?.trim())) + Number(Boolean(text?.trim())) + Number(deny);
          if (selected !== 1) {
            return failUsage(
              "tent task decision respond requires exactly one of --option, --text, or --deny"
            );
          }
          const response = optionId?.trim()
            ? { kind: "option" as const, optionId: optionId.trim() }
            : text?.trim()
              ? { kind: "custom" as const, text }
              : { kind: "deny" as const };
          const result = await client.decisionRequestRespond(
            workspaceId,
            requestId,
            response
          );
          return okPrint(result, json, (r) => {
            const row = r as {
              request?: { id?: string; status?: string };
              state?: string;
              enqueued?: boolean;
            };
            return (
              `✓ Decision Request answered via service RPC\n` +
              (row.request?.id ? `requestId: ${row.request.id}\n` : "") +
              (row.request?.status ? `requestStatus: ${row.request.status}\n` : "") +
              (row.state ? `taskState: ${row.state}\n` : "") +
              (row.enqueued != null ? `enqueued: ${row.enqueued}\n` : "")
            );
          });
        }
        if (action === "escalate") {
          const unknown = findUnknownFlag(flags, TASK_COMMON_FLAGS);
          if (unknown) return failUsage(`Unknown option --${unknown} for task decision escalate`);
          const taskPath = positionals[1];
          const requestId = positionals[2];
          if (!taskPath || !requestId || positionals.length !== 3) {
            return failUsage(
              "Usage: tent task decision escalate <taskPath> <requestId> [--workspace <path>] [--json]"
            );
          }
          const result = await client.decisionRequestEscalate(
            workspaceId,
            taskPath,
            requestId
          );
          return okPrint(result, json, (r) => {
            const row = r as {
              request?: { id?: string; target?: { kind?: string; id?: string } };
            };
            return (
              `✓ Decision Request escalated to user\n` +
              (row.request?.id ? `requestId: ${row.request.id}\n` : "")
            );
          });
        }
        return failUsage(
          "Usage: tent task decision list|get|respond|escalate …\n" + taskHelpText()
        );
      }
      case "send-input":
      case "sendInput": {
        const taskPath = positionals[0];
        if (!taskPath) {
          return failUsage(
            "Usage: tent task send-input <taskPath> [--text <text>|-] [--refs id,id] [--workspace <path>] [--json]"
          );
        }
        let text = flags.text;
        if (text === "-") text = await readStdinText();
        const contextRefs = parseRefsFlag(
          flags.refs || flags["context-refs"] || flags.contextRefs
        );
        if (!(text?.trim() || (contextRefs && contextRefs.length > 0))) {
          return failUsage(
            "tent task send-input requires --text and/or --refs (stable entity ids)"
          );
        }
        const result = await client.taskSendInput(workspaceId, taskPath, {
          text,
          contextRefs,
          actor: flags.actor || "user",
        });
        return okPrint(result, json, (r) => {
          const row = r as {
            taskPath?: string;
            state?: string;
            input?: { id?: string; status?: string };
            accepted?: boolean;
            enqueued?: boolean;
            continued?: boolean;
            continueError?: string;
          };
          return (
            `✓ TaskInput accepted via service RPC\n` +
            `taskPath: ${row.taskPath ?? taskPath}\n` +
            (row.state ? `state: ${row.state}\n` : "") +
            (row.input?.id ? `inputId: ${row.input.id}\n` : "") +
            (row.input?.status ? `inputStatus: ${row.input.status}\n` : "") +
            (row.accepted != null ? `accepted: ${row.accepted}\n` : "") +
            (row.enqueued != null ? `enqueued: ${row.enqueued}\n` : "") +
            (row.continued != null ? `continued: ${row.continued}\n` : "") +
            (row.continueError ? `continueError: ${row.continueError}\n` : "")
          );
        });
      }
      case "task-input":
      case "taskInput": {
        // tent task task-input list|get|ack — always workspace-scoped via --workspace
        const action = positionals[0];
        if (!action || action === "list") {
          const taskPathFilter =
            flags.task ||
            flags["task-path"] ||
            flags.taskPath ||
            positionals[1];
          if (!taskPathFilter) {
            return failUsage(
              "Usage: tent task task-input list <taskPath> | --task <taskPath> [--workspace <path>] [--json]"
            );
          }
          const result = await client.taskInputListPending(
            workspaceId,
            taskPathFilter
          );
          return okPrint(result, json, (r) => formatTaskInputList(r));
        }
        if (action === "get") {
          const inputId = positionals[1];
          const taskPathFilter =
            flags.task || flags["task-path"] || flags.taskPath;
          if (!inputId || !taskPathFilter) {
            return failUsage(
              "Usage: tent task task-input get <inputId> --task <taskPath> [--workspace <path>] [--json]"
            );
          }
          const result = await client.taskInputGet(
            workspaceId,
            taskPathFilter,
            inputId
          );
          return okPrint(result, json, (r) => formatTaskInputGet(r));
        }
        if (action === "ack") {
          const inputId = positionals[1];
          const taskPathFilter =
            flags.task || flags["task-path"] || flags.taskPath;
          if (!inputId || !taskPathFilter) {
            return failUsage(
              "Usage: tent task task-input ack <inputId> --task <taskPath> [--actor <role|sessionId>] [--workspace <path>] [--json]"
            );
          }
          const result = await client.taskInputAck(
            workspaceId,
            taskPathFilter,
            inputId,
            flags.actor
          );
          return okPrint(result, json, (r) => {
            const row = r as {
              input?: { id?: string; status?: string; taskPath?: string };
            };
            return (
              `✓ TaskInput acked via service RPC\n` +
              (row.input?.id ? `inputId: ${row.input.id}\n` : "") +
              (row.input?.status ? `status: ${row.input.status}\n` : "") +
              (row.input?.taskPath ? `taskPath: ${row.input.taskPath}\n` : "")
            );
          });
        }
        return failUsage(
          "Usage: tent task task-input list|get|ack …\n" + taskHelpText()
        );
      }
      case "help":
      case "--help":
      case "-h":
        return { exitCode: 0, stdout: taskHelpText(), stderr: "" };
      default:
        return failUsage(
          `Unknown task subcommand: ${sub || "(empty)"}\n` + taskHelpText()
        );
    }
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return { exitCode: 1, stdout: "", stderr: message + "\n" };
  }
}

type TaskLike = {
  path?: string;
  id?: string;
  assigneeRoleId?: string;
  state?: string;
  status?: string;
  nodeIds?: string[];
  executionSessionId?: string;
  statusDetail?: {
    kind?: string;
    report?: string;
    error?: string;
    code?: string;
    at?: string;
    executionSessionId?: string;
  };
  prompt?: string;
};

/** Human-readable dispatch result; prints managed Session id/state when present. */
function formatTaskDispatch(result: unknown): string {
  const row = result as {
    taskPath: string;
    state?: string;
    relayPrompt?: string;
    requester?: { kind?: string; id?: string };
    assigneeRoleId?: string;
    executionSessionId?: string;
    session?:
      | {
          sessionId?: string;
          id?: string;
          state?: string;
          connectionId?: string;
          session?: {
            sessionId?: string;
            id?: string;
            state?: string;
            connectionId?: string;
          };
        }
      | null;
  };
  // task.dispatch with startSession nests projectStartSessionResult under `session`,
  // whose own `session` field holds sessionId/state. Accept a flat shape too.
  const nested = row.session && "session" in row.session ? row.session.session : undefined;
  const sessionView = nested ?? row.session ?? undefined;
  const sessionId =
    sessionView && (sessionView.sessionId || sessionView.id)
      ? String(sessionView.sessionId || sessionView.id)
      : undefined;
  const sessionState = sessionView?.state ? String(sessionView.state) : undefined;
  const sessionConnectionId = sessionView?.connectionId ? String(sessionView.connectionId) : undefined;
  const parentLabel =
    row.requester?.kind && row.requester?.id
      ? `${row.requester.kind}:${row.requester.id}`
      : undefined;

  return (
    `✓ Dispatched via service RPC\n` +
    `taskPath: ${row.taskPath}\n` +
    `state: ${row.state ?? "queued"}\n` +
    (row.assigneeRoleId ? `assigneeRoleId: ${row.assigneeRoleId}\n` : "") +
    (parentLabel ? `requester: ${parentLabel}\n` : "") +
    (sessionId ? `sessionId: ${sessionId}\n` : "") +
    (sessionState ? `sessionState: ${sessionState}\n` : "") +
    (sessionConnectionId ? `sessionConnectionId: ${sessionConnectionId}\n` : "") +
    (row.relayPrompt ? `\n--- Relay prompt ---\n${row.relayPrompt}` : "")
  );
}

function formatTaskList(result: unknown): string {
  const row = result as { workspaceId?: string; tasks?: TaskLike[] };
  const tasks = row.tasks ?? [];
  if (tasks.length === 0) {
    return `workspaceId: ${row.workspaceId ?? "?"}\ntasks: (none)\n`;
  }
  const lines = [`workspaceId: ${row.workspaceId ?? "?"}`, `tasks: ${tasks.length}`, ""];
  for (const t of tasks) {
    lines.push(
      `- ${t.path ?? t.id ?? "?"}` +
      `\tstate=${t.state ?? t.status ?? "?"}` +
      (t.assigneeRoleId ? `\tassigneeRole=${t.assigneeRoleId}` : "") +
      `\tnodes=${(t.nodeIds ?? []).join(",") || "-"}` +
      (t.executionSessionId ? `\texecutionSession=${t.executionSessionId}` : "") +
      (t.statusDetail?.kind ? `\treturn=${t.statusDetail.kind}` : "")
    );
  }
  return lines.join("\n") + "\n";
}

function formatTaskGet(result: { task: TaskLike }): string {
  const t = result.task;
  const lines = [
    `path: ${t.path ?? "?"}`,
    `id: ${t.id ?? "?"}`,
    `assigneeRoleId: ${t.assigneeRoleId ?? "-"}`,
    `state: ${t.state ?? t.status ?? "?"}`,
    `status: ${t.status ?? "?"}`,
    `nodeIds: ${(t.nodeIds ?? []).join(", ") || "-"}`,
  ];
  if (t.executionSessionId) lines.push(`executionSessionId: ${t.executionSessionId}`);
  if (t.statusDetail?.kind) {
    lines.push("", "--- last return ---", `kind: ${t.statusDetail.kind}`);
    if (t.statusDetail.code) lines.push(`code: ${boundedCliText(t.statusDetail.code, 160)}`);
    if (t.statusDetail.at) lines.push(`at: ${boundedCliText(t.statusDetail.at, 80)}`);
    if (t.statusDetail.executionSessionId) {
      lines.push(`executionSessionId: ${boundedCliText(t.statusDetail.executionSessionId, 80)}`);
    }
    if (t.statusDetail.report) {
      lines.push("report:", boundedCliText(t.statusDetail.report, 2000));
    }
    if (t.statusDetail.error) {
      lines.push("error:", boundedCliText(t.statusDetail.error, 2000));
    }
  }
  if (t.prompt) {
    lines.push("", "--- prompt ---", t.prompt.trimEnd());
  }
  return lines.join("\n") + "\n";
}

function boundedCliText(value: string, maxChars: number): string {
  const text = String(value).trim();
  return text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n[truncated]`;
}

function formatTaskWorktreeReclaim(
  result: unknown,
  action: "preview" | "reconcile"
): string {
  const row = result as {
    taskPath?: string;
    taskId?: string;
    code?: string;
    reason?: string;
    eligible?: boolean;
    reclaimed?: boolean;
    alreadyGone?: boolean;
    worktree?: string;
  };
  return (
    `✓ Task worktree reclaim ${action}\n` +
    `taskPath: ${row.taskPath ?? "?"}\n` +
    (row.taskId ? `taskId: ${row.taskId}\n` : "") +
    `code: ${row.code ?? "?"}\n` +
    (row.eligible != null ? `eligible: ${row.eligible}\n` : "") +
    (row.reclaimed != null ? `reclaimed: ${row.reclaimed}\n` : "") +
    (row.alreadyGone != null ? `alreadyGone: ${row.alreadyGone}\n` : "") +
    (row.worktree ? `worktree: ${row.worktree}\n` : "") +
    (row.reason ? `reason: ${row.reason}\n` : "")
  );
}

function okPrint(
  result: unknown,
  json: boolean,
  human: (r: unknown) => string
): TaskCommandResult {
  const stdout = json ? JSON.stringify(result, null, 2) + "\n" : human(result);
  return { exitCode: 0, stdout, stderr: "" };
}

function failUsage(msg: string): TaskCommandResult {
  return { exitCode: 1, stdout: "", stderr: msg + "\n" };
}

function parseCommitsFlag(raw: string | undefined): string[] | undefined {
  if (raw === undefined) return undefined;
  const commits = raw
    .split(",")
    .map((s) => s.trim())
    .filter(Boolean);
  return commits;
}

/** Parse `id=label,id=label` into Decision Request options. */
function parseDecisionOptionsFlag(
  raw: string | undefined
): Array<{ id: string; label: string }> | undefined {
  if (raw === undefined || !raw.trim()) return undefined;
  const choices: Array<{ id: string; label: string }> = [];
  for (const part of raw.split(",")) {
    const trimmed = part.trim();
    if (!trimmed) continue;
    const eq = trimmed.indexOf("=");
    if (eq <= 0) {
      throw new Error(`Invalid --options entry (expected id=label): ${trimmed}`);
    }
    const id = trimmed.slice(0, eq).trim();
    const label = trimmed.slice(eq + 1).trim();
    if (!id || !label) {
      throw new Error(`Invalid --options entry (empty id/label): ${trimmed}`);
    }
    choices.push({ id, label });
  }
  return choices.length ? choices : undefined;
}

/** Parse `id,id` into U2A contextRefs (stable entity ids). */
function parseRefsFlag(raw: string | undefined): string[] | undefined {
  if (raw === undefined || !raw.trim()) return undefined;
  const refs: string[] = [];
  const seen = new Set<string>();
  for (const part of raw.split(",")) {
    const id = part.trim();
    if (!id || seen.has(id)) continue;
    seen.add(id);
    refs.push(id);
  }
  return refs.length ? refs : undefined;
}

function formatDecisionRequestList(result: unknown): string {
  const row = result as {
    requests?: Array<{
      id?: string;
      taskPath?: string;
      question?: string;
      status?: string;
    }>;
  };
  const requests = row.requests ?? [];
  if (requests.length === 0) return "decisionRequests: (none)\n";
  const lines = [`decisionRequests: ${requests.length}`, ""];
  for (const a of requests) {
    lines.push(
      `- ${a.id ?? "?"}` +
        `\ttask=${a.taskPath ?? "?"}` +
        `\tstatus=${a.status ?? "?"}` +
        `\tq=${(a.question ?? "").slice(0, 80)}`
    );
  }
  return lines.join("\n") + "\n";
}

function formatDecisionRequestGet(result: unknown): string {
  const row = result as {
    request?: {
      id?: string;
      taskPath?: string;
      question?: string;
      status?: string;
      response?: { kind?: string; optionId?: string; text?: string };
      options?: Array<{ id: string; label: string }>;
      target?: { kind?: string; id?: string };
    };
  };
  const a = row.request ?? {};
  const lines = [
    `id: ${a.id ?? "?"}`,
    `taskPath: ${a.taskPath ?? "?"}`,
    `status: ${a.status ?? "?"}`,
    `question: ${a.question ?? ""}`,
  ];
  if (a.target) lines.push(`target: ${a.target.kind ?? "?"}:${a.target.id ?? "?"}`);
  if (a.response?.kind) lines.push(`response: ${a.response.kind}`);
  if (a.response?.optionId) lines.push(`optionId: ${a.response.optionId}`);
  if (a.response?.text) lines.push(`text: ${a.response.text}`);
  if (a.options?.length) {
    lines.push("options:");
    for (const c of a.options) lines.push(`  - ${c.id}=${c.label}`);
  }
  return lines.join("\n") + "\n";
}

function formatTaskInputList(result: unknown): string {
  const row = result as {
    inputs?: Array<{
      id?: string;
      taskPath?: string;
      status?: string;
      text?: string;
      contextRefs?: string[];
      lastError?: string;
      uncertainAt?: string;
    }>;
  };
  const inputs = row.inputs ?? [];
  if (inputs.length === 0) return "inputs: (none)\n";
  const lines = [`inputs: ${inputs.length}`, ""];
  for (const i of inputs) {
    const preview =
      (i.text ?? "").slice(0, 60) ||
      (i.contextRefs?.length ? `refs=${i.contextRefs.join(",")}` : "");
    lines.push(
      `- ${i.id ?? "?"}` +
        `\ttask=${i.taskPath ?? "?"}` +
        `\tstatus=${i.status ?? "?"}` +
        (i.uncertainAt ? `\tuncertainAt=${i.uncertainAt}` : "") +
        (i.lastError ? `\terror=${i.lastError.slice(0, 80)}` : "") +
        (preview ? `\t${preview}` : "")
    );
  }
  return lines.join("\n") + "\n";
}

function formatTaskInputGet(result: unknown): string {
  const row = result as {
    input?: {
      id?: string;
      workspaceId?: string;
      taskPath?: string;
      status?: string;
      text?: string;
      contextRefs?: string[];
      deliveredAt?: string;
      consumedAt?: string;
      cancelledAt?: string;
    };
  };
  const i = row.input ?? {};
  const lines = [
    `id: ${i.id ?? "?"}`,
    `workspaceId: ${i.workspaceId ?? "?"}`,
    `taskPath: ${i.taskPath ?? "?"}`,
    `status: ${i.status ?? "?"}`,
  ];
  if (i.text) lines.push(`text: ${i.text}`);
  if (i.contextRefs?.length) lines.push(`contextRefs: ${i.contextRefs.join(", ")}`);
  if (i.deliveredAt) lines.push(`deliveredAt: ${i.deliveredAt}`);
  if (i.consumedAt) lines.push(`consumedAt: ${i.consumedAt}`);
  if (i.cancelledAt) lines.push(`cancelledAt: ${i.cancelledAt}`);
  return lines.join("\n") + "\n";
}

const BOOLEAN_FLAGS = new Set([
  "json",
  "attach-only",
  "resume",
  "no-resume",
  "yes",
  "as-sub",
  "deny",
]);

/** Flags that may appear more than once (values collected in order). */
const REPEATABLE_FLAGS = new Set(["node", "output-node"]);

const DISPATCH_FLAGS = new Set([
  "target",
  "node",
  "prompt",
  "workspace",
  "json",
  "data-dir",
  "attach-only",
  "service-entry",
]);

const TASK_COMMON_FLAGS = new Set([
  "workspace",
  "json",
  "data-dir",
  "attach-only",
  "service-entry",
]);

function findUnknownFlag(flags: Record<string, string>, allowed: ReadonlySet<string>): string | null {
  for (const name of Object.keys(flags)) {
    if (!allowed.has(name)) return name;
  }
  return null;
}

/** Collect repeated --node values in exact CLI order; Core remains the duplicate authority. */
function collectTaskNodeIds(raw: string[] | undefined): string[] {
  const nodes: string[] = [];
  for (const value of raw ?? []) {
    const id = String(value ?? "").trim();
    if (!id) continue;
    nodes.push(id);
  }
  return nodes;
}

export function parseTaskFlags(args: string[]): {
  positionals: string[];
  flags: Record<string, string>;
  /** Multi-value flags (e.g. repeated --node). */
  repeatable: Record<string, string[]>;
} {
  const positionals: string[] = [];
  const flags: Record<string, string> = {};
  const repeatable: Record<string, string[]> = {};
  for (let i = 0; i < args.length; i++) {
    const a = args[i];
    if (a.startsWith("--")) {
      const name = a.slice(2);
      if (BOOLEAN_FLAGS.has(name)) {
        flags[name] = "true";
      } else if (REPEATABLE_FLAGS.has(name)) {
        const value = args[i + 1] ?? "";
        i++;
        if (!repeatable[name]) repeatable[name] = [];
        repeatable[name]!.push(value);
        // Last occurrence also lands in flags for simple presence checks.
        flags[name] = value;
      } else {
        flags[name] = args[i + 1] ?? "";
        i++;
      }
    } else {
      positionals.push(a);
    }
  }
  return { positionals, flags, repeatable };
}

function readStdinText(): Promise<string> {
  return new Promise((resolve, reject) => {
    let data = "";
    process.stdin.setEncoding("utf8");
    process.stdin.on("data", (chunk) => (data += chunk));
    process.stdin.on("end", () => resolve(data));
    process.stdin.on("error", reject);
  });
}

export function taskHelpText(): string {
  return `tent task — collaboration lifecycle via Local Service (RPC)

New-architecture path: attach → mount workspace → task.* RPC.
Local Service is the sole mutation entry; CLI does not kill the service on exit.

Commands:
  tent task list [--workspace <path>] [--json]
  tent task get <taskPath> [--workspace <path>] [--json]
  tent task package <taskPath> [--workspace <path>] [--json]
  tent task claim <taskPath> [--workspace <path>] [--json]
  tent task claim [--node <nodeId> ...] --prompt <text>|- [--from-task <taskPath>] [--workspace <path>] [--json]
      # direct Role execution: create + claim atomically; no --target and no downstream dispatch
      # requires canonical TENT_ROLE_ID plus the current trusted Role Session capability
      # Service derives requester/review authority from durable facts
  tent task submit <taskPath> --report <text>|- [--commits sha,sha] [--decision integrate|request-review] [--workspace <path>] [--json]
  tent task dispatch --target role:<roleId>|connection:<connectionId> [--node <nodeId> ...] --prompt <text>|- [--workspace <path>] [--json]
      # --target role:*  durable Role handoff (queued; never starts managed ACP at dispatch)
      # --target connection:* machine Settings Connection + exact managed Session
      # --node           repeatable ordered root Nodes; omit for a prompt-only Task
      # requester derives from the durable Role or local user boundary
      # Any flag outside this command's canonical grammar is rejected
  tent task accept <resultId> --actor <user|roleId> [--workspace <path>] [--json]
  tent task bind-output <resultId> --output-node <nodeId> [--output-node <nodeId> ...] --actor <user|roleId> [--workspace <path>] [--json]
  tent task reject <resultId> --actor <user|roleId> [--note <text>] [--resume|--no-resume] [--workspace <path>] [--json]
  tent task cancel <taskPath> [--workspace <path>] [--json]
  tent task interrupt <taskPath> [--workspace <path>] [--json]
  tent task worktree-reclaim preview <taskPath> [--workspace <path>] [--json]
  tent task worktree-reclaim reconcile <taskPath> [--workspace <path>] [--json]
  tent task request-decision <taskPath> --question <text>|- [--options id=label,…] [--workspace <path>] [--json]
  tent task decision list|get|respond|escalate […] [--workspace <path>] [--json]
  tent task send-input <taskPath> [--text <text>|-] [--refs id,id] [--workspace <path>] [--json]
  tent task task-input list <taskPath>|get <inputId>|ack <inputId> --task <taskPath> [--actor <role|sessionId>] [--workspace <path>] [--json]

Service options:
  --data-dir <path>       Machine-local service data area (default: %APPDATA%/Tent)
  --attach-only           Fail if no healthy service (do not bootstrap)
  --service-entry <path>  Path to service.mjs when bootstrapping

Task mutations are Local Service RPC only. Formal result is TaskResult-only
via tent task submit (no direct-core compatibility path).
Derived role-init remains available because it regenerates bootstrap context only.
`;
}
