Three years ago, I wrote a short article after I managed to build a terminal Spotify client using nothing but Ctrl+C and Ctrl+V. Back then, in What To Focus On When Programming With Gpt4?, I predicted what would happen in the near future, and so far those predictions have turned out to be fairly accurate. At the time, I often encountered skepticism and opinions that AI would never be able to program well because it would always make mistakes and hallucinate. Meanwhile, the way we build software has changed very quickly. Less and less code is written entirely by hand, while an increasing share of the work is being done by AI tools and agents. Knowing a specific programming language is also becoming less important. What matters much more is the ability to read and evaluate code, understand architecture, and clearly define what actually needs to be built. In that sense, AI has already taken over a large part of the work that programmers used to do manually. The role of the programmer itself is gradually shifting toward that of an architect, a product developer, and, put simply, someone who coordinates and supervises the work of AI agents.
Later came the jokes about vibe coding. And those jokes have aged very badly. I am using the term fairly loosely here. I do not mean blindly accepting whatever an agent produces without reading the code or understanding the project. I mean building software where most of the implementation is produced by AI and the programmer focuses mainly on requirements, architecture, constraints, review, and feedback.
Sure, you can create an application this way that scales terribly and is difficult to maintain. But this can be addressed to a large extent. Practices around using AGENTS.md appeared, followed later by SKILLS. In 13 Programming Books as AI Agents Rules, I described one way of turning software-engineering habits into rules for coding agents. These rules do not solve the problem by themselves, but they can prevent a large class of recurring problems. You can use them to instruct the agent how it should organize the code, which patterns it should use, what layers the project should have, and how much emphasis we want to put on bounded contexts, which, in my opinion, are currently crucial in larger software projects. By following these rules, I can create projects with much higher code quality than the ones I used to write manually, when I constantly had to balance implementation speed against code quality.
For a vibe-coded project to have high code quality, however, it is worth focusing on a few things that are rarely discussed. You can put very strong guardrails around an agent by imposing strict rules on how it works and forcing it into a certain engineering discipline. I do this through a demanding quality check, not only in CI, but already in a pre-commit hook.
The quality check consists of several steps. First, I run a linter. At this stage alone, you can get rid of not only ugly code, but also many unnecessarily long blocks and methods. This is very cheap in terms of CPU usage and execution time. I also run the linter with automatic fixes enabled, which takes some work away from the agent and makes the process deterministic.
The next step is checking for code smells. Not everything can be measured, and a code smell reported by a static analyzer does not automatically mean that the code is bad. But many suspicious patterns can be detected mechanically. If one appears, the agent has to at least reconsider the code and either refactor it or make a conscious decision that the warning is acceptable. This happens already at commit time, before these problems have a chance to accumulate.
Without this step, they can grow very quickly. I measured this while building a project from a backlog of 80 tasks without any structural feedback for the agent. The most common problem was DuplicateMethodCall. For example, AI can produce something like this:
def report_summary(report)
"Average: #{report.calculate_statistics.average}, median: #{report.calculate_statistics.median}"
end
This performs the same potentially expensive operation twice. Depending on what the expression does, repeated calls like this can be merely unnecessary or actually expensive. It is often better to make the intermediate result explicit:
def report_summary(report)
statistics = report.calculate_statistics
"Average: #{statistics.average}, median: #{statistics.median}"
end
The point is not that every duplicate method call is a serious problem. The useful part is that the agent gets immediate feedback about something worth looking at instead of repeating the same pattern throughout the project.
The second most common problem is TooManyStatements. The model tends to keep adding more steps to an existing method instead of stopping for a moment and thinking about whether the responsibilities should be split. So it can produce something like this:
def process_order(order)
validate(order)
calculate_total(order)
apply_discount(order)
save_order(order)
send_email(order)
log_order(order)
update_metrics(order)
end
Depending on the context, it may be clearer to separate these responsibilities:
def process_order(order)
prepare_order(order)
persist_order(order)
finalize_order(order)
end
def prepare_order(order)
validate(order)
calculate_total(order)
apply_discount(order)
end
def persist_order(order)
save_order(order)
end
def finalize_order(order)
send_email(order)
log_order(order)
update_metrics(order)
end
And in a larger application it may make sense to extract separate objects:
def process_order(order)
OrderPreparer.new(order).call
OrderRepository.new.save(order)
OrderFinalizer.new(order).call
end
Of course, splitting a method only to satisfy a static analyzer does not automatically make the code better. It can just as easily create unnecessary indirection. The value of this check is that it forces the agent to stop and reconsider the structure instead of endlessly extending whatever method already happens to exist.
The list of problems it produces is much longer, though, and it is worth seeing what it looks like on a chart:
Enforcing these checks at commit time therefore does not guarantee good code, but it prevents many simple structural problems from silently accumulating.
The next stage is test coverage. A large number of tests obviously does not give us confidence that the tests themselves are correct. The same is true for 100% coverage. I do not treat it as a quality metric, because coverage only tells us that the code was executed, not that the right behavior was actually verified. I treat it as a mechanical constraint that prevents the agent from adding completely untested paths without noticing. It also forces the agent to look at the tests whenever a refactoring changes behavior or makes some branch unreachable. For that reason, requiring 100% coverage is still a useful safeguard, especially when agents are modifying the code repeatedly.
At the end of the pre-commit process, all tests are run. Later, the process is repeated in CI, which gives us confidence that the same checks also pass outside the local environment. Which, of course, is nothing new.
What is new is the economics of this feedback loop. A strict pre-commit hook can be annoying for a human developer, because every failure means stopping, going back to the code, fixing it, and trying again. An agent does not really care. It gets an error, changes the code, runs the check again, and repeats the process until it passes. This makes checks that used to feel overly strict much more practical.
Outside of the quality check, most of my supervision is necessary in the context of code organization at the application structure level: layers, directories, files, boundaries, and their organization. SKILLS are obviously very helpful here as well. This is especially important when starting a project and when we have some idea of how large it is going to become. Based on that, we choose an architecture appropriate for the project, and if the project grows more than we initially expected, we supervise the refactoring.
This is also where I think the programmer's role is changing the most. Writing the implementation is becoming the cheap part. The more important part is defining the constraints under which the implementation is created: the architecture, boundaries, tests, static analysis, conventions, and feedback loops. Instead of describing every line of code, we increasingly describe the environment in which the code is allowed to exist.
The jokes about vibe coding aged poorly. Not because blindly generated code suddenly became good, but because we learned how to put enough engineering around the agent that blindly trusting it is no longer necessary.