Skip to content
8 min read · 1,601 words

Dev Tools

This is a featured battery

The dev tools battery is large enough to warrant its own section. This page is the hub; the spokes cover the workspace model, the steps, engines and capability selection, in-place fixers, and the agent glue.

Ask an agent to fix a lint error and count the round-trips. Read the file. Send an edit. Call the linter. Read what the linter changed, because it changed things you did not ask about. Call the typechecker. Somewhere in there the model is holding a mental copy of a file it last saw three turns ago, and quietly getting it wrong.

That is four tool calls and one hallucination waiting to happen, and the reason is not that the model is stupid. It is that nothing in the stack owns the workspace. Every tool reads disk, writes disk, and forgets. The model is left doing the state-threading by hand, in a context window, which is the one place state should never live.

This battery does the whole thing in one plan:

typescript
await dp(['src/**/*.ts'])
  .edit({ path: 'src/index.ts', edits: [{ find: 'const x = 1', replace: 'const x = 2' }] })
  .write()
  .lint({ fix: true })
  .check()

One plan. One gate per step. One composite result. Note where write sits — not at the end.

What it is

typescript
import { createDevPipeline } from '@nhtio/adk/batteries/dev-tools'

const dp = await createDevPipeline({
  handle,          // SandboxHandle — supplies effective policy and epoch discipline
  fileSystem,      // SandboxFileSystem — optional mutators decide capability availability
  pathTranslator,  // PathTranslator — backend locators must be absolute host paths
  gate,            // DevGateFn — called once per step, and once for acquisition
  root: '/srv/project',
  engines: [/* deployment-supplied formatters, linters, checkers */],
})

// The chainable builder — immutable, thenable, no .execute()
const result = await dp(['src/**/*.ts', 'tsconfig.json'])
  .readLines({ path: 'src/index.ts', start: 1, end: 40, label: 'head' })
  .edit({ path: 'src/index.ts', edits: [{ find: 'oldName', replace: 'newName' }] })
  .write()
  .check()

// The same plan as structured ops
const viaOps = await dp.ops(['src/index.ts'], [
  { step: 'edit', args: { path: 'src/index.ts', edits: [{ find: 'a', replace: 'b' }] } },
  { step: 'write', args: {} },
  { step: 'lint', args: { fix: true } },
  { step: 'check', args: {} },
])

Three subpaths, and no more: the root, /forge for forgeDevTools, and /conformance. Construction is strict — handle, fileSystem, pathTranslator, gate and root are required, and an unknown key is rejected outright. A config typo fails at construction with the key named, rather than surfacing an hour later as an unrelated runtime error.

There is no pipe DSL, and that is not an oversight

The media battery has one. verb name=value | verb ... is what models write there, and it earns its place: that grammar absorbs seven engine vocabularies — pdf-lib page ranges, ExcelJS A1 addresses, tesseract segmentation modes — behind one syntax. The chaos is real and it has to go somewhere.

Source editing has no such chaos. One vocabulary: lines and text. So the grammar would be paying a cost for a benefit that does not arrive — and the cost is not theoretical.

The pipe grammar cannot carry code

unquote applies its replacements in order: \n → newline, then \t → tab, then one final pass unescaping an escaped delimiter — \\, \", \', \/. Feed it a Windows path escaped the obvious way and watch the second replacement fire before the third can collapse anything:

C:\\temp  →  C:\<TAB>emp

Naive escaping, quote-only escaping and double-quoted forms were all tested against the real transform. All corrupt. This is not an escaping bug you can escape your way out of — it is how the transform is built. A json arg type does not save you either: JSON.parse runs on the string after unquoting has already eaten it.

The corroborating evidence was sitting in the media battery the whole time. Its own apply_patch verb cannot use the pipe: its tests drive it through structured ops, and its source notes that pipe strings exclude raw newlines by grammar. When two verbs need the surface's core mechanism switched off, the surface is telling you something.

So this battery takes structured arguments only — native tool-call parameters, the builder, structured ops. The provider parses the tool call, values arrive structured, nothing is re-encoded. What that buys is a list of problems that simply do not exist here: no unquoting lossiness, no arg-type encoding debate, no envelope grammar, no marker collisions, no serialiser to write and no serialiser to maintain.

A workspace, not a payload

Media processes one {bytes, mimeType, filename} payload. This battery holds a workspace: Map<path, WorkspaceFile>, where a WorkspaceFile is {text, mimeType}.

The tools being wrapped are project-shaped and there is no way around it. A typechecker cannot judge one file. A rename crosses files. Add File in a patch grows the set. A single-file abstraction would have to be widened the first time a real checker attached, so it starts wide.

write sits in the middle of the chain

This is the one place the media model does not transfer, and it is worth being explicit about why.

In media, the write-equivalent is terminal: bytes flow forward and the chain ends. Here it cannot be, because the tools that matter — eslint --fix, prettier --write, codemods — mutate files on disk. Run one and the in-memory workspace is instantly stale.

So write is an ordinary step, the pipeline continues past it, and after an in-place capability runs the runtime re-reads what it actually changed. A check after a fixer sees the fixed content, not what the model wrote. That is the AI-X win and it is the reason the interceptor seam exists.

Acquisition is a decision you make, not a scan we run

paths: readonly string[]. An entry containing no * is a literal path; anything else is a glob. That single test is the whole disambiguation rule.

The pipeline reads exactly the resolved set. There is no project scan, no "load the repository" mode, and there will not be one: an unbounded recursive read of a repository is precisely the memory hazard the bounds exist to prevent, and offering it as a convenience would undo them.

A glob matching nothing is an error, not an empty workspace — a silent empty acquisition produces a confusing "no diagnostics" success. A literal naming a directory is an error too, with a message saying to use a glob: silently expanding src to src/** would make the bounds a caller reasoned about wrong by orders of magnitude. dp([]) is legal and means what it says — the project-initialisation case, where a generator creates files from nothing.

BoundDefaultWhy
maxFiles500A typecheck over a large project is legitimate; an accidental **/* is not
maxBytesPerFile2 MiBA minified bundle or a lockfile is not editable content
maxTotalBytes32 MiBThe real memory ceiling

Supplied bounds are validated as integer >= 1. A fraction, zero, a negative, NaN, Infinity — each is a config error naming the field. Nothing is clamped, because maxFiles: 0.5 is a mistake, and silently reading it as 0 (acquire nothing) or 1 (acquire one file) produces a baffling failure far from its cause. Infinity is rejected specifically: "no bound" is not a supported configuration, and an escape hatch that removes the caps would be used by the first person who hit one.

Acquisition gates before it reads

One gate call for acquire, then one per step.

This is not ceremony. Acquisition reads the whole path set before any step runs, so an ungated acquisition discloses file contents to the runtime and to every engine you composed before the operator has approved anything. Reading is a discovery primitive. The sandbox's own tools gate before their reads; so does this.

The workspace is UTF-8 and LF, and writing back is lossy

We are about to state an opinion

This is a choice, not a fact, and you are free to disagree:

A code-editing battery should have one canonical text form. WorkspaceFile.text is decoded text with LF endings, and every write encodes UTF-8 with no BOM. A UTF-16 file read and written back comes out UTF-8. CRLF comes out LF — normalisation happens once, at acquisition, in decodeText, so nothing downstream has to reason about terminators.

The shared hunk primitive underneath is stricter than that: applyUpdateHunks rejoins with whatever convention its input used, so a caller outside this battery editing a CRLF file gets a one-line diff rather than a whole-file rewrite. Dev-tools never exercises that branch, because its text is already LF by the time a hunk sees it.

That is a real conversion and it is the right default: a repository with a .gitattributes expects LF, and mixed endings inside one file are a reliable source of spurious diffs. The alternative — round-tripping each file's original encoding — sounds conservative until you cost it. It means threading an encoding tag through every delta, every re-read, and every engine that creates a file, and it produces a workspace where two files with identical text have different bytes and a generated file has no defensible encoding at all.

What this documentation owes you is the statement, not the preservation.

What comes back

DevResult carries diagnostics, changes, reads, written, unreadable, ok and lineCountsAvailable.

ok is derived, never engine-reported: false if any diagnostic is error-severity. An error diagnostic does not abort the plan — lint → check should report both, and stopping at the first would hide the second. A thrown step failure does abort. Those are different events and they are treated differently.

changes is a final-state diff against acquisition, one row per path — not an event log. A file edited and then reverted produces no row at all, because nothing changed.

Direct calls — dp(...), dp.ops(...), chain.run() — return a plain in-memory DevResult. Only the forged tools spool to a SpooledJsonArtifact. A direct caller is TypeScript code that wants the object; minting an artifact for it would be indirection with no beneficiary.

  • The Workspace — the eight fields, the two baselines, and why identity survives a rename.
  • The Steps — all seven, their exact failure modes, and the on-disk write ordering.
  • Engines & Capabilities — declarations, selection arbitration, generators, diagnostic stamping.
  • In-Place FixersDevFileAccess, the authoritative re-read, and the limits we will not overstate.
  • Agent ToolsforgeDevTools, the two surfaces, and why granular tools persist.