Target: a harness that can be pointed at a real Go or TypeScript repo, told "make the failing test pass," and actually do it — with numbers proving how often it works.
Stopping condition: 7 out of 10 eval tasks passing, or six weeks, whichever comes first. (See METRICS.md.)
harness/
cmd/harness/main.go # flag parsing, wiring, REPL entry
internal/
llm/
client.go # HTTP + SSE against the Messages API
types.go # Message, ContentBlock, ToolUse, ToolResult
stream.go # partial tool-call JSON accumulation
agent/
loop.go # the core turn loop
state.go # conversation history, file-read tracking
compact.go # summarization when the window fills
tools/
registry.go # Tool interface + typed wrapper
read.go glob.go grep.go
edit.go write.go
bash.go
truncate.go # shared output-capping helper
approval/
policy.go # allow/deny/ask, with session memory
metrics/
log.go # JSONL event log — every tool call, every token count
ui/
render.go diff.go
evals/
tasks/ # one dir per task: setup.sh, prompt.txt, verify.sh
runner.go
METRICS.md # you fill this in as you go. This is the deliverable.
Two rules that save pain later:
agent never imports ui. The loop must be runnable headless, because
the eval runner drives it.(string, error) where the error is for the model to
read, not for you to log and swallow. A failed edit is a normal event in
the transcript, not an exception.Time: half a day. Done when: go test ./... passes with a fake tool
registered and dispatched.
The one piece of Go design worth getting right on day one is typed tool
arguments, because otherwise you'll be doing map[string]any assertions in
every tool for the rest of the project.
Derive schemaFor[T]() from struct tags with github.com/invopop/jsonschema
rather than hand-writing JSON Schema. Hand-written schemas drift from the
structs and you won't notice until the model sends a field you don't parse.
Also in this phase: metrics.Log(event) writing one JSON object per line to
./.harness/events.jsonl. Wire it before you have anything to measure.
Retrofitting instrumentation is how projects end up with no numbers.
Time: 1–2 days. Done when: you ask "what does main.go do?" and the agent reads the file and answers.
Non-streaming first. Streaming is a distraction until the loop is correct.
build messages → POST /v1/messages → response
if stop_reason == "tool_use":
for each tool_use block: dispatch, collect result
append assistant message + user message containing tool_result blocks
repeat
else: print text, return
Things that bite here:
tool_result blocks,
one per tool_use id, in a single message. Multiple parallel tool calls
means multiple result blocks in that one message — not one message each.tool_use id must get a result, even if execution failed. Send the
error text with is_error: true. A missing result is an API error.context.Context from main through the loop into every tool from the
very start. Retrofitting cancellation later means touching every signature.Only one tool this phase: read_file(path, offset?, limit?).
Time: 1 week. Done when: the agent can find code it hasn't been shown, change it, and run the tests.
edit_file(path, old_str, new_str) — the centerpiecemap[string]time.Time of reads; if the file's mtime changed since, force a
re-read. This single rule kills a large class of hallucinated edits.old_str.
bash(command, timeout_ms)exec.CommandContext so cancellation works.… N lines elided … marker), never head alone — test failures live at the end.glob(pattern) and grep(pattern, path?, glob?)Shell out to rg if present, fall back to filepath.WalkDir + regexp. Cap
results at ~100 with a count of how many were suppressed. This is your entire
code-search story; you do not need embeddings.
write_file(path, content)New files only, or full rewrites the model explicitly chose. Keep it separate from edit so your metrics don't blend them.
Hard rule from this phase onward: run the harness only against a throwaway
git clone, or inside a container. There is no approval layer until Phase 6.
Assume it will run rm -rf on something eventually.
Time: 3–4 days. Done when: go run ./evals -n 3 prints a pass rate and a
metrics table.
This is the pivot point of the whole project. Everything after this gets measured; everything before it was guesswork.
Task format — one directory each:
evals/tasks/fix-nil-deref/
setup.sh # clone/copy a repo into a temp dir, apply a breaking patch
prompt.txt # what the user would type
verify.sh # exit 0 == task solved
Ten tasks, drawn from real repos you know. A good spread:
Runner requirements:
-n 3 to repeat each (agents
are nondeterministic; a single run tells you nothing).evals/runs/<ts>/<task>/<n>.json. You
will read these constantly. Most regressions are obvious within ten seconds
of reading a trajectory.Record the baseline in METRICS.md that day. Every later phase adds a row.
Time: 4–5 days. Done when: a task requiring 40+ turns completes, and your eval numbers didn't drop.
-compact-floor (0.97), compact anyway with a
one-message tail — the smallest that is still a valid conversation; if even
that finds nothing to fold, say so rather than let the next request fail as an
opaque API error. This is a backstop, not a second opinion about when to
compact: 70% is still where it normally happens./compact: the same fold on demand, for starting a long task with a
clean slate. It waits for the current turn, since it rewrites the history
that turn is building on, and past the floor it takes the smaller tail too —
with auto-compaction off it is the only move left.auto_compact: "off" (or -no-auto-compact) for anyone
who would rather lose the turn than the transcript. The floor then stops the
turn instead of folding, refusing the prompt before it joins the history so
that compacting and retrying does not send it twice. /compact is unaffected:
the setting is about the automatic fold, not the manual one.read_file results with a stub pointing at the current version.cache_read_input_tokens in responses to confirm
hits. This is your single biggest cost and latency lever, and compaction
breaks it, which is another argument for compacting rarely.Run evals after each change. Expect at least one of these to make things worse.
Time: 3–4 days. Done when: you can hit Ctrl-C mid-go test and give a
correction without losing the session.
llm.Client to SSE. The fiddly part is accumulating
input_json_delta fragments per tool-call index into complete JSON before
dispatch — buffer per block index, parse on content_block_stop..harness/sessions/<id>.json and add --resume.Keep agent free of terminal dependencies. The eval runner depends on that.
Time: 2–3 days. Done when: you'd run it on your actual working repo without flinching.
bash, write_file,
edit_file prompt, with "yes / no / always for this exact command / always
for this tool" and session memory.git status; rm -rf ~
defeats it, and so do backticks, $(), &&, xargs, and any interpreter
with -c. Either parse with mvdan.cc/sh/syntax and check each command
node, or don't pretend and rely on the sandbox plus prompts.filepath.EvalSymlinks and
verify it's inside the working root. Cheap and catches a lot.Only after you've hit your eval target.
Give it one phase at a time, with the interfaces fixed. A prompt shape that works:
Implement Phase 2 of the plan in PLAN.md: the
edit_filetool ininternal/tools/edit.go. It must satisfy thetools.Toolinterface already defined inregistry.go— don't change that interface. Requirements: reject edits to files not read this session (use the tracker inagent/state.go); exact-matchold_str; on zero matches retry with normalized whitespace and return the 20 surrounding lines of the closest candidate; on multiple matches return every match's line number and refuse; on success return a unified diff of the changed region; preserve the file's original line endings. Write table-driven tests covering all four match outcomes plus CRLF preservation. Rungo test ./internal/tools/...and iterate until green.
What makes this work: one file, an interface it may not change, explicit failure-mode requirements, and a command it can run to check itself. Vague phase-level prompts ("build the tool system") produce plausible code that fails in ways you won't find for a week.
The code is the byproduct. The deliverable is METRICS.md.