I have a small agent that handles one piece of routine work at a time. It looks
at what needs doing, picks the thing most worth doing, shows me the plan, and
does it if I say yes. Underneath, it is glue: it drives a few command-line
tools, calls a model, reshapes a lot of JSON, and prints a readable summary.
It was 2150 lines of bash across seven files. It is now Python.
So the answer looks like yes. I don't think it is, and why I don't is most of
the reason I'm writing this down.
I had been through this question before and decided to stay β carefully enough
that the reasoning became a section of the project README titled Why this is still bash. Nine tenths of the program is subprocess orchestration, which is
I still think all of that is true.
What moved was a requirement. I had been treating runs with no build step as
hard, which made "python3 is already installed" the load-bearing argument. Then
the requirement got clarified: no build step isn't a rule, it just shouldn't be
complicated to start.
That one sentence killed my best argument. So I measured what was actually at
stake β 89ms for Python plus every standard library module it needs, against
3ms for bash. Eighty-six milliseconds, in a program that waits thirty to a
hundred seconds on a model.
My reasoning was valid; its inputs weren't, and I had spent almost no effort checking them. That ratio was backwards, and I don't think that's unusual.
jq
Every list length, every filter, forking a process to handle data that should
have been sitting in memory.
The cost was not performance. 162 forks are nothing next to a minute of model
latency. The cost was expressiveness. Every structure in the program either
fit in a one-line jq expression or got split into three pieces. What I wrote
was never the structure I wanted; it was the structure jq could state on one
line. That cost is invisible in any single line of code and shows up in the
designs you never consider.
I had three lists: the files a change actually touched, the files the agent
itself had written, and the files I had approved in advance. I needed two
differences between them.
later="$(jq -nc --argjson a "$actual" --argjson w "$written" \
'if $w == null then [] else ($a - $w) end')"
extra="$(jq -nr --argjson w "$written" --argjson p "$planned" \
'($w - $p) | join(", ")')"
later = [] if written is None else [f for f in actual if f not in written]
extra = [f for f in written if f not in planned]
The second one is barely shorter. It is what I would have written on the first
attempt; the first took me several tries to get right.
There was also a run that died on line 567: 1: command not found, which I
never located. It went away when that section was rewritten for unrelated
reasons. A bug you can't find after the fact doesn't just go unfixed β it tells you the next one of its kind will too.
The port took a day.
bash 2150 lines
Python 2258 lines β up 108
of which:
code 1278 β down 40%
comments 980
Most people expect the opposite, so it's worth being blunt about. The code
shrank by forty percent; the difference is comments and docstrings β the notes
recording which specific incident each safety check exists to prevent, which
were exactly what I'd been afraid of losing.
Evaluate this rewrite by total line count and it accomplished nothing.
This is the part I actually wanted to write down.
The prompts are files, not strings. Every prompt lives in its own Markdown
file and the code fills {{placeholder}} holes in it. I did that for unrelated
reasons: prompts get edited constantly, they want to be read as prose, and a
stray $ or backtick has to stay inert instead of being eaten by the shell.
The result was that the migration did not touch one word of any prompt. I
checked modification times afterward to be sure. Everything that determines
this program's behavior β the criteria I've tuned over and over, the order
judgments get made in β lives in those files. Changing languages only replaced
the glue that assembles them.
Each kind of task is a separate executable that speaks JSON. Verb on argv,
JSON on stdin, JSON on stdout. I built it that way because bash has no modules,
and putting them behind a process boundary beat having them scribble on each
other's variables. Pure coping.
The result: there was no big-bang rewrite to choose. The orchestrator could
be Python while the task types were still bash, or the reverse. I moved one
file at a time and ran each one on its own afterward. (That boundary is
probably also why this never hit the wall bash projects hit β the largest bash
agent I know of reached 4700 lines as a single file assembled by cat src/*.sh,
and what broke was module structure, not correctness.)
Neither seam was built with portability in mind. Both were built to solve
something annoying at the time.
Whether a rewrite will be cheap is decided before you start it.
I kept the process boundary afterward, by the way. Folding the task types into
Python imports would save a JSON round trip, and it is the best structural
decision in the project.
With a shebang and standard library only, it is invoked exactly the way it was
before. No virtualenv, no install.
The risk is that Python invites dependencies, and bash's poverty was itself
a form of protection. requests when urllib is right there; a schema
validator when the model CLI already enforces the schema; an argument parser
for 25 lines of parsing; a formatting library for a display layer that exists.
Each has a plausible case, and after all four "quick to start" is gone.
So there is one rule in the README now: standard library only, and say why it can't be done with it before adding anything. The whole program needs six
I exercised every path after the port, including a full dry run of the
expensive one β isolated checkout, model writes the code, formatter, vet,
build, tests, commit β stopping short of pushing. Fifteen tests on the pure
functions pass.
The two lines that push a branch and open a change request never ran,
because running them means actually opening one. And the safety checks inside
the task types I translated by hand, one at a time. I believe I got them right,
and this project still has no test that can prove it.
I don't think bash was the wrong choice. It carried this to 2150 lines, and for
all of that time I was changing judgment logic rather than fighting the
language. Its problem was never that it couldn't do the job. Its problem was that its expressiveness had started deciding my designs.