Skip to content
Ende Sun
Back to blog

AI & automation

Spec-driven development with parallel AI agents: what worked and what broke

6 min read

Spec-driven development with parallel AI agents: what worked and what broke

This site was rebuilt in one large change: repositioning it as an engineering profile, making every piece of content translatable into Spanish, English and Chinese from the admin panel, adding project categories with filters and search, a technical blog, an education section and a new visual layer. Too much to keep in my head, and too much to hand to an AI assistant as one vague prompt. What made it manageable was writing the specification first and treating it as a contract.

Interview first, one question at a time

Before writing a single requirement I answered questions about the cases that would change what had to be built, one at a time, never as a list. Could client names be shown publicly? Should a blog post that exists in only one language be hidden or shown with a notice? Which dates win when two versions of my CV disagree? Could the real material from my final degree project be published? Each answer removed a branch of work or added a constraint. Nine decisions came out of that interview, and several of them would have been silent assumptions otherwise. One, for example, is that the clinical footage from the laryngeal segmentation project cannot be published, so it must only ever be represented by an original illustration.

EARS: one behaviour per sentence

The spec ended up with 78 functional requirements, numbered and grouped into blocks (positioning, translatable content, experience, education, projects, images, skills, contact, blog, visual presentation, SEO), plus two that were added during the interview. Every requirement follows one of the EARS patterns:

  • Ubiquitous: THE SYSTEM shall …
  • Event-driven: WHEN the visitor chooses a category, THE SYSTEM shall show only the projects in that category.
  • State-driven: WHILE reduced motion is enabled, THE SYSTEM shall …
  • Optional: WHERE a feature is present …
  • Unwanted behaviour: IF no project matches the filter, THEN THE SYSTEM shall show a "no results" message with an action to clear it.

Two rules made the difference. First, one behaviour per sentence: if a requirement needs an "and" to join two behaviours, it is two requirements. Second, no unmeasurable adjectives. "Fast" or "intuitive" are not requirements; a threshold is. Every requirement had to be checkable, and if I could not think of how to check it, it was badly written.

The section I value most is Out of scope. Comments, newsletters, multiple authors, a downloadable CV: written down explicitly so the feature could not grow by itself.

From spec to a TDD plan

The spec says what and why; it deliberately contains no stack, file names or schemas. That went into a separate plan: 13 tasks, each with the files it touches, the interfaces it consumes from earlier tasks and the ones it produces for later tasks, and steps in test-first order: write the failing test, run it, implement the minimum, run it again, commit. The interface section mattered more than I expected. An agent implementing one task only sees that task, so the exact method names and types that neighbouring tasks rely on have to be written down, or two agents will invent two different names for the same thing.

Running independent tasks in parallel

Once the foundation (translations and the data migration) was in place, many tasks were independent: the admin panel, the project illustrations, the blog, the content sync command. I ran them in waves, each task handled by its own AI agent working in an isolated git worktree on its own branch. Each agent ran its own tests and finished with a single commit, which I then cherry-picked onto the feature branch.

This worked well for throughput. It also created a specific blind spot: every agent's test suite was green in isolation, and that said nothing about whether the pieces worked together.

What broke after merging

A stale autoloader pointing into a deleted worktree. After one wave, a controller change in the main checkout seemed to have no effect, and a view failed with undefined variables even though the source was clearly correct. No error explained it. The cause was that an agent's dependency install had regenerated the Composer class map with paths into its own worktree. PHP happily kept loading the old file from there. (new ReflectionClass(Foo::class))->getFileName() revealed the wrong path, and composer dump-autoload in the main checkout fixed it. After that, every agent prompt included that check before committing.

A view bypassing the model. The model had a correct displayImage() method that returns the illustration when a project is in illustration mode, which is exactly how the laryngeal project should be shown. The project card and detail views, however, passed the raw image field as the main source and used the illustration only as a fallback when the image failed to load. The old clinical photo loaded fine, so the fallback never triggered, and a photo that must not be public was displayed. The model tests passed, because the model was right. I only caught it by looking at the rendered page. The fix was one line in each view plus a test that renders both pages and asserts the photo's URL is absent.

Row stealing on a fresh database. The content sync command matches existing rows by a stable key, or, the first time, by the production id they already have. On a database without those production rows, auto-increment ids were handed out during the same run, so a later entry whose expected id coincided with a row just created for a different entry would adopt and overwrite it. In production, where the ids already exist, this never happens, which is exactly why it went unnoticed. The fix was to adopt a row by id only if it does not have a key yet, and the test fixture now seeds legacy rows at their real ids instead of testing against an unrealistically empty database.

What I take from it

  • The spec is the most valuable artefact, more than any generated code. It is what lets you split work, review it and say "done".
  • Parallel agents multiply output, but integration is a separate job. Green tests per task are necessary, not sufficient.
  • Verification after merging has to include running the real application and looking at it, not just the test suite. Two of the three bugs above were invisible to tests until I wrote the test after seeing the bug.
  • Every bug found at integration became a regression test and, where it applied, a line in the instructions for the next wave of agents.

Working this way did not remove the engineering work. It moved it: less time typing, more time specifying, reviewing and verifying.