Files
harness-sync/docs/superpowers/specs/2026-05-23-harness-sync-design.md
T
lukaszraczylo 1b4b4b2005 spec: harness-sync v1 design
Canonical config at ~/.config/harness-sync/ (git repo) propagates skills,
agents, MCP, LLM endpoints (via profiles), and global instructions into
each detected harness. Hybrid sync: symlinks where shapes match, rendered
files where they differ. Plugin-style adapters; conflict resolution via
git merge-file three-way merge tracked in state/.
2026-05-23 02:28:14 +01:00

286 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# harness-sync — design
**Status:** draft (pending user approval)
**Date:** 2026-05-23
**Author:** Lukasz Raczylo
## Problem
Lukasz uses multiple LLM coding harnesses (claude code, crush, kilo, opencode, goose, cagent, zed, others). Each maintains its own copy of:
- **Skills** (Claude-style markdown + frontmatter)
- **Agents / subagents** (markdown with tool allowlists)
- **LLM endpoint and provider configuration** (gateway URL + token + model list)
- **MCP server registry**
- **Global instructions** (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, etc.)
The duplication causes drift, manual copy-paste maintenance, and inconsistent capabilities across harnesses. Lukasz also runs a personal LLM gateway with a dummy token that fronts multiple upstream providers (anthropic, openai, openrouter, groq, ollama, etc.); harnesses should point at the gateway, not at providers directly.
## Goal
A single Go binary, `harness-sync`, that:
1. Maintains `~/.config/harness-sync/` as the canonical source of truth for all of the above.
2. On `apply`, propagates canonical content into each detected harness, using symlinks where formats match and rendered/transformed files where they differ.
3. Uses git to track render history and resolve conflicts.
4. Supports a plugin-style adapter contract so new harnesses can be added by dropping in a Go package and one registry line.
Non-goals (v1): watcher daemon, GUI, remote sync (user has git), keychain/Vault backends beyond env-var substitution.
## Architecture
Single binary, subcommand CLI. Three layers:
- **Canonical** — hand-edited YAML + markdown under `~/.config/harness-sync/`.
- **Adapters** — Go interface implementations, one Go package per supported harness under `internal/adapters/<name>/`. Each adapter knows its harness's file paths and how to render canonical → native schema.
- **State** — last-rendered snapshot tracked in the same git repo, used as merge base for conflict detection.
Flow per `apply`:
```
canonical (YAML/MD) ──► adapter.Render() ──► rendered_new ──┐
├─► 3-way merge ──► target file
git HEAD: state/<harness>/<path> ──► rendered_old ──────────┤
target filesystem ──► target_current ───────────────────────┘
│
└─► after write: stage state/, git commit
```
## Canonical layout
```
~/.config/harness-sync/
├── config.yaml # global: enabled harnesses, default profile, paths
├── profiles/
│ ├── home.yaml # gateway URL, dummy token, model allowlist, upstream providers
│ ├── work.yaml
│ └── offline.yaml # e.g. local ollama only
├── instructions/
│ ├── global.md # rendered to CLAUDE.md / AGENTS.md / GEMINI.md / opencode AGENTS.md per harness
│ └── per-harness/<name>.md # optional override
├── skills/ # markdown + frontmatter; claude-code shape is canonical
│ └── <skill-name>/SKILL.md
├── agents/ # markdown + frontmatter
│ └── <agent-name>.md
├── mcp.yaml # canonical MCP server registry
├── state/ # git-tracked render snapshots — DO NOT EDIT BY HAND
│ └── <harness>/...
└── backups/ # pre-apply backups; git-ignored
```
`~/.config/harness-sync/` is itself a git repository. The user may push it to a private remote for cross-machine sync.
## Profile model
A profile bundles the LLM stack. Harnesses bind to the active profile (named in `config.yaml`). Switching profiles re-renders all harness LLM configs in one command.
```yaml
# profiles/home.yaml
name: home
gateway:
url: https://gateway.lan.example.com
token: dummy-local-token # plaintext OK; gateway accepts any non-empty token
default_model: claude-sonnet-4-6
upstreams: # informational: gateway already knows these; harnesses see only models
- name: anthropic
api_key: ${ANTHROPIC_API_KEY} # env-var substitution, never plaintext for real keys
- name: openai
api_key: ${OPENAI_API_KEY}
- name: openrouter
api_key: ${OPENROUTER_API_KEY}
- name: ollama
base_url: http://10.0.1.21:11434
models: # allowlist exposed to harnesses
- id: claude-sonnet-4-6
alias: sonnet
- id: claude-opus-4-7
alias: opus
- id: gpt-4o
alias: gpt
```
Secret handling: dummy gateway token is plaintext (no value if leaked). Any real provider keys reference env vars via `${VAR}`. `harness-sync apply` substitutes at render time; canonical files never contain plaintext provider keys.
## Adapter interface
```go
package adapter
type Adapter interface {
Name() string // "crush", "claude-code", ...
Detect() bool // true if harness config dir exists on this machine
Targets() []Target // declarative list of what gets written where
Render(c *Canonical, p *Profile) (FileSet, error)
Import(fs Filesystem) (CanonicalDelta, error) // reverse: read native files, produce canonical deltas
}
type Target struct {
Kind Kind // SymlinkDir | SymlinkFile | RenderedFile
SourcePath string // path within canonical (e.g. "skills/")
DestPath string // absolute target path (expanded; e.g. "/home/nvm/.config/crush/prompts")
Format Format // JSON | TOML | YAML | Markdown | Raw
}
type FileSet map[string]File // dest path -> rendered file (bytes + perms + symlink target)
```
Each adapter is expected to be roughly 150–300 LOC: declare targets, marshal canonical structs into native schema, handle import.
Adapters live at `internal/adapters/<name>/` and self-register through a static slice. No process-boundary plugin system — YAGNI.
### v1 adapter set
- `claudecode` — `~/.claude/{skills,agents,CLAUDE.md}` + `~/.claude/settings.json` (MCP block)
- `crush` — `~/.config/crush/`
- `kilo` — `~/.config/kilo/`
- `opencode` — `~/.config/opencode/`
- `goose` — `~/.config/goose/`
- `cagent` — `~/.config/cagent/`
- `zed` — `~/.config/zed/`
Each ships with golden-file tests under `internal/adapters/<name>/testdata/`.
## Sync mechanics by asset
| Asset | Mechanism | Reasoning |
|---|---|---|
| `skills/` | symlink directory into harness's expected location (or render if shape differs) | bulk, frequently edited, identical shape across claude-code-likes |
| `agents/` | symlink directory or render to native list file | same |
| `profiles/<active>.yaml` → endpoint config | **render** to native schema (per-harness keys, format) | each harness uses different keys (`providers`, `models`, `endpoints`) and serialization |
| `mcp.yaml` → harness MCP block | render to native (JSON/YAML/TOML) | shape and embedding location vary |
| `instructions/global.md` | render/copy to harness's expected filename and path | identical content, different filename per harness |
## Commands
```
harness-sync init # first run: scan ~/.config + ~/.claude, detect harnesses,
# offer multi-select import, merge+dedupe into canonical,
# git init, initial commit
harness-sync detect # list detected harnesses and their config paths
harness-sync diff [harness] # show pending changes (canonical vs target)
harness-sync apply [harness] [--dry-run] [--force]
# render + write; default does 3-way merge,
# --force overwrites without merge, --dry-run shows plan only
harness-sync rollback [n] # git revert N commits in canonical repo, then reapply
harness-sync profile use <name> # switch active profile in config.yaml + reapply
harness-sync profile list
harness-sync adapter list # show registered adapters + detection status
```
Default behavior with no subcommand: print `detect` summary + suggest `diff` or `apply`.
## Conflict handling
`apply` performs git-style three-way merge per rendered file:
1. `rendered_new` = adapter output for this run.
2. `rendered_old` = `state/<harness>/<path>` at git HEAD (or empty if first apply).
3. `target_current` = current contents at destination (or empty if missing).
```
if target_current == rendered_old:
fast-path write rendered_new # clean, no manual edits since last apply
elif target_current == rendered_new:
skip # already in sync
else:
git merge-file rendered_new rendered_old target_current
on clean merge: write result
on conflict: write .rej file alongside target, skip, accumulate report
```
After all adapters run successfully, stage and commit the new `state/` tree:
```
git add state/ && git commit -m "apply: <harnesses> profile=<name>"
```
Rollback: `git revert` on the canonical repo, then `apply` again to push the reverted state outward.
Symlink conflicts: if target should be a symlink and is currently a regular file (or a symlink elsewhere), move it to `backups/<harness>/<timestamp>/` and replace with the correct symlink.
## Import flow (first run)
`harness-sync init` on empty `~/.config/harness-sync/`:
1. Scan `~/.config/*` and `~/.claude/` for known harness layouts (using each adapter's `Detect()`).
2. Print discovered harnesses with checkboxes:
```
Found harnesses. Import from which?
[x] claude-code (~/.claude/)
[x] crush (~/.config/crush/)
[x] kilo (~/.config/kilo/)
[ ] cagent (~/.config/cagent/)
...
```
3. For each selected harness, invoke `adapter.Import()` which reads native files and yields a `CanonicalDelta`.
4. Merge deltas:
- **skills/agents:** dedupe by filename + content hash. Identical → keep one. Divergent → write all variants as `<name>.from-<harness>.md` and emit a TODO for the user to resolve.
- **profiles:** create one `imported.yaml` aggregating discovered endpoints; user edits later.
- **mcp.yaml:** union by server name; conflicts on same name go into a `# CONFLICT:` block.
- **instructions:** concatenate sourced files into `instructions/imported/<harness>.md`; user later promotes a unified version to `global.md`.
5. `git init && git add -A && git commit -m "import from {harnesses}"`.
After init the user has a fully populated, version-controlled canonical tree that they then prune and curate.
## Error handling
- Errors wrapped with adapter + operation context: `fmt.Errorf("adapter %s: render %s: %w", ...)`.
- `apply` is atomic per-file but not across the run: a fatal error in one adapter does not roll back files already written by previous adapters; the run report lists all writes and failures.
- On partial failure, the `state/` commit is **not** created — next run will re-attempt and detect divergence cleanly.
- Symlink writes go through `os.Symlink` + atomic rename via tempfile to avoid races.
## Testing strategy
- **Unit tests per adapter**: table-driven, with `testdata/` golden fixtures. Input: canonical struct + profile. Output: expected rendered files. Diffs surface on regenerate.
- **Integration test**: build the binary, run against a tempdir HOME populated with fake harness configs, assert `init → apply → diff` is empty.
- **Merge tests**: cover the three-way merge logic with synthetic `rendered_old`, `rendered_new`, `target_current` permutations.
## Logging and observability
- Default output: one line per harness with summary counts (`crush: 12 files synced, 0 conflicts`).
- `--verbose`: per-file actions.
- `--quiet`: errors only.
- No log file; structured logs to stderr if `HARNESS_SYNC_LOG_JSON=1`.
## Module layout
```
harness-sync/
├── cmd/harness-sync/main.go # cobra/cli entrypoint
├── internal/
│ ├── canonical/ # canonical struct types + loaders
│ ├── adapter/ # Adapter interface, registry, common helpers
│ ├── adapters/
│ │ ├── claudecode/
│ │ ├── crush/
│ │ ├── kilo/
│ │ ├── opencode/
│ │ ├── goose/
│ │ ├── cagent/
│ │ └── zed/
│ ├── merge/ # 3-way merge wrapper around git merge-file
│ ├── git/ # thin wrapper over `git` CLI invocation
│ ├── render/ # format-specific marshallers (JSON/TOML/YAML)
│ ├── secrets/ # env-var substitution
│ └── ui/ # interactive prompts (init multi-select)
├── docs/superpowers/specs/2026-05-23-harness-sync-design.md
├── go.mod
└── README.md
```
## Open questions / decisions to confirm
- **CLI library:** `spf13/cobra` (familiar, batteries included) vs `urfave/cli` (lighter). Recommend cobra.
- **Interactive prompts:** `charmbracelet/huh` or `AlecAivazis/survey`. Recommend huh (aligns with crush's stack).
- **YAML library:** `goccy/go-yaml` (preserves comments) vs `gopkg.in/yaml.v3`. Recommend goccy since canonical YAML is hand-edited and comments matter.
- **Adapter discovery:** static registry slice (decided: yes; simple, fast).
## Acceptance criteria
1. `harness-sync init` on a fresh machine with several harness configs produces a populated, git-committed canonical tree without data loss.
2. `harness-sync apply` renders correct native files into each detected harness; subsequent `harness-sync diff` is empty.
3. After hand-editing a synced file, the next `apply` detects the divergence and either three-way-merges cleanly or writes a `.rej` and exits non-zero.
4. `harness-sync profile use work` re-renders LLM endpoint config in every harness atomically.
5. Adding a new harness requires only a new package under `internal/adapters/` and one line in the registry; existing adapters untouched.
6. All adapters have golden-file tests; integration test covers the init → apply → diff cycle.