import {
  describe,
  parseAST,
  fill,
  toSource,
  typecheck,
  compile,
  test,
  run,
  runFile,
  getNodesOfType,
  ModuleInfo,
  ExportInfo,
  TestReport,
  AgencyTestCase,
 } from "std::agency"
import { question } from "std::agent"
import { agencyCodingAgent } from "std::agents/agency/coding"
import { agencyReviewAgent } from "std::agents/agency/review"
import { llmOptions } from "std::agents/lib/shared"
import { now, format } from "std::date"
import { mkdir, remove, move } from "std::fs"
import { ls, exists } from "std::shell"
import { MAX_TOOL_NAME_LEN } from "std::skills"
import { Json } from "std::validation"

import { _read, _write } from "agency-lang/stdlib-lib/builtins.js"
import { _realDir } from "agency-lang/stdlib-lib/fs.js"
import { _mkdir, _move, _remove } from "agency-lang/stdlib-lib/fs.js"

/** @module
@summary Create, save, and run reusable Agency tools.

```ts
import { designTool, runTool } from "std::toolbox"

node main() {
  const saved = designTool(
    name: "addTax",
    purpose: "Add a tax rate to an amount.",
    request: "{ amount: number; rate: number }",
  )
  if (saved is failure) {
    return saved
  }
  return runTool("addTax", { amount: 100, rate: 0.08 })
}
```

Run interactively or supply handlers for the review and save requests.
`designTool` drafts and checks a tool, then lets the user accept it or ask
for changes. Use `writeTool` when the source is already written, and
`listTools` to inspect saved tools.

Each tool has an `impl.agency` exporting `Request` and `run`. A generated
`tool.agency` wraps it with cost and time limits. You can also import its
`tool` export directly from the saved directory.
*/

// Refuse oversized listings rather than returning an incomplete toolbox.
const MAX_TOOLS = 500

// Added to a tool's own time limit for the subprocess that runs it, so
// the guard trips before the process is killed. runFile clamps wallClock
// to one hour, so a tool's limit stays below that minus the headroom.
const WALL_CLOCK_HEADROOM = 30s
const MAX_TOOL_TIME = 1h - WALL_CLOCK_HEADROOM

// Drafts are built under <root>/staging/<name>-<random>, one directory
// per designTool or writeTool call. Not a dot directory: the with-writes policy scopes
// writes with `base/**`, and `**` does not match a dot-led segment.
const STAGING_DIR = "staging"

const IMPL_FILE = "impl.agency"
const TOOL_FILE = "tool.agency"
const REQUEST_SCHEMA_NODE = "requestSchema"
const TEST_FILE = "tool.test.json"
const META_FILE = "meta.json"

// One approval covers the scan; the reads that fulfil it use the
// non-interrupt primitive, as std::skills does.
@alwaysUnder(dir)
effect std::toolbox::scan {
  dir: string
}

// runTool's use count. Its own effect, so a policy can approve the
// stdlib's record without approving any std::write to meta.json. The
// approval covers the write that fulfils it.
@alwaysUnder(dir)
effect std::toolbox::recordUse {
  dir: string;
  name: string
}

// Scope draft writes to the toolbox without granting general file writes.
// Pin the root so an always-rule covers later drafts with different staging names.
@alwaysUnder(root)
effect std::toolbox::writeFile {
  root: string;
  dir: string;
  filename: string;
  content: string
}

// The staging directory for one draft, created before the draft is
// written. Same scope as the writes that go into it.
@alwaysUnder(root)
effect std::toolbox::createStaging {
  root: string;
  dir: string;
  name: string
}

// The staging directory of a draft that was not saved, removed with
// everything in it.
@alwaysUnder(root)
effect std::toolbox::removeStaging {
  root: string;
  dir: string;
  name: string
}

// One file in a draft's staging directory, removed when a revision stops
// being testable and the previous round's tests must not ship with it.
@alwaysUnder(root)
effect std::toolbox::removeStagedFile {
  root: string;
  dir: string;
  name: string;
  filename: string
}

/** A review point the drafting agent could not fix, in the reviewer's
  words, and the agent's reason. */
export type Unresolved = {
  point: string;
  reason: string
}

effect std::toolbox::review {
  name: string;
  stagingDir: string;
  source: string;
  /** The draft the previous round showed, or "" on the first round. The
    approval prompt diffs `source` against it, so a redraft shows what
    changed rather than the whole tool again. */
  previous: string;
  effects: string[];
  tested: boolean;
  /** Reviewer findings that do not block the draft: advice to the user
    who asked for the tool. */
  notes: string[];
  /** Reviewer findings the author was asked to fix and the reviewer
    raised again. Empty unless the draft is here because of them. */
  blocking: string[];
  /** Review points the author reported it could not fix, with its reason. */
  unresolved: Unresolved[];
  /** True when this is a draft from earlier in the session that nobody
    accepted, shown again instead of writing a new one. */
  resumed: boolean
}

// Every publish into a toolbox raises this, whether the source came from
// the design loop or from the caller. `dir` is the toolbox root.
@alwaysUnder(dir)
effect std::toolbox::save {
  dir: string;
  name: string;
  source: string;
  effects: string[]
}

/** What `describe` says about a tool's `run` function. */
export type ModuleFacts = {
  signature: string;
  docstring?: string;
  effects: string[]
}

/** What meta.json holds: how the tool was made, and how often it ran.
  `requestSchema` is the JSON Schema of the Request type, as the tool's
  own `requestSchema` node reported it at save time; null for a tool
  saved before schemas were recorded. */
export type ToolMeta = {
  purpose: string;
  request: string;
  requestSchema: Json | null;
  createdAt: string;
  maxTime: number;
  version: number;
  uses: number;
  lastUsedAt?: string
}

/** One tool in a toolbox. This is what `listTools` returns. If the tool
  could not be read correctly, `broken` holds the reason. */
export type ToolEntry = {
  name: string;
  dir: string;
  module: ModuleFacts;
  meta: ToolMeta;
  broken?: string
}

const ACCEPT = "accept"

const DEFAULT_META: ToolMeta = {
  purpose: "",
  request: "",
  requestSchema: null,
  createdAt: "",
  maxTime: 3m,
  version: 1,
  uses: 0,
  lastUsedAt: null
}

const EMPTY_MODULE: ModuleFacts = {
  signature: "",
  docstring: null,
  effects: []
}

// ---- reading a toolbox ----

// Every entry point takes the toolbox directory the same way. An empty
// string is refused because the file primitives would resolve it to the
// process cwd.
// The real spelling of the toolbox directory, so every scan and save
// interrupt names the directory the reads and writes will use.
@capabilities(fileRead)
def expandRoot(dir: string): Result<string> {
  if (dir == "") {
    return failure("dir must name the toolbox directory; got an empty string")
  }
  return try _realDir(dir)
}

def runExport(info: ModuleInfo): ExportInfo | null {
  return find(info.exports, \entry -> entry.name == "run" && entry.kind == "def")
}

def factsOf(exported: ExportInfo): ModuleFacts {
  return {
    signature: exported.signature,
    docstring: exported.docstring,
    effects: exported.effects
  }
}

// `root` is the toolbox directory the scan interrupt named and `name` the
// tool's directory under it, so a linked tool directory is refused.
@capabilities(fileRead)
def moduleFacts(root: string, name: string): Result<ModuleFacts> {
  const source = try _read(root, "${name}/${IMPL_FILE}", 0, 0)?
  const info = describe(source)?
  const exported = runExport(info)
  if (exported == null) {
    return failure("no exported `def run` in ${IMPL_FILE}")
  }
  return success(factsOf(exported))
}

/** meta.json as written; every field may be missing. */
type MetaFile = {
  purpose?: string;
  request?: string;
  requestSchema?: Json;
  createdAt?: string;
  maxTime?: number;
  version?: number;
  uses?: number;
  lastUsedAt?: string
}

// A missing meta.json is the default record. A present one that cannot be
// read, is not JSON, or has a field of the wrong type is a failure, so a
// corrupt file never passes as a healthy tool.
@capabilities(fileRead)
def metaFacts(root: string, name: string): Result<ToolMeta> {
  if (!exists("${name}/${META_FILE}", root)) {
    return success(DEFAULT_META)
  }
  const raw = try _read(root, "${name}/${META_FILE}", 0, 0)
  if (raw is failure(readErr)) {
    return failure("${META_FILE} could not be read: ${readErr}")
  }
  const parsed = try JSON.parse(raw.value)
  if (parsed is failure(parseErr)) {
    return failure("${META_FILE} is not valid JSON: ${parseErr}")
  }
  const file: MetaFile! = parsed.value
  if (file is failure(shapeErr)) {
    return failure("${META_FILE} has the wrong shape: ${shapeErr}")
  }
  return success({
    ...DEFAULT_META,
    ...presentFields(file.value)
  })
}

// Validation fills a missing optional field with null, and a null would
// win over the default in a spread. Only the fields the file has are kept.
def presentFields(file: MetaFile): Record<string, Json> {
  const fields: Record<string, Json> = file
  const present: Record<string, Json> = {}
  for (key, value in fields) {
    if (value != null) {
      present[key] = value
    }
  }
  return present
}

// An entry is broken when runTool would refuse it: a name that is not a
// tool name, no tool.agency to run, or an impl.agency or meta.json that
// cannot be read.
def entryFor(root: string, name: string): ToolEntry {
  const toolDir = "${root}/${name}"
  const module = moduleFacts(root, name)
  const meta = metaFacts(root, name)
  const moduleFields: ModuleFacts = module catch EMPTY_MODULE
  const metaFields: ToolMeta = meta catch DEFAULT_META
  const problems: string[] = []
  const syntax = checkNameSyntax(name)
  if (syntax is failure(nameErr)) {
    problems.push("${nameErr}")
  }
  if (!exists("${name}/${TOOL_FILE}", root)) {
    problems.push("no ${TOOL_FILE}")
  }
  if (module is failure(moduleErr)) {
    problems.push("${moduleErr}")
  }
  if (meta is failure(metaErr)) {
    problems.push("${metaErr}")
  }
  let broken: string | null = null
  if (problems.length > 0) {
    broken = problems.join("; ")
  }
  return {
    name: name,
    dir: toolDir,
    module: moduleFields,
    meta: metaFields,
    broken: broken
  }
}

/** The fields of an `ls` entry this module reads. */
type DirEntry = {
  name: string;
  type: string
}

def isToolDir(entry: DirEntry): boolean {
  return entry.type == "dir" && entry.name != STAGING_DIR && !entry.name.startsWith(".")
}

// A toolbox directory that does not exist yet is an empty catalog, not an
// error. `ls` counts every entry against its cap, files included, and
// says nothing when it stops, so it is asked for one entry more than the
// cap and a listing that long is refused.
def toolNames(root: string): Result<string[]> {
  if (!exists(root)) {
    return success([])
  }
  const entries = ls(root, false, MAX_TOOLS + 1)?
  if (entries.length > MAX_TOOLS) {
    return failure("${root} has more than ${MAX_TOOLS} entries; a toolbox holds fewer")
  }
  return success(map(filter(entries, isToolDir), \entry -> entry.name))
}

/**
Use the catalog to select a tool before calling `runTool`. Each entry
includes its signature, tool description, effects, and saved metadata.
A `broken` entry explains why the tool cannot be used.
*/
@capabilities(fileRead)
export idempotent def listTools(
  dir: string = "~/.agency-agent/tools",
): Result<ToolEntry[]> raises <std::toolbox::scan, std::ls> {
  """
  List saved tools and their metadata, marking unreadable or invalid entries as broken.

  @param dir - Nonempty toolbox directory to scan.
  """
  const root = expandRoot(dir)?
  return interrupt std::toolbox::scan("Are you sure you want to scan this directory for tools?", {
    dir: root
  })
  const names = toolNames(root)?
  return success(map(names, \name -> entryFor(root, name)))
}

// ---- the tool template ----

// Only the limits and the description vary. The guard, the exports, and
// the signatures are fixed here, so no draft can leave them out. `main`
// returns the guard's Result.
static const toolTemplate = [|
  import { Json } from "std::validation"

  import { run, Request } from "./impl.agency"

  def runGuarded(request: Request): Result<Json> {
    """
    Run this tool. The request type is in impl.agency.

    @param request - The tool's input
    """
    return guard(time: #maxTime, cost: #maxCost) {
      return run(request)
    }
  }

  export static const tool = runGuarded.describe(#purpose)

  export node main(request: Request): Result<Json> {
    return tool(request)
  }

  export node requestSchema(): Json {
    return schema(Request).toJSONSchema()
  }
|]

/** The settings one designTool or writeTool call hands every pipeline
  stage. */
type DraftSettings = {
  purpose: string;
  request: string;
  /** The tool's name. Every toolbox effect names it. */
  name: string;
  /** The toolbox root. The staging directory sits under it, and every
    toolbox effect is scoped to it. */
  root: string;
  stagingDir: string;
  maxTime: number;
  maxCost: number;
  model: string;
  provider: string;
  /** False skips the review agent, so a draft goes from the author
    straight to its checks and the user. */
  review: boolean
}

/**
A checked draft ready for user review. `source` is impl.agency.
`tested` records whether generated tests ran; `review` may contain open findings.
*/
type Draft = {
  source: string;
  effects: string[];
  tested: boolean;
  review: Review
}

/** What the review step found. `blocking` and `unresolved` are empty for
  a draft the reviewer passed. */
type Review = {
  notes: string[];
  blocking: string[];
  unresolved: Unresolved[]
}

// What the case generator returns. The generator's own copy of this type
// uses the tool's `Request`; here `request` is `Json`, because this file
// cannot name that type.
type GeneratedCase = {
  request: Json;
  expectedJson: string
}

// Asks the model for test cases. It runs from the staging directory so
// it can import the draft's `Request`. That puts the tool's real field
// names and types in the structured-output schema. `Json` is open, so
// the expected value comes back as JSON text instead.
static const casesProgram = [|
  import { llmOptions } from "std::agents/lib/shared"

  import { Request } from "./impl.agency"

  type GeneratedCase = {
    request: Request;
    expectedJson: string
  }

  export node main(brief: string, model: string, provider: string): Result {
    const cases: GeneratedCase[]! = llm(brief, llmOptions(model: model, provider: provider))
    return cases
  }
|]

// What the author is told to do with feedback. Feedback from the user,
// the typecheck, or the tests has to be fixed. A reviewer's point may be
// one the code in this module cannot fix, so there the author gets a way
// to say so.
static const FIX_ALL = "Fix every point above in the new draft."
static const FIX_OR_REPORT = "For each point above, fix it in the new draft. If the code in this module cannot fix a point, call the `cannotFix` tool with the point and the reason, then finish the draft without fixing that point."

// The line about `Request`, for each reader. The author is told to copy
// it. The reviewer is told to leave it alone: it sees the draft in the
// formatter's layout, where the type may span several lines, and
// `checkRequestMatches` already compares the two.
static const REQUEST_FOR_AUTHOR = "copy this line as is."
static const REQUEST_FOR_REVIEWER = "this type is fixed and a separate check has already verified it, whatever its layout. Do not review it."

static const ASK_RULE = "- When the work depends on a fact the task does not give you, such as an address, an account, a provider, or the name of an environment variable, ask the user with the `question` tool before you write the draft. Do not search for the fact and do not invent it."

// `drafted` is the draft the feedback is about, or "" when there is none.
def feedbackSection(feedback: string, fromReviewer: boolean, drafted: string): string {
  if (feedback == "") {
    return ""
  }
  const instruction = if fromReviewer then FIX_OR_REPORT else FIX_ALL
  let draftedBlock = ""
  if (drafted != "") {
    draftedBlock = """
## The previous draft
```
${drafted}
```
Start from this draft. Change what the feedback names and keep the rest.
"""
  }
  return """
${draftedBlock}
## Feedback on the previous draft
${feedback}
${instruction}
"""
}

def taskText(
  settings: DraftSettings,
  requestNote: string,
  rules: string,
  closing: string,
  feedbackBlock: string,
): string {
  const answers = answeredSection(settings.stagingDir, closing)
  return """
Write an Agency module that does this: ${settings.purpose}

The module exports exactly two things:

1. `export type Request = ${settings.request}` — ${requestNote} Every input the tool needs comes through it. Do not hardcode a topic, path, count, or date in the body. Call `today()` from std::date when a date is needed.
2. `export def run(request: Request): Json` — the work. Import `Json` from std::validation. Give `run` a docstring that says what it returns and documents `@param request`.

Rules:
- Imports name std:: modules only.
- Never write `with approve`.
- Do not wrap the body in a guard; the caller adds one.
${rules}

${answers}
${feedbackBlock}
"""
}

def brief(
  settings: DraftSettings,
  feedback: string,
  fromReviewer: boolean = false,
  drafted: string = "",
): string {
  return taskText(
    settings,
    REQUEST_FOR_AUTHOR,
    ASK_RULE,
    ANSWERS_FOR_AUTHOR,
    feedbackSection(feedback, fromReviewer, drafted),
  )
}

def reviewBrief(settings: DraftSettings): string {
  return taskText(settings, REQUEST_FOR_REVIEWER, "", ANSWERS_FOR_REVIEWER, "")
}

def casesBrief(source: string): string {
  return """
Write two test cases for this Agency module. Each case has a `request` to pass to `run`, and `expectedJson`: the exact value `run` returns for that request, written as JSON text: `"hi"` for the string hi, `42` for the number 42.
The first case is the happy path; the second is an edge case (an empty list, zero items). The module is pure computation, so every expected value is exact.

```agency
${source}
```
"""
}

/** One case, ready to run and to write: the request as the tool takes it,
  and what `main` returns for it. `main` returns the guard's Result, so
  the expected value is wrapped in the runtime's own Result envelope. */
type PreparedCase = {
  request: Json;
  expected: Json
}

// The model writes each expected value as JSON text. Parsing happens once,
// here, so the file and the run that checks it can never disagree.
def prepareCases(cases: GeneratedCase[]): Result<PreparedCase[]> {
  const prepared: PreparedCase[] = []
  for (testCase in cases) {
    const parsed = try JSON.parse(testCase.expectedJson)
    if (parsed is failure(parseErr)) {
      return failure("expectedJson is not valid JSON: ${parseErr}")
    }
    prepared.push({
      request: testCase.request,
      expected: success(parsed.value)
    })
  }
  return success(prepared)
}

// The cases as `test` takes them.
def memoryCases(cases: PreparedCase[]): AgencyTestCase[] {
  return map(cases) as prepared {
    return {
      node: "main",
      args: {
        request: prepared.request
      },
      expected: prepared.expected
    }
  }
}

// The same cases as `tool.test.json`, which ships with the tool so it can
// be re-tested later. The file spells an expected value as JSON text.
def testFileJson(cases: PreparedCase[]): string {
  const tests = map(cases) as prepared {
    return {
      nodeName: "main",
      args: {
        request: prepared.request
      },
      expectedOutput: JSON.stringify(prepared.expected),
      evaluationCriteria: [{
        type: "exact"
      }]
    }
  }
  return JSON.stringify({
    sourceFile: TOOL_FILE,
    tests: tests
  }, null, 2)
}

// Calls whose result changes from run to run without raising an
// interrupt, so they never show in the effect list. A tool that makes one
// has no output an exact test can predict. `random` covers Math.random,
// which parses as a call named random.
const UNPREDICTABLE_CALLS = ["llm", "today", "now", "random"]

/** The fields of an import statement this module reads. */
type ImportFacts = {
  importedNames: { aliases: Record<string, string> }[]
}

// `import { now as clock }` calls `clock()`, so the local names of the
// unpredictable calls are collected from the import aliases too.
def unpredictableNames(source: string): string[] {
  const imports: ImportFacts[] = getNodesOfType(source, ["importStatement"]) catch []
  const names = [...UNPREDICTABLE_CALLS]
  for (statement in imports) {
    for (group in statement.importedNames) {
      for (original, local in group.aliases) {
        if (UNPREDICTABLE_CALLS.includes(original)) {
          names.push(local)
        }
      }
    }
  }
  return names
}

def callsUnpredictable(source: string): boolean {
  const names = unpredictableNames(source)
  const calls = getNodesOfType(source, ["functionCall"]) catch []
  return some(calls, \call -> names.includes(call.functionName))
}

// ---- the designTool pipeline ----

/** Marks a failure the next draft can fix, as opposed to one it cannot (a
  refused write, a review agent that did not run). Only these become
  feedback for another round. It rides in the failure's data; the failure's
  message is the problem itself. */
type DraftProblem = {
  retryable: boolean;
  /** True when the problem is the reviewer's findings, which the author
    may answer with `cannotFix`. */
  fromReviewer?: boolean;
  /** The draft the problem is about, when one was written. The next
    round's author starts from it. */
  drafted?: string
}

def draftProblem(message: string): Result<any, DraftProblem> {
  return failure(message, { retryable: true })
}

// Review points the author could not fix, keyed by staging directory.
// Two designTool calls made as tool calls in one LLM round share this
// module's globals, and the staging directory is unique to each call.
let _unresolved: Record<string, Unresolved[]> = {}

// Offered to the coding agent with `stagingDir` bound, so the model sees
// `point` and `reason` only.
def cannotFix(stagingDir: string, point: string, reason: string): string {
  """
  Report a review point this module cannot fix, then finish the draft.
  Call once per unresolved point so the user can decide what to change.

  @param point - The review point in the reviewer's words.
  @param reason - Why this module cannot fix it.
  """
  const soFar = _unresolved[stagingDir] ?? []
  const already = filter(soFar, \item -> item.point.trim() == point.trim())
  if (already.length > 0) {
    return "Already noted. The user will be shown this point and your reason."
  }
  _unresolved[stagingDir] = [...soFar, { point: point, reason: reason }]
  return "Noted. The user will be shown this point and your reason."
}

type Answered = {
  question: string;
  answer: string
}

// What the user told the author, keyed by staging directory like
// `_unresolved`. Each round's author is a new thread and the reviewer is
// another, so without this record the next author asks again and the
// reviewer reads an answer in the code as a value someone made up.
let _answered: Record<string, Answered[]> = {}

// Offered to the coding agent as `question`, with `stagingDir` bound.
def askAndRecord(stagingDir: string, prompt: string): string {
  """
  Ask for a fact only the user can supply, such as the intended address or account.
  Ask instead of guessing. Do not ask for anything you can look up.
  """
  const answer = question(prompt)
  if (answer != null) {
    const soFar = _answered[stagingDir] ?? []
    _answered[stagingDir] = [...soFar, { question: prompt, answer: "${answer}" }]
  }
  return answer
}

def answeredFor(stagingDir: string): Answered[] {
  return _answered[stagingDir] ?? []
}

// The user's answers as a section of text, or "" when nobody asked.
// `closing` says what this reader should do with them.
def answeredSection(stagingDir: string, closing: string): string {
  const answered = answeredFor(stagingDir)
  if (answered.length == 0) {
    return ""
  }
  let lines = ["## What the user has said", "The author asked the user these questions while writing an earlier draft."]
  for (item in answered) {
    lines.push("")
    lines.push("Question: ${item.question}")
    lines.push("Answer: ${item.answer}")
  }
  lines.push("")
  lines.push(closing)
  return lines.join("\n")
}

static const ANSWERS_FOR_AUTHOR = "Use these answers. Do not ask for something they already cover."
static const ANSWERS_FOR_REVIEWER = "The user wants these values written into the module. Do not report one of them as hard-coded, and do not ask for it to be read from an environment variable or passed in the request."

def unresolvedFor(stagingDir: string): Unresolved[] {
  return _unresolved[stagingDir] ?? []
}

def clearUnresolved(stagingDir: string) {
  _unresolved[stagingDir] = []
}

def progress(settings: DraftSettings, message: string, points: string[] = []) {
  print("${settings.name}: ${message}")
  for (point in points) {
    print("  - ${point}")
  }
}

// Allow several parser, typechecker, or compiler corrections within a
// drafting round. Test failures and user feedback consume separate rounds.
static const CHECK_ATTEMPTS = 6

def draftSource(
  settings: DraftSettings,
  feedback: string,
  fromReviewer: boolean,
  drafted: string,
): Result<string> {
  // Every draft starts with nothing reported, so the points that skip
  // the review, and the points the user reads, are always ones the
  // author made about the draft in front of them.
  clearUnresolved(settings.stagingDir)
  let extraTools: any[] = [askAndRecord.partial(stagingDir: settings.stagingDir).rename("question")]
  if (fromReviewer) {
    extraTools.push(cannotFix.partial(stagingDir: settings.stagingDir))
  }
  const written = agencyCodingAgent(
    task: brief(settings, feedback, fromReviewer, drafted),
    requireMain: false,
    dir: settings.stagingDir,
    model: settings.model,
    provider: settings.provider,
    extraTools: extraTools,
    maxAttempts: CHECK_ATTEMPTS,
    // A saved tool runs sandboxed, so its drafts are checked that way.
    strict: true,
    // The task allows std:: imports only, so nothing in the caller's
    // project can help the author.
    projectTools: false,
  )
  if (written is failure(writeFailure)) {
    const attempted = written.data.source
    if (attempted == "") {
      return draftProblem(writeFailure)
    }
    // The next round's author continues from the draft that failed, and
    // not from the older one this round started with.
    return failure(writeFailure, { retryable: true, drafted: attempted })
  }
  return written
}

// Separate fixable code problems from limitations that require the user's decision.
static const REVIEW_LIMITS = """
The author of this code can change the code in this module and nothing else. It cannot change the task, the Request type, or what a standard library function returns.

Set error=true only for a problem the author can fix inside those limits.

When the task asks for something the available functions cannot supply, or your finding disagrees with the task itself, set error=false and write the finding as advice to the user who asked for this tool: say what the limit is and what they could change.
"""

def reviewSource(source: string, settings: DraftSettings): Result<Review> {
  const reviewed = agencyReviewAgent(
    source,
    task: reviewBrief(settings),
    context: REVIEW_LIMITS,
    dir: settings.stagingDir,
    model: settings.model,
    provider: settings.provider,
  )
  if (reviewed is failure(reviewErr)) {
    return failure("review failed: ${reviewErr}")
  }
  const blocking = filter(reviewed.value, \finding -> finding.error)
  const notes = filter(reviewed.value, \finding -> !finding.error)
  return success({
    notes: map(notes, \finding -> finding.feedback),
    blocking: map(blocking, \finding -> finding.feedback),
    unresolved: []
  })
}

// One of a tool's own files, in the directory the toolbox chose. The
// interrupt carries the directory in the real spelling, and the write
// that fulfils it uses that same spelling, as `recordUse` does for the
// use count.
@capabilities(fileRead, fileWrite)
def writeToolFile(
  settings: DraftSettings,
  filename: string,
  content: string,
): Result {
  const real = try _realDir(settings.stagingDir)?
  return interrupt std::toolbox::writeFile("Are you sure you want to write this tool file?", {
    root: settings.root,
    dir: real,
    filename: filename,
    content: content
  })
  destructive {
    return try _write(real, filename, content, "overwrite")
  }
}

// Create this draft's staging directory. Raised before it exists, so the
// payload carries the path the toolbox built, not a resolved one.
@capabilities(fileRead, fileWrite)
def makeStagingDir(root: string, dir: string, name: string): Result {
  return interrupt std::toolbox::createStaging("Are you sure you want to create this staging directory?", {
    root: root,
    dir: dir,
    name: name
  })
  destructive {
    return try _mkdir(dir)
  }
}

@capabilities(fileRead, fileWrite)
def removeStagingDir(root: string, dir: string, name: string): Result {
  return interrupt std::toolbox::removeStaging("Are you sure you want to remove this staging directory?", {
    root: root,
    dir: dir,
    name: name
  })
  destructive {
    return try _remove(dir)
  }
}

@capabilities(fileRead, fileWrite)
def removeStagedFile(
  root: string,
  dir: string,
  name: string,
  filename: string,
): Result {
  return interrupt std::toolbox::removeStagedFile("Are you sure you want to remove this staged file?", {
    root: root,
    dir: dir,
    name: name,
    filename: filename
  })
  destructive {
    return try _remove("${dir}/${filename}")
  }
}

// Assemble the fixed wrapper and check the draft before user review.
def assembleTool(source: string, settings: DraftSettings): Result<ModuleFacts> {
  const wroteImpl = writeToolFile(settings, IMPL_FILE, source)?
  const filled = fill(
    toolTemplate,
    {
      maxTime: settings.maxTime,
      maxCost: settings.maxCost,
      purpose: settings.purpose
    },
  )?
  const toolSource = toSource(filled)
  const wroteTool = writeToolFile(settings, TOOL_FILE, toolSource)?
  // The sandboxed compile resolves the pair's imports and refuses
  // TypeScript, Node, and pkg:: imports anywhere in the closure, so a
  // draft cannot reach a raw primitive that raises no interrupt. It runs
  // first because a draft that exports the wrong name fails here, and
  // that is a problem the next draft can fix.
  const compiled = compile(toSource(filled), settings.stagingDir, strict: true)
  if (compiled is failure(compileErr)) {
    return draftProblem("${compileErr}")
  }
  // The compile does not report type errors; the typecheck does. It takes
  // the source the template just produced, so nothing is read back.
  const report = typecheck(toolSource, dir: settings.stagingDir, strict: true)?
  if (report.errors.length > 0) {
    const messages = map(report.errors, \diag -> diag.message)
    return draftProblem("the tool does not typecheck:\n${messages.join("\n")}")
  }
  const requestOk = checkRequestMatches(source, settings.request)?
  return moduleFacts(settings.stagingDir, ".")
}

def requestSignature(source: string): Result<string> {
  const info = describe(source)?
  const exported = find(info.exports, \entry -> entry.name == "Request" && entry.kind == "type")
  if (exported == null) {
    return failure("no exported `type Request`")
  }
  return success(exported.signature)
}

// The wrapper imports whatever `Request` the source declares, so the
// typecheck cannot tell that it differs from the request text meta.json
// will advertise. Both are printed by `describe`, so spacing and line
// breaks do not count as a difference.
def checkRequestMatches(source: string, request: string): Result {
  const expected = requestSignature("export type Request = ${request}")?
  const actual = requestSignature(source)
  if (actual is failure(missing)) {
    return draftProblem("${missing} in ${IMPL_FILE}")
  }
  if (actual.value != expected) {
    return draftProblem(
      "${IMPL_FILE} declares `${actual.value}` but the request type is `${expected}`. Copy the request type as given.",
    )
  }
  return success(null)
}

def failedCases(report: TestReport): string[] {
  const failing = filter(report.cases, \caseReport -> !caseReport.pass)
  return map(failing, \caseReport -> "${caseReport.node}: ${caseReport.feedback}")
}

def runGeneratedTests(source: string, settings: DraftSettings): Result {
  const generator = compile(toSource(casesProgram), settings.stagingDir)
  if (generator is failure(compileErr)) {
    return failure("the test case generator did not compile: ${compileErr}")
  }
  const generated = run(
    compiled: generator.value,
    node: "main",
    args: {
      brief: casesBrief(source),
      model: settings.model,
      provider: settings.provider
    },
  )
  if (generated is failure(runErr)) {
    return failure("the test case generator did not run: ${runErr}")
  }
  // `data` is the generator node's own Result.
  const cases: Result<GeneratedCase[]> = generated.value.data
  if (cases is failure(caseErr)) {
    return failure("the test cases had the wrong shape: ${caseErr}")
  }
  const prepared = prepareCases(cases.value)?
  const wroteCases = writeToolFile(settings, TEST_FILE, testFileJson(prepared))?
  // The cases are already in hand, so they run from memory: the file is
  // written for the saved tool to be re-tested later, never read back.
  const report = test(settings.stagingDir, TOOL_FILE, memoryCases(prepared))
  if (report is failure(testErr)) {
    return failure("tests could not run: ${testErr}")
  }
  if (!report.value.pass) {
    return draftProblem("tests failed:\n${failedCases(report.value).join("\n")}")
  }
  return success(null)
}

// Only a tool that does nothing but compute gets generated tests.
def testSource(source: string, settings: DraftSettings, review: Review): Result<Draft> {
  const assembled = assembleTool(source, settings)?
  const effects = assembled.effects
  const pure = effects.length == 0 && !callsUnpredictable(source)
  if (!pure) {
    // A previous round may have been pure and left a test file behind;
    // it must not ship with this draft.
    if (exists(TEST_FILE, settings.stagingDir)) {
      const cleared = removeStagedFile(
        settings.root,
        settings.stagingDir,
        settings.name,
        TEST_FILE,
      )?
    }
    return success(
      {
        source: source,
        effects: effects,
        tested: false,
        review: review
      },
    )
  }
  const tested = runGeneratedTests(source, settings)?
  return success({
    source: source,
    effects: effects,
    tested: true,
    review: review
  })
}

// The review of one draft. A redraft that answered reviewer feedback with
// `cannotFix` is not reviewed again: the reviewer would raise the same
// point, and the user is the one who can settle it.
def reviewDraft(source: string, settings: DraftSettings, fromReviewer: boolean): Result<Review> {
  let unresolved: Unresolved[] = []
  if (fromReviewer) {
    unresolved = unresolvedFor(settings.stagingDir)
  }
  if (unresolved.length > 0) {
    progress(
      settings,
      "the author could not fix ${unresolved.length} review points, so this draft goes to you",
      map(unresolved, \item -> "${item.point} (${item.reason})"),
    )
    return success({ notes: [], blocking: [], unresolved: unresolved })
  }
  if (!settings.review) {
    return success({ notes: [], blocking: [], unresolved: [] })
  }
  progress(settings, "the draft is written, reviewing it")
  return reviewSource(source, settings)
}

// Feedback for a draft that failed its checks after the author reported
// points it could not fix. The failure comes first, then the points, so
// the author fixes the failure and says again which of them it still
// cannot fix.
def stillOpen(problem: string, unresolved: Unresolved[]): string {
  let lines = [problem, "", "You also reported these points as ones this module cannot fix. They are still open. Report each one again with `cannotFix` if the new draft cannot fix it either."]
  for (item in unresolved) {
    lines.push("- ${item.point} (you said: ${item.reason})")
  }
  return lines.join("\n")
}

// Parse errors, typecheck errors, and failing tests go back to the author
// without the user. So does the reviewer's first set of blocking
// findings. When the reviewer blocks the redraft too, the draft goes to
// the user with the findings, so nobody waits through a third review.
def prepareDraft(
  settings: DraftSettings,
  feedback: string,
  fromReviewer: boolean,
  drafted: string,
  round: number,
): Result<Draft> {
  progress(settings, "writing draft ${round + 1}")
  const source = draftSource(settings, feedback, fromReviewer, drafted)?
  const reviewed = reviewDraft(source, settings, fromReviewer)?
  const review = reviewed
  if (review.blocking.length > 0) {
    if (!fromReviewer) {
      progress(
        settings,
        "the reviewer found ${review.blocking.length} problems, writing another draft",
        review.blocking,
      )
      return failure(
        review.blocking.join("\n"),
        { retryable: true, fromReviewer: true, drafted: source },
      )
    }
    progress(
      settings,
      "the reviewer still has ${review.blocking.length} problems, so this draft goes to you",
      review.blocking,
    )
  }
  const draft = testSource(source, settings, review)
  if (draft is failure(problem)) {
    // The draft did not assemble or its tests failed. The points the
    // author could not fix are still open, so they go back with the
    // failure and the author keeps `cannotFix`. It has to report again
    // the ones the next draft still cannot fix: a draft nobody reported
    // anything about is one the reviewer should see.
    if (draft.data.retryable != true) {
      return draft
    }
    let message = problem
    const open = review.unresolved.length > 0
    if (open) {
      message = stillOpen(problem, review.unresolved)
    }
    return failure(message, { retryable: true, fromReviewer: open, drafted: source })
  }
  if (draft.value.tested) {
    progress(settings, "the generated tests pass")
  }
  return draft
}

// Return null for a bare approval or "accept", and other nonempty text as feedback.
def askUser(
  name: string,
  settings: DraftSettings,
  draft: Draft,
  previous: string,
  resumed: boolean,
): Result<string | null> {
  const answer = interrupt std::toolbox::review("Review this tool before it is saved: answer accept, or give feedback for the next draft", {
    name: name,
    stagingDir: settings.stagingDir,
    source: draft.source,
    previous: previous,
    effects: draft.effects,
    tested: draft.tested,
    notes: draft.review.notes,
    blocking: draft.review.blocking,
    unresolved: draft.review.unresolved,
    resumed: resumed
  })
  if (answer == null || answer == ACCEPT) {
    return success(null)
  }
  const feedback: string! = answer
  if (feedback is failure) {
    return failure("unrecognized review answer ${JSON.stringify(answer)}: answer \"${ACCEPT}\", or give feedback for the next draft as text")
  }
  if (feedback.value.trim() == "") {
    return failure("a revise answer needs feedback")
  }
  return feedback
}

// The save gate. Both entry points publish through here, so a policy
// that matches std::toolbox::save sees every tool that enters a toolbox.
// Data carried on the approval is not consulted.
@capabilities(fileRead, fileWrite)
def askToSave(
  root: string,
  name: string,
  source: string,
  effects: string[],
): Result {
  return interrupt std::toolbox::save("Do you want to save this tool?", {
    dir: root,
    name: name,
    source: source,
    effects: effects
  })
  return success(null)
}

// Derive the tool parameter schema from its compiled Request type in the
// sandbox, using the requestSchema node supplied by the fixed wrapper.
def requestSchemaOf(settings: DraftSettings): Result<Json> {
  const ran = runFile(settings.stagingDir, TOOL_FILE, REQUEST_SCHEMA_NODE)
  if (ran is failure(runErr)) {
    return failure("could not derive the request schema: ${runErr}")
  }
  return success(ran.value.data)
}

// Add metadata and publish the staged directory with one rename. Recheck
// the name because approval and model calls can take time.
@capabilities(fileRead, fileWrite)
def saveTool(
  root: string,
  name: string,
  settings: DraftSettings,
): Result<ToolEntry> {
  const requestSchema = requestSchemaOf(settings)?
  const meta: ToolMeta = {
    ...DEFAULT_META,
    purpose: settings.purpose,
    request: settings.request,
    requestSchema: requestSchema,
    createdAt: format(now()),
    maxTime: settings.maxTime
  }
  const savedMeta = writeToolFile(settings, META_FILE, JSON.stringify(meta, null, 2))?
  if (exists(name, root)) {
    return failure(
      "a tool named `${name}` appeared in ${root} while this one was being written",
    )
  }
  // The save gate was answered a moment ago and named this root and
  // name; the rename that fulfils it raises nothing of its own.
  const published = try _move(settings.stagingDir, "${root}/${name}")?
  return interrupt std::toolbox::scan("Are you sure you want to read the saved tool?", {
    dir: "${root}/${name}"
  })
  return success(entryFor(root, name))
}

// A draft that was ready for the user and that nobody accepted: the turn
// was cancelled at the review prompt, or the review was rejected. Kept
// for the rest of the session, keyed by toolbox root and tool name, so
// "go ahead and save that tool" does not cost another drafting run. It is
// the source and its review, not a staging directory, because a rejected
// review clears staging. A saved tool is forgotten.
type Unfinished = {
  request: string;
  purpose: string;
  source: string;
  review: Review
}

let _unfinished: Record<string, Unfinished> = {}

def unfinishedKey(root: string, name: string): string {
  return "${root}/${name}"
}

// Only a draft written for the same request type and the same purpose is
// offered again. Both are compared because a draft nobody accepted stays
// kept for the session: if a different purpose still matched, asking for
// another tool of the same name and request shape would keep bringing
// back the draft the user already turned down, and no model would ever
// write the one they asked for.
def unfinishedDraft(
  root: string,
  name: string,
  request: string,
  purpose: string,
): Unfinished | null {
  const kept = _unfinished[unfinishedKey(root, name)]
  if (kept == null || kept.request.trim() != request.trim()) {
    return null
  }
  if (kept.purpose.trim() != purpose.trim()) {
    return null
  }
  return kept
}

// The kept draft goes through assembleTool and the tests again, into this
// call's own staging directory, so it is checked and staged like any
// other draft. No model writes or reviews it a second time.
def resumeDraft(
  root: string,
  name: string,
  settings: DraftSettings,
): RoundOutcome | null {
  const kept = unfinishedDraft(root, name, settings.request, settings.purpose)
  if (kept == null) {
    return null
  }
  progress(settings, "picking up the draft from earlier in this session")
  const draft = testSource(kept.source, settings, kept.review)
  if (draft is failure) {
    _unfinished[unfinishedKey(root, name)] = null
    progress(settings, "that draft no longer passes its checks, so starting a new one")
    return null
  }
  return presentDraft(root, name, settings, draft.value, "", true)
}

// Four things carry between rounds: `feedback`, the last problem or the
// user's revision request; whether that feedback is the reviewer's, which
// decides if the author may answer it with `cannotFix`; the draft the
// feedback is about, which the next author starts from; and the draft the
// user last saw, so the next round's review prompt shows a diff against it.
def rounds(
  root: string,
  name: string,
  settings: DraftSettings,
  maxRounds: number,
): Result<ToolEntry> {
  let feedback = ""
  let fromReviewer = false
  let previous = ""
  let drafted = ""
  const resumed = resumeDraft(root, name, settings)
  if (resumed != null) {
    if (resumed.done != null) {
      return resumed.done
    }
    feedback = resumed.feedback
    previous = resumed.source
    drafted = resumed.drafted
  }
  for (round in range(maxRounds)) {
    const outcome = oneRound(root, name, settings, feedback, fromReviewer, previous, drafted, round)
    if (outcome.done != null) {
      return outcome.done
    }
    feedback = outcome.feedback
    fromReviewer = outcome.fromReviewer
    previous = outcome.source
    drafted = outcome.drafted
  }
  return failure("gave up after ${maxRounds} rounds. Last problem:\n${feedback}")
}

def gateAndSave(
  root: string,
  name: string,
  settings: DraftSettings,
  source: string,
  effects: string[],
): Result<ToolEntry> {
  const approved = askToSave(root, name, source, effects)?
  return saveTool(root, name, settings)
}

/** `done` ends the loop; otherwise `feedback` goes into the next round,
  with `fromReviewer` saying whose it is. `source` is the draft this round
  showed the user, which the next round diffs its own draft against.
  `drafted` is the draft the feedback is about, which the next round's
  author starts from. The two differ when a draft never reached the user. */
type RoundOutcome = {
  done?: Result<ToolEntry>;
  feedback: string;
  fromReviewer: boolean;
  source: string;
  drafted: string
}

// A draft nobody accepted is not saved. When it reached the user because
// of open review points, the failure names them, so a model that called
// this in a run with no user at a terminal can report why.
def notAccepted(rejected: string, review: Review): string {
  let lines = [rejected]
  for (point in review.blocking) {
    lines.push("Reviewer: ${point}")
  }
  for (item in review.unresolved) {
    lines.push("Reviewer: ${item.point}\n  The author could not fix it: ${item.reason}")
  }
  return lines.join("\n")
}

def oneRound(
  root: string,
  name: string,
  settings: DraftSettings,
  feedback: string,
  fromReviewer: boolean,
  previous: string,
  drafted: string,
  round: number,
): RoundOutcome {
  const prepared = prepareDraft(settings, feedback, fromReviewer, drafted, round)
  if (prepared is failure(problem)) {
    // No draft to show, so the next round still diffs against the last
    // one the user saw.
    if (prepared.data.retryable == true) {
      return {
        done: null,
        feedback: problem,
        fromReviewer: prepared.data.fromReviewer == true,
        source: previous,
        drafted: prepared.data.drafted ?? drafted
      }
    }
    return {
      done: failure(problem),
      feedback: "",
      fromReviewer: false,
      source: previous,
      drafted: drafted
    }
  }
  progress(settings, "draft ${round + 1} is ready for you to look at")
  return presentDraft(root, name, settings, prepared.value, previous, false)
}

// Show a checked draft to the user and act on the answer. The draft is
// remembered first: a cancelled turn never comes back from `askUser`.
def presentDraft(
  root: string,
  name: string,
  settings: DraftSettings,
  draft: Draft,
  previous: string,
  resumed: boolean,
): RoundOutcome {
  const key = unfinishedKey(root, name)
  _unfinished[key] = {
    request: settings.request,
    purpose: settings.purpose,
    source: draft.source,
    review: draft.review,
  }
  const answer = askUser(name, settings, draft, previous, resumed)
  if (answer is failure(rejected)) {
    return {
      done: failure(notAccepted(rejected, draft.review)),
      feedback: "",
      fromReviewer: false,
      source: draft.source,
      drafted: draft.source
    }
  }
  if (answer.value == null) {
    const saved = gateAndSave(root, name, settings, draft.source, draft.effects)
    if (saved is success) {
      _unfinished[key] = null
    }
    return {
      done: saved,
      feedback: "",
      fromReviewer: false,
      source: draft.source,
      drafted: draft.source
    }
  }
  return {
    done: null,
    feedback: answer.value,
    fromReviewer: false,
    source: draft.source,
    drafted: draft.source
  }
}

// The name is a directory name under the toolbox, so anything but an
// identifier (a path, `..`, a dot directory) is refused.
def checkNameSyntax(name: string): Result {
  if (!(name =~ re/^[a-z][A-Za-z0-9]*$/)) {
    return failure("tool name must be a camelCase identifier, got `${name}`")
  }
  if (name.length > MAX_TOOL_NAME_LEN) {
    return failure(
      "tool name must be at most ${MAX_TOOL_NAME_LEN} characters, got ${name.length}",
    )
  }
  if (name == STAGING_DIR) {
    return failure(
      "`${STAGING_DIR}` is the toolbox's staging directory and cannot be a tool name",
    )
  }
  return success(null)
}

def checkName(name: string, root: string): Result {
  const syntax = checkNameSyntax(name)?
  if (exists(name, root)) {
    return failure("a tool named `${name}` already exists in ${root}")
  }
  return success(null)
}

// `request` is the caller's own type text, so parsing it is the right
// check: it must read as exactly one type alias.
def checkRequest(request: string): Result {
  const parsed = parseAST("export type Request = ${request}")
  if (parsed is failure(parseErr)) {
    return failure(
      "request must be Agency type text such as `{ n: number }`: ${parseErr}",
    )
  }
  const aliases = filter(parsed.value.nodes, \astNode -> astNode.type == "typeAlias")
  const others = filter(parsed.value.nodes, \astNode -> astNode.type != "typeAlias")
  if (aliases.length != 1 || others.length != 0) {
    return failure("request must be one Agency type, got `${request}`")
  }
  return success(null)
}

/** What the two entry points share once their inputs pass: the expanded
  toolbox root and the settings for this call's staging directory. */
type Staged = {
  root: string;
  settings: DraftSettings
}

// Every check runs before anything is written or anyone is asked.
def stage(
  name: string,
  purpose: string,
  request: string,
  dir: string,
  maxTime: number,
  maxCost: number,
  model: string,
  provider: string,
  review: boolean = false,
): Result<Staged> {
  // Expanded once here: the sandboxed testFile resolves paths without
  // expanding `~`, unlike the file primitives.
  const expanded = expandRoot(dir)?
  const root = expanded
  const nameOk = checkName(name, root)?
  if (maxTime > MAX_TOOL_TIME) {
    return failure(
      "maxTime must be at most ${MAX_TOOL_TIME} ms (an hour minus the subprocess headroom), got ${maxTime}",
    )
  }
  const requestOk = checkRequest(request)?
  // Each call stages in its own directory, so two writes of the same
  // name never share a draft; the publish rename decides which one wins.
  const stagingDir = "${root}/${STAGING_DIR}/${name}-${Math.floor(Math.random() * 1000000000)}"
  const made = makeStagingDir(root, stagingDir, name)?
  return success({
    root: root,
    settings: {
      purpose: purpose,
      request: request,
      name: name,
      root: root,
      stagingDir: stagingDir,
      maxTime: maxTime,
      maxCost: maxCost,
      model: model,
      provider: provider,
      review: review
    }
  })
}

// On success the publish rename already emptied staging. On failure a
// refused removal is folded into the returned failure.
def clearStaging(
  outcome: Result<ToolEntry>,
  root: string,
  name: string,
  stagingDir: string,
): Result<ToolEntry> {
  if (outcome is success) {
    return outcome
  }
  const removed = removeStagingDir(root, stagingDir, name)
  if (removed is failure(removeErr)) {
    return failure(
      "${outcome.error}\n(could not clear staging directory ${stagingDir}: ${removeErr})",
    )
  }
  return outcome
}

/**
Use this when a tool still needs to be written or refined. The coding agent
writes against your fixed request type. Each draft gets sandbox compilation
and typechecking; pure, predictable tools also get generated tests.
Set `review: true` to add a review agent before those checks.

The user can accept the draft or give text feedback for another round.
Acceptance still requires the save approval shared with `writeTool`.
An unfinished draft stays available during the session for the same name,
request type, and purpose.

The saved tool runs under `maxTime` and `maxCost`. These limits apply to
future calls of the tool, rather than the drafting process.
*/
export def designTool(
  name: string,
  purpose: string,
  request: string,
  dir: string = "~/.agency-agent/tools",
  maxRounds: number = 3,
  maxTime: number = 3m,
  maxCost: number = $1.00,
  model: string = "",
  provider: string = "",
  review: boolean = false,
): Result<ToolEntry> {
  """
  Draft a reusable tool, revise it with the user, and save it after acceptance.

  @param name - Unused camelCase name of at most 64 letters or digits, starting with a lowercase letter and excluding "staging".
  @param purpose - What the tool should do, in plain language.
  @param request - Input type as Agency text, such as `{ amount: number; rate: number }`.
  @param dir - Nonempty toolbox directory to write into.
  @param maxRounds - Maximum draft and revision rounds before returning a failure.
  @param maxTime - Saved tool's time limit in milliseconds, at most 59m30s.
  @param maxCost - Saved tool's cost limit in dollars.
  @param model - Model override for drafting and review, or "" for the configured model.
  @param provider - Provider for the model override.
  @param review - Have a review agent inspect drafts before their checks when true.
  """
  const staged = stage(name, purpose, request, dir, maxTime, maxCost, model, provider, review)?
  const outcome = rounds(staged.root, name, staged.settings, maxRounds)
  return clearStaging(
    outcome,
    staged.root,
    name,
    staged.settings.stagingDir,
  )
}

def assembleAndSave(
  root: string,
  name: string,
  source: string,
  settings: DraftSettings,
): Result<ToolEntry> {
  const assembled = assembleTool(source, settings)
  if (isFailure(assembled)) {
    return assembled
  }
  return gateAndSave(root, name, settings, source, assembled.value.effects)
}

/**
Use this for source you have already written and tested. It compiles and
typechecks the source with a generated wrapper, shows the source and effects
for save approval, and publishes the tool. It makes no model calls and
generates no tests. Use `designTool` to draft or revise with the user.

The source must export a `Request` type matching `request` and a
`run(request: Request): Json` function. Import `Json` from `std::validation`.
The wrapper enforces `maxTime` and `maxCost` on later tool calls.
*/
export def writeTool(
  name: string,
  purpose: string,
  request: string,
  source: string,
  dir: string = "~/.agency-agent/tools",
  maxTime: number = 3m,
  maxCost: number = $1.00,
): Result<ToolEntry> {
  """
  Save an already-written tool after checking its source and obtaining approval.

  @param name - Unused camelCase name of at most 64 letters or digits, starting with a lowercase letter and excluding "staging".
  @param request - Input type as Agency text, matching the source's Request type.
  @param source - Agency-only module exporting `Request` and `run(request: Request): Json`.
  @param dir - Nonempty toolbox directory to write into.
  @param maxTime - Saved tool's time limit in milliseconds, at most 59m30s.
  @param maxCost - Saved tool's cost limit in dollars.
  """
  const staged = stage(name, purpose, request, dir, maxTime, maxCost, "", "")?
  const outcome = assembleAndSave(staged.root, name, source, staged.settings)
  return clearStaging(
    outcome,
    staged.root,
    name,
    staged.settings.stagingDir,
  )
}

// ---- running a tool ----

@capabilities(fileRead, fileWrite)
def recordUse(root: string, name: string, meta: ToolMeta): Result {
  return interrupt std::toolbox::recordUse("Record one use of this tool in its ${META_FILE}?", {
    dir: "${root}/${name}",
    name: name
  })
  const used: ToolMeta = {
    ...meta,
    uses: meta.uses + 1,
    lastUsedAt: format(now())
  }
  return try _write(root, "${name}/${META_FILE}", JSON.stringify(used, null, 2), "overwrite")
}

/**
Runs the saved `main` node in a subprocess under the tool's stored time
limit. Metadata must be readable before the tool starts. After the run,
recording its use count is best-effort: a rejected or failed metadata write
does not change the tool's result. The result itself is not recorded there.
*/
@capabilities(fileRead)
export def runTool(
  name: string,
  request: Json,
  dir: string = "~/.agency-agent/tools",
): Result<Json> {
  """
  Run a saved tool with its input and return its result.

  @param name - The saved tool's name under dir.
  @param request - A value matching the tool's Request type.
  @param dir - Nonempty toolbox directory holding the tool.
  """
  const syntax = checkNameSyntax(name)?
  const root = expandRoot(dir)?
  const toolDir = "${root}/${name}"
  if (!exists("${name}/${TOOL_FILE}", root)) {
    return failure("no tool named `${name}` in ${root}")
  }
  // The same approval listTools asks for, here for one tool's directory:
  // meta.json is read before the run, so a corrupt record stops the tool
  // before it has side effects rather than after.
  return interrupt std::toolbox::scan("Are you sure you want to read this tool?", {
    dir: toolDir
  })
  const meta = metaFacts(root, name)?
  const ran = runFile(
    toolDir,
    TOOL_FILE,
    "main",
    {
      request: request
    },
    wallClock: meta.maxTime + WALL_CLOCK_HEADROOM,
  )
  let result: Result<Json> = ran
  if (ran is success(finished)) {
    result = finished.data
  }
  // Best-effort: the tool has already run.
  recordUse(root, name, meta)
  return result
}
