AI Development Orchestrator: Building the CLI Layer

Step 3 of the Series: Building DevRag: A Deterministic Runtime for AI Development


Milestones 1 and 2 built the core guarantees: tasks can be reliably parsed and written, state transitions are enforced by a state machine, no invalid state can be persisted. But none of that is accessible to anything external. The task files live on disk, the repository operations live in Python classes, and getting to any of it requires knowing the internal API.

Step 3 changes that. This milestone builds the internal CLI layer — the interface through which the Runtime and, eventually, AI agents will interact with the system. Not the public-facing CLI that a developer types into a terminal. The machine-readable contract that code calls.

Human-readable CLIs optimize for convenience. Internal CLIs optimize for deterministic orchestration.

The distinction matters: this is not a developer tool wrapped in argparse. It is a stable, typed, deterministic interface designed to be consumed by automated processes. Every response is structured JSON. Every error is typed. Every exit code means something specific. And none of that happens by accident — it is the direct result of the design constraints established in the earlier milestones.


What Step 3 Builds

Six components ship in this milestone:

cli.py — the shared internal CLI infrastructure. This is the foundation that all three internal CLIs (task.py, config.py, repo.py) are built on. It defines the success/error response contract, the --json output behavior, the exit code policy, and the error handling wrapper that ensures no failure is silent.

task.py — the internal CLI for task operations. Read-oriented commands (create, list, filter by role and parent), plus the full set of mutation commands: transitions, assignment, comments, commits, branches, subtasks. All mutations go through TransitionEngine — no direct status changes, no bypass of the state machine.

config.py — typed configuration management. Get, set, list operations with schema validation, typed parsing (bool, int, string), and atomic persistence. The config contract needs to be stable because the Runtime will read it; an unknown key or a mistyped value must fail explicitly, not silently.

slugify() — deterministic branch name generator. Given a task title, returns a Git-safe ASCII slug. Handles Cyrillic transliteration, collapses separators, enforces configurable max length, raises SlugifyError for titles that normalize to nothing. Deterministic means: same input, always same output.

RepoBackend + GitCliBackend — the repository abstraction and its Git implementation. RepoBackend defines the interface: create branch, checkout, commit, push, merge, delete. GitCliBackend implements it over subprocess calls to the local Git CLI, with a typed exception hierarchy that distinguishes each failure mode.

repo.py — the internal CLI over GitCliBackend. All six branch operations, deterministic JSON responses, merge conflict represented as a structured result rather than an exception.

DevRag - Internal Cli Scheme
DevRag – Internal Cli Scheme

The test suite after Step 3: 1,330 tests, all green.


System Architecture After Step 3

Every layer above cli.py and RepoBackend interacts only with typed Python objects and structured JSON — never with raw file content or Git text output. The DevRag Runtime, when it exists, will sit between agents and this tooling layer without bypassing it.


The Machine-Readable Contract

The first design decision in cli.py is the response envelope:

json

{"status": "ok", "data": {...}}
{"status": "error", "error": {"type": "...", "message": "..."}}

Every command, every response, always one of these two shapes. JSON output uses sort_keys=True — the order of keys in the response is deterministic and does not depend on Python’s dict insertion order. This matters when the consumer is a parser, not a human.

Exit codes are typed: EXIT_OK=0, EXIT_TYPED_ERROR=1, EXIT_UNEXPECTED_ERROR=2. The distinction between 1 and 2 is not cosmetic. A typed error (DevragError subclass) means the system encountered a known failure mode — invalid task ID, wrong transition, unknown config key. An unexpected error (exit code 2) means something went wrong that the system does not recognize. A Runtime that reads exit codes can route these differently: a typed error may warrant a structured retry or escalation; an unexpected error warrants investigation.

run_command(fn, use_json) is the single wrapper that enforces this contract across all three CLIs. It executes the provided function, catches DevragError subclasses, catches unexpected exceptions, emits the appropriate response, and returns the exit code. There is no path through any internal CLI that bypasses this wrapper.


Step 3 Incidents

The Changelog Gap

Step 3 has two tasks that went through Manager rework. The first is task.py mutation commands.

The Worker implemented all six mutation commands. Five of them updated metadata and appended a corresponding changelog entry. The --subtasks command updated the metadata correctly but did not append a changelog entry.

The Manager’s review caught it:

Gap: --subtasks updates metadata but does not append a changelog entry. This violates AC7: metadata updates must remain coupled with changelog entries. Existing task tests cover deterministic subtasks metadata update, but they do not verify changelog coupling for this command.

The fix is one additional append_log() call. The engineering point is not the complexity of the fix — it is what the gap represents. The entire consistency model of devrag is built on the invariant that metadata and changelog are always updated together. A task’s state is not just its current metadata; it is the sequence of changes that produced that metadata. Break that coupling in one place and you have a task whose history cannot be reconstructed.

The Manager rejected the first implementation even though the test suite was green. This is the same pattern from task in Step 2: passing tests are not a sufficient acceptance signal when the tests did not cover the coupling constraint. The Manager holds the architectural contract, not just the test results.

The Error That Hides Escalation Decisions

The second rework is more consequential. It concerns GitCliBackend.merge() and what happens when the base branch checkout fails.

Why merge() is a workflow decision point

The merge operation in devrag is not just a Git operation. The Manager’s post-merge decision tree is: clean merge → close the task; merge conflict → escalate to devreview. For that routing to work, the merge result must accurately represent what happened.

GitCliBackend.merge() does the following: checkout the base branch, then merge the task branch into it. The Worker’s first implementation handled merge conflicts correctly — they returned a structured MergeResult(success=False, conflicting_files=[...]) without raising. But the base branch checkout step had a hidden problem:

python

# Inside merge() — first implementation
self._run("checkout", base)  # any failure → BranchNotFoundError

Missing base branch: BranchNotFoundError. Dirty working tree blocking the checkout: also BranchNotFoundError.

Why error classification is part of control flow

The Manager’s review:

Gap: GitCliBackend.merge() checks out base directly via _run("checkout", base). Any checkout failure is currently translated to BranchNotFoundError. This hides non-conflict Git errors such as checkout blocked by local changes. Existing checkout() already distinguishes BranchNotFoundError vs CheckoutError, but merge() does not reuse that classification.

The fix mirrors the error classification from checkout() inside merge(): missing base raises BranchNotFoundError, dirty working tree raises CheckoutError.

The reason this matters: the Runtime’s escalation logic reads error types, not error messages. If a dirty working tree during merge produces BranchNotFoundError, the Runtime interprets it as “branch missing” and makes a wrong escalation decision. Error classification is not metadata here — it is the control flow.

The same principle drove the parser design in Step 1: “malformed title” and “malformed task ID” must not produce the same error, because they represent different debugging paths. At the repository layer, “branch missing” and “working tree dirty” are not the same recovery path, so they must not raise the same exception.


The Merge Conflict Design

Conflict Is Not Failure

One design decision in this milestone is not obvious: merge conflicts are returned as structured results, not raised as exceptions.

python

# Clean merge
MergeResult(success=True, commit="abc123", conflicting_files=[])

# Merge conflict
MergeResult(success=False, commit=None, conflicting_files=["src/main.py", "config/settings.py"])

A Git merge conflict is a predictable outcome of a known operation. The Manager expects it and has a defined response path: escalate to devreview. Raising an exception would force the Runtime to catch it and recover from it as if it were an error — which it is not. A conflict is a workflow state, not a failure.

The Clean Decision Surface

Non-conflict Git errors — missing branches, push rejections, dirty working tree — are genuinely unexpected failures that interrupt the workflow. They raise typed exceptions from the GitError hierarchy.

The distinction gives the Runtime a clean decision surface: if merge() returns, inspect MergeResult.success; if merge() raises, handle the exception as a failure. Two code paths, two clearly different semantics, no ambiguity about which situation you are in.


What Step 3 Guarantees

After this milestone, the system has a complete internal tooling layer with the following properties:

The CLI contract is stable and machine-readable. Every response is a JSON envelope with a predictable shape. Every exit code carries semantic meaning. No command can produce a silent failure.

Git operations are isolated behind the RepoBackend abstraction. The Runtime does not call git directly. It calls GitCliBackend, which translates Git’s text output and exit codes into typed Python objects. Merge conflicts are structured results. Git failures are typed exceptions. If the Git backend is replaced or mocked in tests, the layer above it does not change.

Metadata and changelog remain coupled. Every mutation command that changes task metadata also appends a corresponding changelog entry, atomically. The history is always consistent with the state.

The regression suite covers everything. 1,330 tests across parser, state machine, repository, CLI, Git backend, merge flows, and integration paths. A change that breaks any of these contracts surfaces immediately.

The system still has no Runtime orchestration, no LLM providers, and no agent execution loop. That is the boundary of Step 3, and it is a hard one.


Where the System Stands

At the end of three milestones, devrag has a fully tested deterministic foundation: task workflow, repository abstraction, Git orchestration, and a machine-readable internal CLI that everything above it will build on.

The next step is Step 4: the public CLI — the single devrag.py entry point that the Runtime will eventually invoke. This is where the internal utilities get assembled into a coherent external interface, and where the first real integration between task management and repository operations happens under a unified command. Whether the layering decisions made in Steps 1–3 hold up under that integration, or whether cracks start appearing at the seams — that is what Step 4 will show.

This series documents the development of devrag as it actually happens — including the parts that get sent back for rework


Step 3 of the Series: Building DevRag: A Deterministic Runtime for AI Development