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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
See [docs/deployment.md](docs/deployment.md) for what patch/minor/major mean for
this package.

## [Unreleased]

### Added

- `fleet init`: brings a repository into the fleet workflow in one command.
Writes `.fleet/` into `.git/info/exclude`, a starter `.fleetrc.json` wired to
the config schema, the Claude Code skill into `.claude/skills/switchyard/`,
and an agent-neutral protocol block into `AGENTS.md` (created if absent).
Idempotent — re-run after upgrading to refresh the agent-facing docs.
`--force` overwrites an existing `.fleetrc.json`; `--json` prints the result.

### Changed

- **The shipped skill now installs itself.** 0.3.0 shipped it in the tarball and
documented a manual `cp` into `.claude/skills/`, which meant the convention
usually never reached the agents that needed it. `fleet init` installs it, and
re-running refreshes it.
- Agents other than Claude Code can now learn the convention. The `AGENTS.md`
block carries the short version for anything that reads that file — Codex,
Cursor, and others — where previously only a Claude Code skill existed.

### Notes

- What `fleet init` overwrites is split on ownership. `.fleetrc.json` is your
file and is never replaced without `--force`; the skill and the `AGENTS.md`
block are package-managed and refreshed every run. In `AGENTS.md` only the
region between `<!-- switchyard:begin -->` and `<!-- switchyard:end -->` is
rewritten. Broken markers are an error, not a guess.
- No new runtime dependencies, and no change to `state.json` (still
`version: 1`) — `fleet init` adds no persisted fleet state of its own.
- The MCP surface is unchanged and still read-only.

## [0.3.0] - 2026-07-19

### Added
Expand Down
36 changes: 24 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Requires Node.js >= 18.17 and git >= 2.31. The installed command is `fleet`.

```sh
cd your-repo
fleet init # config, ignore entry, and the agent-facing docs
fleet spawn claude # isolated worktree on branch fleet/claude
cd .fleet/worktrees/claude # point your agent here and let it work
fleet check # any files also touched by other agents?
Expand All @@ -101,6 +102,7 @@ fleet pr claude # …or push it and open a PR via gh instead

| Command | Description | Key flags |
| --- | --- | --- |
| `fleet init` | Set the repo up for the fleet workflow: starter `.fleetrc.json`, `.fleet/` in `.git/info/exclude`, the Claude Code skill in `.claude/skills/`, and a protocol block in `AGENTS.md`. Idempotent — re-run after upgrading to refresh the agent-facing docs | `--force` overwrite an existing `.fleetrc.json`, `--json` machine-readable output |
| `fleet spawn <agent>` | Create a worktree in `.fleet/worktrees/<agent>/` on a new branch `fleet/<agent>`, then provision it (`copyOnSpawn` / `postSpawn` below) | `--from <branch>` base branch (default: current branch) |
| `fleet list` | All active agents: branch, base, ahead/behind, uncommitted count, last activity | `--json` machine-readable output |
| `fleet status <agent>` | One agent in detail: uncommitted files, diff stat vs base, ahead/behind | `--json` machine-readable output |
Expand Down Expand Up @@ -232,21 +234,31 @@ time, and the shipped skill says so again — an agent should *ask* for
A pleasant consequence: since `spawn` is not exposed, the `postSpawn` hook
(arbitrary shell from `.fleetrc.json`) is not reachable from an agent at all.

### The skill
### Teaching agents the convention

The package ships a Claude Code skill at
`node_modules/@switchyardhq/switchyard/skills/switchyard/SKILL.md`. Copy it into
your repo's `.claude/skills/` to install it:
The tools report state; they cannot convey that you are expected to check
*before* editing rather than before merging, or that provisioning is something
to ask a human for. That convention is the actual product, and `fleet init`
installs it in two forms:

```sh
mkdir -p .claude/skills/switchyard
cp node_modules/@switchyardhq/switchyard/skills/switchyard/SKILL.md .claude/skills/switchyard/
```
| Artifact | Audience |
| --- | --- |
| `.claude/skills/switchyard/SKILL.md` | Claude Code, which loads the full skill on demand |
| A marked block in `AGENTS.md` | Any agent that reads `AGENTS.md` up front — Codex, Cursor, and others |

Two texts rather than one generated from the other, because the audiences
differ: a skill loaded on demand can afford a hundred lines, an always-read
file cannot.

Both are package-managed and refreshed on every `fleet init`, so upgrading the
package and re-running is enough to keep them current. In `AGENTS.md` only the
region between `<!-- switchyard:begin -->` and `<!-- switchyard:end -->` is
rewritten — the rest of the file is yours and is never touched. `.fleetrc.json`
is treated the opposite way: it is your file, so init never overwrites it
without `--force`.

It teaches the convention the tools alone cannot: work in your own worktree,
check before editing rather than before merging, how to read each verdict, and
that provisioning is something to ask for. Installing it is a manual copy on
purpose — writing into your repo deserves its own design pass.
If the markers are ever half-deleted or inverted, init refuses rather than
guessing where your content ends.

## Configuration

Expand Down
44 changes: 44 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,50 @@ No mutating tool is exposed, which also means the `postSpawn` hook is not
reachable from an agent. Adding `fleet_spawn` later reopens that trust boundary
and would need saying so.

## Onboarding: `fleet init`

Every guarantee above is reachable only if the agents in the repo know the
convention exists. Before `fleet init` that knowledge travelled by manual file
copy, which meant it usually did not travel at all: the MCP tools report state
but cannot convey that checking belongs *before* the edit, and an agent that
finds no spawn tool and was never told why falls back to raw `git worktree add`
— the exact untracked state Switchyard exists to prevent.

`fleet init` (`src/commands/init.ts`) writes four things, in this order:

1. `.fleet/` into `.git/info/exclude`, so a repo that has never spawned still
ignores the directory.
2. A starter `.fleetrc.json` carrying only `$schema`, which is what makes every
valid key discoverable through editor autocomplete.
3. `.claude/skills/switchyard/SKILL.md`, copied from the packaged skill.
4. A protocol block in `AGENTS.md`, created if the file is absent.

**Two artifacts, not one generated from the other.** A skill loaded on demand
can afford a hundred lines; a file every agent reads up front cannot. Rendering
a summary out of the skill automatically produces a worse block than writing the
short version directly, so `AGENTS_BLOCK` in `src/lib/protocol.ts` is maintained
as its own text — a TypeScript constant rather than a shipped file, so it needs
no `files` entry and has no runtime resolution failure mode. The skill *is* read
from disk, because Claude Code requires it as a file at a fixed path anyway.

**What init overwrites is split on ownership.** `.fleetrc.json` is the user's
file and is never replaced without `--force`. The skill and the `AGENTS.md`
block are package-managed content, refreshed on every run — a stale convention
is the failure this command exists to fix, so re-running after an upgrade is the
intended way to stay current.

Idempotence rests on the `<!-- switchyard:begin -->` / `<!-- switchyard:end -->`
markers: `upsertMarkedBlock` (pure, so the placement rules test without a
filesystem) replaces the marked region when the pair is present and appends when
it is not. A half-present or inverted pair is a hard error rather than a guess,
because guessing where the user's content ends risks eating it. Files are
written only when the content would actually change, so a second `init` reports
`unchanged` and touches no mtimes.

Init takes the mutation lock even though it never reads or writes
`state.json` — two concurrent runs would otherwise interleave their
read-modify-write of `AGENTS.md` and could duplicate the block.

## Config file

An optional `.fleetrc.json` at the repo root (committed or not — the user's choice) provides per-repo defaults, read by `src/lib/config.ts`:
Expand Down
151 changes: 151 additions & 0 deletions docs/design/2026-07-19-v0.4-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# v0.4 `fleet init` — design

- **Date:** 2026-07-19
- **Status:** implemented
- **Baseline:** 0.3.0, live on npm
- **Supersedes on scheduling:** [2026-07-19-v0.3-fleet-protocol.md](2026-07-19-v0.3-fleet-protocol.md)
§"Feature 3 — `fleet init`", which deferred this to v0.4 alongside claims.
**Claims are deferred again**; the reasoning is in §1.

## Context

0.3.0 shipped three things agents cannot reach:

1. The Claude Code skill ships in the tarball, and the README asks the user to
`cp` it into `.claude/skills/`. The convention — the actual product — is
gated behind a manual file copy most people will never perform.
2. The skill's largest section tells the agent what it *cannot* do. The v0.3
plan flagged this itself (§6, "a read-only surface may feel inert").
3. Nothing announces that a fleet exists. There is no protocol block in
`AGENTS.md` or `CLAUDE.md`, so an agent only calls `fleet_list` if the skill
loaded, and the skill only loaded if someone copied it.

These are one problem: **the convention does not install itself.**

## 1. Why this before claims

The prior plan put claims next. Claims are advisory — they pay off only if every
agent participates. But nothing currently makes agents participate, for the
reason above. Shipping claims first adds a *second* convention with the same
compliance problem as the first, and would be built on the assumption that
agents already follow the first one.

So the order inverts: make the existing convention reach agents, then add to it.
Claims remain designed and remain next; the v0.4 demo
([2026-07-19-v0.4-demo.md](2026-07-19-v0.4-demo.md)) stays blocked on them.

## 2. Scope

**In:** `fleet init`, writing four artifacts. **Out:** mutating MCP tools
(`fleet_spawn` in particular), claims, and any `state.json` schema change.

Leaving the MCP surface read-only is deliberate. Promoting `fleet_spawn` would
reopen the `postSpawn` trust boundary — arbitrary shell from `.fleetrc.json`
becomes reachable by an agent — and the process-global reentrancy hazard in
`withLock` that v0.3 dodged by exposing no mutations. Neither is required to fix
the delivery gap, and the boundary stays easy to widen and impossible to narrow.

## 3. What init writes

In order:

| # | Artifact | Ownership |
| --- | --- | --- |
| 1 | `.fleet/` in `.git/info/exclude` | tool-managed (already idempotent) |
| 2 | `.fleetrc.json` (starter, `$schema` only) | **the user's** |
| 3 | `.claude/skills/switchyard/SKILL.md` | package-managed |
| 4 | Marked block in `AGENTS.md` | package-managed region only |

The exclude entry goes first so a repo that has never spawned still ignores
`.fleet/`. The starter config carries only `$schema`: that single key is what
makes every valid option discoverable via editor autocomplete, and inventing
values (`defaultBase: "main"`) would be wrong in any repo on `master`.

### Ownership decides overwrite behavior

`.fleetrc.json` is the user's file — never replaced without `--force`. The skill
and the `AGENTS.md` block are package-managed — refreshed on every run, because
a stale convention is precisely the failure this command exists to fix. Making
re-running `init` the upgrade path depends on that.

### Two texts, not one generated from the other

The alternative considered was a single canonical protocol document rendering to
both the full skill and a summary block. Rejected: the audiences genuinely
differ — a skill loaded on demand can afford a hundred lines, a file every agent
reads up front cannot — and auto-summarizing prose into a good fifteen-line
block is the kind of thing that reliably produces a bad block. It would also add
a generation step to a repo that has none.

Accepting two hand-maintained texts is the honest cost. They live adjacently
(`skills/switchyard/SKILL.md` and `AGENTS_BLOCK` in `src/lib/protocol.ts`) so
drift is visible in review.

`AGENTS_BLOCK` is a TypeScript constant rather than a shipped markdown file: it
needs no `files` entry and has no runtime resolution failure mode. The skill
*is* read from disk, because Claude Code requires it as a file at a fixed path
regardless.

### Why `AGENTS.md` and not per-tool detection

A third option was probing for `.claude/`, `.cursor/`,
`.github/copilot-instructions.md`, and writing the right artifact for each.
Rejected: every new agent tool becomes a maintenance obligation and a new format
to get wrong. `AGENTS.md` is the cross-tool convention that already exists, and
one block there covers Codex, Cursor, and anything else that reads it.

Departing from the earlier design: that document said append to
`AGENTS.md`/`CLAUDE.md` **if present**. Init now **creates `AGENTS.md` when it is
absent**. A repo without one is exactly the repo that most needs the block, and
skipping silently is how a command appears to succeed while doing nothing.

## 4. Idempotence

Markers `<!-- switchyard:begin -->` / `<!-- switchyard:end -->` delimit the
package-owned region. `upsertMarkedBlock(existing, block)` replaces that region
when the pair is present and appends when it is not, separating appended content
with exactly one blank line. It is pure, so placement rules test without a
filesystem.

A half-present or inverted pair throws. Rewriting on a guess could silently
consume the user's content, and the recovery instruction (fix or delete the
markers, re-run) is cheap to follow.

Every write goes through `writeIfChanged`, so a second `init` reports `unchanged`
and touches no mtimes.

## 5. Locking

Init takes the mutation lock despite never reading or writing `state.json`. Two
concurrent runs would otherwise interleave their read-modify-write of
`AGENTS.md` and could duplicate the block. The lock is acquired after the
exclude entry is written, so the `.fleet/` directory the lock file lives in is
already ignored.

## 6. Testing

`tests/init.test.ts`, real temp repos via `makeTempRepo()`, no mocks:

- Creates all four artifacts; the written config passes `readConfig`'s own
validator rather than merely parsing as JSON.
- Idempotent: a second run yields `unchanged` and exactly one block.
- `.fleetrc.json` kept without `--force`, overwritten with it.
- Existing `AGENTS.md` content preserved, block appended after it.
- A stale block is replaced in place with surrounding sections intact.
- Broken markers reject and leave the file untouched.
- A hand-corrupted skill copy is refreshed (the upgrade path).
- `--json` emits exactly one parseable line.
- No `.fleet/lock` survives the run.
- `upsertMarkedBlock` unit tests, including a fixed-point round trip.

`tests/package.test.ts` additionally asserts `dist/commands/init.js` ships,
since init resolves the packaged skill at runtime.

## 7. Follow-ups, deliberately not in this change

- **`fleet init --check`** — verify without writing, exit 1 on drift, for CI.
Cheap given the pure core, but not needed to close the delivery gap.
- **Claims**, and the v0.4 demo that depends on them.
- **Rigor backlog**, untouched here: `doctor.ts` (378 lines) and `check.ts`
(333) both carry too much; `check.test.ts` is ~52s of a 54s suite, multiplied
across twelve CI legs; the README demo GIF still shows the v0.2 flow.
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { completion } from './commands/completion.js';
import { diff } from './commands/diff.js';
import { doctor } from './commands/doctor.js';
import { exec } from './commands/exec.js';
import { init } from './commands/init.js';
import { list } from './commands/list.js';
import { mcp } from './commands/mcp.js';
import { merge } from './commands/merge.js';
Expand Down Expand Up @@ -60,6 +61,13 @@ program
.version(pkg.version)
.showHelpAfterError('(run `fleet --help` for usage)');

program
.command('init')
.description('set up this repo for the fleet workflow: config, ignore entry, agent docs')
.option('--force', 'overwrite an existing .fleetrc.json')
.option('--json', 'print machine-readable JSON instead of the summary')
.action((opts: { force?: boolean; json?: boolean }) => run(() => init(opts)));

program
.command('spawn')
.description('create an isolated worktree + branch (fleet/<name>) for an agent')
Expand Down Expand Up @@ -203,6 +211,7 @@ program
program.addHelpText(
'after',
'\nExamples:\n' +
' fleet init set this repo up for the fleet workflow\n' +
' fleet spawn claude spawn an agent off the current branch\n' +
' fleet spawn codex --from main spawn a second agent off main\n' +
' fleet check --lines any files touched by both, line-precise?\n' +
Expand Down
Loading
Loading