Skip to content
← ProjectsAIOpsDevIn progress

From a spec to a repo delivered autonomously by agents.

Role
Designer and maintainer
Period
2025 — aujourd’hui
Context
Internal tool, used on my products
Shipyard

1

command for a full repo

~30

incidents turned into guardrails

3

layers: framework, project, delivery

0

merges without green CI

Context

AI assistants code fast but without guardrails: no tests, no review, no traceability from ticket to production.

The challenge

Make an agent loop reliable over time: resume after quota exhaustion, detect and retry blockers, enforce quality gates that cannot be bypassed.

  1. 01AI

    Bootstrap from a spec

    A shell renderer + templates generate code, tests, CI (Trivy, commitlint, Release Please, Dependabot), branch protection and a GitHub Kanban.

  2. 02Ops

    Delivery loop on a VPS

    Ticket → TDD → PR → review by a fresh session → auto-merge when CI is green. Back-off on quota, human escalation only when needed.

  3. 03AI

    Locked skill packs

    Engineering rules composed per agent, language and domain, pinned to a commit for reproducible runs. Local CVE MCP server.

  4. 04Ops

    Mobile control

    tmux sessions + Happy: follow, pause and unblock the loop from a phone, with push notifications.

Architecture

Stack

  • Claude Code
  • AOAgents & orchestration
  • MCP
  • GitHub Actions
  • RPRelease Please
  • Trivy
  • Linux

Results

  • ✦Used to ship my own products
  • ✦About thirty production incidents encoded as fixes
  • ✦Became the foundation of koklo-cli

The story

The screenshots on this page come from throwaway public repos that my end-to-end tests create on GitHub, such as shipyard-e2e-full-cycle. The issues are synthetic, but everything else is real: real Claude Code sessions, real GitHub Actions CI, real merges.

How it works

1. An issue with checkable criteria

Everything starts from an issue. The bootstrap creates it from the spec, or I write it by hand. It has a goal, acceptance criteria as checkboxes and a definition of done. The dev session gets nothing but that text, the project context and the engineering rules for its role.

GitHub issue “expose a machine-readable status” with goal, acceptance criteria and definition of done
The starting point: an issue with checkable criteria. That is all the dev session gets, plus the project context.

2. The loop develops and opens the PR

agent-loop.sh takes the next “Todo” issue in the current iteration, creates an isolated worktree on feat/<n> and starts a fresh claude -p session on Sonnet. The test comes first, then the implementation. Before any push, the script itself replays the project’s lint, tests and build. If a gate is red, a fix session gets the exact command and the tail of its output. Then the script commits in Conventional Commits, pushes and opens the PR. The issue moves to “In Review”. The dev phase does not wait for CI: it moves on to the next issue.

Pull request feat/2 opened by the loop, merged into develop
The PR the loop opened on `feat/2`, linked to the issue with `Closes #2`, then merged less than a minute after it opened.

3. The hard gate, then the judgement

The review phase picks up every “In Review” issue. It first waits for the required checks to register and finish. A red CI goes straight back as fix feedback, with the failing job’s name and the tail of its log. The reviewer is only called on green CI, in a fresh Opus session that never saw the code being written.

PR Checks tab: green build job
The hard gate: until the required checks are green, the reviewer is not even called.

4. Merge, release, promotion

On approve, the script rebase-merges under a global lock, closes the issue, updates the board and the vault, then notifies my phone. In the full-cycle test, the same repo then goes through a dependency bump, the promotion to main and the Release Please release. The dependency PR is simulated by the test account there, because GitHub does not let a test impersonate Dependabot.

PR list of the test repo: feature, dependency bump, promotion to main, release 1.0.0
The whole cycle in one list: the feature, a dependency bump, the `develop → main` promotion and the Release Please 1.0.0 release.

5. When a human is needed

An issue with contradictory criteria must not produce code. The dev session returns NEEDS-HUMAN:, the loop moves the issue to “Blocked” and posts a single comment: the cause, and what to do to resume. On the next run, a triage phase re-reads every blocker against the repo’s actual state. Anything that was only blocked for technical reasons goes back into the queue.

“Human needed” comment posted by the loop on an issue with contradictory criteria
A deliberately impossible issue: the dev session refuses to guess and the loop asks one question, with the command to resume.

At the end of a sprint, a retrospective is opened as an issue, facts first.

Automatically generated sprint retrospective issue
At the end of a sprint the loop opens a retro: facts first (delivered, blocked), analysis second.

Under the hood

The model never decides what happens next. Control flow is a 2,800-line bash script. Each agent runs one bounded step in a fresh session and returns output that the script parses: COMMIT:, NEEDS-HUMAN:, VERDICT:. Progress lives in git, on the GitHub board and in state.json, never in a model’s context.

The lifecycle of an issue in the loop

Read the diagram left to right. Every arrow is a status change written by the script, never by the agent. All loops are bounded: 3 dev attempts by default, 4 review rounds, 2 base re-integrations on conflict. Past that, the issue goes to “Blocked”. Getting out of “Blocked” is the triage phase’s job. An Opus session judges the blocker there, and the script applies its decision. For that session, gh is removed at the CLI level with --disallowedTools.

templates/loop/agent-loop.sh.tpl
while [ "$round" -le "$MAX_REVIEW" ]; do
    round=$((round + 1))
    _review_state_set "$n" "$round" "$conflict_fixes"

    # Hard gate: objective signals. Waits for checks to register + complete.
    local ci_rc=0
    ci_is_green "$pr" || ci_rc=$?
    # … rc 2 = timeout : on réattend sans consommer de tour
    if [ "$ci_rc" -ne 0 ]; then
        verdict="request-changes"
        review_out="$(ci_failure_report "$pr")"
    else
        review_prompt="$(render_prompt "$LOOP_DIR/prompt-review.md" "$review_ctx" qa-reviewer)"
        review_out="$(claude_invoke "$SHIPYARD_REVIEW_MODEL" "$review_prompt")" || break
        verdict="$(grep -m1 '^VERDICT:' <<<"$review_out" | sed 's/^VERDICT:[[:space:]]*//')"
    fi

    [ "$verdict" = "approve" ] || post_review_comment "$pr" "$round" "$MAX_REVIEW" "$verdict" "$review_out"

    if [ "$verdict" = "approve" ]; then
        _with_lock "$REPO_ROOT/.project/.merge.lock" merge_pr "$pr" || { …; }
        finalize_merged_issue "$n" "$pr" "$SHIPYARD_REVIEW_LABEL" "$round"
        return 0
    fi
    # sinon : session de correction (Sonnet) avec le retour réinjecté, push, tour suivant
done

This is the core of the merge gate. CI is checked before the reviewer, and any verdict other than approve is posted as a PR comment, so verdicts stay readable afterwards. The merge runs under a lock, because two PRs merging at once used to fight over “base out of date”.

What the machine says about itself

The host check, run on my machine:

make doctor

The loop smoke test stubs gh and claude but pushes to a real local git remote. It replays 29 scenarios in about a minute, with no quota. The day I ran it for this page, it returned 170 green checks and one red: in one scenario, the loop’s own doc pass still tripped the monorepo scope check. I am leaving it as it is.

test/loop-smoke.sh (excerpt)

Where it comes from, where it goes

agent-setup → shipyard → koklo-cli

shipyard reuses agent-setup’s foundation: shell renderer, templates, TDD roles and state.json. It drops Codex to focus on Claude Code and adds the autonomous loop. koklo-cli then takes over the loop, and above all the order of its guards, but rewrites it in Rust on a single SQLite database.

What I take away

  • The script decides, the agent executes. The model never picks the next step. A failure then becomes a state of the script, which a smoke test can replay, rather than a decision you have to guess at afterwards.
  • Every incident becomes a guardrail, and a test. A CI timeout counted as a failure, a last fix never reviewed before escalation, a review budget reset on every resume: about thirty cases are encoded in the code, each with its comment and its smoke scenario.
  • Three sources of truth is two too many. state.json, GitHub labels and the board drifted apart. An issue whose work was already pushed went back to dev, produced an empty diff, then got escalated, over and over. That is the main reason for the koklo-cli rewrite.

Gallery

PR list of the test repo: feature, dependency bump, promotion to main, release 1.0.0
Automatically generated sprint retrospective issue
PR Checks tab: green build job
PaaS as code
Next project →PaaS as codeMy whole hosting platform described as code: one app = one YAML file.

Let’s talk. Describe your need in a few lines, I’ll get back to you within 48 hours.