IMR Lopez Logo

A Year of Shipping with AI

From three months per CRUD app to running five projects in parallel — the tools, the workflow, and the one opinion I refuse to give up.

A year ago, a simple application — twelve tables, some business logic, nothing exotic — took me two to three months.

Today that's not the unit of work anymore. And the interesting part isn't that I type faster. It's that the job changed underneath me: I went from writing code most of the day to making decisions most of the day, and somewhere in there I became Technical Lead Engineer.

This is the honest account of how that happened, including the parts I'm still bad at.

The headline: AI didn't make me write code faster. It moved the bottleneck from producing code to deciding and verifying. Everything below follows from that.

What changed about learning

The old path into a language was strictly layered. You started at the top — the syntax everyone writes — then slowly went deeper: idioms, architecture, tooling, the tricks that separate someone who uses a language from someone who knows it. That descent took years.

That order inverted. I no longer need to memorize a language end to end. What I need is to understand how it behaves: its idioms, its escape hatches, its linters, the low-level details that used to be the last thing you learned.

This is not the same as "you don't need to learn." It's that the expensive knowledge moved. Syntax is cheap now. Knowing why a design is wrong is not.

Frameworks noticed. A new framework now ships with AI affordances built in — skills, documented migration paths between comparable tools, machine-readable conventions. Moving between ecosystems stopped being a rewrite and became a translation.

The stack that actually works

Some tools are dramatically better to build with when AI is in the loop — not because they're trendy, but because they're mature, strongly typed, and well represented in what the models already know.

The pattern: mature, typed, conventional tools produce better AI output. A framework the model knows deeply and a contract that fails loudly are worth more than any prompt technique.

The opinion I won't give up: read the code

There's a loud take going around podcasts and developer social media that you shouldn't read the code anymore. I think that's wrong, and I think it's the fastest way to build something you can't maintain.

To be precise: you shouldn't obsess over every line. But you absolutely must know what the AI is doing to your codebase.

Here's the concrete reason. Left alone, it will not use the file structure you want. That sounds trivial. It isn't.

ProductForm.tsx
productHelpers.ts
api.ts
types.ts
utils2.ts
db.ts

Everything runs. Nothing can leave. utils2.ts is imported by nine files, api.ts reaches the database directly, and pulling "catalog" out means reading all of it first.

A good file structure is what makes a module possible to decouple later. If you can't lift a piece of your application out and drop it somewhere else, you don't have an architecture — you have a pile that happens to run.

The same goes for the patterns people treat as forgotten — dependency injection, command/query separation. They're not obsolete. They're exactly what keeps a fast-moving codebase from calcifying, and they're precisely what generated code will skip unless you insist.

Concretely, this is what "insist" looks like. Every mutation in the ERP I'm building goes through one registry, and the registry — not the handler — owns permission, data classification, the tenant guard, and idempotency:

convex/domain/catalog/commands.ts
export const catalogCommands = new TenantCommand({
  defaults: {
    permission: 'erp.catalog.manage',
    classification: 'restricted_mutable_master',
    guard: requireCatalogTenant,
  },
  operations: {
    'catalog.product.create': catalogOperation(
      createProductCommandSchema,
      products.publicDto,
      'product',
    ),
    'catalog.sku.create': catalogOperation(
      createSkuCommandSchema,
      skuDto,
      'sku',
    ),
  },
})

The transport layer stays thin on purpose — it spreads the command's input schema and returns the public DTO, and that's all it knows:

convex/api/catalog.ts
export const createProduct = tenantMutation({
  args: {
    idempotencyKey,
    ...products.commandInput.shape,
  },
  returns: products.publicDto,
  handler: (ctx, args) => createProductCommand(ctx, args),
})

This is the part an agent will not invent for you. Ask for "create a product mutation" and you get a handler that validates inline, hits the database, and forgets the audit entry. The registry is the thing you have to insist on — once it exists, every generated operation inherits permissions, auditing, and idempotency for free.

This is the line between a good engineer and someone who merely produces working code. It's also the line between a vibe coder and an application engineer. Those are different jobs.

The journey: five stages

Going all-in happened over the last seven or eight months, and it moved through distinct stages. Each one broke a ceiling the previous one had.

01tab completion

Completions and editor chat

The starting point. Completions plus "look at this, check that" in the sidebar. Not really an integration — I wrote code, it wrote code, and we alternated. Useful, but it never left the file I was in.

Swipe, or use the arrows — each stage broke a ceiling the one before it had.

The loop: grill, plan, iterate

This is the single highest-leverage change I made, and it's worth describing precisely because it's not what most people mean by "using AI."

~/dev/one-officeall suites green
add catalog.price-list-item.create — mirror catalog.sku.create
  1. Grill the requirements (completed)
  2. Write the plan (completed)
  3. Implement U3 (completed)
  4. Green the suites (completed)
Before I touch anything — which layer owns the effective range, and should an overlap reject or supersede?
Read(convex/domain/catalog/commands.ts)Read 214 lines · catalog.sku.create
Update(convex/domain/catalog/commands.ts)
Updated commands.ts with 24 additions
61 operations: {
62+added: 'catalog.price-list-item.create':
63+added: catalogOperation(
64+added: createPriceListItemSchema,
65 ),
Bash(bun test convex/domain/catalog)140 pass · 2 fail(ctrl+o to expand)
✗ price list › rejects an overlapping effective range expected VALIDATION_FAILED, received undefined ✗ price list › money parsed at price scale expected 1250 (scale 2), received 12.5
Update(convex/domain/catalog/rules.ts)
Updated rules.ts with 11 additions
88+added: assertNoOverlap(existing, next, {
89+added: field: 'effectiveFrom',
90+added: })
Bash(bun test convex/domain/catalog)141 pass · 1 fail(ctrl+o to expand)
✗ price list › rejects an overlapping effective range boundary is inclusive — same-day ranges still collide
Update(convex/domain/catalog/rules.ts)
Updated rules.ts with 1 addition and 1 removal
88-removed: if (next.from < existing.to) {
88+added: if (next.from <= existing.to) {
Bash(bun test convex/domain/catalog)142 pass · 0 fail in 3.2s
xhigh · /effort
auto mode on (shift+tab to cycle) · ← for agents
2 failed passes before green — the loop is the part nobody shows you.

Before any code, the AI interrogates me. I don't hand-roll this — I run grill-me, Matt Pocock's interrogation skill. It refuses to accept a vague answer, keeps a decision tree of everything still unresolved, and drills into each branch until we reach shared understanding:

- Which layer owns this: the contract, the service, or the UI?
- You said "cache it" — invalidate on write, or TTL? What's the staleness budget?
- Does this need to work offline, or is optimistic UI enough?
- You're NOT touching auth here, correct?
- What should happen when the provider returns a 429?

Nothing is written until every one of these has an answer. The skill's whole job is to be annoying now so the loop isn't lost later.

The stages:

  1. Get grilled. Before any code, the AI interrogates me: what am I implementing, how, what am I using, what am I explicitly not using, what shouldn't work this way. It's a quiz whose only purpose is to surface the decisions I hadn't actually made yet.
  2. Produce the plan. The answers become a detailed action plan — guidelines, code examples, flow diagrams. Not a summary. A specification.
  3. Run the loop. Then it executes the whole plan, repeating and correcting until every test suite passes and everything holds together. A single loop can run four or five hours.

The order is the point. Most of the value is created before any code is written, in the grilling step. The loop only works because the plan is unambiguous.

What the agentic UI actually looks like

The agentic IDE: projects and threads on the left, the active run with its changed files in the middle, and the composer with model, effort and access controls.

An IDE for agents rather than for files. Threads on the left grouped by project, the active conversation in the center, and a right-hand panel that switches between a browser to test the app, a git diff, open pull requests, and the files themselves. A terminal sits underneath, exactly where an editor would put it.

That layout is what made parallelism possible. Running five loops at once is only sane if you can see what each one is doing.

Scale, and being honest about tests

Two numbers that show how much the ceiling moved.

the same task · five waysWHO DOES THE ITERATING, BY MODE
you stepping inthe AI iteratingsecond project, another layerthird project, another layer

5/5modes reaching flow

task startiteratingrecoveringtask done
tab completion · editor
editor extensions · IDE
CLIs · terminal
skills + loops · planned
agentic UIs · parallel

Burning 100 million tokens used to feel like a lot. These days I can go through 1.5 billion tokens in a day across parallel projects. The constraint stopped being how fast I work and became how much I can meaningfully review.

On testing: I used to write and run very few tests. Now my applications carry 300 to 400 test suites, which sounds robust — and it partly is.

But I'll be straight about the weakness. A large share of those tests are mocks.

Mocked unit test

35% confidence

proves the unit's logic, given the inputs I imagined

misses whether the real collaborator returns that shape at all

Integration test

80% confidence

proves the layers actually agree

misses slower, and harder to pin a failure to one cause

What I have · ~400 suites

40% confidence

proves the pieces agree with my assumptions about each other

misses the seam where things really break

Suite count and real confidence are different axes — mine is mostly the first row.

They exercise the happy path rather than the system's real behavior, so they don't prove the application works end to end; they prove the pieces agree with my assumptions about each other. That's a real gap, and it's my main thing to fix.

More tests is not the same as more confidence. A suite made of mocks tells you your code is internally consistent, not that it's correct.

What it did to the job

The part I didn't expect: the further I got, the less the work was about code.

I moved from deciding variable names and file layouts — where a lot of developers get stuck — to deciding what we build, how the system should be shaped, and which trade-offs the team accepts. That shift is why I ended up as Technical Lead Engineer. I advanced quickly, but not because I produce more code. Because I spend the day making decisions that used to sit above me.

writing codedeciding & verifying
writing code most of the day0%
reviewing and directing agents0%
architecture and product decisions0%
today0%

Technical Lead Engineer

Share of the day, by phase. The code never disappeared — who writes it did.

Where I've landed

  • Pick tools the models know well. Mature, typed, conventional. Convex, Hono, oRPC, TanStack Start, WorkOS, Redis, Postgres.
  • Plan before generating. Get quizzed, produce a real specification, then loop. The value is created before the first line.
  • Read what it writes. Not every line — but always the structure, the boundaries, and the file layout. That's what you'll have to maintain.
  • Keep the patterns. Dependency injection and command/query separation aren't legacy. They're what makes a fast codebase survivable.
  • Measure confidence, not volume. 400 mocked tests are not 400 proofs.

The speedup is real. It's also entirely conditional on being able to tell good output from bad — and that skill is now the job.

On this page