Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Configuration is optional — middle ships with working defaults. To override, d

```toml
[global]
dispatcher_port = 8822 # the hook server agents report to
dispatcher_port = 4120 # the hook server + dashboard agents report to
max_concurrent = 4 # how many agents in the office at once
default_adapter = "claude"
worktree_root = "~/.middle/worktrees"
Expand Down Expand Up @@ -122,7 +122,7 @@ mm stop # everybody go home
One Bun process. No build step. The dispatcher is `bunqueue` + a hook receiver + a watchdog ticker, all writing to a local SQLite (WAL) and serving a read-only dashboard over HTTP+SSE. GitHub is the source of truth for the work; the dispatcher spawns `tmux` sessions for the agents and `git` worktrees to isolate them.

<p align="center">
<img src="docs/diagrams/architecture.svg" alt="middle dispatcher (single process) — bunqueue engine, hook receiver, and watchdog ticker write to SQLite (WAL); Bun.serve fronts HTTP + SSE on :8822 to a read-only dashboard; GitHub on the left is the source of truth; tmux sessions and git worktrees are spawned below." width="720" />
<img src="docs/diagrams/architecture.svg" alt="middle dispatcher (single process) — bunqueue engine, hook receiver, and watchdog ticker write to SQLite (WAL); Bun.serve fronts HTTP + SSE on :4120 to a read-only dashboard; GitHub on the left is the source of truth; tmux sessions and git worktrees are spawned below." width="720" />
</p>

---
Expand All @@ -139,6 +139,12 @@ If you want to see it: browse the open [Epics](https://github.com/thejustinwalsh

## Going deeper

- **[`docs/operator.md`](docs/operator.md)** — the operator how-to: every `mm` command, the daily run loop, `mm doctor`, backups, and resetting state.
- **[`docs/architecture.md`](docs/architecture.md)** — how the pieces fit: the daemon, the dispatch lifecycle, the crons, and why SQLite is operational state while GitHub is the system of record.
- **[`docs/adapters.md`](docs/adapters.md)** — the `AgentAdapter` interface every coding-agent CLI implements.
- **[`docs/bootstrap.md`](docs/bootstrap.md)** — what `mm init` stamps into a target repo, and how to remove it.
- **[`docs/skill-enforcement.md`](docs/skill-enforcement.md)** — the gates that hold a dispatched agent to the workflow.
- **[`docs/dogfooding.md`](docs/dogfooding.md)** — how middle builds itself.
- **`planning/middle-management-build-spec.md`** — the authoritative design: architecture, the adapter interface, the dispatch lifecycle, the state-issue schema, and the full build sequence (phases 0–11).
- **`CLAUDE.md`** — the working conventions every contributor and every dispatched agent follows (Conventional Commits, the Epic/PR workflow, the byte-identical state-issue round-trip invariant).

Expand Down
104 changes: 104 additions & 0 deletions docs/adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Adapters

An adapter is the interface every coding-agent CLI sits behind. middle dispatches every agent as an interactive `tmux` session — there is no headless mode — so the adapter abstracts the per-CLI launch command, the text to send into the session, how to enter auto mode, and how to read and classify the on-disk transcript. This is a reference for that contract. The source of truth is `packages/core/src/adapter.ts`.

## The `AgentAdapter` interface

```ts
export interface AgentAdapter {
readonly name: string; // 'claude' | 'codex' | ...

/** Write hook config + any per-CLI setup into the worktree. */
installHooks(opts: InstallHookOpts): Promise<void>;

/** Build the INTERACTIVE launch command. tmux runs this; it takes no prompt. */
buildLaunchCommand(opts: LaunchOpts): { argv: string[]; env: Record<string, string> };

/** The literal text to send-keys into the session to start or continue the agent. */
buildPromptText(opts: BuildPromptOpts): string;

/** Put the ready session into auto mode — a launch flag or post-ready keystrokes. */
enterAutoMode(opts: { sessionName: string }): Promise<void>;

/** The normalized event that signals the CLI is ready for input. */
readonly readyEvent: NormalizedEvent;

/** Locate the on-disk session transcript from the ready/session hook payload. */
resolveTranscriptPath(payload: HookPayload): string;

/** Read activity, state, and context/token usage from the transcript. */
readTranscriptState(transcriptPath: string): TranscriptState;

/** Classify the agent's state at a Stop hook. */
classifyStop(opts: {
payload: HookPayload;
transcriptPath: string;
sentinelPresent: boolean;
worktree: string;
}): StopClassification;

/** Optional: detect a rate-limit message in a Stop-hook payload or transcript. */
detectRateLimit?(opts: { payload: HookPayload; transcriptPath: string }): RateLimitDetection | null;
}
```

## Methods

| Member | Contract |
|---|---|
| `name` | The adapter's identifier (`'claude'`), matched against `default_adapter` and the per-Epic adapter choice. |
| `installHooks` | Writes the hook config and any per-CLI setup into the worktree before launch. |
| `buildLaunchCommand` | Returns the `argv` + `env` for the **interactive** CLI. `tmux` runs it; it takes no prompt. |
| `buildPromptText` | Returns the literal text to `send-keys` into the session to start or continue the agent. |
| `enterAutoMode` | Puts a ready session into auto mode — a launch flag or post-ready keystrokes. |
| `readyEvent` | The normalized hook event that means the CLI is ready for input. |
| `resolveTranscriptPath` | Locates the on-disk transcript from the ready/session hook payload. |
| `readTranscriptState` | Reads activity, turn count, and token usage from the transcript. |
| `classifyStop` | Classifies a `Stop` hook into one of the `StopClassification` outcomes. |
| `detectRateLimit` | Optional. Detects a rate-limit message and returns when the limit resets. |

## Prompt kinds

`buildPromptText` takes a discriminated union on `kind`, so the `kind`/`epicNumber` coupling is enforced at compile time:

```ts
export type BuildPromptOpts =
| { promptFile: string; kind: "initial" | "resume" | "answer"; epicNumber: number }
| { promptFile: string; kind: "recommender" | "docs"; epicNumber?: never };
```

The dispatched-issue kinds (`initial`, `resume`, `answer`) carry an `epicNumber`. The repo-level kinds (`recommender`, `docs`) run against no Epic and must omit it — the union makes `kind: "initial"` without an Epic a compile error rather than a malformed `implement #undefined` prompt.

## Stop classification

`classifyStop` resolves the agent's state at each turn boundary. `worktree` is the workstream root where `.middle/` lives — sentinel files resolve from there, never from `payload.cwd`, which may be a subdirectory the agent has `cd`'d into.

```ts
export type StopClassification =
| { kind: "done" } // agent marked the PR ready
| { kind: "asked-question"; sentinelPath: string; sentinel: BlockedSentinel | null }
| { kind: "rate-limited"; resetAt: string /* ISO */ }
| { kind: "bare-stop" } // stopped, no sentinel, not done
| { kind: "failed"; reason: string };
```

The `asked-question` sentinel is `.middle/blocked.json`, parsed tolerantly — a missing or malformed file yields `null` rather than failing the Stop:

```ts
export type BlockedSentinel = {
question: string;
context?: string;
kind?: "question" | "complexity"; // "complexity" marks a complexity pause; absent = plain question
};
```

See [skill-enforcement.md](skill-enforcement.md) for how the dispatcher acts on each classification.

## Adapter selection

The dispatcher resolves an adapter by name through a registry in `packages/dispatcher/src/main.ts`. A repo's default comes from `global.default_adapter`; an Epic can be dispatched against a specific adapter.

## Shipped adapters

- **`@middle/adapter-claude`** — shipped. It implements the full interface for Claude Code: `installHooks` writes `.claude/settings.json`, `readyEvent` is the `SessionStart` hook, `classifyStop` reads the `.middle/` sentinels, and `detectRateLimit` matches Claude's usage-limit message.
- **`@middle/adapter-codex`** — a stub on the roadmap. Its bootstrap hook config is written by `mm init`, but the `AgentAdapter` implementation is not yet complete; the dispatcher's registry currently accepts only `claude`.
58 changes: 58 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Architecture

middle is one long-running daemon that dispatches coding agents at GitHub Epics, plus a CLI to drive it. This document explains how the pieces fit and why the design is shaped the way it is. For how to operate it, see [operator.md](operator.md); for the agent-facing contract, see [adapters.md](adapters.md).

## The packages

middle is a Bun monorepo. Each package owns one concern.

- **`@middle/dispatcher`** — the long-running daemon. It owns the workflow engine, the SQLite store, the hook receiver, the SSE control feed, and every cron. `mm start` spawns it. The runnable entry is `packages/dispatcher/src/main.ts`.
- **`@middle/cli`** — the `mm` binary. Each subcommand delegates to a `run*` function and exits with its return code.
- **`@middle/core`** — shared types: the `AgentAdapter` interface, the config loader, the normalized hook-event taxonomy. No process-level side effects.
- **`@middle/state-issue`** — parse, render, and validate the GitHub **state issue**. The dispatcher edits it one section at a time, so the parser and renderer guarantee a byte-identical round-trip (see the root `CLAUDE.md`).
- **`@middle/adapter-claude`** — the shipped adapter; it launches and drives Claude Code. `@middle/adapter-codex` is a stub on the roadmap.
- **`@middle/dashboard`** — a React SPA plus `Bun.serve` route handlers, mounted on the dispatcher's port.

## One daemon, one port

The dispatcher composes the hook receiver and the dashboard onto a single port (default `4120`). `main.ts` starts the hook server with the dashboard's routes merged in, so an operator runs one process and visits one URL.

The server binds to `127.0.0.1` only. The hook receiver has no cryptographic auth and uses predictable session names, so a `0.0.0.0` bind would let any host on the network hijack a running workflow. Localhost-only is the security boundary.

## The dispatch lifecycle

Every dispatch is **launch → drive → observe**. There is no headless mode and no exit code to read — the agent runs as an interactive process that does not exit between turns.

1. **Launch.** `mm dispatch` (or the auto-dispatch loop) POSTs to `/control/dispatch`. The dispatcher checks slot limits and in-flight collisions, then starts an `implementation` workflow. The workflow creates a worktree and launches the agent in a detached `tmux` session running the interactive CLI with no prompt. State: `launching`.
2. **Drive.** The agent's `SessionStart` hook fires; its payload yields the session id and the transcript path, which the dispatcher records on the workflow row. The adapter answers the CLI's boot dialogs (`enterAutoMode`), then sends the dispatch prompt with `send-keys`. State: `running`.
3. **Observe.** The agent works. Each `Stop` hook is a turn boundary; the dispatcher classifies it (`classifyStop`) against the transcript, the `.middle/blocked.json` sentinel, and PR state — done, asked-a-question, rate-limited, or a bare stop.

### The transcript is the state channel

An interactive `tmux` session gives no captured stdout. The CLI's on-disk JSONL **transcript** replaces it: the adapter reads activity, turn boundaries, tool use, and token usage from the transcript file. Hooks are the fast-path notification; the transcript is the source of truth. A reconciler cron corrects any drift between what the hooks reported and what the transcript shows. The transcript is retained after the `tmux` session ends so `--resume` stays available.

## The crons

The daemon runs several recurring passes, each a bunqueue cron:

- **Watchdog** (every 30s) — the liveness safety net. It reconciles `launching`/`running` workflows: launch timeout, `tmux` liveness, activity freshness, and re-arming a `waitFor` signal when a blocked sentinel appeared after the workflow advanced. It acts on staleness only and never overrides an in-progress hook decision. Freshness checks are skipped while a session is human-controlled.
- **Poller** (every 60s) — for each parked workflow with an armed `waitFor`, it fires the resume signal when the unblocking event appears (a human reply, or a PR review verdict), and finalizes parked workflows whose Epic PR has merged or closed.
- **Recommender** (60s due-check) — runs the recommender for each managed repo whose configured interval has elapsed, ranking the backlog into the state issue.
- **Retention** (daily) — prunes old events and archives old completed workflows (see [operator.md](operator.md#retention)).
- **Epic-cache refresh** (every 60s) — refreshes the Epic browse cache the dashboard reads.

## SQLite is operational state; GitHub is the system of record

middle keeps two kinds of state in two places, deliberately.

**GitHub holds the work.** Epics, sub-issues, PRs, and the state issue live on GitHub. They survive a database reset because they were never middle's to lose.

**SQLite holds the bookkeeping.** `~/.middle/db.sqlite3` (WAL mode) tracks workflow rows, the event log, rate-limit state, the managed-repo registry, and the Epic cache. It is operational state — losing it loses in-flight tracking, not work. That is why `scripts/reset-db.sh` is safe by design and retention can prune freely: the durable record is always GitHub.

The schema is a sequence of numbered migrations under `packages/dispatcher/src/db/migrations/`, applied on daemon start. The core tables are `workflows`, `events`, `rate_limit_state`, `repo_config`, `waitfor_signals`, and `retention_runs`.

## Workflow states

A workflow row moves through a fixed set of states, enforced by a `CHECK` constraint:

`pending` → `launching` → `running`, then to a terminal state (`completed`, `compensated`, `failed`, `cancelled`) — or sideways into `waiting-human` (parked on a question or review) or `rate-limited` (the adapter hit a usage limit). The poller and watchdog move parked workflows back to `running` when the blocking condition clears.
72 changes: 72 additions & 0 deletions docs/bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Bootstrap

`mm init <repo-path>` stamps middle into a target repo: the skills a dispatched agent runs, the hook config that reports its activity, the per-repo config, and the dispatch state issue. This is a reference for what gets written and where. `mm uninit` reverses it.

## What `mm init` writes

`mm init` takes a path to a local checkout. Run it with `--dry-run` first to print the planned actions without touching anything.

```bash
mm init <repo-path> --dry-run # print the plan
mm init <repo-path> # apply it
```

Before writing, `mm init` validates the target: it must be a git repo with a clean worktree and an `origin` remote, and `gh` must be authenticated.

It then performs these actions:

1. **Stage skills** into `.claude/skills/` and `.codex/skills/` — copied from middle's canonical `packages/skills/`. These are the skills a dispatched agent invokes (`implementing-github-issues`, `recommending-github-issues`, `creating-github-issues`, `documenting-the-repo`).
2. **Stage the hook script** to `.middle/hooks/hook.sh` — the universal POST script that reports agent activity to the dispatcher.
3. **Write hook config** — `.claude/settings.json` (Claude Code hook entries) and a sentinel-delimited block appended to `.codex/config.toml`.
4. **Resolve the state issue** — trust the number in an existing local config, otherwise find or create the dispatch state issue on GitHub and label it.
5. **Write `.middle/config.toml`** — the per-repo config, merged from global defaults plus the resolved state-issue number and bootstrap version.
6. **Add `.middle/` to `.gitignore`** — middle's operational directory is local, never committed.

## What lands in the target repo

| Path | Purpose | Committed? |
|---|---|---|
| `.middle/config.toml` | Per-repo config (limits, recommender, state-issue number) | No (`.gitignore`) |
| `.middle/hooks/hook.sh` | Universal hook POST script | No |
| `.claude/settings.json` | Claude Code hook entries | Per the repo's own policy |
| `.claude/skills/` | Stamped skill copies | Per the repo's own policy |
| `.codex/config.toml` | Codex hook block (sentinel-delimited) | Per the repo's own policy |
| `.codex/skills/` | Stamped skill copies | Per the repo's own policy |

`.middle/prompt.md` — the dispatch brief — is written per dispatch by the workflow, not by `mm init`.

## The hook script

`.middle/hooks/hook.sh` is a small POSIX script that POSTs each hook payload to the dispatcher and never blocks the agent:

```sh
#!/bin/sh
EVENT="$1"
curl -sS -X POST "${MIDDLE_DISPATCHER_URL}/hooks/${EVENT}" \
-H "X-Middle-Session: ${MIDDLE_SESSION}" \
-H "X-Middle-Token: ${MIDDLE_SESSION_TOKEN}" \
-H "X-Middle-Epic: ${MIDDLE_EPIC}" \
-H "Content-Type: application/json" \
--data-binary @- --max-time 3 || true
exit 0
```

It reads the hook payload on stdin, posts it to `${MIDDLE_DISPATCHER_URL}/hooks/<event>`, and exits 0 regardless — a dispatcher that is slow or down (the `--max-time 3` cap, the `|| true`) never stalls the agent. The session token authenticates the post.

## The two-copy skills invariant

Skill text exists in two places:

- `packages/skills/<skill>/` — the **canonical** source.
- `packages/cli/src/bootstrap-assets/skills/<skill>/` — the **mirror** `mm init` stamps from.

The two must stay byte-identical. `bun run sync-skills` regenerates the mirror; a pre-commit hook (`scripts/hooks/pre-commit`) runs `sync-skills --check` and fails the commit on drift. `mm doctor` surfaces the same drift as a `skills` warning. Edit the canonical copy, then re-sync — never edit the mirror directly.

## Removing middle

`mm uninit <repo-path>` reverses `mm init`: it strips middle's hook entries from `.claude/settings.json` (preserving any other hooks), removes the sentinel-delimited block from `.codex/config.toml`, deletes the `.middle/` directory, and removes the `.gitignore` entry.

```bash
mm uninit <repo-path> --dry-run # print the plan
mm uninit <repo-path> # apply it
```
2 changes: 1 addition & 1 deletion docs/diagrams/architecture.excalidraw
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
},
{
"type": "text", "id": "serve-txt", "x": 467, "y": 422, "width": 190, "height": 36,
"text": "Bun.serve\nHTTP + SSE · :8822", "originalText": "Bun.serve\nHTTP + SSE · :8822",
"text": "Bun.serve\nHTTP + SSE · :4120", "originalText": "Bun.serve\nHTTP + SSE · :4120",
"fontSize": 14, "fontFamily": 3, "textAlign": "center", "verticalAlign": "middle",
"strokeColor": "#212529", "backgroundColor": "transparent", "fillStyle": "solid",
"strokeWidth": 1, "strokeStyle": "solid", "roughness": 1, "opacity": 100, "angle": 0,
Expand Down
2 changes: 1 addition & 1 deletion docs/diagrams/architecture.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/dogfooding.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ the hook script, session bookkeeping). The crossover commands conflict with that
in two load-bearing ways:

1. **`mm dispatch . <issue>` nests agents.** It needs the dispatcher process
(`mm start`, port 8822), creates a *second* worktree, and spawns a *second*
(`mm start`, port 4120), creates a *second* worktree, and spawns a *second*
`claude`/tmux session. Run from inside a live dispatch it collides with the
running dispatcher and consumes a slot against itself. It also needs a
**manually created** Epic to target.
Expand Down Expand Up @@ -56,7 +56,7 @@ gh issue list --repo thejustinwalsh/middle --label agent-queue:state

# 5. Dispatch a manually created Epic against middle. `mm dispatch` is
# self-contained — it runs its OWN hook server + workflow engine inline — so
# do NOT run `mm start` first: both bind the dispatcher port (8822) and a
# do NOT run `mm start` first: both bind the dispatcher port (4120) and a
# running `mm start` makes `mm dispatch` fail with EADDRINUSE. (`mm start` is
# the long-running daemon for Phase 8 auto-dispatch, not manual dispatch.)
mm dispatch . <epic-number> # the Epic (see "Creating a dispatchable Epic" below)
Expand Down
Loading