From 990a7b1383d4c5ce05dfa91eb1cda367aa88f42e Mon Sep 17 00:00:00 2001 From: Mohammed Alkindi Date: Sun, 19 Jul 2026 20:35:02 +0400 Subject: [PATCH 1/2] feat(init): add fleet init to install the agent-facing convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code skill shipped in 0.3.0, but installing it was a manual copy documented in the README, so the convention that makes Switchyard work usually never reached the agents that needed it. The tools report state; they cannot convey that checking belongs before the edit, and an agent that finds no spawn tool and was never told why falls back to a raw `git worktree add` — the untracked state this project exists to prevent. fleet init writes the exclude entry, a starter .fleetrc.json wired to the config schema, the skill into .claude/skills/, and a protocol block into AGENTS.md so agents that do not read Claude skills learn the convention too. Ownership decides overwrite behavior: .fleetrc.json is the user's file and needs --force, while the skill and the AGENTS.md block are package-managed and refresh on every run, making re-running init the upgrade path. Marker delimiters keep the AGENTS.md rewrite scoped to our own region; a broken marker pair is an error rather than a guess, since guessing where the user's content ends risks eating it. ensureFleetExcluded now reports whether it wrote, so init can tell "already ignored" from "just ignored it" instead of always claiming the former. --- src/cli.ts | 9 ++ src/commands/init.ts | 121 +++++++++++++++++++++++++++ src/lib/git.ts | 8 +- src/lib/protocol.ts | 108 ++++++++++++++++++++++++ tests/init.test.ts | 187 ++++++++++++++++++++++++++++++++++++++++++ tests/package.test.ts | 2 + 6 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 src/commands/init.ts create mode 100644 src/lib/protocol.ts create mode 100644 tests/init.test.ts diff --git a/src/cli.ts b/src/cli.ts index dabb1dd..254ef26 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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'; @@ -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/) for an agent') @@ -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' + diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..fc29b7b --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,121 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { CONFIG_FILE } from '../lib/config.js'; +import { ensureFleetExcluded, getMainRepoRoot } from '../lib/git.js'; +import { bold, dim, ok } from '../lib/format.js'; +import { withLock } from '../lib/lock.js'; +import { + AGENTS_BLOCK, + AGENTS_MD_FILE, + SKILL_INSTALL_PATH, + readPackagedSkill, + upsertMarkedBlock, +} from '../lib/protocol.js'; + +export interface InitOptions { + /** Overwrite `.fleetrc.json` when it already exists. */ + force?: boolean; + json?: boolean; + cwd?: string; +} + +/** + * What init did to one path. + * + * `kept` is the only outcome that means "your file was left alone because + * replacing it needs --force"; `unchanged` means the content was already right. + */ +export type InitAction = 'created' | 'updated' | 'unchanged' | 'kept'; + +export interface InitStep { + /** Repo-root-relative, forward slashes. */ + path: string; + action: InitAction; + note?: string; +} + +export interface InitResult { + repoRoot: string; + steps: InitStep[]; +} + +const STARTER_CONFIG = `{ + "$schema": "https://unpkg.com/@switchyardhq/switchyard/schema/fleetrc.schema.json" +} +`; + +/** Write only when the content would actually change, so re-runs stay quiet. */ +function writeIfChanged(file: string, content: string): InitAction { + if (existsSync(file)) { + if (readFileSync(file, 'utf8') === content) return 'unchanged'; + writeFileSync(file, content, 'utf8'); + return 'updated'; + } + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content, 'utf8'); + return 'created'; +} + +/** + * Bring a repository into the fleet workflow: config, ignore entry, and the + * two artifacts that teach agents the convention. + * + * The split between what init overwrites and what it protects is deliberate. + * `.fleetrc.json` is the user's file and is never clobbered 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. + */ +export async function init(options: InitOptions = {}): Promise { + const repoRoot = await getMainRepoRoot(options.cwd ?? process.cwd()); + return withLock(repoRoot, 'init', () => initLocked(options, repoRoot)); +} + +async function initLocked(options: InitOptions, repoRoot: string): Promise { + const steps: InitStep[] = []; + + // First, so a repo that has never spawned still stops tracking .fleet/. + const excluded = await ensureFleetExcluded(repoRoot); + steps.push({ + path: '.git/info/exclude', + action: excluded ? 'updated' : 'unchanged', + note: '.fleet/ is ignored', + }); + + const configFile = path.join(repoRoot, CONFIG_FILE); + if (existsSync(configFile) && !options.force) { + steps.push({ path: CONFIG_FILE, action: 'kept', note: 'already exists; --force overwrites' }); + } else { + steps.push({ path: CONFIG_FILE, action: writeIfChanged(configFile, STARTER_CONFIG) }); + } + + const skillFile = path.join(repoRoot, ...SKILL_INSTALL_PATH.split('/')); + steps.push({ path: SKILL_INSTALL_PATH, action: writeIfChanged(skillFile, readPackagedSkill()) }); + + const agentsFile = path.join(repoRoot, AGENTS_MD_FILE); + const existingAgents = existsSync(agentsFile) ? readFileSync(agentsFile, 'utf8') : ''; + steps.push({ + path: AGENTS_MD_FILE, + action: writeIfChanged(agentsFile, upsertMarkedBlock(existingAgents, AGENTS_BLOCK)), + note: existingAgents === '' ? undefined : 'switchyard block only; the rest is untouched', + }); + + const result: InitResult = { repoRoot, steps }; + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return result; + } + + console.log(ok(`Initialized Switchyard in ${bold(repoRoot)}`)); + for (const step of steps) { + const label = step.action.padEnd(9); + console.log(` ${label} ${step.path}${step.note ? ` ${dim(`(${step.note})`)}` : ''}`); + } + console.log(''); + console.log('Next:'); + console.log(` fleet spawn claude ${dim('give an agent its own worktree')}`); + console.log(` fleet check ${dim('see what two agents both touched')}`); + console.log(''); + console.log(dim('Re-run `fleet init` after upgrading to refresh the agent-facing docs.')); + + return result; +} diff --git a/src/lib/git.ts b/src/lib/git.ts index fe99ae8..cf06e4d 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -242,8 +242,11 @@ export async function supportsMergeTree(git: SimpleGit): Promise { /** * Ensure `.fleet/` is ignored via `.git/info/exclude` so Switchyard never dirties * the repos it manages — even ones whose .gitignore doesn't mention it. + * + * Returns whether the entry had to be added, so callers that report what they + * did (`fleet init`) can tell "already ignored" from "just ignored it". */ -export async function ensureFleetExcluded(repoRoot: string): Promise { +export async function ensureFleetExcluded(repoRoot: string): Promise { const commonDirRaw = await gitAt(repoRoot).raw([ 'rev-parse', '--path-format=absolute', @@ -253,8 +256,9 @@ export async function ensureFleetExcluded(repoRoot: string): Promise { const excludeFile = path.join(infoDir, 'exclude'); const entry = '.fleet/'; const current = existsSync(excludeFile) ? readFileSync(excludeFile, 'utf8') : ''; - if (current.split(/\r?\n/).some((line) => line.trim() === entry)) return; + if (current.split(/\r?\n/).some((line) => line.trim() === entry)) return false; mkdirSync(infoDir, { recursive: true }); const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; appendFileSync(excludeFile, `${prefix}# added by fleet\n${entry}\n`, 'utf8'); + return true; } diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts new file mode 100644 index 0000000..db3c29b --- /dev/null +++ b/src/lib/protocol.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { FleetError } from './errors.js'; + +/** + * Delimiters around the block `fleet init` manages inside AGENTS.md. Everything + * between them is package-owned and replaced on every init; everything outside + * is the user's and never touched. HTML comments because they render invisibly + * in every markdown viewer. + */ +export const BLOCK_BEGIN = ''; +export const BLOCK_END = ''; + +/** Where `fleet init` installs the Claude Code skill, repo-root-relative. */ +export const SKILL_INSTALL_PATH = '.claude/skills/switchyard/SKILL.md'; + +/** The agent-neutral protocol block written into AGENTS.md. */ +export const AGENTS_MD_FILE = 'AGENTS.md'; + +/** + * Resolve the SKILL.md shipped inside this package. + * + * The relative hop is the same compiled (`dist/lib/protocol.js`) and from + * source under vitest (`src/lib/protocol.ts`): both sit two levels below the + * package root, so neither needs a build-time constant. + */ +export function packagedSkillPath(): string { + return fileURLToPath(new URL('../../skills/switchyard/SKILL.md', import.meta.url)); +} + +export function readPackagedSkill(): string { + const file = packagedSkillPath(); + if (!existsSync(file)) { + throw new FleetError( + `Could not find the packaged skill at ${file}.\n` + + 'This usually means the install is incomplete — reinstall @switchyardhq/switchyard.', + ); + } + return readFileSync(file, 'utf8'); +} + +/** + * The short protocol summary for AGENTS.md. + * + * Deliberately not generated from SKILL.md. The two have different audiences — + * a Claude agent loading a full skill on demand, versus any agent that reads + * AGENTS.md up front and needs the short version — and auto-summarizing one + * into the other produces a worse block than writing it directly. + */ +export const AGENTS_BLOCK = `${BLOCK_BEGIN} +## Working in a Switchyard fleet + +This repository uses Switchyard so that several AI agents can work in it +without overwriting each other. Each agent gets its own git worktree and +branch (\`fleet/\`). + +1. **Find your worktree before editing anything.** Run \`fleet list\` and match + your own agent name. Work inside that directory — never in the main + checkout. Editing the main checkout while other agents hold worktrees off + it is the exact failure this tool exists to prevent. +2. **Run \`fleet check\` before you start on a file, not just before merging.** + Checking only at merge time finds the collision after both agents have + already done the work. A \`conflicts\` verdict means stop and coordinate; + \`uncommitted\` means another agent has unsaved work there that merge + simulation could not see. +3. **Do not create a worktree or branch yourself.** If \`fleet list\` has no + entry for you, ask for \`fleet spawn \` instead of running + \`git worktree add\` — an untracked worktree is invisible to every other + agent's \`fleet check\`, which is precisely the uncoordinated state + Switchyard prevents. +4. **Provisioning and merging are human actions.** Ask for \`fleet merge\`, + \`fleet sync\`, or \`fleet pr\` by name. There is no agent-facing tool for + them, by design. + +This block is managed by \`fleet init\`; edits inside it are overwritten. +Full protocol: \`${SKILL_INSTALL_PATH}\`. +${BLOCK_END}`; + +/** + * Insert `block` into `existing`, replacing a previously written block when the + * markers are already present and appending when they are not. + * + * Pure, so the placement rules are testable without touching a filesystem. + * `block` carries its own markers. + */ +export function upsertMarkedBlock(existing: string, block: string): string { + const begin = existing.indexOf(BLOCK_BEGIN); + const end = existing.indexOf(BLOCK_END); + + if (begin === -1 && end === -1) { + if (existing.trim() === '') return `${block}\n`; + // Leave exactly one blank line between the user's content and the block. + const gap = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n'; + return `${existing}${gap}${block}\n`; + } + + // A half-present or inverted pair means someone hand-edited the markers. + // Rewriting on a guess could silently eat their content, so refuse instead. + if (begin === -1 || end === -1 || end < begin) { + throw new FleetError( + `The Switchyard block in ${AGENTS_MD_FILE} has broken markers.\n` + + `Expected "${BLOCK_BEGIN}" followed by "${BLOCK_END}".\n` + + 'Fix or delete the markers by hand, then re-run `fleet init`.', + ); + } + + return `${existing.slice(0, begin)}${block}${existing.slice(end + BLOCK_END.length)}`; +} diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..7224a0a --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,187 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { init } from '../src/commands/init.js'; +import { readConfig } from '../src/lib/config.js'; +import { + AGENTS_BLOCK, + BLOCK_BEGIN, + BLOCK_END, + SKILL_INSTALL_PATH, + readPackagedSkill, + upsertMarkedBlock, +} from '../src/lib/protocol.js'; +import { makeTempRepo } from './helpers.js'; +import type { TempRepo } from './helpers.js'; + +let repo: TempRepo; + +beforeEach(async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + repo = await makeTempRepo(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + repo.cleanup(); +}); + +function read(relative: string): string { + return readFileSync(path.join(repo.root, ...relative.split('/')), 'utf8'); +} + +function write(relative: string, content: string): void { + writeFileSync(path.join(repo.root, ...relative.split('/')), content, 'utf8'); +} + +function actionFor(steps: { path: string; action: string }[], file: string): string | undefined { + return steps.find((s) => s.path === file)?.action; +} + +describe('fleet init', () => { + it('creates the config, the skill, and the AGENTS.md block', async () => { + const result = await init({ cwd: repo.root }); + + expect(actionFor(result.steps, '.fleetrc.json')).toBe('created'); + expect(actionFor(result.steps, SKILL_INSTALL_PATH)).toBe('created'); + expect(actionFor(result.steps, 'AGENTS.md')).toBe('created'); + + // The config must survive its own validator, not merely be valid JSON. + expect(readConfig(repo.root)).toEqual({}); + expect(read(SKILL_INSTALL_PATH)).toBe(readPackagedSkill()); + expect(read('AGENTS.md')).toContain(BLOCK_BEGIN); + expect(read('AGENTS.md')).toContain('fleet check'); + }); + + it('adds .fleet/ to .git/info/exclude without a spawn', async () => { + const first = await init({ cwd: repo.root }); + expect(read('.git/info/exclude')).toContain('.fleet/'); + // Reports what it actually did: added on the first run, not on the second. + expect(actionFor(first.steps, '.git/info/exclude')).toBe('updated'); + + const second = await init({ cwd: repo.root }); + expect(actionFor(second.steps, '.git/info/exclude')).toBe('unchanged'); + }); + + it('is idempotent: a second run changes nothing', async () => { + await init({ cwd: repo.root }); + const before = read('AGENTS.md'); + + const second = await init({ cwd: repo.root }); + + expect(read('AGENTS.md')).toBe(before); + expect(actionFor(second.steps, SKILL_INSTALL_PATH)).toBe('unchanged'); + expect(actionFor(second.steps, 'AGENTS.md')).toBe('unchanged'); + // One block, not two — the marker replacement is what prevents duplication. + expect(before.split(BLOCK_BEGIN)).toHaveLength(2); + }); + + it('keeps an existing .fleetrc.json unless --force is passed', async () => { + write('.fleetrc.json', '{ "defaultBase": "dev" }'); + + const kept = await init({ cwd: repo.root }); + expect(actionFor(kept.steps, '.fleetrc.json')).toBe('kept'); + expect(readConfig(repo.root)).toEqual({ defaultBase: 'dev' }); + + const forced = await init({ cwd: repo.root, force: true }); + expect(actionFor(forced.steps, '.fleetrc.json')).toBe('updated'); + expect(readConfig(repo.root)).toEqual({}); + }); + + it('appends to an existing AGENTS.md, preserving the user content', async () => { + write('AGENTS.md', '# Our agents\n\nDo not touch the vendored directory.\n'); + + await init({ cwd: repo.root }); + + const content = read('AGENTS.md'); + expect(content).toContain('# Our agents'); + expect(content).toContain('Do not touch the vendored directory.'); + expect(content).toContain(BLOCK_BEGIN); + expect(content.indexOf('# Our agents')).toBeLessThan(content.indexOf(BLOCK_BEGIN)); + }); + + it('replaces a stale block in place, leaving surrounding content alone', async () => { + write( + 'AGENTS.md', + `# Our agents\n\n${BLOCK_BEGIN}\nold and wrong\n${BLOCK_END}\n\n## House rules\n\nRun the linter.\n`, + ); + + const result = await init({ cwd: repo.root }); + + const content = read('AGENTS.md'); + expect(actionFor(result.steps, 'AGENTS.md')).toBe('updated'); + expect(content).not.toContain('old and wrong'); + expect(content).toContain('# Our agents'); + expect(content).toContain('## House rules'); + expect(content).toContain('Run the linter.'); + expect(content.split(BLOCK_BEGIN)).toHaveLength(2); + }); + + it('refuses to guess when the markers are broken', async () => { + write('AGENTS.md', `# Our agents\n\n${BLOCK_BEGIN}\nhalf a block, no end marker\n`); + + await expect(init({ cwd: repo.root })).rejects.toThrow(/broken markers/); + // The user's file is left exactly as it was. + expect(read('AGENTS.md')).toContain('half a block, no end marker'); + }); + + it('refreshes the installed skill after a package upgrade', async () => { + await init({ cwd: repo.root }); + write(SKILL_INSTALL_PATH, '# stale copy from an older version\n'); + + const result = await init({ cwd: repo.root }); + + expect(actionFor(result.steps, SKILL_INSTALL_PATH)).toBe('updated'); + expect(read(SKILL_INSTALL_PATH)).toBe(readPackagedSkill()); + }); + + it('--json prints the result as parseable JSON and writes nothing else', async () => { + const logged: string[] = []; + vi.mocked(console.log).mockImplementation((...args: unknown[]) => { + logged.push(args.map(String).join(' ')); + }); + + const result = await init({ cwd: repo.root, json: true }); + + expect(logged).toHaveLength(1); + expect(JSON.parse(logged[0] as string)).toEqual(JSON.parse(JSON.stringify(result))); + }); + + it('never acquires the mutation lock beyond its own run', async () => { + await init({ cwd: repo.root }); + expect(existsSync(path.join(repo.root, '.fleet', 'lock'))).toBe(false); + }); +}); + +describe('upsertMarkedBlock', () => { + const block = `${BLOCK_BEGIN}\nnew\n${BLOCK_END}`; + + it('returns just the block for an empty file', () => { + expect(upsertMarkedBlock('', block)).toBe(`${block}\n`); + expect(upsertMarkedBlock(' \n\n', block)).toBe(`${block}\n`); + }); + + it('separates appended content with exactly one blank line', () => { + expect(upsertMarkedBlock('# Title\n', block)).toBe(`# Title\n\n${block}\n`); + expect(upsertMarkedBlock('# Title\n\n', block)).toBe(`# Title\n\n${block}\n`); + expect(upsertMarkedBlock('# Title', block)).toBe(`# Title\n\n${block}\n`); + }); + + it('replaces only the marked region', () => { + const existing = `before\n\n${BLOCK_BEGIN}\nold\n${BLOCK_END}\n\nafter\n`; + expect(upsertMarkedBlock(existing, block)).toBe(`before\n\n${block}\n\nafter\n`); + }); + + it('throws on a half-present or inverted marker pair', () => { + expect(() => upsertMarkedBlock(`${BLOCK_BEGIN}\nx\n`, block)).toThrow(/broken markers/); + expect(() => upsertMarkedBlock(`${BLOCK_END}\nx\n`, block)).toThrow(/broken markers/); + expect(() => upsertMarkedBlock(`${BLOCK_END}\n${BLOCK_BEGIN}\n`, block)).toThrow( + /broken markers/, + ); + }); + + it('round-trips: applying the real block twice is a fixed point', () => { + const once = upsertMarkedBlock('# Title\n', AGENTS_BLOCK); + expect(upsertMarkedBlock(once, AGENTS_BLOCK)).toBe(once); + }); +}); diff --git a/tests/package.test.ts b/tests/package.test.ts index 7967afd..b4b51e5 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -40,6 +40,8 @@ describe('published package contents', () => { const files = packedFiles(); expect(files).toContain('dist/cli.js'); expect(files).toContain('dist/commands/mcp.js'); + // `fleet init` reads the packaged skill at runtime; both must ship. + expect(files).toContain('dist/commands/init.js'); expect(files.some((f) => f.startsWith('schema/'))).toBe(true); }); From 904a6e2bb62c4d93619b3a0936890b4318fd4e18 Mon Sep 17 00:00:00 2001 From: Mohammed Alkindi Date: Sun, 19 Jul 2026 20:35:09 +0400 Subject: [PATCH 2/2] docs: document fleet init and why onboarding came before claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the reasoning the code cannot carry: why two hand-maintained texts beat one generated from the other, why AGENTS.md beats per-tool detection, and why claims are deferred a second time — they are advisory, so they only pay off once agents reliably follow the first convention, which is what init establishes. Also replaces the README's manual-copy instructions, which are now the thing init exists to remove. --- CHANGELOG.md | 32 ++++++ README.md | 36 ++++--- docs/architecture.md | 44 ++++++++ docs/design/2026-07-19-v0.4-init.md | 151 ++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 docs/design/2026-07-19-v0.4-init.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d82bf..a8d4e87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` and `` 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 diff --git a/README.md b/README.md index 861c6e5..02cce2e 100644 --- a/README.md +++ b/README.md @@ -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? @@ -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 ` | Create a worktree in `.fleet/worktrees//` on a new branch `fleet/`, then provision it (`copyOnSpawn` / `postSpawn` below) | `--from ` base branch (default: current branch) | | `fleet list` | All active agents: branch, base, ahead/behind, uncommitted count, last activity | `--json` machine-readable output | | `fleet status ` | One agent in detail: uncommitted files, diff stat vs base, ahead/behind | `--json` machine-readable output | @@ -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 `` and `` 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 diff --git a/docs/architecture.md b/docs/architecture.md index dc1fa3a..1f8be2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 `` / `` +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`: diff --git a/docs/design/2026-07-19-v0.4-init.md b/docs/design/2026-07-19-v0.4-init.md new file mode 100644 index 0000000..4779a49 --- /dev/null +++ b/docs/design/2026-07-19-v0.4-init.md @@ -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 `` / `` 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.