// These imports are NOT unused. Effect declarations come with imports,
// and without them the `raise std::bash(...)` and `raise std::git::*(...)`
// sites below compile with their payload contracts UNENFORCED. Measured:
// dropping them makes `raise std::git::status("...", { wrongField: x })`
// typecheck clean. `std::write` is covered by the auto-imported prelude.
import { gitStatus } from "std::git"
import { WriteMode } from "std::index"
import { bash } from "std::shell"

import {
  Effect,
  Plan,
  runBash,
  runWrite,
  truncate,
 } from "./safeBash/actions.agency"
import { _bashParser, _astToBash } from "agency-lang/stdlib-lib/safeBash.js"

export { MAX_STDOUT_LEN } from "./safeBash/actions.agency"

export type Word =
  | LiteralWord
  | PathWord
  | FlagWord
  | SingleQuotedWord
  | DoubleQuotedWord
  | VariableWord
  | InterpolatedVariableWord

export type ScriptName = LiteralWord | PathWord

export type LiteralWord = {
  tag: "literal";
  text: string
}

export type PathWord = {
  tag: "path";
  text: string
}

export type FlagWord = {
  tag: "flag";
  flagName: string;
  flagValue?: string
}

export type SingleQuotedWord = {
  tag: "singleQuoted";
  text: string
}

export type DoubleQuotedWord = {
  tag: "doubleQuoted";
  parts: Word[]
}

export type VariableWord = {
  tag: "variable";
  name: string
}

/** A word built from two or more adjacent parts: `$HOME.txt`, `"a"b`,
 * `"$HOME"/x`. These are ONE word in bash; split into separate words they
 * become separate arguments and the command means something else.
 */
export type InterpolatedVariableWord = {
  tag: "interpolatedVariable";
  parts: (
  | LiteralWord
  | VariableWord
  | SingleQuotedWord
  | DoubleQuotedWord)[]
}

/** `name=value` (or `name=` with a null value) before a command. */
export type Assignment = {
  tag: "assignment";
  name: string;
  value?: Word
}

/** A redirect like `> out.txt`, `>> log`, `2> err.txt` or `< in.txt`.
 * `fd` is the explicit file descriptor (`2` in `2>`), or undefined for the
 * default. Only `>`, `>>`, `<` and `&>` are recognized; `2>&1`, heredocs
 * and here-strings are rejected rather than parsed. */
export type Redirect = {
  tag: "redirect";
  fd?: number;
  op: string;
  target: Word
}

export type SimpleCommand = {
  tag: "simpleCommand";
  assignments: Assignment[];
  /* The command name, or null for an assignment-only line (`FOO=bar`).
   *  Bash requires at least one of a command name or an assignment. */
  command?: ScriptName;
  /** Every word after the command name, in source order. There is no
   *  `subcommands` field: no syntactic rule separates `git status` from
   *  `echo status`, so splitting them would put a command's real
   *  arguments in whichever bucket the preceding word happened to pick. */
  args: Word[];
  redirects: Redirect[]
}

export type Command = SimpleCommand | And | Or | Parens

export type And = {
  tag: "and";
  left: Command;
  right: Command
}

export type Or = {
  tag: "or";
  left: Command;
  right: Command
}

export type Parens = {
  tag: "parens";
  command: Command
}

export type BashNode =
  | Command
  | SimpleCommand
  | Assignment
  | Redirect
  | Word
  | ScriptName

export type BashAST = Command[]

def literalArgs(command: SimpleCommand): Result<string[]> {
  """
  A command's non-flag arguments as text, but only when every one is a
  plain literal.

  Flags are SKIPPED, not rejected: they are collected separately by
  `flagNames`, and rejecting them here would make `git diff --staged`
  unrecognized before any rule saw it.
  """
  let out: string[] = []
  for (arg in command.args) {
    if (arg.tag == "flag") {
      continue
    }
    if (arg.tag != "literal") {
      return failure("`${arg.tag}` is not a literal argument")
    }
    out.push(arg.text)
  }
  return success(out)
}

def flagNames(command: SimpleCommand): string[] {
  """
  Every flag on the command, in source form (`--staged`, `-n`).
  """
  let out: string[] = []
  for (arg in command.args) {
    if (arg.tag == "flag") {
      out.push(renderFlag(arg))
    }
  }
  return out
}

def redirectEffect(redirects: Redirect[], cwd: string): Result<Effect[]> {
  """
  What a command's redirects contribute, independently of its verb.

  A redirect writes a file whatever the command is, so it has to be
  accounted for on its own. Without this, `echo pwned >> .bashrc; git
  status` would classify as a git read and ride along under whatever
  approves git reads.

  Anything other than a plain `>` or `>>` to a literal target is a
  failure, which sends the whole string to `std::bash`. An input redirect
  is a read the effect set would otherwise never mention, and a variable
  target cannot fill the payload without expanding it ourselves.
  """
  if (redirects.length == 0) {
    return success([])
  }
  if (redirects.length > 1) {
    return failure("more than one redirect")
  }
  const only = redirects[0]
  if (only.fd != null) {
    return failure("redirect with an explicit file descriptor")
  }
  if (only.op != ">" && only.op != ">>") {
    return failure("`${only.op}` is not an output redirect")
  }
  const target = literalTarget(only.target)
  if (target is failure(why)) {
    return failure(why)
  }
  const mode = writeMode(only.op)
  return success(
    [
    {
    name: "std::write",
    payload: {
      dir: cwd,
      filename: target.value,
      content: "",
      mode: mode
    }
  },
  ],
  )
}

def literalTarget(word: Word): Result<string> {
  """
  A redirect target's text, when the target is fully literal.

  A variable target cannot fill the `std::write` payload without expanding
  it ourselves, which is the one thing this module never does. A quoted
  target with no variable in it is fine — `> "my file.txt"` names a real
  file — so this is the same question `echoWords` asks of an argument.
  """
  return literalWordText(word)
}

def writeMode(op: string): WriteMode {
  """
  `>` truncates, `>>` appends.

  The return type is narrowed rather than `string` so an invalid mode
  cannot reach the `std::write` payload or the `_write` call.
  """
  return match(op) {
    ">>" => "append"
    _ => "overwrite"
  }
}

export def effectsFor(command: SimpleCommand, cwd: string): Result<Effect[]> {
  """
  Which interrupts this one command needs raised.

  A failure means the command is not recognized. The caller turns that
  into a `std::bash` question for the whole string rather than trying to
  ask a narrow question about part of it.

  @param command - One simple command from the parsed AST
  @param cwd - The resolved working directory, for payloads that need it
  """
  const name = command.command
  if (name == null) {
    return failure("an assignment with no command")
  }
  // A path word is never a recognized command: `/bin/git` must not become
  // a git read. The wall may look at path words, because a wall match can
  // only ever cause a refusal.
  if (name.tag != "literal") {
    return failure("the command word is not a literal")
  }
  // `GIT_DIR=/elsewhere git status` reads a DIFFERENT repository than the
  // payload would claim. Rather than model what each assignment means,
  // any command carrying one is unrecognized.
  if (command.assignments.length > 0) {
    return failure("a leading assignment changes what the command does")
  }
  const flags = flagNames(command)
  const redirect = redirectEffect(command.redirects, cwd)
  if (redirect is failure(why)) {
    return failure(why)
  }

  // `echo` contributes nothing on its own, whatever its arguments: bash
  // runs it and expands anything in it, so `echo $HOME` and `echo -n hi`
  // are both fine on this path. The flag and literal-argument restrictions
  // belong to the shell-free path, which computes the output itself. Only
  // the git arm reads the arguments as text — a quoted `echo "hi"` must
  // not be demoted by a check that only git needs.
  return match(name.text) {
    "echo" => success(redirect.value)
    "git" => {
      // Both links can fail, which is what earns the pipe. Appending the
      // redirect contribution cannot, so it stays inline rather than
      // becoming a third link that pretends it could.
      // Result patterns rather than an `is failure` guard: flow narrowing
      // does not reach inside a match arm block, so `git.value` after a
      // guard fails to typecheck here even though the identical shape
      // typechecks at function scope. The patterns bind the value
      // directly and need no narrowing.
      const git: Result<Effect[]> = literalArgs(command) |> gitEffects.partial(flags: flags, cwd: cwd)
      return match(git) {
        success(effects) => success([...effects, ...redirect.value])
        failure(why) => failure(why)
      }
    }
    _ => failure("no rule for `${name.text}`")
  }
}

def gitEffects(args: string[], flags: string[], cwd: string): Result<Effect[]> {
  """
  The four git reads we recognize, one arm per rule, guarded by the flags
  each rule tolerates.

  An unrecognized flag is a failure rather than something to ignore.
  Answering `git log --graph` with a plain `std::git::log` would tell the
  model it asked one question when it asked another.
  """
  if (args.length != 1) {
    return failure("only a bare git subcommand is recognized")
  }
  const sub = args[0]
  return match(sub) {
    "status" if (flags.length == 0) => success(
      [
      {
      name: "std::git::status",
      payload: {
        cwd: cwd
      }
    },
    ],
    )
    "log" if (flags.length == 0) => success(
      [
      {
      name: "std::git::log",
      payload: {
        cwd: cwd,
        ref: "",
        path: ""
      }
    },
    ],
    )
    "diff" if (flags.length == 0) => success(
      [
      {
      name: "std::git::diff",
      payload: {
        cwd: cwd,
        ref: "",
        ref2: "",
        staged: false,
        path: ""
      }
    },
    ],
    )
    "diff" if (flags.length == 1 && flags[0] == "--staged") => success(
      [
      {
      name: "std::git::diff",
      payload: {
        cwd: cwd,
        ref: "",
        ref2: "",
        staged: true,
        path: ""
      }
    },
    ],
    )
    _ => failure("no rule for `git ${sub}`")
  }
}

static const BASH_EFFECT: Effect = {
  name: "std::bash"
}

def refusePlan(reason: string): Plan {
  return {
    effects: [],
    execution: {
      kind: "refuse",
      reason: reason
    }
  }
}

def bashPlan(command: string, effects: Effect[]): Plan {
  // Spawning a shell always raises at least one effect, so a log of
  // effects is a complete audit of shell invocations. A string of nothing
  // but echoes would otherwise raise nothing and still start a shell.
  const raised = if effects.length == 0 then [BASH_EFFECT] else effects
  return {
    effects: raised,
    execution: {
      kind: "bash",
      command: command
    }
  }
}

def flattenCommands(commands: Command[]): SimpleCommand[] {
  """
  Every simple command in the string, including the halves of a chain.

  Used for BOTH the refuse wall and classification, because a wall that
  only looks at the top level is bypassed by two characters
  (`true && rm -rf /`), and a classifier that only looks at the top level
  demotes every chained invocation to the broad question.
  """
  let out: SimpleCommand[] = []
  for (command in commands) {
    out = [...out, ...flattenOne(command)]
  }
  return out
}

def flattenOne(command: Command): SimpleCommand[] {
  // The simple case is an `if` rather than a match arm because match arms
  // do not narrow the scrutinee — `[command]` only types as
  // SimpleCommand[] under `if` narrowing. The match over what remains is
  // exhaustive with no catch-all, so a fifth command shape becomes a type
  // error here instead of silently flattening to nothing — and this feeds
  // the refuse wall, where silently-empty is the one result that must
  // never happen.
  if (command.tag == "simpleCommand") {
    return [command]
  }
  return match(command) {
    { tag: "and", left, right } => [...flattenOne(left), ...flattenOne(right)]
    { tag: "or", left, right } => [...flattenOne(left), ...flattenOne(right)]
    { tag: "parens", command as inner } => flattenOne(inner)
  }
}

def rebuild(commands: Command[]): Result<string> {
  """
  The command string, rendered back from the tree we classified.

  Used only when every command was recognized. We claimed the string is
  `git status`, so we must run what we classified rather than text we only
  assumed matched it. A parser bug then degrades to running what we
  thought we read.
  """
  let parts: string[] = []
  for (command in commands) {
    const text = try _astToBash(command)
    if (text is failure(why)) {
      return failure("could not render `${why}`")
    }
    parts.push(text.value)
  }
  return success(parts.join("; "))
}

def trySeeIfShellFree(command: SimpleCommand, cwd: string): Result<Plan> {
  """
  A shell-free path applies only to a single `echo`, and only when the
  wall has nothing to say about it.
  """
  if (isRefused(command)) {
    return failure("refused")
  }
  const name = command.command
  if (name == null) {
    return failure("no command")
  }
  if (name.tag != "literal") {
    return failure("the command word is not a literal")
  }
  if (name.text != "echo") {
    return failure("not echo")
  }
  if (command.assignments.length > 0) {
    return failure("a leading assignment changes what the command does")
  }
  return shellFreePlan(command, cwd)
}

def echoWords(command: SimpleCommand): Result<string[]> {
  """
  `echo`'s arguments as text, but only when every one is fully literal.

  A plain word, a path, or a single-quoted string qualifies. A variable
  does not, and neither does a double-quoted string containing one: this
  path never invokes a shell, so there is nothing here to expand a
  variable, and expanding it ourselves is where v2's divergence bugs
  lived.
  """
  let out: string[] = []
  for (arg in command.args) {
    const text = literalWordText(arg)
    if (text is failure(why)) {
      return failure(why)
    }
    out.push(text.value)
  }
  return success(out)
}

def literalWordText(word: Word): Result<string> {
  return match(word) {
    { tag: "literal", text } => success(text)
    { tag: "path", text } => success(text)
    { tag: "singleQuoted", text } => success(text)
    { tag: "doubleQuoted", parts } => literalParts(parts)
    _ => failure("`${word.tag}` is not fully literal")
  }
}

def literalParts(parts: Word[]): Result<string> {
  """
  The text of a double-quoted word, when it contains no expansions.
  """
  let out = ""
  for (part in parts) {
    if (part.tag != "literal") {
      return failure("a double-quoted word contains `${part.tag}`")
    }
    out = out + part.text
  }
  return success(out)
}

def echoOutput(words: string[]): string {
  """
  What `echo` prints: its arguments joined by single spaces, then a
  newline. Bare `echo` is still a newline, not nothing.
  """
  return words.join(" ") + "\n"
}

def echoPlan(command: SimpleCommand): Result<Plan> {
  """
  A single `echo` with nothing else in the string and no redirect.

  Nothing here can write a file or reach the network no matter how wrong
  the classifier is, because all it does is build a string. That is the
  one place v2's structural safety was free.
  """
  if (command.redirects.length > 0) {
    return failure("a redirected echo is handled elsewhere")
  }
  if (flagNames(command).length > 0) {
    return failure("`echo` with flags prints differently")
  }
  const words = echoWords(command)
  if (words is failure(why)) {
    return failure(why)
  }
  return success(
    {
    effects: [],
    execution: {
      kind: "echo",
      content: echoOutput(words.value)
    }
  },
  )
}

def writePlan(command: SimpleCommand, cwd: string): Result<Plan> {
  """
  A single `echo` redirected to a literal file.

  The content is computed rather than produced by running anything, so no
  shell is spawned and the bytes are in the approval payload before anyone
  approves. If the equivalence claim is good enough to say what `echo`
  prints, it is good enough to say what `echo` writes.
  """
  if (command.redirects.length != 1) {
    return failure("not a single redirect")
  }
  if (flagNames(command).length > 0) {
    return failure("`echo` with flags prints differently")
  }
  const only = command.redirects[0]
  if (only.fd != null) {
    return failure("redirect with an explicit file descriptor")
  }
  if (only.op != ">" && only.op != ">>") {
    return failure("`${only.op}` is not an output redirect")
  }
  const target = literalTarget(only.target)
  if (target is failure(why)) {
    return failure(why)
  }
  const words = echoWords(command)
  if (words is failure(why)) {
    return failure(why)
  }
  const content = echoOutput(words.value)
  const mode = writeMode(only.op)
  const effect: Effect = {
    name: "std::write",
    payload: {
      dir: cwd,
      filename: target.value,
      content: content,
      mode: mode
    }
  }
  return success(
    {
    effects: [effect],
    execution: {
      kind: "write",
      filename: target.value,
      dir: cwd,
      content: content,
      mode: mode
    }
  },
  )
}

def shellFreePlan(command: SimpleCommand, cwd: string): Result<Plan> {
  """
  The two paths that never invoke a shell, tried in order.
  """
  const bare = echoPlan(command)
  if (bare is success(plan)) {
    return bare
  }
  return writePlan(command, cwd)
}

export def planFor(source: string, cwd: string): Plan {
  """
  Decide everything about a call before any of it happens.

  Parses the string, classifies every command, and works out which
  interrupts to raise and what to run. Nothing here has an effect, which
  is what makes the decision testable: hand in a string, look at the plan.

  @param source - One or more bash commands
  @param cwd - The resolved working directory
  """
  const parsed = bashParser(source)
  if (parsed is failure(why)) {
    // Unparseable: nothing was understood, so ask the broad question and
    // hand bash the text the human will have seen.
    return bashPlan(source, [])
  }
  const commands: Command[] = parsed.value

  if (commands.length == 1) {
    const only = commands[0]
    if (only.tag == "simpleCommand") {
      const shellFree = trySeeIfShellFree(only, cwd)
      if (shellFree is success(plan)) {
        return plan
      }
    }
  }

  // Flatten first. A chain hides its halves inside `and`/`or`/`parens`
  // nodes, and `true && rm -rf /` has no top-level simple command at all —
  // without this walk the wall never sees the `rm`.
  const simple = flattenCommands(commands)

  let narrow: Effect[] = []
  let allRecognized = true
  for (command in simple) {
    if (isRefused(command)) {
      const name = command.command
      if (name == null) {
        return refusePlan("this command is not allowed")
      }
      return refusePlan("`${name.text}` is not allowed")
    }
    const effects = effectsFor(command, cwd)
    if (effects is failure(why)) {
      allRecognized = false
    } else {
      narrow = [...narrow, ...effects.value]
    }
  }

  if (!allRecognized) {
    // We have to ask the broad question anyway, so asking narrow ones
    // first adds prompts without adding information. The ORIGINAL text
    // runs, because that is what the human read.
    return bashPlan(source, [BASH_EFFECT])
  }
  if (narrow.length == 0) {
    // Everything recognized but nothing contributed an effect — a string
    // of nothing but echoes. The audit rule makes this a std::bash
    // question, so by the same rule it runs the original text.
    return bashPlan(source, [BASH_EFFECT])
  }
  // Narrow questions were asked, so run the tree we classified.
  const rebuilt = rebuild(commands)
  if (rebuilt is failure(why)) {
    // We cannot produce the text we classified, so we must not ask narrow
    // questions about it. Doing this AFTER raising would break the first
    // hard rule.
    return bashPlan(source, [BASH_EFFECT])
  }
  return bashPlan(rebuilt.value, narrow)
}

/** Commands we decline to run even when a human approves. */
static const REFUSED_COMMANDS: string[] = ["rm", "rmdir", "dd", "shred", "mkfs", "truncate"]

def baseName(text: string): string {
  """
  The last part of a path. `/bin/rm` is `rm`.
  """
  const parts = text.split("/")
  return parts[parts.length - 1]
}

export def isRefused(command: SimpleCommand): boolean {
  """
  True when this command must not run, whatever anyone approves.

  Matches the command word, including the last part of a path, so `rm`,
  `/bin/rm` and `./rm` are all refused.

  This wall is friction against the obvious spelling, not a guarantee.
  `find . -delete` and `xargs rm` get past it and are approvable under
  `std::bash`, which is where the actual control lives.
  """
  const name = command.command
  if (name == null) {
    return false
  }
  const word = baseName(name.text)
  if (REFUSED_COMMANDS.includes(word)) {
    return true
  }
  if (word != "git") {
    return false
  }
  return isDestructiveGit(command)
}

def isDestructiveGit(command: SimpleCommand): boolean {
  """
  The git subcommands that throw work away.

  Only the FIRST argument is the subcommand. `git log reset` is a log, not
  a reset, and matching a subcommand name anywhere in the arguments would
  refuse it.

  `clean` and `restore` are destructive by purpose. `reset` and `checkout`
  are only destructive in particular spellings, and refusing the whole
  subcommand would refuse `git checkout main`, which agents do constantly.
  """
  if (command.args.length == 0) {
    return false
  }
  const first = command.args[0]
  if (first.tag != "literal") {
    return false
  }
  // `git checkout .` discards working-tree changes; `git checkout main`
  // does not. `git checkout -- .` is NOT handled here, and cannot be: the
  // parser rejects a bare `--`, so that string never reaches
  // classification at all. It falls through as unparseable and is
  // approvable under `std::bash`.
  return match(first.text) {
    "clean" => true
    "restore" => true
    "reset" => hasFlag(command, "--hard")
    "checkout" => hasLiteralArg(command, ".")
    _ => false
  }
}

def hasFlag(command: SimpleCommand, name: string): boolean {
  for (arg in command.args) {
    if (arg.tag == "flag" && arg.flagName == name) {
      return true
    }
  }
  return false
}

def hasLiteralArg(command: SimpleCommand, text: string): boolean {
  for (arg in command.args) {
    if (arg.tag == "literal" && arg.text == text) {
      return true
    }
  }
  return false
}

export def bashParser(code: string): Result<BashAST> {
  """
  Parse a string of bash code into an AST.
  """
  const res = _bashParser(code)
  return match(res.success) {
    true => success(res.result)
    false => failure(res.message)
  }
}

def renderFlag(flag: FlagWord): string {
  """
  A flag back in the form it was written: `-n`, `--staged`, `--format=x`.

  `flagName` already carries its dashes, so this only has to put a
  `--name=value` flag back together.
  """
  if (flag.flagValue == null) {
    return flag.flagName
  }
  return "${flag.flagName}=${flag.flagValue}"
}

export def resolveCwd(cwd: string): string {
  """
  Which directory a command runs in: the caller's, or the agent's when the
  caller did not say.

  Resolved once, at the top, and then carried on every action. An action
  that did not carry it would run wherever the process happened to be,
  which is the same command doing two different things.
  """
  if (cwd != "") {
    return cwd
  }
  const agent = getAgentCwd()
  if (agent != "") {
    return agent
  }
  // `write` rejects an empty dir, and `getAgentCwd()` is empty whenever
  // nothing set one — which is the case the tests run in.
  return "."
}

export def safeBash(
  command: string,
  cwd: string = "",
): Result<string> raises <std::bash, std::write, std::git::status, std::git::diff, std::git::log> {
  """
  Run a shell command, asking the narrowest question that describes it.

  `bash` cannot be pre-approved, because a command is a string and a
  string could do anything, so every call needs a human. Many of the
  commands an agent writes can be identified, and for those we ask a more
  specific question — one a policy can answer in advance.

  Nothing runs until every question is answered. If everything is
  approved, the whole string goes to bash in one call, so bash does the
  control flow and produces the output.

  @param command - One or more bash commands
  @param cwd - Working directory. Defaults to the agent working directory.
  """
  const dir = resolveCwd(cwd)
  const plan = planFor(command, dir)
  const exec = plan.execution

  // A refusal returns before anything is raised: there is nothing to
  // approve. It still has an arm in the match below, because the match is
  // exhaustive over Execution.
  if (exec is { kind: "refuse", reason }) {
    return failure(reason)
  }

  const raised = raiseAll(plan.effects, command, dir)
  if (raised is failure(why)) {
    return failure(why)
  }

  // The write case is an `if` rather than a match arm for the same
  // reason as `flattenOne`: match arms do not narrow the scrutinee, and
  // rebuilding a WriteExec from bound fields would silently drop any
  // field the type gains later. `if` narrowing passes the value through
  // whole.
  if (exec.kind == "write") {
    return runWrite(exec)
  }

  // Echo output is truncated like every other path: a long echo returning
  // in full while a long `ls` came back capped would be exactly the
  // distinguishability this design exists to remove. For bash, `planFor`
  // already decided which text to run — rebuilt when it asked narrow
  // questions, original when it asked the broad one. There is no fallback
  // here: choosing the original AFTER raising narrow effects would break
  // the design's first hard rule.
  return match(exec) {
    { kind: "echo", content } => success(truncate(content))
    { kind: "bash", command as text } => runBash(text, dir)
    // Unreachable — the early return above already handled refusal. The
    // arm exists only to keep the match exhaustive over Execution.
    { kind: "refuse", reason } => failure(reason)
  }
}

def raiseAll(effects: Effect[], fullCommand: string, cwd: string): Result {
  """
  Ask every question the plan needs answered, before anything happens.
  """
  for (effect in effects) {
    const answered = raiseOne(effect, fullCommand, cwd)
    if (answered is failure(reason)) {
      return failure(reason)
    }
  }
  return success(null)
}

def raiseOne(effect: Effect, fullCommand: string, cwd: string): Result {
  """
  Raise one effect.

  An effect is raised at a named site, not by name-as-data, so this is a
  closed dispatch with one arm per effect. That is also why safeBash
  declares them all in its `raises` clause: it is how an agent author
  discovers which handlers to write.

  The match is on `effect.name`, not on `effect` with object patterns:
  matching a member path narrows its siblings per arm, so `effect.payload`
  has the right variant type in each body — the same idiom handlers use
  on `data.effect`. It is exhaustive with no catch-all, so a new Effect
  variant without a raise site is a type error here, not a runtime
  failure.

  Every payload carries the full command string. A human approving a git
  read inside `echo hi; git status` needs to see the whole thing, not the
  fragment.
  """
  return match(effect.name) {
    "std::bash" => {
      raise std::bash("Are you sure you want to run this shell command?", {
        command: fullCommand,
        cwd: cwd,
        timeout: 0,
        stdin: ""
      })
      return success(null)
    }
    "std::write" => {
      raise std::write("Are you sure you want to write to this file?", {
        dir: effect.payload.dir,
        filename: effect.payload.filename,
        content: effect.payload.content,
        mode: effect.payload.mode,
        command: fullCommand
      })
      return success(null)
    }
    "std::git::status" => {
      raise std::git::status("Show git status", {
        cwd: effect.payload.cwd,
        command: fullCommand
      })
      return success(null)
    }
    "std::git::log" => {
      raise std::git::log("Show git log", {
        cwd: effect.payload.cwd,
        ref: effect.payload.ref,
        path: effect.payload.path,
        command: fullCommand
      })
      return success(null)
    }
    "std::git::diff" => {
      raise std::git::diff("Show git diff", {
        cwd: effect.payload.cwd,
        ref: effect.payload.ref,
        ref2: effect.payload.ref2,
        staged: effect.payload.staged,
        path: effect.payload.path,
        command: fullCommand
      })
      return success(null)
    }
  }
  return success(null)
}
