import { withLock } from "std::concurrency"
import { keys } from "std::object"
import { dirname, basename } from "std::path"
import { exists } from "std::shell"
import { highlight, diff } from "std::syntax"
import { ChoiceItem } from "std::ui/cli"
import { interruptBodyWidth, interruptChoice } from "std::ui/cli"
import { render, text } from "std::ui/layout"
import { table } from "std::ui/table"

import {
  _checkPolicy,
  _validatePolicy,
  _preparePolicyWrite,
  _writePreparedPolicy,
  _policyFileDir,
  _builtinPolicy,
  _builtinPolicyNames,
  _BUILTIN_POLICIES,
  _minimalAutoApprovePolicy,
  _recommendedAutoApprovePolicy,
  _withWritesPolicy,
  _approveAllPolicy,
  _escapeGlob,
  _alwaysScopeFor,
  _allAlwaysScopes,
  _missingPolicyRules,
  _appendPolicyRules,
 } from "agency-lang/stdlib-lib/policy.js"
import { describeProblem } from "agency-lang/stdlib-lib/workflow/index.js"


/** @module
@summary Match interrupts against saved approval rules.

```ts
import { checkPolicy } from "std::policy"

const policy = {
  "std::read": [{ action: "approve" }],
  "std::write": [{ action: "reject", rejectMessage: "Leave files unchanged." }]
}

node main() {
  handle {
    return read("README.md")
  } with (intr) {
    return checkPolicy(policy, intr)
  }
}
```

Rules match an effect and optional glob patterns over its data. The first
matching rule decides. If no effect-specific rule matches, `checkPolicy`
tries the `"*"` rules, then returns `propagate()` to request a user decision.
Other handlers can still reject an approved interrupt.

Use `cliPolicyHandler` for a terminal prompt that remembers "always"
answers in a JSON policy file. Use the rule-building functions below when
your application provides its own approval UI.
*/

/** Key of an interrupt's `data` object (e.g. `"dir"`, `"command"`). */
export type InterruptDataKey = string

/**
 Glob pattern used to match an interrupt-data value. Patterns are
 picomatch globs. They support `*`, `**`, and brace-expansion like
 `{a,b}` for unions. A literal string with no glob metacharacters
 matches only that exact value.
 */
export type InterruptDataVal = string

/**
 Identifier for an interrupt's effect (e.g. `"std::read"`,
 `"myapp::deploy"`).
 */
export type InterruptEffect = string

/**
 One row of a `Policy`. A rule passes if every key in `match` is
 present in the interrupt's `data` and its value matches the glob
 pattern. Omit `match` (or set it to `{}`) for a catch-all that
 applies to every interrupt of the parent effect.

 A reject rule may carry a `rejectMessage`: the message the rejection
 hands back to whoever raised the interrupt. For a rejected tool call
 this is what the model reads, so it can steer the next attempt — for
 example, a rule rejecting `std::bash` with
 `rejectMessage: "Use safeBash instead"`.
 */
export type PolicyRule = {
  match?: Record<InterruptDataKey, InterruptDataVal>;
  action: "approve" | "reject" | "propagate";
  rejectMessage?: string
}

/**
 Ordered rules per effect, with optional `"*"` fallback rules.
`checkPolicy` returns the first matching decision, or `propagate()`
when no rule matches. Propagation requests a user decision unless a
handler rejects the interrupt.
 */
export type Policy = Record<InterruptEffect, PolicyRule[]>

/** The minimal built-in policy used by `agency run --policy minimal`. */
export static const minimalAutoApprovePolicy = _minimalAutoApprovePolicy
/** The recommended policy, with scoped file reads and service reads. */
export static const recommendedAutoApprovePolicy = _recommendedAutoApprovePolicy
/** Approve every effect; value-seeking interrupts still need an answer in the CLI handler. */
export static const approveAllPolicy = _approveAllPolicy
/** Built-in policy names and their descriptions, shared with the CLI. */
export static const BUILTIN_POLICIES = _BUILTIN_POLICIES

export def withWritesPolicy(baseDir: string) {
  """
  Return the recommended policy with file and Git writes scoped to a directory.
  """
  return _withWritesPolicy(baseDir)
}

export def builtinPolicy(name: string, baseDir: string): Policy | null {
  """
  Resolve a built-in policy name, or return null for an unknown name.

  @param baseDir - Directory scope for the with-writes policy.
  """
  return _builtinPolicy(name, baseDir)
}

export def builtinPolicyNames(): string[] {
  """
  List the policy names accepted by builtinPolicy and the CLI.
  """
  return _builtinPolicyNames()
}

/**
CLI approval choices. "Always" choices save an effect-wide rule;
"approve-always-here" saves a rule scoped to selected interrupt data.
Questions that need a value offer one-time choices only.
*/
export type Decision =
  | "approve"
  | "reject"
  | "approve-always"
  | "approve-always-here"
  | "reject-always"

/**
 One column in a `ScopedRuleFields` entry — names an interrupt-data
 field that the "approve-always-here" rule should pin.

 - `field` — the key in `intr.data` to pin (e.g. `"dir"`).
 - `matchSubpaths` — when `true`, brace-expand the value so the
   rule matches both the exact value AND any nested path under it.
   Pass `true` for directory-like fields (so approving `/tmp/x`
   also approves `/tmp/x/sub/file.txt`). Pass `false` for opaque
   identifiers (commands, IDs, env names) that shouldn't fan out.
 */
export type ScopedField = {
  field: string;
  matchSubpaths: boolean
}

/**
 Per-effect override consumed by `buildScopedMatch` and the
 `cliPolicyHandler`. Maps an interrupt effect to the fields its
 "approve-always-here" rule should pin. Effects not present in this
 map fall back to the scope their `effect` declaration carries
 (`@always` / `@alwaysUnder`); an empty list turns the (ap) option
 off for that effect, so the user falls back to (a) / (r) / (aa) / (rr).

 Example:
 ```ts
 const FIELDS: ScopedRuleFields = {
   "std::read":  [{ field: "dir", matchSubpaths: true }],
   "std::exec":  [
     { field: "command",    matchSubpaths: false },
     { field: "subcommand", matchSubpaths: false },
   ],
 }
 ```
 */
export type ScopedRuleFields = Record<InterruptEffect, ScopedField[]>

type CliPolicyOpts = {
  file: string;
  fields: ScopedRuleFields
}

// Internal state for `cliPolicyHandler`. Singleton. Calling
// `cliPolicyHandler` more than once silently overwrites these.
let globalCliPolicyOpts: CliPolicyOpts = {
  file: "",
  fields: {}
}
let _policy: Policy | null = null
let _policyLoaded: boolean = false
let _pendingSave: boolean = false
// Whether a user is at a terminal to answer prompts. When false, an
// interrupt the policy does not decide is rejected with a reason instead
// of prompting (there is no one to answer, and a pending prompt starves
// the event loop until the process dies).
let _interactive: boolean = true
// Name the handler's active operation on its own policy file, a load
// (std::read) or a save (std::write), so `isOwnPolicyIo` can approve it.
let _internalIo: string = ""
// The real directory of the policy file while `_internalIo` is set.
let _internalIoDir: string = ""
// Whether the last save of pending "always" answers failed, so the warning
// prints once and not on every retry.
let _saveFailing: boolean = false

/**
```ts
handle {
  return read("README.md")
} with (intr) {
  return checkPolicy(policy, intr)
}
```

Return this decision directly from a custom handler. A rejection includes
the rule's `rejectMessage`. No matching rule returns `propagate()`, which
requests a user decision even if another handler approves.
Use `cliPolicyHandler` when you also want terminal prompts and saved rules.
*/
export def checkPolicy(
  policy: Record<string, any>,
  interrupt: Record<string, any>,
) {
  """
  Find the first matching approval rule for an interrupt.
  Return approve, reject, or propagate when no rule matches.
  """
  return _checkPolicy(policy, interrupt)
}

/**
Check user-supplied or hand-edited policy data before using it.
`setPolicy` and `addPolicyRules` also validate before saving.
*/
export def validatePolicy(policy: Record<string, any>): Result<void> {
  """
  Validate a policy object, returning a failure that describes invalid rules.
  """
  return _validatePolicy(policy)
}

export def alwaysScopeFor(effect: string): ScopedField[] {
  """
  List the fields declared by an effect's @always and @alwaysUnder tags.
  Return an empty list when the effect declares no scope.
  """
  return _alwaysScopeFor(effect)
}

export def defaultScopedFields(): ScopedRuleFields {
  """
  Return the declared approval scopes for all effects, keyed by effect name.
  """
  return _allAlwaysScopes()
}

// The one resolution rule: an explicit entry wins (an empty list
// suppresses the option); otherwise the effect's declared scope.
def scopedFieldsFor(effect: string, fields: ScopedRuleFields): ScopedField[] {
  // Own keys only: `effect` is a user-controlled string, and a plain `{}`
  // override must not resolve `constructor` or `toString` from the prototype.
  if (keys(fields).includes(effect)) {
    return fields[effect]
  }
  return _alwaysScopeFor(effect)
}

/**
Use this to preview a scoped rule in a custom approval UI:

```ts
const match = buildScopedMatch(intr)
```

Directory fields can match the selected directory and its subpaths; other
fields match their escaped literal values. Overrides in `fields` replace
declared scopes.

An absent scope or missing scoped value returns `{}`. Check for an empty
result before making a rule: an empty `match` applies to every interrupt
of the effect. `recordScopedRule` performs that check for you.
*/
export def buildScopedMatch(
  intr: Record<string, any>,
  fields: ScopedRuleFields = {},
): Record<string, string> {
  """
  Build glob patterns that pin an interrupt's scoped field values.
  Return {} if there is no scope or a scoped value is missing.

  @param fields - Per-effect scope overrides, with unlisted effects using their declared scopes.
  """
  const specs = scopedFieldsFor(intr.effect, fields)
  let match: Record<string, string> = {}
  for (spec in specs) {
    const value = intr.data[spec.field]
    if (value == null) {
      return {}
    }
    const literal = _escapeGlob("${value}")
    if (spec.matchSubpaths) {
      match[spec.field] = "{${literal},${literal}/**}"
    } else {
      match[spec.field] = literal
    }
  }
  return match
}

/**
```ts
let policy = recordRule({}, "std::read", "reject")
policy = recordRule(policy, "std::read", "approve")
```

The first rule still rejects: evaluation uses the first match, and this
function appends rules. Replace or clear an existing rule when changing
a previous decision. The input policy remains unchanged.
*/
export def recordRule(
  policy: Policy,
  effect: InterruptEffect,
  action: "approve" | "reject",
): Policy {
  """
  Append an effect-wide approval or rejection rule and return a new policy.
  """
  const existing = policy[effect] || []
  const next: Policy = {
    ...policy,
    [effect]: [...existing, {
      action: action
    }]
  }
  return next
}

/**
Use this for an "approve always here" choice. It prepends an approval,
so a matching scoped rule takes precedence over existing catch-all rules.
The input policy remains unchanged. If no scope can be built, it returns
the input policy without adding a rule.
*/
export def recordScopedRule(
  policy: Policy,
  intr: Record<string, any>,
  fields: ScopedRuleFields = {},
): Policy {
  """
  Prepend an approval rule scoped to an interrupt's field values.
  Return the policy unchanged when there is no scope to pin.

  @param fields - Per-effect scope overrides, with unlisted effects using their declared scopes.
  """
  const match = buildScopedMatch(intr, fields)
  if (keys(match).length == 0) {
    return policy
  }
  const existing = policy[intr.effect] || []
  const next: Policy = {
    ...policy,
    [intr.effect]: [{
      match: match,
      action: "approve"
    }, ...existing]
  }
  return next
}

export type ParsePolicyFailureStatus =
  | "doesnt-exist"
  | "read-error"
  | "malformed-json"
  | "policy-not-valid"

export type ParsePolicyFailure = {
  status: ParsePolicyFailureStatus
}

/**
Use the failure's `data.status` to distinguish a missing file, a failed
read, malformed JSON, and an invalid policy. The function prints no warnings.
Its inner read uses `with approve`; an outer handler can still reject it.
*/
export def parsePolicyFile(path: string): Result<Policy, ParsePolicyFailure> {
  """
  Read a JSON policy file and validate its rules, returning a failure on error.
  """
  const dir = dirname(path)
  const name = basename(path)
  if (!exists(name, dir)) {
    return failure("No policy file at ${path}.", { status: "doesnt-exist" })
  }
  const readResult = read(name, dir) with approve
  if (isFailure(readResult)) {
    return failure(
      "Could not read the policy file at ${path}: ${readResult.error}",
      { status: "read-error" }
    )
  }
  const parsed = try JSON.parse(readResult.value)
  if (isFailure(parsed)) {
    return failure(
      "The policy file at ${path} is not valid JSON.",
      { status: "malformed-json" }
    )
  }
  const valid = validatePolicy(parsed.value)
  if (!valid.success) {
    return failure(
      "The policy file at ${path} does not match the policy schema: ${valid.error}",
      { status: "policy-not-valid" }
    )
  }
  return parsed
}

/**
Save a policy to `path`, then make it the CLI handler's active policy.
The save raises `std::write`, so the caller's handlers decide whether it
happens. If the save fails or is rejected, the active policy is unchanged.
Later saves of "always" answers still go to the file passed to
`cliPolicyHandler`.
*/
export def setPolicy(path: string, policy: Policy): Result raises <std::write> {
  """
  Save a policy file and install it for the CLI approval handler.
  """
  const written = writePolicyFile(path, policy, [])
  if (written is success) {
    _policy = policy
    _policyLoaded = true
  }
  return written
}

// Validate a policy, then save it as JSON after a std::write interrupt that
// names the real file and carries the full new contents. A policy file
// decides which actions run without asking, so every save goes through the
// caller's handlers like any other write. The handler's own saves mark
// `_internalIo` so `isOwnPolicyIo` can approve them.
@capabilities(fileRead, fileWrite)
def writePolicyFile(
  path: string,
  policy: Policy,
  allowedPaths: string[] = [],
): Result {
  const prepared = try _preparePolicyWrite(path, policy, allowedPaths)
  if (isFailure(prepared)) {
    return prepared
  }
  return interrupt std::write("Are you sure you want to save this approval policy? It decides which actions run without asking you.", {
    dir: prepared.value.dir,
    filename: prepared.value.filename,
    content: prepared.value.content,
    mode: "overwrite"
  })
  destructive {
    return try _writePreparedPolicy(prepared.value)
  }
}

// Mark the start of the handler's own operation on its policy file. The
// directory is the real one, spelled the way read and write payloads spell
// it. If it cannot be resolved, nothing matches and the policy decides.
@capabilities(fileRead)
def beginOwnIo(effect: string) {
  const dir = try _policyFileDir(globalCliPolicyOpts.file)
  _internalIoDir = dir catch ""
  _internalIo = effect
}

def endOwnIo() {
  _internalIo = ""
  _internalIoDir = ""
}

// A handler skips its own raises, but a second registration of the same
// handler shares these globals and can hear the policy-file read. A save
// also reaches every other handler in the chain, which can include a round
// trip to a parent process. Other branches can raise interrupts during
// that time, so match the operation, the file name, and the real directory
// before approving. See "The handler's own file operations" in
// docs/dev/agents/approval-policies.md.
def isOwnPolicyIo(intr: any): boolean {
  if (_internalIo == "" || intr.effect != _internalIo) {
    return false
  }
  if (intr.data == null) {
    return false
  }
  return intr.data.filename == basename(globalCliPolicyOpts.file) && intr.data.dir == _internalIoDir
}

// Lazy-load the on-disk policy on first handler invocation. Uses
// flip-flag-first so the std::read interrupt's chain re-entry sees
// `_policyLoaded == true` and returns without recursing. Missing or
// invalid policy files start from an empty policy. Direct callers can
// inspect `parsePolicyFile` for the exact failure status.
def maybeLoadPolicy() {
  if (!_policyLoaded) {
    _policyLoaded = true
    beginOwnIo("std::read")
    const loaded = parsePolicyFile(globalCliPolicyOpts.file) with approve
    endOwnIo()
    if (isFailure(loaded)) {
      // A missing file is a normal first run. Anything else means the
      // user's saved decisions are NOT in effect — say so, or a
      // non-interactive run rejects everything with no visible cause.
      if (loaded.data.status != "doesnt-exist") {
        print("Warning: ${loaded.error} Starting from an empty policy.")
      }
      _policy = {}
    } else {
      _policy = loaded.value
    }
  }
}

// Persist any pending policy changes. Same flip-flag-first pattern
// as `maybeLoad`. Runs at the top of every handler invocation so a
// decision recorded on turn N is on disk before turn N+1's first
// interrupt fires. Decisions on the FINAL interrupt of a session
// may not flush. `flushPolicy()` is the escape hatch for that.
//
// The save raises std::write, so another handler in the chain can refuse
// it. A refused or failed save keeps the answers pending, so the next
// interrupt tries again, and prints a warning once.
def maybeFlush() {
  if (_pendingSave) {
    // Clear before writing so re-entry cannot repeat the flush.
    _pendingSave = false
    // `_pendingSave` is only set after a decision is recorded, which also
    // sets `_policy` — but if that invariant ever breaks, skip the write
    // rather than clobber the policy file with `null`.
    if (_policy != null) {
      beginOwnIo("std::write")
      const written = writePolicyFile(globalCliPolicyOpts.file, _policy, []) with approve
      endOwnIo()
      if (written is failure(why)) {
        _pendingSave = true
        if (!_saveFailing) {
          print("Warning: could not save your new approval rules to ${globalCliPolicyOpts.file}: ${why}. They apply for the rest of this session, and the next prompt tries to save them again.")
        }
        _saveFailing = true
      } else {
        _saveFailing = false
      }
    }
  }
}

/**
Use this to offer updates when a saved policy lacks rules added to a newer
built-in policy. Effects already covered by a catch-all are left out.
Pass accepted additions to `addPolicyRules`.
*/
@capabilities(fileRead)
export def missingPolicyRules(base: Policy): Policy {
  """
  Find rules in a base policy that the CLI handler's active policy lacks.

  @param base - Policy to compare against, such as recommendedAutoApprovePolicy.
  """
  maybeLoadPolicy()
  return _missingPolicyRules(_policy ?? {}, base)
}

/**
Append accepted additions without overriding earlier decisions. If saving
fails, the active policy stays unchanged.
*/
@capabilities(fileRead, fileWrite)
export def addPolicyRules(additions: Policy) raises <std::read, std::write> {
  """
  Append rules to the CLI handler's active policy and save it immediately.

  @param additions - Rules to add, such as the result of missingPolicyRules.
  """
  maybeLoadPolicy()
  const merged = _appendPolicyRules(_policy ?? {}, additions)
  beginOwnIo("std::write")
  const written = writePolicyFile(globalCliPolicyOpts.file, merged, []) with approve
  endOwnIo()
  // Only a saved rule takes effect. Otherwise the session would approve
  // under rules the user was just told could not be saved.
  if (written is success) {
    _policy = merged
  }
  return written
}

/**
Call between user turns or before process exit to persist the last "always"
decision. Automatic flushing happens at the next interrupt, so the final
decision can otherwise remain unsaved. Does nothing without pending changes.
If a handler refuses the save, the answers stay pending and a warning prints.
*/
export def flushPolicy() raises <std::write> {
  """Write any pending always-rule additions to the policy file now."""
  maybeFlush()
}

// Human-readable summary of the match `buildScopedMatch` would
// produce, shown next to the (ap) prompt so the user sees the
// exact rule they're agreeing to before they say yes.
def describeScopedMatch(intr: any): string {
  const match = buildScopedMatch(intr, globalCliPolicyOpts.fields)
  const ks = keys(match)
  if (ks.length == 0) {
    return "(no scoped fields)"
  }
  let parts: string[] = []
  for (k in ks) {
    parts.push("${k}=${match[k]}")
  }
  return parts.join(", ")
}

/**
 Result returned by `askUser`. When the user picks a known key,
 `action` is the corresponding `Decision` and `reason` is null.
 When `allowFreeText` lets the user type a free-form rejection
 reason, `action` is `"reject"` and `reason` is the typed text.
 The handler uses it as the reject reason directly, skipping the
 follow-up "Why are you rejecting?" prompt.
 */
type AskUserResult = {
  action: Decision;
  reason?: string;
  /** Set when a rule decided this interrupt while it waited for the
    terminal, so no one was asked. Holds that rule's decision. */
  rule?: any
}

// Text a model wrote, on its way to a terminal the user is reading in
// order to decide whether to approve it. Three groups come out:
//
//   - Control characters. An escape sequence moves the cursor or clears
//     the screen; C1 codes do the same on terminals that decode them.
//   - A lone carriage return, which returns the cursor to the start of
//     the line so that whatever follows overwrites what came before it.
//     `diff` splits on newlines, so a CR stays inside a rendered line.
//     CRLF is normalized to LF first, leaving a Windows file readable.
//   - The bidi overrides and zero-width characters behind trojan source,
//     which make code read as something other than what it runs as.
//
// Tabs and newlines stay, because they are part of the code.
def stripControlChars(text: string): string {
  const unixEndings = (text || "").replace(re/\r\n/g, "\n")
  const noControls = unixEndings.replace(re/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "")
  return noControls.replace(re/[\u061c\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "")
}

// A change to code, as a header line and a syntax-highlighted unified
// diff. `before` is "" for something being created, which reads as a
// diff of all insertions. Pass `ignoreWhitespace: false` for data, where
// a space inside a string is a real change: `"annual  report.txt"` and
// `"annual report.txt"` name different files.
def renderSourceDiff(
  header: string,
  before: string,
  after: string,
  language: string,
  ignoreWhitespace: boolean = true,
): string {
  let lines = []
  lines.push(color.yellow("⏺ ${stripControlChars(header)}"))
  lines.push(
    diff(
    stripControlChars(before),
    stripControlChars(after),
    language: language,
    color: true,
    lineNumbers: true,
    context: 3,
    ignoreWhitespace: ignoreWhitespace,
  ),
  )
  return lines.join("\n")
}

// A skill draft in full: its name, its description, and its body as
// written. The body is not rendered as markdown: a rendered link shows
// only its text, and the user must see the URL they are approving. The
// model wrote all three, so they are stripped before the header is
// colored; stripping after would turn the color into visible text.
def renderSkillDraft(data: any): string {
  const name = stripControlChars(data.name)
  const description = stripControlChars(data.description)
  const body = stripControlChars(data.body)
  return [color.yellow("⏺ Skill: ${name}"), description, "", body].join("\n")
}

// A workflow draft: in full as JSON on the first round, then as a diff
// against the draft the previous round showed. The check's warnings go
// under it, one per line. The model wrote the draft, so it is stripped the
// way a tool's source is.
def renderWorkflowDraft(data: any): string {
  const header = "Workflow: ${data.name} (round ${data.round} of ${data.maxRounds})"
  const current = JSON.stringify(data.workflow, null, 2)
  let shown = ""
  if (data.previous == null) {
    shown = [color.yellow("⏺ ${stripControlChars(header)}"), highlight(stripControlChars(current), language: "json")].join("\n")
  } else {
    shown = renderSourceDiff(header, JSON.stringify(data.previous, null, 2), current, "json", false)
  }
  if (data.warnings.length == 0) {
    return shown
  }
  const warnings = map(data.warnings, \warning -> "  - ${stripControlChars(describeProblem(warning))}")
  return [shown, "", "Warnings:", ...warnings].join("\n")
}

/** The fields of a `std::toolbox::review` payload that say what the
  reviewer and the author thought of the draft. All optional: a caller
  may raise the effect without them. */
type ReviewFindings = {
  notes?: string[];
  blocking?: string[];
  unresolved?: { point: string; reason: string }[];
  resumed?: boolean
}

// Printed under a tool draft's diff. The text is written by a model, so it
// is stripped the way the source is.
def renderReviewFindings(data: ReviewFindings): string {
  let lines: string[] = []
  if (data.resumed == true) {
    lines.push(color.yellow("This is the draft from earlier in this session. It was checked again, and not reviewed again."))
  }
  const unresolved = data.unresolved ?? []
  if (unresolved.length > 0) {
    lines.push(color.yellow("The author could not fix ${unresolved.length} review points:"))
    for (item in unresolved) {
      lines.push("- Reviewer: ${stripControlChars(item.point)}")
      lines.push("  Author: ${stripControlChars(item.reason)}")
    }
  }
  const blocking = data.blocking ?? []
  if (blocking.length > 0) {
    lines.push(color.yellow("The reviewer still objects after a redraft:"))
    for (point in blocking) {
      lines.push("- ${stripControlChars(point)}")
    }
  }
  const notes = data.notes ?? []
  if (notes.length > 0) {
    lines.push(color.yellow("The reviewer notes:"))
    for (note in notes) {
      lines.push("- ${stripControlChars(note)}")
    }
  }
  return lines.join("\n")
}

// What an interrupt asks the user to judge, printed above the prompt, or
// "" for every other effect: a diff for edits and tool reviews, or the
// full content for writes and skill reviews. The prompt's own body would
// cut these off after a few lines.
def renderInterruptDiff(intr: any): string {
  // Reading a field off a null payload throws, and a handler is the
  // worst place for that. `prettyPrintInterruptData` guards the same way.
  if (intr.data == null) {
    return ""
  }
  if (intr.effect == "std::write" && typeof intr.data.content == "string") {
    // Writes carry the content being written, but no previous contents
    // to compare it with. Some write tools carry only file metadata.
    const mode = if intr.data.mode then " (${intr.data.mode})" else ""
    const header = stripControlChars("⏺ Write${mode}: ${intr.data.filename}")
    const content = highlight(stripControlChars(intr.data.content), language: "auto")
    return [color.yellow(header), content].join("\n")
  }
  if (intr.effect == "std::edit") {
    return renderSourceDiff(
      "Edit: ${intr.data.filename}",
      intr.data.before,
      intr.data.after,
      "auto",
    )
  }
  if (intr.effect == "std::skills::review") {
    return renderSkillDraft(intr.data)
  }
  if (intr.effect == "std::workflow::review") {
    return renderWorkflowDraft(intr.data)
  }
  if (intr.effect == "std::toolbox::review") {
    const changed = renderSourceDiff(
      "Tool: ${intr.data.name}",
      intr.data.previous,
      intr.data.source,
      "agency",
    )
    const findings = renderReviewFindings(intr.data)
    if (findings == "") {
      return changed
    }
    return "${changed}\n\n${findings}"
  }
  return ""
}

// Print an interrupt's diff, when it has one. Called on both paths: a
// rule can approve without asking, and then this is the only place the
// user sees what the agent did.
def printInterruptDiff(intr: any) {
  const changed = renderInterruptDiff(intr)
  if (changed != "") {
    print(changed)
  }
}

// Render the data of one interrupt for the approval prompt. `data` is the
// interrupt's payload (`intr.data`); `effect` is `intr.effect`, used to
// leave out the fields the effect prints above the prompt
// (PROMPT_HIDDEN_FIELDS).
// `cols` is the width to draw the table at; 0 means the terminal's.
def prettyPrintInterruptData(data: any, effect: string = "", cols: number = 0): string {
  // JS reports `null` as `typeof == "object"`, so guard explicitly.
  // Otherwise `for (key in data)` would throw and crash the interrupt
  // prompt.
  if (data == null) {
    return ""
  }

  // The agent picks some of these (a tool's name, a filename), and the
  // footer's width math counts a CSI sequence as visible width, so one
  // in a value breaks the wrap as well as reaching the terminal.
  if (typeof data == "string") {
    return stripControlChars(data)
  }

  // Arrays are `typeof == "object"` too, but `for (x in array)` yields
  // the ELEMENTS, not indices, so the object branch below would pass a
  // non-string element straight into `text()` and crash `render` with
  // `content.split is not a function`. Handle arrays explicitly, keyed
  // by index, recursing into each element.
  if (Array.isArray(data)) {
    let i = 0
    const paramTable = table() as t {
      for (item in data) {
        t.row(text("${i}", bold: true), text(prettyPrintInterruptData(item)))
        i = i + 1
      }
    }
    return render(paramTable, cols: cols)
  }
  if (typeof data == "object") {
    return renderObjAsTable(data, excludeKeys: promptHiddenFields(effect, data), cols: cols)
  }
  return JSON.stringify(data)
}

// The fields an effect prints above the prompt (see renderInterruptDiff),
// left out of the prompt's own table so they are not shown twice. The
// table keeps the metadata that says which change this is. A write drops
// its content. An edit drops its source and `edits`, the raw op list the
// diff already shows. A tool review drops its source and its findings,
// printed under its diff. A skill review drops the draft it prints in full.
// A workflow review drops the draft, the previous draft, and the warnings,
// which it prints as JSON or a diff.
static const PROMPT_HIDDEN_FIELDS: Record<string, string[]> = {
  "std::write": ["content"],
  "std::edit": ["before", "after", "edits"],
  "std::toolbox::review": ["source", "previous", "notes", "blocking", "unresolved", "resumed"],
  "std::skills::review": ["description", "body"],
  "std::workflow::review": ["workflow", "previous", "warnings"]
}

// Checked with `keys` so an effect named like an Object.prototype
// property (`toString`) gets no list instead of a function.
def promptHiddenFields(effect: string, data: any): string[] {
  // Custom tools may attach non-text content, which has no source preview.
  if (effect == "std::write" && typeof data.content != "string") {
    return []
  }
  if (keys(PROMPT_HIDDEN_FIELDS).includes(effect)) {
    return PROMPT_HIDDEN_FIELDS[effect]
  }
  return []
}

// Render an object's key/value pairs as a two-column table. Keys in
// `excludeKeys` are skipped. Returns "" when no rows remain so callers can
// omit an empty table.
def renderObjAsTable(
  obj: Record<string, any>,
  excludeKeys: string[] = null,
  cols: number = 0,
): string {
  const skip = excludeKeys || []
  let rows = 0
  const paramTable = table() as t {
    for (key in obj) {
      if (skip.includes(key)) {
        continue
      }
      t.row(text(key, bold: true), text(prettyPrintInterruptData(obj[key])))
      rows = rows + 1
    }
  }
  if (rows == 0) {
    return ""
  }
  return render(paramTable, cols: cols)
}

@hidden
export def askUserChoices(intr: any): ChoiceItem[] {
  """
  List the approval choices appropriate for an interrupt.
  """
  let items: ChoiceItem[] = [
    {
    key: "a",
    label: "approve once"
  },
    {
    key: "r",
    label: "reject once"
  },
  ]
  if (intr.expectsValue == true) {
    return items
  }
  items.push({
    key: "aa",
    label: "approve always (every future ${intr.effect})"
  })
  // Offered only when there is something to pin: an empty match would be
  // a catch-all approve, never what "here" means.
  if (keys(buildScopedMatch(intr, globalCliPolicyOpts.fields)).length > 0) {
    items.push({
      key: "ap",
      label: "approve always here (${describeScopedMatch(intr)})"
    })
  }
  items.push({
    key: "rr",
    label: "reject always"
  })
  return items
}

// Present the (a)/(r)/(aa)/(ap)/(rr) menu for one interrupt and
// return the user's choice as an `AskUserResult`. `(ap)` is offered
// only when the effect has a scope (an override entry, or its declaration).
// Renders as a modal over the active repl(); falls back to plain print
// + input when no REPL is running (e.g. headless agent runs).
//
// We pass `allowFreeText: true` to `interruptChoice` so the user can
// type a rejection reason instead of picking a key. That single
// keystroke flow replaces the old two-step "(r), then type reason".
def askUser(intr: any): AskUserResult {
  // The message comes from whoever raised the interrupt, a tool the agent
  // wrote among them, and it goes to the footer whole. The effect beside
  // it is a name from the code.
  const title = "${color.bold(intr.effect)}  ${stripControlChars(intr.message)}"
  const body = prettyPrintInterruptData(intr.data, intr.effect, cols: interruptBodyWidth())

  const items = askUserChoices(intr)

  // `allowCancel: true` makes Escape cancel the whole request (raises a
  // cancellation that unwinds the turn back to the REPL prompt) instead of
  // re-prompting. The "r" option still lets you reject a single action
  // (and optionally tell the agent what to do instead).
  const answer: AskUserResult = withLock("std::tty") {
    // Checked again here, at this interrupt's turn at the terminal: the
    // interrupt ahead of it may have saved an "always" rule since its
    // first check ran. See approval-policies.md.
    //
    // Not for a raise that expects a value. `askUserChoices` withholds
    // the "always" answers from those for the same reason: each raise
    // wants its own answer, and approving one from an effect-wide rule
    // hands the raise site an empty one. A sibling in this round can
    // create such a rule, which is what makes this check different from
    // the one in `_handler`.
    const rules = _policy
    if (rules != null && intr.expectsValue != true) {
      const decided = checkPolicy(rules, intr)
      if (decided.type == "approve" || decided.type == "reject") {
        return {
          action: decided.type,
          reason: null,
          rule: decided
        }
      }
    }
    // After the check above, so an interrupt a rule decides prints its
    // diff through `applyRule` rather than twice. Under the lock, so the
    // diff and the prompt it belongs to reach the terminal together even
    // when other branches are interrupting too.
    printInterruptDiff(intr)
    const choice = interruptChoice(
      title,
      body,
      items,
      allowFreeText: true,
      allowCancel: true,
      expectsValue: intr.expectsValue == true,
    )
    const result = choiceResult(choice, askUserChoices(intr))
    // Recorded before the lock is released, so the interrupt waiting
    // behind this one finds the rule the moment its turn comes. Recording
    // it after would lose the race to that interrupt's check.
    recordAnswer(intr, result)
    return result
  }
  return answer
}

// An "always" answer becomes a rule now, in memory; maybeFlush writes it.
//
// Nothing is recorded for a raise that expects a value. `askUserChoices`
// offers it only "once" answers and `choiceResult` reads any other key as
// text, so no "always" action should arrive here. This check keeps that
// true if either changes: an effect-wide rule would answer every later
// raise of the effect, each of which wants its own answer.
def recordAnswer(intr: any, answer: AskUserResult) {
  const rules = _policy
  if (rules == null || intr.expectsValue == true) {
    return
  }
  if (answer.action == "approve-always") {
    _policy = recordRule(rules, intr.effect, "approve")
    _pendingSave = true
  }
  if (answer.action == "approve-always-here") {
    _policy = recordScopedRule(rules, intr, globalCliPolicyOpts.fields)
    _pendingSave = true
  }
  if (answer.action == "reject-always") {
    _policy = recordRule(rules, intr.effect, "reject")
    _pendingSave = true
  }
}

// The user's keystroke, or the text they typed, as a decision. Only a key
// the menu offered is a keystroke. A question offers "a" and "r" alone,
// so "aa" typed at one is the user's answer.
def choiceResult(answer: string, offered: ChoiceItem[]): AskUserResult {
  const isOffered = filter(offered, \item -> item.key == answer).length > 0
  if (!isOffered) {
    return { action: "reject", reason: answer }
  }
  return match(answer) {
    "a" => ({
      action: "approve",
      reason: null
    })
    "r" => ({
      action: "reject",
      reason: null
    })
    "aa" => ({
      action: "approve-always",
      reason: null
    })
    "ap" => ({
      action: "approve-always-here",
      reason: null
    })
    "rr" => ({
      action: "reject-always",
      reason: null
    })
    _ => ({
      action: "reject",
      reason: answer
    })
  }
}

// The payload fields that say what an interrupt is about, as opposed to
// what it carries: an edit's before/after text, a write's content, or a
// command's stdin would swamp the line and echo file contents.
static const IDENTIFYING_FIELDS = ["filename", "dir", "command", "cwd", "name", "url", "pattern", "target", "path", "src", "dest"]

/**
 One line naming what a rejected interrupt asked for, so a person watching
 the session sees the refusal instead of only its effect on the agent's
 next move.
 */
def oneLineData(data: any): string {
  if (data == null || typeof data != "object") {
    return ""
  }
  let parts: string[] = []
  for (key in IDENTIFYING_FIELDS) {
    const value = data[key]
    if (typeof value == "string" || typeof value == "number") {
      // JSON-quoted, so a control character in a command or name is shown
      // escaped rather than sent to the terminal.
      let text = JSON.stringify(value)
      if (text.length > 80) {
        text = text[:80] + "…"
      }
      parts.push("${key}: ${text}")
    }
  }
  if (parts.length == 0) {
    return ""
  }
  return " (" + parts.join(", ") + ")"
}

// A raise that expects a value is a question, so text typed at its
// prompt is the answer to it, not a reason to reject. "r" still rejects.
// Null when the text is a rejection reason or there is none.
def typedValue(intr: any, answer: any): string | null {
  if (intr.expectsValue == true && answer.reason != null) {
    return answer.reason
  }
  return null
}

/**
Internal handler returned by `cliPolicyHandler`.
*/
def _handler(intr: any): any {
  // Re-entrancy: the handler's own read or write of the policy file
  // re-enters it. See `isOwnPolicyIo`.
  if (isOwnPolicyIo(intr)) {
    return approve()
  }

  // A guard trip is not a policy decision. It is owned by whatever `handle`
  // block wraps the guard, which alone knows how much extra budget to grant
  // (an approval with too little is refused by the runtime). Passing here
  // lets that handler's verdict stand; an empty approve/reject or a
  // `propagate` from this generic handler would either be refused or, since
  // propagate outranks approve, override the real grant and re-prompt the
  // user. The auto-approve handler passes on `std::guard` for the same
  // reason (see agencyFunction.ts).
  if (intr.effect == "std::guard") {
    return pass()
  }

  maybeLoadPolicy()
  maybeFlush()

  if (_policy == null) {
    print(
      "Error: no policy loaded. Rejecting by default. Load a policy with parsePolicyFile and set it using setPolicy before installing the handler.",
    )
    return reject()
  }

  // A rule may reject anything. A rule may not approve a raise that
  // expects a value: `approve()` carries no value, so the raise site
  // reads it as a bare yes, and for `std::toolbox::review` that accepts a
  // draft nobody looked at. The menu withholds the "always" answers from
  // these for the same reason, and an effect-wide rule is reachable here
  // one round after a plain raise of the same effect was answered
  // "approve always". Such an interrupt goes on to ask.
  const decision = checkPolicy(_policy, intr)
  if (decision.type == "reject") {
    return applyRule(intr, decision)
  }
  const ruleApproves = decision.type == "approve"
  if (ruleApproves && intr.expectsValue != true) {
    return applyRule(intr, decision)
  }

  // Nobody is at a terminal to ask. Reject with a reason: it becomes the
  // failing call's error, so an LLM whose tool raised this effect reads
  // why and can take another approach instead of the process dying on a
  // prompt nobody can answer.
  //
  // A rule that approves this effect but could not be used, because the
  // raise asks for a value, gets its own reason. "No rule for this
  // effect" is false in the one case that produces it, and sends whoever
  // reads it looking for a policy bug. `--policy approve-all` is the
  // usual way to arrive here.
  if (!_interactive) {
    if (ruleApproves) {
      return reject(
        "\"${intr.effect}\" was rejected automatically: a policy rule approves this effect, but this raise asks for a value and an approval from a rule carries none, so only a person can answer it. This is a non-interactive run. Take an approach that does not need an answer, or re-run interactively.",
      )
    }
    return reject(
      "\"${intr.effect}\" was rejected automatically: this is a non-interactive run and the approval policy has no rule for this effect, so there is no one to ask. Take an approach that does not need this permission, or the user must re-run with a policy that decides \"${intr.effect}\".",
    )
  }

  const answer = askUser(intr)
  if (answer.rule != null) {
    // A rule saved by the interrupt ahead of this one decided it while it
    // waited, so say why the prompt did not appear. Only an approval gets
    // here: a "reject always" answer sends its siblings back through the
    // handler, where the check above them finds the rule.
    if (answer.rule.type == "approve" && _interactive) {
      print(
        color.dim("⏺ Approved ${intr.effect}${oneLineData(intr.data)} by the rule just saved"),
      )
    }
    return applyRule(intr, answer.rule)
  }
  // The "always" answers were recorded by askUser, under the lock.
  if (answer.action == "approve-always" || answer.action == "approve-always-here") {
    return approve()
  }
  if (answer.action == "reject-always") {
    return reject("The user rejected this call and all later ${intr.effect} requests.")
  }
  if (answer.action == "approve") {
    return approve()
  }

  const typed = typedValue(intr, answer)
  if (typed != null) {
    return approve(typed)
  }

  // Free-text path: the user typed a rejection reason at the choice
  // prompt instead of picking a key. Use it directly so the LLM sees
  // their feedback without a second prompt.
  if (answer.reason != null) {
    return reject(userRejection(answer.reason))
  }

  // Interactive reject (user picked "r"): ask for optional guidance so
  // the LLM sees concrete feedback as the tool's return value and the
  // agent can course-correct without ending the turn.
  const reason = input("What should the agent do instead? (press enter to skip) ")
  return reject(userRejection(reason))
}

// What the model reads when the user rejects a call at the prompt. It has
// to say a person wrote the text: a bare "Why are you running this?" reads
// to the model like an error from the tool.
def userRejection(message: string): string {
  if (message == "") {
    return "The user rejected this call."
  }
  return "The user rejected this call and wrote you this message:\n${message}"
}

// A rule's decision, carried out. Both checks end here: the one every
// interrupt runs, and the one an interrupt runs again at its turn at the
// terminal.
def applyRule(intr: any, decision: any): any {
  if (decision.type == "approve") {
    // No prompt is drawn on this path, so this is the only place the
    // change is shown. Both routes here need it: the check every
    // interrupt runs, and the one an interrupt runs again at its turn at
    // the terminal, which is how a rule a sibling saved this round
    // decides it. Effects that carry no source approve silently.
    printInterruptDiff(intr)
    return approve()
  }
  // A rule rejected without asking. Say so on the terminal: a silent
  // rejection looks, from the outside, like the agent changing its mind.
  if (_interactive) {
    print(color.dim("⏺ Policy rejected ${intr.effect}${oneLineData(intr.data)}"))
  }
  // The rule's rejectMessage, when set: the model reads it as the
  // rejection reason and can steer its next attempt.
  return reject(decision.message ?? "A rule in the approval policy rejected this call.")
}

/**
```ts
const handler = cliPolicyHandler(file: "/home/me/.myapp/policy.json")
handle {
  return read("README.md")
} with handler
```

Bind the returned function before using it in a `with` clause. Call once
per program: the handler shares module-level policy state, and another call
replaces its options and active policy.

It loads the file on the first interrupt unless you supply `policy`.
A missing file starts with an empty policy. Other load errors also start
empty and print a warning. Rules approve or reject matching interrupts;
questions that need a value still require an answer. Unresolved interrupts
prompt when interactive and fail when noninteractive.

"Always" decisions persist at the next interrupt. Call `flushPolicy` before
exiting to save the final decision. Supplying `policy` skips the initial
read; later decisions still save to `file`. The file's parent directory
must already exist.

"Approve always here" uses scopes from effect declarations unless `fields`
overrides them. An empty override disables that choice for the effect.
*/
export def cliPolicyHandler(
  file: string,
  fields: ScopedRuleFields = {},
  policy: Policy | null = null,
  interactive: boolean = true,
): any {
  """
  Build a CLI approval handler that remembers decisions in a policy file.
  Call once per program and bind the result before installing it.

  @param file - Policy file path whose parent directory already exists.
  @param fields - Per-effect scope overrides, with [] disabling scoped approval for an effect.
  @param policy - Initial rules to use instead of reading the file, or null to load it.
  @param interactive - Prompt for unresolved interrupts when true, or reject them when false.
  """
  globalCliPolicyOpts = {
    file: file,
    fields: fields
  }
  _interactive = interactive
  _saveFailing = false
  if (policy != null) {
    // Seed from the caller and mark loaded so `maybeLoadPolicy` skips the
    // file read; persistence of later decisions still targets `file`.
    _policy = policy
    _policyLoaded = true
  } else {
    _policy = null
    _policyLoaded = false
  }
  _pendingSave = false
  return _handler
}
