Skip to content

Make the CLI Grove's only agent interface, and remove MCP - #68

Open
nicksong-z wants to merge 21 commits into
masterfrom
gw-63
Open

Make the CLI Grove's only agent interface, and remove MCP#68
nicksong-z wants to merge 21 commits into
masterfrom
gw-63

Conversation

@nicksong-z

Copy link
Copy Markdown
Collaborator

Closes #63.

Makes the gw CLI the only first-party agent interface: a versioned machine
contract, structured results, discovery, coordination, and a review step for
destructive work — and removes the built-in MCP server.

Why not MCP

MCP was a second interface to a small set of features for users who already have
shell access. It cost a JSON-RPC implementation, .mcp.json lifecycle management,
a SQLite announcements database, and its own failure modes — without providing a
trust boundary the CLI doesn't already have.

Its one genuine feature was cross-agent coordination, so that came back as CLI
commands (see below) rather than being dropped: coordination came from shared
state on disk, not from the protocol.

What an agent gets

gw context --format json     # where am I, repo git state, announcements, next actions
gw create feat-x -r svc-auth,api-gateway -b feat/x --format json
gw status feat-x --format json
gw sync feat-x --format json
gw plan delete feat-x --format json > plan.json && gw apply plan.json --format json
  • Versioned envelopeok, schemaVersion, result/error, warnings,
    next_actions. One JSON document on stdout; progress, warnings, and hook output
    on stderr. 15 stable error codes mapped to 8 semantic exit classes, with a
    documented compatibility policy in docs/agent-cli.md.
  • Structured per-repo results for create/delete/sync/add-repo/remove-repo/run.
    Multi-repo work is partially failable, so "did it work?" isn't a boolean:
    outcomes come from a fixed vocabulary (created, already_present, rebased,
    up_to_date, skipped, not_found, failed, …) with a reason attached.
  • gw context — one read-only call answering "where am I and what can I do?".
    A projection over config, state, and local git; it shares RepoStatus with
    gw status so the two cannot disagree.
  • gw plan / gw apply — plans enumerate every repo, path, and branch, mark
    destructive changes, and carry a fingerprint of the state they assume. Apply
    recomputes it and refuses with STATE_CHANGED rather than executing against a
    world that moved.
  • gw announce / gw announcements — cross-workspace coordination for
    parallel agents, and recent notes surface in gw context, so an agent receives
    coordination while orienting instead of having to remember a tool exists. That
    fixes the reason the MCP version went unused: 0 rows in messages.db after
    months of real use.
  • Non-interactive by construction — pickers and prompts refuse to run in
    machine mode, and destructive commands require --force rather than treating a
    prompt they cannot show as consent.

Human output, interactive pickers, and the legacy -j/--json shapes are unchanged;
-j is marked deprecated but still byte-identical for existing scripts.

Removals and migration

internal/mcp/, gw mcp-serve, .mcp.json generation, models.MCPConfig, and
the modernc.org/sqlite dependency tree are gone. Release binary
13.03 MB → 9.10 MB (-30%).

Migration is handled rather than assumed: gw doctor --fix strips the stale
grove entry (preserving other MCP servers) and deletes the orphaned
~/.grove/messages.db. scripts/cleanup-mcp-migration.sh covers what doctor
cannot reach — untracked directories and trees outside the workspace dir — and is
dry-run by default.

Defects found and fixed along the way

Most of these were found by writing the tests, not by reading the code:

Defect Impact
Delete plans ignored the error from rev-list origin/<branch> A branch that was never pushed produced no warning — the only unrecoverable case was the quietest
Plan fingerprint stored a boolean "is dirty" Work added to an already-dirty repo (the normal agent situation) didn't invalidate a reviewed plan; a commit made after review slipped through entirely
IsGitURL didn't recognize ssh:// gw create -r ssh://git@host/org/repo.git failed with "repo not found" instead of cloning
Invalid --format produced no envelope Bare stderr text in exactly the case where the caller asked for machine output
gw rename reported INTERNAL/1 where others reported NO_WORKSPACES/3 Same condition, three answers
-r "api," produced a repo named "" Error read repo not found
ParseRemoteName("C:/repos/api")"repos/api" Windows drive letter parsed as scp syntax
Two implementations of "is this path inside this workspace?" state and gw context could disagree about where the caller is
gw context resolved each base branch twice 20 git subprocesses for 2 repos where 14 suffice

Testing

  • 231 e2e assertions, including envelope invariants across every read command,
    stdout purity under a hook that writes to stdout, every error-code→exit-class
    pair, plan/apply recovery, the JSON-only lifecycle, and the migration script.
  • Coverage is now measurable: go build -cover -coverpkg=./... makes the bash
    suite report real numbers — 59% from e2e alone, 65% combined. It doubles as a
    dead-code detector; it found three unused helpers I'd added and one documented
    but unreachable
    error code, all removed.
  • The e2e suite sandboxes itself properly. It previously relied only on
    export HOME, which git ignores when XDG_CONFIG_HOME is set — so on a
    developer machine with XDG configured it could have modified the real
    gitconfig
    . It now pins GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM, keeps all
    artifacts inside the sandbox, reaps child processes, and asserts the boundary
    before any test trusts it.
  • CI runs the suite on ubuntu + macOS (macOS matters: path resolution goes
    through the /var/private/var symlink) plus offline in a container
    (--network=none, non-root).
  • just check clean: tests, vet, gofmt, gocyclo -over 20, staticcheck.

Refactors, kept separate from behavior

Three commits are pure refactors, each verified behavior-preserving:

  • createCmd.Run: 240 lines → a 32-line orchestrator over named steps.
    gocyclo never flagged it because the complexity lived in a closure. Verified
    with a 19-invocation golden baseline (stdout + stderr + exit codes byte-identical).
  • Shared state.PathContains, announce.Store.each, parseRepoList,
    pickWorkspaceName, shortenPath — each replacing 2–6 divergent copies.
  • Human output for list -s, ws show, repos, and context verified
    byte-identical against the previous binary.

Notes for review

  • WORKTREE_DIRTY is deliberately not in the code catalog. The issue's example
    uses it, but no operation returns it: gw sync reports dirtiness as a per-repo
    skipped outcome, and applying a plan after a repo changed is STATE_CHANGED.
    A documented code that cannot occur invites dead client branches.
  • Memoizing ResolveBaseBranch was implemented, measured, and reverted. Every
    remaining repeat resolution is separated by a Fetch that must invalidate it, so
    it bought nothing; the fix was data flow instead. Cost note left in gitops.go.
  • The container image build is unverified locally (no Docker daemon on my
    machine) — the e2e-container CI job is its first real run.
  • Plan/apply owns the public contract only; the transactional execution and
    rollback semantics underneath belong with Epic: Make workspace operations transactional and recoverable #59.
  • This should land before Epic: Turn a task or PR URL into a ready workspace with gw grab #60 so gw grab composes this contract.

Grove's MCP server was a second interface to a small set of features for
users (local devs and coding agents) who already have shell access. It cost
a JSON-RPC protocol implementation, `.mcp.json` lifecycle management, an
announcements SQLite database, and its own tests and failure modes — without
providing a trust or authentication boundary the CLI doesn't already have.

The `gw` CLI is now the only first-party agent interface.

Removed:
- `internal/mcp/` (server + announcements store) and `cmd/mcp.go`
- the hidden `gw mcp-serve` command
- `.mcp.json` generation on create and cleanup on delete
- `models.MCPConfig` / `models.MCPServer`
- the `modernc.org/sqlite` dependency tree

Added a migration path instead of silent breakage: workspaces created by
older versions still carry a `grove` entry pointing at the removed command,
so `gw doctor` reports it and `gw doctor --fix` removes just that entry.
`internal/workspace/mcpmigrate.go` only claims entries Grove actually wrote
(command `gw`, args containing `mcp-serve`) so an external adapter named
`grove` and any other server in the file are left untouched.

`announce` / `get_announcements` are intentionally not reimplemented; there
is no demonstrated usage to preserve.

Release binary (darwin/arm64, -s -w): 13,030,674 → 8,951,234 bytes (-31%).
Defines the versioned envelope agents parse, the stable error codes they
branch on, and the exit-code classes they react to. No command uses it yet —
this commit is the contract plus its documentation so the following commits
have one shape to converge on.

Design notes:

- Error classification lives with the envelope, not in string matching at the
  CLI boundary. Services return `*machine.Error` carrying a code, a suggested
  fix, and safe next commands; unclassified errors surface as INTERNAL rather
  than being guessed onto a neighbouring code.
- Exit codes are classes (usage / not-found / conflict / precondition /
  permission / transient / cancelled) so a shell caller can decide whether to
  retry without parsing JSON. Only TRANSIENT is retry-safe, and a test
  enforces that every declared code has a class.
- `Emit` is a no-op in text mode, so call sites can be unconditional and human
  output never gets JSON interleaved into it.
- Warnings are collected and attached to the envelope once, then drained, so a
  degraded-but-successful run is machine-visible instead of stderr-only.
- `DetectEarly` reads --format before Cobra parses, so pre-command output like
  the update notice can be suppressed in machine mode. Cobra stays the source
  of truth and still rejects invalid values.
- Even a marshal failure writes a valid envelope, keeping the "stdout is always
  parseable JSON" guarantee true on the error path.

docs/agent-cli.md documents the envelope, the code/exit table, the
compatibility policy, and the create → inspect → sync → delete example.
`--format json` (`-o json`) is now available on every command and turns on
machine mode: one envelope on stdout, everything else on stderr, no colors,
no prompts, no version notice, and an exit code matching the failure class.

Plumbing:

- Root registers --format as a persistent flag and validates it in
  PersistentPreRunE, so a bad value is a structured USAGE error rather than a
  pflag message. A test asserts every subcommand inherits the flag — an agent
  can't know which commands opted in.
- Execute() reads --format before Cobra parses so the update notice is
  suppressed in machine mode; otherwise the first line of stdout... would be
  fine, but the first line of *stderr* would be release news an agent has to
  filter, and the notice is irrelevant to a non-human caller anyway.
- Cobra's own parse failures are mapped to USAGE (exit 2) by marker matching,
  which is confined to that one boundary. Already-classified errors pass
  through untouched.
- fail() replaces ad-hoc "print red text, exit 1": one envelope or one stderr
  line plus the fix hint, and the error's exit class.

Non-interactivity is enforced where blocking could happen, not just promised:
pickers refuse to run in machine mode with a USAGE error, and Confirm/Prompt
return their defaults without touching stdin. console.NoColor strips ANSI from
stderr diagnostics, and console.Warning now also records into the envelope's
warnings array so a degraded-but-successful run is machine-visible.

Read commands emit the envelope: list, list --status, ws show, status, doctor,
repos, preset list/show, plugin list. Two shape decisions worth noting:

- `gw doctor` reporting problems is ok:true with issues in the result. A failed
  *diagnosis* would be ok:false; findings are the successful output.
- `gw repos` with no configured repo dirs is now NOT_INITIALIZED instead of an
  empty list, so an agent cannot misread it as "this machine has no repos".

Status output was refactored so the human table and the envelope both render
one workspace.StatusReport — they cannot drift. Its Dirty()/Behind() helpers
drive state-dependent next_actions (offer sync only when actually behind).

The legacy `--json` flag keeps its exact pre-envelope output for existing
scripts and plugins; its help text now marks it deprecated in favour of
--format json, and a test pins that it still exists.

Service-layer errors are now classified at the point of detection
(internal/workspace/errors.go): WORKSPACE_NOT_FOUND, WORKSPACE_EXISTS,
REPO_NOT_FOUND, WORKTREE_EXISTS, WORKTREE_DIRTY, GIT_FAILED — each carrying a
fix hint and safe next commands.

Note: text-mode exit codes are now semantic too (e.g. `gw status missing-ws`
exits 3, not 1). That is the point of the contract, and 0 still means success.
create/delete/sync/add-repo/remove-repo/run now report what happened to each
repo instead of printing prose and returning a bare error. Multi-repo work is
partially failable by nature, so "did it work?" is not answerable with one
boolean — the envelope carries a result per repo with a stable outcome from a
fixed vocabulary (created, added, already_present, removed, not_found,
rebased, up_to_date, skipped, exited, failed) plus a Detail explaining any
non-obvious outcome.

Service API: CreateWithOpts/Delete/Sync/AddRepos/RemoveRepos/Run return typed
results. The positional Create() wrapper keeps its error-only signature since
it exists for historical call sites.

Failure-vs-data decisions, which are the substance of this commit:

- Sync returns ok:true with per-repo outcomes even when a repo could not be
  rebased. Sibling repos may have advanced, and reporting a command failure
  would hide that. Only an unreadable workspace is an error.
- Create stays all-or-nothing and returns no result on failure, so a caller
  can never mistake a rolled-back attempt for a half-built workspace.
- Delete reports state_removed, distinguishing "gone" from "partially gone"
  when a worktree could not be removed and the state entry was kept on purpose
  so `gw doctor` can still find the leftover.
- add-repo/remove-repo treat already-present and not-found repos as outcomes
  rather than errors, so an agent retrying after a partial failure converges.
- AddRepos now persists repos that succeeded before a later repo failed;
  abandoning them left worktrees on disk that state did not know about.
- Sync's dirty/undeterminable-upstream cases became explicit skipped outcomes
  with reasons instead of stderr-only warnings.
- A post_create hook failure reports HOOK_FAILED with the workspace in details,
  so the caller knows the workspace exists and must not retry create.

Machine-mode stdout stays a single envelope even while running foreign code:
per-repo setup/run hook stdout is redirected to stderr (`gw run` children keep
their [repo] prefixes there), since hook output is arbitrary text. Lifecycle
hooks already wrote to stderr only.

Destructive operations require explicit intent in machine mode: delete,
remove-repo, and create --replace demand --force rather than treating a prompt
they are forbidden to show as consent. `gw delete` with no NAME is a USAGE
error instead of an interactive picker.

Behavior preserved deliberately: remove-repo still deletes branches without
--force while whole-workspace delete still forces, so unmerged work survives
removing a single repo. A test pins that difference now that both paths share
deleteRepo().
An agent's first question is "where am I and what can I do?". Answering it
previously took several commands (list, status, repos, preset list) plus
knowledge of which one to trust. `gw context --format json` answers it once.

It reports the workspace containing the cwd (or null), each repo's live branch,
base branch, remote, and dirty/ahead/behind state, the configured repo dirs,
presets, workspace inventory, and safe next actions.

Design decisions:

- It is a projection, not a new state model: every field is derived from
  config.toml, state.json, or a local git query, and nothing is cached.
- RepoContext embeds RepoStatus, so `gw context` and `gw status` cannot report
  different git state for the same repo. A test asserts they agree.
- Only local git operations run (no fetch, no PR lookups) so the command is
  cheap enough to call before every decision, with per-repo collection in
  parallel like the rest of the service.
- A missing config reports initialized:false instead of erroring — an agent's
  first call is exactly how it should discover Grove needs `gw init`, and the
  next_actions then point at it.
- workspace is null outside a workspace rather than a best guess; that null is
  the signal that later commands need an explicit name.
- Path containment resolves symlinks, prefers the deepest match, and rejects
  ".." results so a sibling like `feat-other` is never treated as inside
  `feat`. Running from a repo subdirectory resolves the parent workspace, which
  is where agents actually work from.
- Fields for features that do not exist in core (blueprint identity,
  preparation/Oven status) are omitted rather than stubbed as null; adding them
  later is a compatible change under the schema policy.
- List-valued fields are always arrays, never null, so clients can iterate
  without nil checks.
The removed MCP server's real feature was letting agents in parallel
workspaces leave each other notes about shared repos. That capability came
from a shared store on disk, not from JSON-RPC — so it belongs in the CLI,
where every agent with a shell can reach it.

This restores announce/get_announcements as `gw announce` and
`gw announcements`, and fixes the discovery weakness that made the MCP version
go unused (0 rows in messages.db after months of real use): notes now arrive in
`gw context` under result.announcements, so an agent receives coordination
while orienting instead of having to notice a tool in a list and choose to call
it.

Storage is a directory of one-file-per-note JSON under
~/.grove/announcements/, not SQLite:

- publishing is a single O_EXCL file creation, so concurrent agents cannot
  clobber each other and no locking is needed — a test runs 24 parallel
  publishers and asserts none are lost;
- reading is a directory scan, unaffected by concurrent writers;
- pruning unlinks expired files, which is safe during reads and writes, and
  runs opportunistically on publish so no background job is required.

SQLite serialized writes that file creation already serializes, and cost the
~4 MB dependency tree this epic just removed. Binary stays at 9.1 MB.

Contract decisions:

- Notes are keyed by normalized repo remote (ssh/https/nested-group forms all
  collapse to owner/repo), so different worktrees of the same upstream match.
  Both publish and read derive keys through one function — deriving them
  differently would mean two agents silently never see each other.
- Repos with no remote fall back to their local name, so the feature still
  works for local-only repos.
- A workspace never sees its own notes; that is noise, not coordination.
- Coordination is advisory, so an unreadable or corrupt store degrades to zero
  announcements rather than failing the command the agent was actually running,
  and unparseable files are left on disk rather than deleted as if they were
  ours.
- Retention is 30 days in the store but 7 days / 20 entries in `gw context`: an
  old note is history, not something to act on while orienting.

AGENTS.md and CLAUDE.md now document the agent interface and these commands, so
the discovery path is explicit rather than depending on tool-list osmosis.
Destructive agent operations now have a review step: `gw plan create` and
`gw plan delete` describe what would change, and `gw apply` executes a plan
that was reviewed.

Two properties make a plan worth more than a printed warning:

- It is produced by the same validation path as execution. validateCreate is
  now shared by PlanCreate and CreateWithOpts, so a plan cannot succeed where
  execution would fail validation — a table test asserts both paths return the
  same error code for duplicate names, unknown repos, missing branch/repos, and
  an already-worktreed branch. Create also now fails validation before touching
  the filesystem rather than mid-provision.
- It pins the state it depends on with a fingerprint, and apply recomputes it.
  A delete plan's fingerprint deliberately includes each repo's dirty flag, so
  if an agent starts editing between review and apply, the delete is refused
  with STATE_CHANGED (exit 4) and the work survives. That is the property worth
  having; a plan that stayed valid while the world moved would be dangerous.

Plans enumerate every repository, path, and branch, each marked destructive or
not, so "delete workspace x" is never the whole story. Delete plans additionally
warn about uncommitted changes and unpushed commits — the things a reviewer
most needs to know.

Apply rebuilds the create request from the plan document (repo set, source
paths, workspace dir from the plan's own path), so a config change or shifted
repo discovery between plan and apply cannot silently redirect the work.

Ergonomics: apply accepts a bare plan, a saved `--format json` envelope (which
is what `> plan.json` actually produces), or `-` for stdin. A saved *failure*
envelope is refused rather than parsed into an empty plan, and an unrecognized
plan schema_version is refused rather than misread. The plan document is
versioned separately from the response envelope since they evolve for different
reasons.

`gw plan create` requires --repos/--preset/--all instead of falling back to an
interactive picker: a plan must be reproducible from its inputs.

The transactional execution and rollback semantics underneath belong with the
transactional-operations epic (#59); this owns the public reviewable contract.
A readability pass over the code added in this branch. No behavior changes:
the full suite passes untouched, and human output for list -s / ws show /
repos / context is byte-identical against the previous binary.

Deduplication (the substantive part):

- state.PathContains is now the single definition of "is this path inside this
  workspace?". state.FindWorkspaceByPath and gw context each had their own
  implementation — prefix-matching versus filepath.Rel — which is exactly the
  kind of divergence that makes two commands disagree about where the caller
  is. Each keeps its own selection policy (first match for state lookups,
  deepest match for context, which matters for nested workspaces) but they now
  share the subtle part: absolute-path resolution, symlink resolution, and
  rejecting "../" so a sibling like feat-other never counts as inside feat. A
  table test pins those edge cases.
- announce's List and Prune both walked the directory and decoded files; they
  now share Store.each, so "an announcement we can act on" has one definition.
- cmd's home-directory-to-~ rewrite existed in four copies; now shortenPath.

Decomposition:

- PlanCreate (cyclomatic 13) split into planRepoProvisioning /
  planBranchProvisioning / effectiveBranchMode / resolveBaseForPlan /
  planSetupHooks. The three-way branch resolution is the part worth reading on
  its own, since it has to mirror provisionWorktreeNoFetch.
- PlanDelete (10) split into planRepoDestruction / unsavedWorkWarnings, which
  also makes it symmetric with the create path.
- announce.List (19) split into a named filter type plus Store.each, leaving
  List about assembling and ordering results.
- announce.Publish (13) extracted writeNew, removing a nested re-marshal in the
  ID-collision retry and putting the O_EXCL rationale next to the syscall.

Also: dropped a hand-rolled min() that shadowed the builtin, and threaded
BranchExists through planRepoProvisioning instead of calling it twice — the
first decomposition attempt added a redundant git subprocess per repo.

gocyclo -over 20 and staticcheck are clean.
A delete plan asked `rev-list origin/<branch>...HEAD` and ignored the error. For
a branch that was never pushed, origin/<branch> does not exist, so the call
failed and the plan reported no warnings at all:

    committed locally, never pushed:  work that exists nowhere else
    gw plan delete losswork  →  {"warnings": null}

The risk was inverted. A pushed branch that was merely ahead of its remote got a
warning, while commits that exist nowhere but that worktree — the only truly
unrecoverable case — got silence. A plan whose purpose is to say what will be
destroyed must not be quietest when the stakes are highest.

Now the comparison target depends on whether the remote branch exists: against
origin/<branch> when it does, and against the resolved base branch when it does
not, which is what makes the never-pushed case visible.

Both failure paths also warn instead of staying silent:

- an unreadable worktree ("could not check for uncommitted changes") is not
  evidence of a clean one;
- a branch with no remote and no resolvable base still says its commits may
  exist only here.

Tests cover never-pushed, pushed-and-ahead, fully-pushed (which must stay quiet
so the warning keeps meaning something), and an unreadable worktree.
Counted with a PATH shim over real git, `gw context` on a two-repo workspace
made 20 git invocations, 6 of them redundant: collectRepoStatus resolves the
base branch to compute ahead/behind, then repoContexts resolved it again for
RepoContext.BaseBranch. Resolution alone is up to three subprocesses
(symbolic-ref, then probing origin/main and origin/master).

The fix is data flow, not caching: RepoStatus now carries the BaseBranch that
ahead/behind were measured against, and RepoContext gets it through the embedded
struct. 20 → 14 invocations.

That field is worth having on its own — `gw status --format json` reported
"ahead: 2, behind: 1" without saying what they were relative to, which is not
interpretable by an agent. It is an additive field, compatible under the schema
policy in docs/agent-cli.md.

Memoizing ResolveBaseBranch was the obvious alternative, so I implemented and
measured it: it changed nothing. Every remaining repeat resolution in a single
process is separated by a gitops.Fetch, which can change the answer and would
have to invalidate the cache anyway (`gw apply` = plan, fetch, provision). I
reverted it rather than ship a cache with invalidation coupling and no measured
benefit, and left the cost note in gitops.go instead, since the durable problem
is that these functions read like cheap accessors and are not.
There were two implementations of "reduce a git remote to owner/repo":
gitops.ParseRemoteName (string splitting) and announce.NormalizeRepo (regexes,
added in this branch). They agreed on common forms and disagreed on case and on
non-URL input — and the announce copy exists to build a *coordination key*, so a
disagreement would mean two agents on the same repo silently never seeing each
other's notes.

announce.NormalizeRepo now delegates the parse and keeps only its own policy:
lowercasing (so a case-different remote still matches) and the bare-name
fallback for repos with no remote.

Two bugs surfaced while consolidating:

- IsGitURL did not recognize ssh:// — it required a colon *without* a scheme for
  the scp-like form, and only allowed https/http/file otherwise. So
  `gw create -r ssh://git@host/org/repo.git` treated the URL as a repo name and
  failed with "repo not found" instead of cloning. It now accepts explicit git
  transports (https, http, ssh, git, git+ssh, file) and still requires "@" for
  the scp-like shorthand.
- ParseRemoteName turned "C:/repos/api" into "repos/api", reading a Windows
  drive letter as scp-like syntax. It was only harmless because callers happened
  to gate on IsGitURL first; it nearly became load-bearing when announce
  delegated to it. It now returns "" for anything IsGitURL rejects.

Tests: ssh:// with and without a port, git://, git+ssh://, an unknown scheme, a
Windows path, and an absolute path joined the existing IsGitURL table; new tests
assert every URL form of one upstream reduces to the same identity, that nested
GitLab groups survive, and that non-URLs return "" rather than a mangled guess.

Also documented the deliberate duplicate `git worktree list` in the create path:
validateCreate fails fast before any directory or fetch exists (and lets a plan
report WORKTREE_EXISTS), while provisionWorktreeNoFetch re-checks immediately
before mutating to guard against a concurrent gw. Removing a guard that protects
a mutation to save one subprocess ahead of a network fetch is not a trade worth
making.
models.ToJSON had zero callers while ten sites marshalled inline; a helper
nobody calls is an invitation to a second convention rather than a shared one.
Its test went with it — the behavior it covered (Workspace round-tripping
through JSON) is already covered by TestWorkspaceJSONRoundTrip.

preset list, preset show, and plugin list each hand-rolled
MarshalIndent + "failed to marshal JSON" + Println behind the legacy --json
flag; they now use emitLegacyJSON like list/status/doctor/repos, so the
deprecated output path has one implementation. Verified byte-identical output
against the previous binary for each command.
Output of `openwiki --update`, covering the MCP removal, the versioned
machine contract, structured per-repo results, gw context, announcements, and
plan/apply. Generated pages, not hand-written — this also replaces the openwiki
pages I hand-edited earlier in this branch, which CLAUDE.md asks us not to do.

Adds openwiki/index.md and openwiki/INSTRUCTIONS.md, and drops the tool's
temporary _plan.md. docs/agent-cli.md remains the authoritative source for the
error codes and exit classes; the wiki links to it rather than restating it.
…erly

Two parts: what the suite tests, and where it runs.

## Coverage

A machine-contract section drives the whole agent surface through JSON only:

- envelope invariants across every read command, as a table-driven loop, so a
  command added without machine support fails here instead of needing its own
  hand-written test;
- stdout purity, proven by giving a repo a setup hook that writes to stdout — it
  must appear on stderr and never inside the envelope;
- a degraded-but-successful sync: ok stays true, the warning rides in the
  envelope, and the per-repo outcome names the dirty repo and why it skipped;
- error codes mapped to exit classes: WORKSPACE_NOT_FOUND 3, WORKSPACE_EXISTS 4,
  REPO_NOT_FOUND 3, USAGE 2 (missing flag, bad --format, unknown command),
  STATE_CHANGED 4;
- destructive refusals: delete and remove-repo without --force fail as USAGE and
  leave the workspace intact;
- plan/apply including apply-after-change (and that the new work survives),
  re-plan surfacing the warning the stale plan lacked, never-pushed commits
  being named, a saved failure envelope being rejected, and plan | apply over a
  pipe;
- the full create → inspect → sync → add → remove → delete lifecycle parsing
  only JSON, including running a next_actions command verbatim to prove those
  strings are executable rather than descriptive;
- text mode and legacy --json unchanged.

Three real defects surfaced while writing it, each fixed with unit tests:

- An invalid --format value produced no envelope at all — bare text on stderr in
  exactly the case where the caller explicitly asked for machine output. Passing
  --format now enables machine mode even when the value is rejected, so the
  rejection is parseable.
- The delete fingerprint stored only a boolean "is dirty", so work added to an
  already-dirty repo did not invalidate a reviewed plan. Since coding agents
  routinely start from a workspace that already has scratch files, this was the
  common case, not the edge case. It now fingerprints the actual status output
  and HEAD commit, which also catches a commit made after review — that would
  previously have left the worktree clean and slipped through entirely.
- My earlier assertion assumed only the dirty repo would skip during sync; a
  second repo skipped for an unrelated reason. Now asserted on the reason.

## Isolation

Sandboxing rested entirely on `export HOME`, which is not enough for git: it
prefers $XDG_CONFIG_HOME/git/config when that is set, so on a developer machine
with XDG configured `git config --global` in the setup block would have edited
their real config. The suite now pins GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM
(git 2.32+), clears XDG and GIT_DIR-style variables, keeps TMPDIR and all test
artifacts inside the sandbox (several of my earlier additions wrote predictable
/tmp paths), reaps child processes on exit so a failed run cannot leave `gw run`
processes behind, refuses to rm -rf a path it did not create, and asserts the
boundary holds before any test relies on it.

A container mode runs the same script — not a variant, which would drift — for
what sandboxing cannot give: nothing from a host reachable, a pinned git, Linux
filesystem semantics, and a non-root user so permission bugs are not masked. CI
now runs the suite on ubuntu and macos (macOS matters: path resolution there goes
through the /var -> /private/var symlink) plus the container offline with
--network=none.

Note: the image build is unverified locally — no Docker daemon on this machine —
so the e2e-container job is its first real execution.

218 assertions pass.
…derers

Building the CLI with `go build -cover -coverpkg=./...` and running the e2e suite
against it turns the bash suite into a coverage report, which doubles as a
dead-code detector. It found three things I had added speculatively:

- workspace.anyFailed — superseded by FailedRepos, never called.
- workspace.ErrWorktreeDirty — never called.
- machine.Emitted — added to guard against emitting a second envelope, a guard
  no command ever needed. Its `emitted` bookkeeping went with it.

Removing ErrWorktreeDirty made WORKTREE_DIRTY unreachable: no operation could
return it, yet docs/agent-cli.md listed it as a code to branch on. A documented
code that cannot occur is worse than a missing one — it invites dead branches in
client code — so the code and its table row are gone, and the docs now explain
where dirtiness actually surfaces instead: `gw sync` reports a per-repo `skipped`
outcome with a reason, `gw plan delete` warns about work that would be destroyed,
and applying a plan after a repo changed is STATE_CHANGED. Adding a code back
later is a compatible change under the policy; advertising a phantom one is not.
The envelope example in the docs now uses a code that can actually be received.

The profile also showed printContext, printPlan, and humanizeAge at 0% — the
human renderers are a separate path from the envelope and nothing exercised them.
The e2e suite now asserts the context summary, the plan table with its
destructive markers, and announcements with a relative age. e2e-only coverage
57.1% → 59.0%; 65.0% combined with the unit tests.

One fix in kind: the new text-mode announcement assertion polluted the later
coordination section, since the announcement store is shared for the whole run.
It now publishes about a repo no other section uses, and the coordination
assertions filter by workspace instead of asserting exact counts, so a later
section publishing a note cannot fail an earlier one.

221 assertions pass.
Applying the same audit to code I did not write. The "load workspaces, bail if
none, build choices, PickOne" block existed four times in create/delete/rename/
add-repo/remove-repo, and the copies had drifted apart in a way that matters now
that error codes are contract:

    gw rename       → INTERNAL       (exit 1)
    gw remove-repo  → NO_WORKSPACES  (exit 3)
    gw add-repo     → NO_WORKSPACES  (exit 3)

Same condition, three different answers, so the code told an agent nothing unless
it already knew which command it had run. All three now go through one helper and
report NO_WORKSPACES with a fix and a next action. (gw delete stays USAGE/2 in
machine mode: its condition is a missing NAME argument, not missing workspaces.)

The `--repos` flag was split and trimmed in six places, and every copy kept empty
entries, so `-r "api,"` produced a repo named "" and the error `repo  not found`:

    before: gw create x -r "api," -b b   → REPO_NOT_FOUND: repo  not found
    after:  gw plan create x -r "api, ,web" -b b → repos [api web]

parseRepoList drops blank entries once for create, plan, add-repo, remove-repo,
preset add, and announce.

Both were found by looking for the pattern behind the earlier PathContains fix —
one concept implemented several times drifts, and the drift shows up as
inconsistent behavior rather than as duplication anyone notices.
createCmd.Run was 240 lines in one closure — the largest readability problem in
the repo, and in the command humans and agents use most. gocyclo never flagged it
because the complexity lived inside a function literal, so the ceiling of 20 that
guards every other function did not apply to the worst offender.

It is now a 32-line orchestrator over named steps: resolveCreateRepos (with
reposFromPreset / reposFromFlag / cloneRepo / reposInteractively / pickPreset /
pickRepos / offerPresetSave), requireKnownRepos, resolveCreateBranch,
buildCreateOpts, createSource, replaceCurrentWorkspace, failCreate, and the two
hook helpers. Highest remaining complexity in the file is 8.

The ordering is the part that needed protecting, so it is now stated at the top
of Run rather than being implicit in 240 lines of flow: repos are resolved (and
possibly cloned) before validation, the branch is resolved after the name argument
is read because it seeds the prompt default, and --replace runs after the new name
is known but before anything is created — so a name collision is caught before the
old workspace is destroyed.

Verified with a 19-invocation golden baseline covering every non-interactive
path (explicit repos, --all, --preset, unknown preset, derived name, name from
branch, duplicate, unknown repo, missing branch, trailing separators, clone URL,
--track, source flags, and three --format json cases): stdout, stderr, and exit
codes are byte-identical before and after, modulo timestamps. e2e's 221
assertions still pass.

The payoff beyond readability is testability: repo-selection precedence
(preset > --all > --repos), provenance assembly, --track mode, and explicit-branch
handling now have unit tests, where previously nothing in the file could be tested
without running the whole command. buildCreateOpts and createSource are at 100%.

Two behavior-preserving details worth noting. Declining the --replace confirmation
previously returned from the Run closure; it is now an explicit os.Exit(0), which
is the same observable outcome (exit 0, nothing created) from a helper that cannot
return to skip the rest. The "Pick manually…" sentinel is now a named constant
instead of a string repeated in the comparison and the choice list.
The feature entries were there; the defects found while writing the e2e suite
and auditing for duplication were not. Also corrects the plan fingerprint
description, which now covers each repo's exact changes and commit rather than a
dirtiness flag.
Two artifacts outlive the removed MCP server, and only one was handled: the
`grove` entry in each workspace's `.mcp.json`, and the announcements SQLite
database at ~/.grove/messages.db. On my own machine the database is still there
from May, 16 KB plus a -wal and -shm sidecar, and nothing in Grove can read it
now that the driver is gone — so it is dead weight rather than data at risk.

`gw doctor` now reports and `--fix` removes it, alongside the .mcp.json entries.

But doctor only visits workspaces Grove still tracks, which misses the cases most
likely to have leftovers: directories abandoned by workspaces removed from state,
and checkouts outside the configured workspace directory. So
scripts/cleanup-mcp-migration.sh covers those, and works without upgrading first.

Design choices worth stating:

- It reports and changes nothing unless given --apply, matching the plan/apply
  posture of the rest of this work. A cleanup that deletes files on first run is
  not something to paste from a README.
- It removes a `grove` entry only when that entry launches `gw mcp-serve`, so an
  external MCP adapter that happens to be named `grove` is left alone — the same
  rule internal/workspace/mcpmigrate.go applies.
- A `.mcp.json` that is not valid JSON is skipped and reported, never rewritten.
- The file is deleted only when Grove's entry was the only thing in it; otherwise
  the other servers are preserved.
- Without jq it refuses to edit JSON and points at `gw doctor --fix` rather than
  attempting text surgery on a config file.
- It reads workspace_dir from config.toml, since the workspace directory need not
  live under the Grove home, and accepts extra directories to scan.

e2e covers the script end to end: dry run changing nothing, the grove-only file
removed, other servers preserved, a foreign adapter untouched, an untracked
directory reached, the database deleted, and a second run reporting no work. Plus
doctor's own path for the database. 231 assertions.
A plan listed the setup commands it would run, but the fingerprint did not cover
them, so approving a plan approved nothing about the code it would execute:

    plan says:      touch SETUP_RAN_ORIGINAL     ← what the reviewer sees
    .grove.toml edited to:  touch PWNED
    gw apply plan.json  →  ok: true, PWNED created

Everything else a plan describes is git work Grove controls; run_setup_hook and
run_teardown_hook are arbitrary code from a repo's .grove.toml. Those are exactly
what a reviewer is being asked to approve, so plannedCommands now folds them into
both fingerprints and apply refuses with STATE_CHANGED when they change.

Fingerprinting the plan's own displayed commands, rather than re-deriving what to
hash, means the guarantee tracks the plan: whatever a plan claims it will execute
is what it is pinned to.

This is also the mechanism blueprints need (#61: "changed remote setup commands
invalidate prior approval"), where the commands arrive from a shared or remote
file rather than a local repo — the same check, with a blueprint digest and
resolved commit added to the fingerprint input.

docs/agent-cli.md now states what the fingerprint covers per plan kind, and adds
the section that was missing: when plan/apply is worth the extra round trip. It
is for the gap between deciding and doing — human review, arbitrary setup
commands, a concurrent mutator, or irreversible destruction. For an agent that
plans and applies in the same breath with nothing reading the plan, it is
overhead, and `gw delete --force --format json` already returns per-repo results.
Overselling a two-step as universally better would earn it a reputation as
ceremony.

e2e covers the approval path end to end.
The interactive paths in create were the last 0%-coverage code in the agent
surface, and not because nobody wrote tests: they need a terminal, so a
non-interactive suite could not reach them at all. Choosing a preset, taking the
"pick manually" escape hatch, being offered a preset save and declining it, the
branch prompt defaulting to the workspace name — all unverified.

Prompter is one interface for every interaction that needs a human: Interactive,
PickOne, PickMany, Confirm, Prompt. All 25 call sites across 11 files in cmd/ now
go through it rather than reaching into picker and console directly, so "asks a
human" has one definition instead of two packages' worth. The production
implementation is a pure delegation holding no logic, so substituting it in a test
cannot change what the code under test does. Machine-mode enforcement stays in
picker and console, where a caller that bypasses this seam still cannot block on
input.

The test double is strict: an unscripted prompt fails the test rather than
returning a zero value, because a silent default would let a flow take a branch
nobody wrote a case for and still pass. It answers by prompt substring rather than
call order, records what was asked, and rejects a scripted answer that is not among
the choices actually offered — a test that "picks" an option the user could never
see is testing nothing.

That strictness immediately caught a wrong assumption of mine: I wrote a test
asserting a sole workspace requires no prompt, which is picker's auto-select
behavior, not the cmd layer's. The seam stops at the cmd boundary deliberately, so
that shortcut stays picker's responsibility (and its own tests); the test now
asserts what the cmd layer offers instead.

Coverage of the previously unreachable paths: reposInteractively 100%, pickPreset
87%, offerPresetSave 86%, resolveCreateBranch 89%, pickWorkspaceName/Names 75%.

Behavior is unchanged: the 19-invocation create baseline is still byte-identical
to the pre-refactor binary, and e2e passes 237 assertions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Epic: Replace MCP with an agent-native CLI

1 participant