Vibe coding feels fast until three people ship three versions of the same feature.

Speed without a contract is not progress. It is parallel debt.

Two Ways to Build

Vibe coding runs on momentum. Prompt, paste, adjust, prompt again. It works until the codebase stops agreeing with itself.

Spec-driven work starts differently. One file defines what must exist before any agent touches code. The agent is not the process. It executes inside one.

For a team, that difference is everything.

The Story: Billing Retry

Your team needs subscription payment retries. Three developers. Two agents. One week.

Nobody writes application code on day one. Everyone writes SPEC.md.

specs/billing-retry/SPEC.md
# SPEC

Feature: Subscription payment retry on failed renewals

Must:
- Create a retry record on failed renewal
- Apply configured backoff: 1h, 6h, 24h
- Notify customer after each failed attempt
- Expose retry state in admin
- Stop after 3 attempts

Must not:
- Send notification before state is persisted
- Retry a subscription already marked exhausted

Objects:
Subscription, PaymentAttempt, RetrySchedule, RetryPaymentJob

Tests required:
- Unit: backoff intervals per attempt number
- Feature: retry record created on failed renewal
- Feature: notification sent only after DB commit
- Feature: no retry after final attempt

Order:
1. Migration + models
2. RetrySchedule service
3. RetryPaymentJob
4. Notification listener
5. Admin endpoint
6. Tests (per list above)

One file. Every tool reads it. Every teammate reviews against it.

Spec → Plan → Execute → Test → Review → Ship

1. Spec

Humans own SPEC.md. Ambiguity dies here.

The test list is not an afterthought. It is part of the contract. Agents optimize for what you measure.

2. Plan

Agent reads the spec. Output goes to PLAN.md — not code.

specs/billing-retry/PLAN.md
Step 1: migration payment_retries (subscription_id, attempt, next_at, status)
Step 2: RetrySchedule::nextDelay(attempt) — pure function, unit test first
Step 3: RetryPaymentJob — dispatch only after RetrySchedule confirms eligibility
Step 4: FailedPaymentNotifier — queue after DB::afterCommit()
Step 5: GET /admin/subscriptions/{id}/retries
Step 6: tests from SPEC section "Tests required"

Refine two rounds. Catch the missing afterCommit before any PHP gets written.

3. Execute

Clean context. Spec and plan only. Agent implements step by step.

app/Models/PaymentRetry.php
app/Services/RetrySchedule.php
app/Jobs/RetryPaymentJob.php
app/Listeners/FailedPaymentNotifier.php
// app/Services/RetrySchedule.php
final class RetrySchedule
{
    private const DELAYS = [3600, 21600, 86400]; // 1h, 6h, 24h

    public function nextDelay(int $attempt): ?int
    {
        return self::DELAYS[$attempt - 1] ?? null;
    }

    public function exhausted(int $attempt): bool
    {
        return $this->nextDelay($attempt) === null;
    }
}
// app/Jobs/RetryPaymentJob.php
final class RetryPaymentJob implements ShouldQueue
{
    public function __construct(public int $subscriptionId) {}

    public function handle(RetrySchedule $schedule, PaymentGateway $gateway): void
    {
        $retry = PaymentRetry::where('subscription_id', $this->subscriptionId)
            ->where('status', 'pending')
            ->firstOrFail();

        if ($schedule->exhausted($retry->attempt)) {
            $retry->update(['status' => 'exhausted']);
            return;
        }

        $gateway->charge($retry->subscription);
    }
}

Code follows the plan. The plan followed the spec.

4. Test

Tests are a gate, not a finale.

Agent writes them from the spec’s test list — before anyone calls it done.

// tests/Unit/RetryScheduleTest.php
it('returns correct backoff per attempt', function () {
    $schedule = new RetrySchedule();

    expect($schedule->nextDelay(1))->toBe(3600);
    expect($schedule->nextDelay(2))->toBe(21600);
    expect($schedule->nextDelay(3))->toBe(86400);
    expect($schedule->nextDelay(4))->toBeNull();
});

it('marks attempt 4 as exhausted', function () {
    expect((new RetrySchedule())->exhausted(4))->toBeTrue();
});
// tests/Feature/RetryPaymentFlowTest.php
it('does not notify before retry is persisted', function () {
    Notification::fake();

    $subscription = Subscription::factory()->failedRenewal()->create();

    $this->artisan('billing:process-failed', ['id' => $subscription->id]);

    expect(PaymentRetry::count())->toBe(1);
    Notification::assertSentTimes(PaymentFailedNotification::class, 1);
});

Red means the spec or the implementation is wrong. Fix before review.

5. Review

Fresh agent or teammate. Three inputs on the table:

  • SPEC.md
  • PLAN.md
  • Code + test output

Checklist: every “Must” covered? Every “Must not” enforced? Every test from the spec exists and passes?

Review catches what tests miss — naming, boundaries, N+1 in the admin endpoint.

6. Ship

Commit when all three agree.

specs/billing-retry/SPEC.md
specs/billing-retry/PLAN.md
app/Services/RetrySchedule.php
app/Jobs/RetryPaymentJob.php
tests/Unit/RetryScheduleTest.php
tests/Feature/RetryPaymentFlowTest.php

PR description points to the spec section, not the chat log.

Why Teams Need This

Shared context does not scale through chat history.

Person A prompts from memory. Person B prompts from a different mental model. Both get green tests. Neither built the same system.

A spec file forces alignment before tokens get spent. Review becomes concrete: “Does this match section 3?” not “Does this feel right?”

Tests in the spec mean agents cannot skip verification. “Make it work” becomes “make it pass these cases.”

What Changes for You

You type less code. You write more clarity.

You stop asking agents to guess architecture mid-flight. You stop reviewing output in a vacuum. You stop shipping features where tests were invented after the fact.

The engineer’s job moves up a layer: define the change, define how to prove it, validate the result.

Key Takeaways

Vibe coding optimizes for output. Spec-driven work optimizes for agreement.

One SPEC.md per feature. Tests listed in the spec, not bolted on later.

The loop: spec → plan → execute → test → review → ship.

Tool-agnostic. The files live in the repo. The agent is interchangeable.

Teams that align on the spec ship once. Teams that vibe ship three times and merge the damage.