Tools II: Hands (write_file, edit_file, bash)


Lecture Overview

Lecture 02 gave your agent eyes: read_file and list_files can look at your machine, but nothing they do can change it. That safety net disappears today. write_file, edit_file, and bash are the three tools that let the model actually change things: create or overwrite files, rewrite a piece of one in place, and run arbitrary shell commands. There is no sandbox, no dry-run mode — runTool("bash", { command: "rm -rf fixtures/" }) really deletes fixtures/, on your actual laptop, the instant it runs. Today you write the code that turns a tool_use block into real action for the three tools capable of doing real damage, and nothing yet stops the model from asking for that action — that gap is deliberate, and it's the whole subject of lecture 04.

Mechanically, today is unsurprising: you're extending the same switch (name) dispatch table from lecture 02, adding three more case branches to runTool, with nothing about its signature, default case, or errors-throw convention changing. The star of the lecture is edit_file: it looks like a simple find-and-replace, but it embeds the single most important idea in this course's tool design — an edit is only safe to perform if you can prove, mechanically, that its target is unique. This is the exact trick the real Claude Code uses.

How this lecture works: three numbered problems, 3.1–3.3, each with a spec, a worked example, constraints lifted directly from tests/03_mutating_tools.test.ts, and 2–4 unlock questions answered only in the Appendix. This lecture is worth 2 of the course's 10 points and is also the course's checkpoint: by the end, all five tools work, and ./ok 00, ./ok 02, and ./ok 03 are all green.


1. From Eyes to Hands: Same Table, New Branches

Concept

Recall lecture 02 §3: runTool is a dispatch table — one entry point, one switch (name), one case per tool, a default that refuses anything unrecognized. You already have two branches in place. Today's job is mechanically identical to what you already did twice: add a case, write the body, return a string, let failures throw. The only thing that changes lecture to lecture is what the branch does, never the shape it does it in.

Open index.ts and find the two cases you wrote for lecture 02, plus the default case beneath them. That's where write_file, edit_file, and bash go — three more case labels in the same switch, in any order relative to each other, as long as they stay above default (a switch doesn't require any particular case ordering, but default conventionally goes last so a reader scans real cases before the fallback).

Why It Matters

A dispatch table's entire value proposition, restated from lecture 02 §3, is that adding a sixth branch never touches the first five. If today's work makes you edit anything inside your existing read_file or list_files cases, that's a signal something is wrong — not a normal part of "extending" the switch. This is the same property that makes TOOLS (the schema array) safe to have five complete entries already sitting in index.ts since lecture 01: the model has known about write_file, edit_file, and bash by name and schema since before you'd written a single line of runTool. Today you're just making three promises the schema already made come true.

Your Task

Before writing any code: predict, don't write. Does adding write_file, edit_file, and bash as new case branches require touching anything inside your existing read_file or list_files cases?

Checkpoint

No. Dispatch tables are exactly this kind of open/closed structure: extending them by adding a case is additive, never a modification to what's already there. If your plan for today involves editing either of lecture 02's cases, stop and reconsider — nothing about them needs to change.

Stuck? See Snippet 1 in the Appendix.


2. Problem 3.1: write_file — Writing Blind, Reporting Honestly

Concept

write_file does two things read_file never had to: it can create a file that doesn't exist yet (including its parent directories), and it changes what's on disk instead of only reading it. Node's fs.writeFileSync(path, content) — the write-side counterpart to fs.readFileSync from lecture 02 — handles the actual write, synchronously, same as before. The new piece is the directory problem: if the model asks to write nested/deep/out.txt and neither nested/ nor nested/deep/ exist yet, a plain writeFileSync call throws ENOENT before it ever gets to create the file, because writeFileSync never creates directories for you.

Node gloss — path.dirname and path.resolve. path (imported at the top of index.ts as import * as path from "node:path") is Node's path-manipulation module. path.dirname(p) strips the last path segment and returns everything before it — the same job as chopping a string at its last / (path.dirname("nested/deep/out.txt") → "nested/deep"; path.dirname("out.txt") → ".", since a bare filename's "directory" is the current one). path.resolve(p) turns a possibly-relative path into an absolute one, anchored at the current working directory if p doesn't already start with /. Combining the two — resolve first, then take the dirname of the absolute path — means the directory-creation step behaves identically whether the model handed you "notes.txt" or "/full/path/notes.txt"; it's a defensive habit, not something today's tests specifically require you to get exactly right.

Node gloss — fs.mkdirSync(dir, { recursive: true }). Plain mkdirSync throws if any intermediate directory in dir is missing — you'd need to create nested/ before nested/deep/, one level at a time. Passing { recursive: true } makes it behave like the shell's mkdir -p: it creates every missing intermediate directory in one call, and — just as importantly — it does not throw if the directory already exists. That second property matters here: you're calling this on every write_file call, including ones where the parent directory is already there.

Why It Matters

Without directory auto-creation, the model would have to plan two tool calls for the common case of "create a new file in a new subfolder" — first some kind of mkdir step, then the write — and your tool schema doesn't even offer a mkdir tool. TOOLS[2]'s description already promises this for you ("Creates parent directories as needed" — go re-read it in index.ts, it's been sitting there, GIVEN, since lecture 01). Your job today is making that promise true.

The second half of this problem — what you return — is easy to under-think because no test in tests/03_mutating_tools.test.ts checks write_file's return value at all. Both write_file tests only inspect the file on disk afterward. That's a trap, not a green light: recall lecture 01 §3, "every capability the model appears to have... is entirely mediated by what the harness chooses to put into messages." If write_file returns "", ./ok 03 still passes — and the model, on its next turn, sees an empty tool_result and has no idea whether the write happened, what path it landed at, or how much content went in. Return something that reports what happened: which path, and some confirmation of what was written. The tests won't catch you skipping this; the whole point of the tool would be quietly broken anyway.

Your Task

Spec. Add a write_file case to the switch. Given input.path and input.content, create any missing parent directories, write the file (creating it if new, overwriting it if it exists), and return a string that reports what happened.

Worked example. runTool("write_file", { path: "out.txt", content: "abc" }) must leave a file at out.txt containing exactly "abc" — no added newline, no wrapping. runTool("write_file", { path: "nested/deep/out.txt", content: "x" }), run against a directory where neither nested/ nor nested/deep/ exist yet, must leave nested/deep/out.txt containing "x", with both intermediate directories created along the way.

Constraints (from tests/03_mutating_tools.test.ts):

it("creates a file with the given content", () => {
  const p = path.join(dir, "out.txt");
  runTool("write_file", { path: p, content: "abc" });
  expect(fs.readFileSync(p, "utf8")).toBe("abc");
});

it("creates missing parent directories so the model needn't mkdir first", () => {
  const p = path.join(dir, "nested/deep/out.txt");
  runTool("write_file", { path: p, content: "x" });
  expect(fs.readFileSync(p, "utf8")).toBe("x");
});

Both path and content are required in write_file's schema (required: ["path", "content"] — check TOOLS[2] in index.ts), unlike list_files's optional path from lecture 02. There's no input.path ?? ... defaulting to write here; the model is contractually obligated to send both.

Hint: Create the parent directory before writing — one call to fs.mkdirSync with the recursive option, using path.dirname (optionally combined with path.resolve, as discussed above) to compute which directory to create from input.path. Then a single call to the write-side counterpart of the readFileSync you used in lecture 02, passing input's path and content. Your return value is up to you — just make sure it names the path and confirms the write happened.

Unlock questions:

  1. Since no test asserts write_file's return value, returning "" would technically pass every test in this file. Why is that still a bad implementation, given what lecture 01 §3 said about what the model can and can't see?
  2. Plain fs.mkdirSync(dir) (no options) throws if any intermediate directory is missing. What specifically does { recursive: true } change, and which of the two tests above exists purely to catch you if you forgot it?
  3. write_file's schema has required: ["path", "content"], unlike list_files's required: [] from lecture 02. Does your write_file case need any input.content ?? ... fallback logic the way list_files needed input.path ?? "."? Why or why not?

Checkpoint

Run just this problem's tests in isolation:

npx vitest run tests/03_mutating_tools.test.ts -t "write_file"

Expected: both write_file tests pass, everything else in the file (edit_file, bash) is skipped by the -t filter — not failing, just not yet attempted.

Stuck? See Snippet 2 in the Appendix.


3. Problem 3.2: edit_file — The Uniqueness Invariant

Concept

edit_file looks, at first glance, like it should be a one-liner: find old_string in the file, swap it for new_string. The catch — the entire reason this is the star of the lecture — is what happens when old_string doesn't appear exactly once.

If old_string appears twice in the file, which occurrence did the model mean? "The first one" is a guess. "The last one" is a guess. Silently picking one is exactly how an agent corrupts a file while reporting success — the model asked for one specific change, the tool made a change, and nobody, including the model, can tell from the returned string which one actually happened. An edit whose target isn't unique isn't an ambiguous edit — it's not a valid edit request at all; a correctly built edit tool refuses it and asks for a more specific old_string, rather than guessing which occurrence was meant.

The mechanical trick for checking uniqueness is a neat one-liner: text.split(old_string) breaks the file's text into pieces wherever old_string occurs. Splitting a string on a substring that occurs N times produces N + 1 pieces, so text.split(old_string).length - 1 gives you the exact occurrence count, with zero scanning logic of your own to write.

Why It Matters

This is, almost verbatim, the mechanism real Claude Code uses for its own edit tool. The reason isn't cleverness for its own sake — it's that "edit a file" is a genuinely dangerous operation to get wrong silently. A read_file bug produces a wrong answer the model might catch on its own; a write_file bug overwrites something you can often just re-run to fix. An edit_file bug that "succeeds" by editing the wrong occurrence of a common string (imagine old_string: "return null;" in a file with a dozen functions) produces a file that looks fine, passes a shallow glance, and is silently, subtly wrong — the worst kind of bug, because nothing about the tool's reported success hints that anything went sideways.

Refusing loudly is the fix. Recall lecture 02 §5's convention: runTool never returns an error as a normal string, it throws — and the caller (eventually agentLoop, lecture 05) converts that into an is_error: true tool_result the model reads and can react to. edit_file needs two different refusal messages, not one generic "edit failed," because the model's correct next move differs completely depending on which failure occurred: zero matches means the model misremembered the file's exact text and needs to re-read it; more than one match means the model needs to include more surrounding context in old_string to pin down a single occurrence (exactly what TOOLS[3]'s description already tells it: "old_string must appear EXACTLY once... include surrounding lines to make it unique"). A generic error would leave the model guessing which kind of mistake it made.

Your Task

Spec. Add an edit_file case. Read the file at input.path. Count how many times input.old_string occurs in its contents. If zero, throw an error whose message contains "not found". If more than one, throw an error whose message contains "appears N times" (with the real count substituted for N) and leaves the file on disk completely untouched. If exactly one, replace that occurrence with input.new_string, write the file back, and return a string reporting what happened.

Worked example. File f.txt contains "one two three". runTool("edit_file", { path: "f.txt", old_string: "two", new_string: "2" }) leaves f.txt containing "one 2 three". File f.txt contains "aa" (the string "a" occurs twice). runTool("edit_file", { path: "f.txt", old_string: "a", new_string: "b" }) throws, and — critically — f.txt still contains "aa" afterward, unchanged.

Constraints (from tests/03_mutating_tools.test.ts):

it("replaces a uniquely-matching old_string", () => {
  const p = path.join(dir, "f.txt");
  fs.writeFileSync(p, "one two three");
  runTool("edit_file", { path: p, old_string: "two", new_string: "2" });
  expect(fs.readFileSync(p, "utf8")).toBe("one 2 three");
});

it("throws when old_string is not found — no silent no-op edits", () => {
  const p = path.join(dir, "f.txt");
  fs.writeFileSync(p, "hello");
  expect(() => runTool("edit_file", { path: p, old_string: "xyz", new_string: "q" })).toThrow(/not found/);
});

it("throws when old_string appears more than once — the invariant that keeps edits unambiguous", () => {
  const p = path.join(dir, "f.txt");
  fs.writeFileSync(p, "aa"); // "a" appears twice
  expect(() => runTool("edit_file", { path: p, old_string: "a", new_string: "b" })).toThrow(/appears 2 times/);
  // File must be untouched when the edit is refused.
  expect(fs.readFileSync(p, "utf8")).toBe("aa");
});
Hint: Read the file first (you already know how — same call as lecture 02's read_file case). Compute the occurrence count using the split-length trick described above. Branch on that count: 0 → throw with "not found" in the message; > 1 → throw with "appears ${count} times" in the message; otherwise, write the replaced text back and return a confirmation string. Notice the ordering this forces: the count check has to happen before any fs.writeFileSync call, or the "file must be untouched" assertion in the third test will fail even if your error message is exactly right.
Common mistake: Using String.prototype.replaceAll or a global regex to "just fix all occurrences at once" instead of refusing. That silently does the opposite of what this tool is for — it doesn't ask which occurrence the model meant, it changes all of them, which is a different (and, for this tool, wrong) operation. edit_file targets exactly one occurrence, or none.

Unlock questions:

  1. Walk through "aa".split("a") by hand — what array does it produce, and why does subtracting 1 from its length give you the correct occurrence count of 2?
  2. The "appears more than once" test also asserts the file on disk is unchanged afterward. What does that imply about the order of operations in your edit_file case — specifically, what must happen (and not happen) before you're sure the count is exactly 1?
  3. Why does the tool need two distinct error messages ("not found" vs. "appears N times") instead of one generic "edit failed, try again"? Think about what the model would need to do differently on its next turn in each of the two failure cases.

Checkpoint

npx vitest run tests/03_mutating_tools.test.ts -t "edit_file"

Expected: all three edit_file tests pass (the unique-match case, the not-found case, and the ambiguous case with its untouched-file assertion).

Stuck? See Snippet 3 in the Appendix.


4. Problem 3.3: bash — Spawning a Real Shell

Concept

bash is the least specialized and most powerful tool in the set: instead of one narrow capability (read a file, write a file, edit a substring), it hands the model an entire shell. Whatever input.command says, bash -c will interpret it exactly the way it would if you'd typed it at a terminal prompt — pipes, &&, redirects, all of it.

Node gloss — spawnSync vs. the alternatives. Node's child_process module offers several ways to run an external program: exec/execSync run a command string through the system shell and buffer its output, while spawn/spawnSync take a program name and an argument array separately, and give you back (or, for the async versions, hand you) a structured result rather than deciding for you what "success" and "failure" mean. execSync in particular throws on its own the moment the child process exits non-zero — a real JavaScript exception, with its own shape, that you'd have to inspect and reformat. spawnSync, by contrast, never throws for a non-zero exit; it just returns an object describing what happened (its exit code among other fields) and leaves the decision of "is this a success or a failure" entirely to your code. That's exactly the control runTool needs: you already have a convention (errors throw, lecture 02 §5) and you want your code — not Node's child-process internals — deciding when that convention kicks in.

Node gloss — spawnSync's return object. spawnSync(command, args, options) returns an object (TypeScript calls it SpawnSyncReturns) with, among other fields: status — the exit code as a number (0 for success; null if the process was killed by a signal instead of exiting normally), stdout — everything the process wrote to standard output, and stderr — everything it wrote to standard error. By default stdout/stderr come back as raw Buffers (same trap as fs.readFileSync without an encoding, lecture 02 §3) — pass { encoding: "utf8" } in the options object to get plain strings instead.

Node gloss — template literals. You've already used one, briefly, in lecture 02's default case (` Unknown tool: ${name} ) — a backtick-delimited string with ${...} interpolation. Today's job needs the fuller version: template literals can span multiple lines and embed \n characters directly, which makes them the natural tool for assembling a multi-line report (exit code on one line, stdout on the next, stderr after that) without a chain of +` concatenation.

Why It Matters

Every prior tool in this course has a narrow, auditable job — you can read runTool's read_file case and know with certainty every possible thing it can do to your machine (read one file, nothing else). bash has no such ceiling: input.command could be "echo hi" or it could be "rm -rf fixtures/", and runTool's job is to run either one, faithfully, and report back exactly what happened — success or failure, stdout, stderr, and the exit code — so the model can react. This is precisely why GATED (already GIVEN in your skeleton: export const GATED = new Set(["write_file", "edit_file", "bash"])) singles out bash alongside the other two mutating tools. Nothing you write today asks permission before running — preview and confirm are still throw new Error("YOUR CODE HERE") stubs, lecture 04's job. Today you're building the capability; the gate that will stand in front of it, deciding whether a human gets asked first, is built next lecture, on purpose, as a separate step.

Your Task

Spec. Add a bash case. Run input.command through a real shell using spawnSync, capture its exit code, stdout, and stderr into one string, and return that string on success. On a non-zero exit, throw — with the same captured information (exit code, stdout, stderr) inside the error's message, not a short generic message — so the model can see why it failed, not just that it did.

Worked example. runTool("bash", { command: "echo hi" }) returns a string that contains "hi" and also contains the text "exit code: 0" somewhere in it — the tests only check .toContain(...), so the exact layout of your output string is your call, as long as those pieces are present. runTool("bash", { command: "exit 3" }) throws, and the thrown error's message contains the text "exit code: 3".

Constraints (from tests/03_mutating_tools.test.ts):

it("returns stdout and a zero exit code for a successful command", () => {
  const out = runTool("bash", { command: "echo hi" });
  expect(out).toContain("hi");
  expect(out).toContain("exit code: 0");
});

it("throws on a non-zero exit, embedding the exit code so the model sees the failure", () => {
  expect(() => runTool("bash", { command: "exit 3" })).toThrow(/exit code: 3/);
});
Hint: spawnSync's signature is spawnSync(command, args, options) — a program name, an argument array, and an options object, returning a result object rather than throwing. bash's -c flag tells it "treat the next argument as a full command string to execute," which is how you hand it input.command without Node re-parsing it itself. Assemble a single string from the result's exit code, stdout, and stderr — a template literal with embedded \ns is the natural fit. Check the exit code: if it's not 0, throw an error using that same assembled string as the message; otherwise return it.

Unlock questions:

  1. execSync throws on its own the moment a command exits non-zero — you'd have to catch its exception and reformat it to fit runTool's conventions. Why does spawnSync's "hand you a status code, you decide" model fit more naturally here, given the errors-throw convention runTool already follows for read_file and edit_file?
  2. The second test asserts the thrown error's message contains "exit code: 3" via a regex match. What does that imply about what you pass to new Error(...) in the failure branch — is a short message like "command failed" enough, or does the error need to carry the same full output a successful call would have returned?
  3. (No test covers this — reason about it anyway.) spawnSync's status field is null, not a number, when a process is killed by a signal rather than exiting normally. Given a check written as status !== 0, what does your bash case do in that case — does it treat a signal-killed process as success or failure? Is that the behavior you'd want?

Checkpoint

npx vitest run tests/03_mutating_tools.test.ts -t "bash"

Expected: both bash tests pass — the successful-command case and the non-zero-exit case.

Stuck? See Snippet 4 in the Appendix.


5. Why Lecture 04 Exists: The Permission Gate

Concept

Step back and look at what you just built. write_file can overwrite any file your Node process can reach. edit_file can rewrite any file's contents, guarded only by the uniqueness invariant — which protects against ambiguity, not against the edit being a bad idea in the first place. bash can run literally anything a shell can run. None of the three ask permission. None of them log a preview for a human to glance at before they run. runTool("bash", { command: "rm -rf fixtures/" }), called directly the way tests/03_mutating_tools.test.ts calls every case, runs immediately, no questions asked — and it will, the moment agentLoop (lecture 05) exists and a model decides that's the right move.

Why It Matters

This isn't a bug in today's work — it's the deliberate seam between two lectures. GATED already names all three of today's tools, sitting GIVEN in your skeleton since lecture 01. preview and confirm, the functions that would actually stop and ask, are still stubs. You're building the dangerous capability now, in isolation, so that when lecture 04 wires a human y/n in front of it, you'll feel exactly what problem it's solving — rather than getting the gate "for free" bundled in with the tools themselves, which would hide how much of an agent's safety actually depends on that one extra step.

Your Task

Predict: right now, with everything you've built through the end of lecture 03, if you typed a message at a hypothetical REPL that got the model to call bash with { command: "rm -rf fixtures/" }, what — concretely — would stop that from actually happening to your real fixtures/ directory?

Checkpoint

Nothing does. Not today. agentLoop doesn't even exist yet (that's lecture 05), so nothing currently calls runTool in response to a live model at all — but the moment it does, unless confirm is wired in front of GATED tools, every one of today's three cases runs unconditionally the instant the model asks. That gap — dangerous capability built, safety mechanism not yet built — is exactly where lecture 04 picks up.

Stuck? See Snippet 5 in the Appendix.


Checkpoint: All Five Tools Work

This is the moment the curriculum calls out as the course's checkpoint. Run each lecture's suite on its own:

./ok 00
ok: running tests for problem 00

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 Test Files  1 passed (1)
      Tests  3 passed (3)
./ok 02
ok: running tests for problem 02

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 Test Files  1 passed (1)
      Tests  4 passed (4)
./ok 03
ok: running tests for problem 03

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 Test Files  1 passed (1)
      Tests  7 passed (7)

(./ok takes exactly one argument per run — there's no single ./ok 00 02 03 invocation; run each in turn, as above. ./ok with no argument at all runs everything currently in tests/, which right now means these same three files together.)

If all three are green, every one of the five tools in TOOLS now has a working runTool case behind it: read_file, list_files (lecture 02), and write_file, edit_file, bash (today). That's 3 of the course's 10 points banked (1 for lecture 01, 2 for lecture 02, 2 for lecture 03), and — more importantly than the points — it de-risks everything downstream. preview (lecture 04) will format previews for exactly these three tools' inputs. confirm (lecture 04) will gate exactly these three. agentLoop (lecture 05) will call runTool and expect exactly the behavior you built today: strings on success, thrown errors on failure. Nothing about the tool layer changes again for the rest of the course — from here on, you're building the machinery around what you just finished.


Key Takeaways

  1. Same dispatch table, three new branches — nothing about the shape changes. Extending runTool with write_file, edit_file, and bash never required touching read_file or list_files; that's the entire point of building it as a dispatch table in lecture 02.
  1. write_file is mkdirSync(..., { recursive: true }) plus writeFileSync, and its return string matters even though no test checks it. The model only knows what runTool tells it; a silently-correct-but-uninformative return value is a real defect the test suite can't catch.
  1. edit_file's uniqueness invariant is the star of this lecture: old_string must behave like a unique key. Count occurrences with text.split(old_string).length - 1; zero means "not found," more than one means "ambiguous, add context" — and the file must stay untouched until you've confirmed the count is exactly 1. This is the same mechanism real Claude Code's edit tool uses, for the same reason: an edit that could land in two places isn't a smaller version of a correct edit, it's corruption wearing success's clothes.
  1. bash hands the model a real shell via spawnSync, capturing exit code, stdout, and stderr into one reportable string. spawnSync (unlike execSync) never throws on its own for a non-zero exit — that decision is yours, and you make it the same way read_file already did: throw on failure, embedding the full context (not a generic message) so the model can see exactly what went wrong.
  1. Errors throw, everywhere, for all five tools now. Lecture 02 §5's convention — runTool returns a plain string or throws, never a { ok: false }-shaped result — holds without exception across today's three cases too, and it's exactly the contract lecture 05's agentLoop will consume when it converts a caught exception into an is_error: true tool_result.
  1. None of today's three tools ask permission — that gap is intentional, and it's lecture 04's entire reason to exist. GATED already names write_file, edit_file, and bash; preview and confirm are still stubs. You built the capability first, on purpose, so the safety mechanism you build next is felt as a real addition, not an invisible default.

What we skipped (for future study or future lectures)


Appendix: Reference Implementation

Check your work here after attempting each section. Don't read ahead — one snippet at a time.

Snippet 1: Section 1 unlock answer

No — adding write_file, edit_file, and bash as new case branches in the same switch (name) never requires touching the existing read_file or list_files cases. Each case is an independent branch keyed on name; the switch doesn't care how many cases exist above or below any given one. Extension is additive: adding a case never requires modifying an existing one.

Snippet 2: write_file case
case "write_file":
  fs.mkdirSync(path.dirname(path.resolve(input.path)), { recursive: true });
  fs.writeFileSync(input.path, input.content);
  return `Wrote ${input.content.length} chars to ${input.path}`;
Snippet 2: write_file case — unlock answers
  1. "" would pass both tests here because neither one inspects write_file's return value — they only check the file on disk. But the model never sees the disk; it only sees what runTool returns as the tool_result's content. An empty return leaves the model with no confirmation of what path was written or how much content landed there, on a turn where that's the only information it has to decide what to do next (lecture 01 §3: "every capability... is entirely mediated by what the harness chooses to put into messages").
  2. Plain mkdirSync(dir) throws ENOENT if any intermediate directory in dir is missing, and also throws EEXIST-style errors in some cases if the target already exists (depending on Node version and exact call). { recursive: true } makes both non-issues: it creates every missing intermediate directory in one call, and is a no-op (no throw) if the directory's already there. The second test — "nested/deep/out.txt" written into a directory where neither nested/ nor nested/deep/ exist — exists specifically to catch an implementation that forgot the recursive option.
  3. No — write_file's schema has required: ["path", "content"], so the model is contractually required to send both fields; input.content will never be undefined for a schema-conformant call the way input.path legitimately could be undefined for list_files. No ?? fallback is needed here.
Snippet 3: edit_file case
case "edit_file": {
  // The same trick real Claude Code uses: string replacement, but the
  // target must be UNIQUE so an edit can never land in the wrong place.
  // This is an INVARIANT check — exactly one match, or we refuse.
  const text = fs.readFileSync(input.path, "utf8");
  const count = text.split(input.old_string).length - 1;
  if (count === 0) throw new Error(`old_string not found in ${input.path}`);
  if (count > 1)
    throw new Error(`old_string appears ${count} times in ${input.path}; add surrounding context to make it unique`);
  fs.writeFileSync(input.path, text.replace(input.old_string, input.new_string));
  return `Edited ${input.path}`;
}
Snippet 3: edit_file case — unlock answers
  1. "aa".split("a") produces ["", "", ""] — three elements. Splitting on every occurrence of "a" in "aa" cuts the string at both positions where "a" appears, leaving three pieces (the text before the first a, between the two as, and after the second a — all empty strings here, but still three array slots). Three elements minus one gives 2, the correct occurrence count. In general, splitting a string on a substring that occurs N times produces N + 1 pieces, since each occurrence is one cut point.
  2. The count check — reading the file and computing text.split(input.old_string).length - 1 — must happen entirely before any fs.writeFileSync call. Concretely: you read the file once, compute the count, and only reach the fs.writeFileSync line at all in the count === 1 path; both throwing branches return control (via throw) before that line is ever reached, so the file is never written to when the edit is refused.
  3. Because the model's correct next move differs completely between the two cases. "Not found" means the model's old_string doesn't match the file's actual text at all — it should re-read the file (read_file) to see what's really there before trying again. "Appears N times" means the text is present, just not uniquely — the model should keep its old_string but add more surrounding lines to pin down one specific occurrence, per TOOLS[3]'s own description. A single generic "edit failed" message would erase that distinction and leave the model guessing which kind of mistake it made.
Snippet 4: bash case
case "bash": {
  const r = spawnSync("bash", ["-c", input.command], { encoding: "utf8" });
  const out = `exit code: ${r.status}\nstdout:\n${r.stdout}stderr:\n${r.stderr}`;
  if (r.status !== 0) throw new Error(out); // non-zero exit is an error the model should see
  return out;
}
Snippet 4: bash case — unlock answers
  1. execSync throws immediately on a non-zero exit, and the exception it throws has its own shape (an Error subclass carrying status, stdout, stderr as properties you'd have to dig out and reformat into a message yourself). spawnSync never throws on its own — it always returns the same shaped result object regardless of exit code — so your code decides when to throw and what message to give it, letting you build one consistent "throw with the full captured context" pattern that matches how edit_file and read_file already behave, rather than adapting to a different exception shape Node hands you for free.
  2. The error needs the same full output a successful call would have returned — exit code, stdout, and stderr all embedded in the message, not a short string like "command failed". The test's regex /exit code: 3/ only requires that substring to be present, but the deeper reason (see §4's Why It Matters) is that the model needs to see why the command failed — what it printed to stdout and stderr — to have any chance of fixing its next attempt. throw new Error(out) reuses the exact same formatted string the success path would have returned.
  3. With r.status !== 0, a signal-killed process (where status is null, not 0) satisfies null !== 0 and is treated as a failure — the throw branch runs. That's arguably the right call (a process that never got to exit normally shouldn't be reported as success), though it's not exercised by any test in this lecture and the error message would show "exit code: null", which is honest but a little confusing without more context. Not something today's tests require you to polish.
Snippet 5: Section 5 unlock answer

Nothing stops it — not yet. GATED (export const GATED = new Set(["write_file", "edit_file", "bash"])) is already sitting in index.ts, fully written since lecture 01, and it does name bash as a tool that's supposed to require confirmation. But naming isn't enforcing: preview and confirm, the two functions that would actually check GATED, print a summary, and wait for a human's y/n, are both still throw new Error("YOUR CODE HERE") stubs. And even upstream of that, agentLoop — the only piece of code that would ever call confirm in the first place — doesn't exist until lecture 05. Right now, if you called runTool("bash", { command: "rm -rf fixtures/" }) directly (the way today's tests do), it would simply run. Lecture 04 closes exactly this gap.


End of lecture.