---
url: 'https://adk.nht.io/batteries/dev-tools/steps.md'
description: >-
  Seven steps, each with its failure modes named — `edit` delegates rather than
  reimplements matching, `apply_patch` clones before it touches anything, and
  `write` orders its renames by dependency because lexical order silently
  destroys a file.
---

# The Steps

## LLM summary — Dev Tools steps

* Seven: `read_lines {path,start,end?}` + top-level `label?`; `edit {path,edits:[{find,replace}]}`; `apply_patch {patch}`; `write {paths?}`; `format {paths?}`; `lint {paths?,fix?}`; `check {}`.
* `format`/`lint`/`check` require a configured capability. Unmet ⇒ NOT ADVERTISED to the model; an explicit call throws with a do-not-retry message and the available list.
* `read_lines` is NOT terminal — output lands in `DevResult.reads` keyed by `label` (default: the path); duplicate labels are a compile-time error. It reads the WORKSPACE, never disk. `start` 1-based required positive integer; `end` inclusive, omitted = EOF; `end` past EOF CLAMPS; `start` past EOF FAILS naming the line count; `end < start` is a distinct error; non-integers rejected; empty file is 0 lines; result joins with `\n` and has NO trailing newline.
* `edit` lowers each `{find,replace}` to a `ParsedHunk` and calls `applyUpdateHunks`. Inherits: forward-cursor anchoring, ambiguity error, cannot-apply error, whole-line matching unit, empty `replace` deletes. Empty `find` and `edits: []` are rejected at argument validation.
* `apply_patch` accepts the structured `*** Begin Patch` envelope ONLY; a unified diff is refused pointing at `edit`. It CLONES the workspace, applies, and derives a delta only on success — all-or-nothing.
* `write` default set = everything differing from `persistedBaseline` (text, deletions, renames, path-only moves). On-disk order: renames → deletions → creates/modifications. Rename phase is DEPENDENCY-sorted, lexical only for ties; a genuine swap uses a stat-checked temporary (`n+1` renames); a net-zero chain issues NOTHING. Bookkeeping advances per completed operation. `written[]` holds destinations, never removals. Not atomic across files. A failed write is terminal — `written` rides on the error for diagnosis, not resumption.

Seven steps. Every one operates over the workspace or emits diagnostics against it. No implicit disk reads, no fuzzy patching, no hidden mutations — and where a step can fail, this page names the failure rather than leaving you to find it.

## The seven steps

| Step | Arguments | Return Value |
|---|---|---|
| `read_lines` | `{ path, start, end? }` + top-level `label?` | Text stored in `DevResult.reads` |
| `edit` | `{ path, edits: [{ find, replace }] }` | Workspace delta |
| `apply_patch` | `{ patch }` | Workspace delta |
| `write` | `{ paths? }` | `written[]` |
| `format` | `{ paths? }` | Workspace delta |
| `lint` | `{ paths?, fix? }` | Diagnostics |
| `check` | `{}` | Diagnostics |

The steps `format`, `lint`, and `check` depend on backing engine capabilities. When a capability is not configured in your environment, the step is not advertised to the model. An explicit attempt to invoke an unconfigured step throws immediately with a non-retryable error naming the available alternatives.

## `read_lines` reads the workspace, not the disk

A common mistake in agent harnesses is treating file reads as terminal actions or reading stale disk state mid-plan. In this battery, `read_lines` does not end plan execution. Text output lands in `DevResult.reads` and execution continues down the step chain. Because the workspace itself acts as the active medium, `read_lines` reads workspace memory—never the filesystem. A file created in memory by a previous `apply_patch` is immediately readable; a file present on disk that was never acquired into the workspace cannot be read.

The boundary rules are strict:

* `start` is 1-based, required, and must be a positive integer.
* `end` is 1-based, inclusive, and optional. Omitting `end` reads to end-of-file (EOF).
* `end` beyond EOF clamps to EOF. Requesting "lines 40 to the end" is completely valid.
* `start` beyond EOF fails, naming the actual total line count in the error.
* `end < start` fails as an invalid range with distinct corrective guidance.
* Non-integer bounds fail validation immediately. The step validates integer bounds directly rather than relying on loose type coercion.
* An empty file has 0 lines. Any `start` value fails with `"the file has 0 lines"`.
* Line endings are normalized: `"a\nb\n"` is 2 lines.
* Returned text joins with `\n` without a trailing newline, even when reading through EOF.

Output is stored under `reads[label ?? path]`. Specifying a duplicate `label` across steps is a compile-time error rather than a silent overwrite.

## `edit` delegates; it refuses to guess

Each `{ find, replace }` entry lowers to a `ParsedHunk` and delegates directly to the existing `applyUpdateHunks` engine. Matching is never re-implemented with ad-hoc string replacement.

The matching contract provides two mandatory failure guarantees:

* **Ambiguous match:** If `find` matches multiple locations, execution fails with `"patch context is ambiguous and matches multiple locations"`.
* **Missing match:** If `find` is not found, execution fails with `"the patch could not be applied cleanly to the source text"`.

String manipulation primitives like `text.split(find).join(replace)` silently replace every occurrence across a file and report success. That is a corrupt edit disguised as a working tool. By contrast, delegating to hunk parsing ensures strict uniqueness and prevents silent misapplication.

```typescript
// Strict, deterministic workspace edit
await dp.ops(['src/config.ts'], [
  {
    step: 'edit',
    args: {
      path: 'src/config.ts',
      edits: [
        {
          find: '  port: 3000,\n  host: "localhost",',
          replace: '  port: 8080,\n  host: "0.0.0.0",',
        },
      ],
    },
  },
])
```

The inherited matching semantics are exact:

* **Forward cursor:** Anchoring proceeds strictly left-to-right through the file.
* **No overlapping edits:** Because the matching cursor only advances forward, overlapping replacements are structurally impossible.
* **Whole-line matching:** Matching operates on whole lines. Sub-line snippets will not match. This whole-line boundary must be explicitly described in tool prompts to prevent language models from attempting partial-line replacements.
* **Deletions:** An empty `replace` string (`""`) cleanly deletes the matched lines.
* **Rejected inputs:** An empty `find` string is rejected during validation. Evaluating `"".split('\n')` yields `[""]`, which would attempt to match an empty line and either anchor to the first blank line or fail ambiguously. An empty `edits: []` array is likewise rejected; submitting an edit step with no edits represents a malformed call that must not pass as a no-op success.

## `apply_patch` is transactional and structured

`apply_patch` accepts the structured `*** Begin Patch` multi-file envelope format only. Unified diffs are explicitly refused with an error pointing callers to `edit`. The workspace already represents multi-file context natively; unified diffs are single-file by construction and lack path metadata, requiring ad-hoc path parameters that do not exist in the dialect.

```text
*** Begin Patch
*** Update File: src/server.ts
--- src/server.ts
+++ src/server.ts
@@ -10,3 +10,3 @@
-const port = 3000
+const port = 8080
*** End Patch
```

The underlying patch primitive mutates `Map` collections in place and can throw mid-execution. To maintain the invariant that capabilities never mutate state on failure, `apply_patch` deep-clones the workspace prior to execution. The delta is derived and committed only when all hunks apply successfully. Patch application is all-or-nothing.

The parsed operation paths within the patch envelope form its authorization boundary, not merely display metadata for gates.

## `write` enforces deterministic disk ordering

Executing `write` persists all changes between the workspace and the `persistedBaseline`. This includes file modifications, newly created files, pending deletions, and path-only moves where content remains unchanged.

On-disk operations follow a normative sequence:

1. **Renames**
2. **Deletions**
3. **Creates and Modifications**

A plan containing `renamed: a -> b` alongside `changed: b` must move the file before writing new content, or the rename will immediately clobber the write.

::: danger Writes across multiple files are not atomic
A failure during filesystem writes leaves previously completed operations on disk. True filesystem atomicity across multiple paths requires staging directories and atomic tree replacement, which cannot be implemented portably across operating systems.
:::

Within the rename phase, execution order is determined by **dependency first**, with lexical sorting used only to break ties. If a disk holds `a` and `c`, and the plan executes `a -> b` and `c -> a`, a naive lexical sort would execute `c -> a` first. That would overwrite `a` before it is moved, causing `a -> b` to move `c`'s original content into `b` and corrupt the repository tree. Dependency ordering ensures `a -> b` executes before `c -> a`.

Rename cycles fall into two distinct categories:

* **Net-zero chains (`a -> b` then `b -> a`):** The chain collapses to an identity operation. No filesystem operations are issued, leaving `a` untouched on disk.
* **Genuine swaps (two files exchanging paths):** The runtime moves one file to a temporary location `<dir>/.dev-tools-rename-<random>`, completes the remaining renames, and moves the temporary file to its final destination (requiring $n+1$ operations for an $n$-node cycle). The temporary path is stat-checked before use and regenerated on collision up to 8 times. It is never added to the workspace, `changes`, or `written[]`. If execution fails mid-cycle, the temporary file is left on disk and named in the thrown error message for manual cleanup.

Disk bookkeeping records update per completed operation rather than as a single commit at the end. If a sequence fails halfway through, the internal record accurately reflects what is currently on disk.

The return array `written[]` contains paths where workspace content was written to disk: creations, modifications, and rename destinations. It does not include deletions or rename source paths. If an operation fails, execution terminates immediately. The thrown error includes `written: readonly string[]` detailing completed writes for diagnostic triage, allowing callers to author a fresh plan over the updated disk state.

## Transaction ordering is an explicit choice

Pipeline structure makes transaction timing completely visible:

```typescript
// 1. Post-write fixing: commits to disk, then runs autofix and checks
const postWritePlan = [
  { step: 'edit', args: { path: 'src/index.ts', edits: [/* ... */] } },
  { step: 'write', args: {} },
  { step: 'lint', args: { fix: true } },
  { step: 'check', args: {} },
]

// 2. Check before commit: validates in-memory workspace before writing to disk
const checkBeforeCommitPlan = [
  { step: 'edit', args: { path: 'src/index.ts', edits: [/* ... */] } },
  { step: 'lint', args: {} },
  { step: 'check', args: {} },
  { step: 'write', args: {} },
]
```

In post-write fixing (`edit -> write -> lint --fix -> check`), a diagnostic failure on `check` occurs after changes have committed to disk. This is standard behavior for post-write workflows and reports `ok: false`. When pre-commit validation is required, callers structure the plan as `edit -> lint -> check -> write`. Tool definitions expose both ordering patterns so the trade-off is deliberately chosen rather than hidden behind implicit framework defaults.
