Skip to content
7 min read · 1,301 words

The Steps

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

StepArgumentsReturn 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.

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.