We closed a JIRA ticket from the wrong machine and it worked. Merge to develop, push, branch cleanup, JIRA transition, browser verification, all of it, without ever opening a session in the repo where the code actually lived. We drove the whole close from a desktop station over the JIRA REST API and the git CLI, treating the target clone as a thing we poked from the outside.

It also hurt in seven distinct ways in a single sitting, and by the end of the day that list of papercuts was worth more than the closed ticket it came from.

Every one of those frictions is a default waiting to be encoded. The fix is never “be more careful next time.” Careful does not scale and it does not survive a context switch. Each correction goes into the tool as a hardcoded behavior or a halt condition, so the wrong move becomes impossible and nobody has to remember the right one.

Here are the seven. Each is a real bug class that will come back.

Windows has no /tmp

We reached for /tmp/ticket.json to stash the JIRA payload mid-close. On a Linux box that is muscle memory. On Windows the path does not exist, and the write failed. The right location is $TEMP, and the deeper answer is that a script should never hardcode a tmp path at all. It should ask the OS where temp lives.

This one is trivial, and that is the point. It still costs a turn every time because it lives in your fingers.

A token-saving filter that lied about state

We run a CLI wrapper that compresses verbose command output to save tokens. On git, curl, and API calls it cuts output 60 to 90 percent, which on a metered plan is real money. It works structurally: it recognizes shapes and summarizes them. When we piped a JIRA REST response through it, it handed our agent a schema sketch, { expand: string[82], fields: { ... } }, instead of the actual field values we needed to verify the transition had taken. The data we were trying to confirm was exactly the data the filter threw away.

The fix was to drop to Python’s urllib for API calls so nothing sat between the agent and the bytes. The rule that fell out: a pretty-printing layer is fine for humans, who read, and a correctness bug for agents, who parse. Anything load-bearing gets the raw bytes.

The same filter hid a merge commit

Right after, we tried to verify HEAD with a compacted git log -1 --oneline, and it returned a non-merge commit. The merge had already happened. The tool said otherwise because its compactor suppressed the merge node as noise. We almost re-ran a merge that was already done.

git rev-parse HEAD gave us the real SHA. Same lesson as the previous one with a different blast radius: for SHA verification, payload inspection, or anything where you are checking ground truth, the output filter has to be lossless or out of the path entirely.

Heredoc quoting hell

We had inline Python running inside a bash heredoc. The Python had an f-string, the f-string had escaped double quotes, and the whole thing was launched through a Windows shell. That is four layers of quoting, and it died. \" means something different at each layer, and the layers fight.

No clever escaping survives this reliably. The answer is to stop nesting languages in a string: write the Python to a file and run the file. It parses cleanly every time and is the fast path. We now treat “we are about to put code inside a heredoc inside a shell command” as a smell that means write a script instead.

git branch -d refused to delete, correctly

After the merge we went to clean up the local feature branch with git branch -d, and git refused, claiming the branch was not fully merged. It was merged. The message pointed at something subtler. The local feature branch carried the develop-into-feature merge commit, and origin’s copy of the feature branch did not. From git’s point of view, deleting the local branch would orphan a commit that existed nowhere else.

The fix was an ordering rule. Delete the local branch first, while the remote still exists as its upstream, then delete the remote branch last. git branch -d checks the branch against its upstream when one is configured, and only falls back to the stricter merged-into-HEAD check once that upstream is gone, which is what turns a clean delete into a forced one. You “learn” this five times before you encode it, because each time the error message sends you hunting for the wrong cause.

A JIRA transition with a hidden four-field screen

The transition we needed was more than a status flip. It pops a screen with four required fields: a links field, story points, fix version, and sprint. Our agent’s instinct was to stop and ask us. That sounds safe and is actually worse, because the agent does not know the screen exists until it tries to POST and gets rejected, and by then it is asking about fields it cannot see.

The encoded fix is specific: always fetch the transition with the fields expanded before the POST, verify every required field is set, then fire. The tool learns the shape of the transition from the API instead of guessing, and asking us becomes the last resort for real ambiguity.

Over-confirmation after one approval

We said “ok, proceed” once. After that the agent asked for a yes or no at every sub-gate: before the merge, before the push, before the branch delete, before the transition. One approved operation got chopped into five confirmation prompts. A close is one decision.

The rule: one approval covers the whole operation. Present the plan once, run to completion, and halt only on a real surprise such as a dirty repo, a missing credential, a ticket already closed, or a merge conflict. Re-asking inside an already-approved scope is noise, and it trains us to stop reading the prompts.

The list is the spec

We built a small Python toolkit where each friction became a default or a halt:

  • No tmp paths, ask the OS, and no heredocs ever, write Python to a file and run it.
  • API calls go through urllib and never a lossy filter, and SHA checks use git rev-parse. The raw-subprocess git wrapper carries a comment saying exactly why: scripts need authoritative output.
  • The develop-into-feature merge order is the only public entry point in the git module, so it cannot be done backwards.
  • The transition function always fetches the field screen and verifies before it POSTs.
  • The orchestrator presents the plan once and runs to completion, halting only on conditions that are themselves encoded: dirty repo, missing creds, closed ticket, missing branch, unavailable transition, unset required field, merge conflict. On a halt it writes a forensic run-log JSON instead of guessing.

That last part is the difference that matters. A memory is a hope that you will behave. A default in code is a guarantee. A halt condition is the tool failing closed and handing you a forensic record instead of shipping a wrong assumption to production. The only thing that scales is making the wrong move structurally impossible in the tool, because nobody remembers the right one at 2 PM three weeks from now when they are tired and switching contexts.

Pain you can count

The manual workflow was worth doing because it hurt in seven separable ways, and pain you can enumerate is a specification. Next time a manual process makes you wince, write down each friction in one line, then classify it as a default the tool should apply automatically or a halt condition it should stop on.