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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,17 @@ loadout sync # install into detected agents (asks first)
| `loadout init` | Create the canonical store (git-friendly directory with a starter manifest) |
| `loadout status [artifact...]` | Drift matrix: every artifact's sync state per agent |
| `loadout diff [artifact...]` | Unified diffs of what sync would change |
| `loadout sync [artifact...]` | Install/update artifacts in agent homes — always asks before writing (`--dry-run`, `--yes`, `--agent`) |
| `loadout install <agent>` | First-time full install into one agent |
| `loadout sync [artifact...]` | Install/update artifacts in agent homes — always asks before writing (`--dry-run`, `--yes`, `--agent`, `--force`) |
| `loadout install <agent>` | First-time full install into one agent (`--force`) |
| `loadout restore [snapshot-id]` | List pre-write snapshots, or restore one (`--yes`) — recover files an overwrite replaced |
| `loadout new <kind> <name>` | Scaffold a `skill`, `instruction`, `command` or `hook` — or draft a skill with `--ai "description"` via your logged-in agent |
| `loadout add <path>` | Adopt an existing native artifact (skill/hook dir, command/instruction file) into the store |
| `loadout source add\|list\|remove <url>` | Track skills from external git repos (pinned in `sources.lock.yaml`) |
| `loadout check` | Check sources for updates — `git ls-remote` only, nothing fetched or applied |
| `loadout update [source]` | Fetch updates, show changelog + artifact diff, apply to the store after confirmation |
| `loadout bundle export [--archive\|--flat]` | Export the store as a shareable directory, `.loadout.tar.gz`, or a single self-contained `.loadout.yaml` document (`--flat`) — everything by default, or exactly `--artifacts`/`--profiles`/`--library` (repeatable) plus `--sources` |
| `loadout bundle import <path>` | Validate a bundle (directory, `.tar.gz`, or flat YAML — auto-detected), show the merge plan (`--on-conflict skip\|overwrite`), apply after confirmation |
| `loadout wizard` (alias `create`) | Guided builder: stack + engineering conventions + SDD → generated skills/instructions/commands (`--preset`, `--profile`, `--answers`, `--save-profile`, `--yes`) |
| `loadout wizard` (alias `create`) | Guided builder: stack + engineering conventions + SDD → generated skills/instructions/commands (`--preset`, `--profile`, `--answers`, `--save-profile`, `--yes`). Set an optional namespace prefix so generated commands become `/af-plan`, `/af-spec`, … instead of clashing with agent built-ins like `/plan` |
| `loadout profile list\|show\|rm` | Save, list, inspect and delete named wizard answer sets (see `--save-profile` above) |
| `loadout loadouts save\|list\|show\|switch\|rm` | Named, switchable sets of active artifacts — `switch` installs the set and removes whatever isn't in it (always asks before writing) |
| `loadout tui` (alias `dash`) | Interactive dashboard: drift matrix, store browser with preview, sources, diff view, gated sync |
Expand Down Expand Up @@ -110,6 +111,21 @@ Instructions render into agents' global instruction files (e.g. `~/.claude/CLAUD

Everything outside the markers is preserved byte-for-byte. Hooks and settings fragments deep-merge into `settings.json` instead of replacing it.

## Safety: conflicts and snapshots

loadout will never silently destroy config you already had. Two guards protect a newcomer who imports someone else's bundle and syncs:

- **Conflicts are skipped by default.** A whole-file artifact (skill, command, hook script) whose target already exists on disk but was *never installed by loadout* is reported as a `conflict` and left untouched. `sync` tells you what it skipped and stops there. Re-run with `--force` to overwrite them on purpose — for example when a store's `plan` command collides with a `~/.claude/commands/plan.md` you wrote yourself. Files loadout provably installed (`drifted-local`) still re-converge on a normal sync; only genuinely-unowned files need `--force`.

- **A snapshot is taken before any overwrite or delete.** Right before `sync`, a loadout `switch`, or a `--force` write touches an existing file, loadout copies the originals into a timestamped snapshot under the machine-local data dir (`<data>/loadout/backups/<timestamp>/`). Recover them any time:

```
loadout restore # list snapshots, newest first
loadout restore 20260709-150257 # restore that snapshot (asks first)
```

Restores are themselves snapshotted, so they're undoable too. loadout keeps the most recent snapshots and prunes older ones — configure the count with `backups.keep` in `~/.config/loadout/config.yaml` (default 10; a negative value keeps all). Snapshots never touch install state, so `loadout status` stays honest after a restore.

## External sources

Track skill repos (yours, your team's, public collections) and stay pinned:
Expand Down Expand Up @@ -148,6 +164,8 @@ Non-interactive: `loadout wizard --preset go-cli --yes` (presets: `go-cli`, `web

**Name the commands and skills yourself** — `/spec`, `/plan`, `/handoff`, `/resume` and the `sdd-workflow` skill each have a naming question (e.g. rename `sdd-workflow` to `fe-sdd-workflow` for a frontend-specific flow). Every generated cross-reference (the workflow skill mentioning `/plan`, the resume command's description, and so on) uses the name you chose — nothing is left pointing at the old one.

**Namespace prefix** — the Naming station asks for an optional prefix (e.g. `af`) that's applied to every *defaulted* command/skill name at once: `/plan` → `/af-plan`, `/spec` → `/af-spec`, and so on. This keeps generated commands from clashing with agents' built-in slash commands like `/plan`. An explicit per-name rename always wins over the prefix. Whenever a generated (or scaffolded, or imported) command/skill name would shadow a built-in, loadout prints an advisory `!` warning — in the wizard, `loadout new`, `loadout bundle import`, and `loadout doctor` — but never blocks it: shadowing is allowed when you mean to.

**Tell it how aggressively to change your codebase** — `sdd.changeScope` (minimal / balanced / thorough) becomes a "Change scope" section in the generated `sdd-workflow` skill: *minimal* scopes every change to the fewest files, insists on reusing existing code over duplicating it, and favors several small reviewable PRs; *thorough* allows broader changes when they clearly help; *balanced* (default) is the middle ground.

**Crash-safe by design** — `/plan` writes a checkbox-tracked plan file to disk next to the spec; a killed session (crash, restart, laptop closed) loses nothing, because `/resume` reads that file — not the conversation — and continues at the first unchecked step:
Expand Down Expand Up @@ -239,7 +257,7 @@ loadout bundle import config.yaml --yes # same merge plan/apply path as any ot

`bundle import` auto-detects the input shape (directory, `.tar.gz`, or flat YAML) from content, not the file extension, so there's no separate import command to remember.

The web UI covers the same import flow: from the dashboard's "share" section, either upload a `.loadout.tar.gz` file or paste a flat YAML document directly into a textarea, review the plan (new / conflicting / identical artifacts, new sources), toggle skip-vs-overwrite for conflicts, and confirm — then run a sync to install into your agents. Directory bundles (the non-`--archive` export) are import-able from the CLI only. For picking exactly what to share instead of exporting everything, the dashboard's share section links to a dedicated **Export** page: chip-select artifacts (grouped by kind, with per-kind and select-all shortcuts), profiles, and library entries or whole groups, then either download a `.loadout.tar.gz` or render a copyable/downloadable flat YAML document.
The web UI covers the same import flow: from the dashboard's "share" section, either upload a bundle file (`.loadout.tar.gz` **or** `.loadout.yaml`) or paste a flat YAML document directly into a textarea, review the plan (new / conflicting / identical artifacts, new sources, plus any reserved-name warnings), toggle skip-vs-overwrite for conflicts, and confirm — then run a sync to install into your agents. Directory bundles (the non-`--archive` export) are import-able from the CLI only. For picking exactly what to share instead of exporting everything, the dashboard's share section links to a dedicated **Export** page: chip-select artifacts (grouped by kind, with per-kind and select-all shortcuts), profiles, and library entries or whole groups, then either download a `.loadout.tar.gz` or render a copyable/downloadable flat YAML document.

## Supported agents

Expand Down
95 changes: 95 additions & 0 deletions docs/specs/004-sync-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# 004 — Sync safety: conflict blocking and pre-write snapshots

## Context

loadout's whole reason to exist is writing config into agent homes (`~/.claude`, …), and until now `engine.Apply` (`internal/engine/engine.go`) wrote every planned change unconditionally: for skills, commands and hook scripts it does a whole-file `MergeReplace` overwrite. `classify()` already knew how to label a target that exists on disk but was never installed by loadout as `conflict`, but that classification was display-only (`status`/TUI) — the apply path never consulted it. So a newcomer who imported someone else's bundle (a `plan` command, a `handoff` skill) and ran `loadout sync` would have their **own** pre-existing `~/.claude/commands/plan.md` or `~/.claude/skills/plan/` silently overwritten, with no warning and no way to get the original back. There has never been any backup, snapshot, or undo.

This is the failure the tool most needs to not have. Bundle *import* is already safe (it writes only to the store, gated, skip-on-conflict); the danger is entirely at **sync** time, which is where this spec adds two guards:

1. **Block unmanaged overwrites by default.** A whole-file target that already exists but loadout can't prove it owns is a conflict — skipped unless the user explicitly opts in per-run (`--force` / a web checkbox).
2. **Snapshot before writing.** Right before any overwrite or delete, copy the originals into a timestamped, machine-local snapshot, and add `loadout restore` to recover them.

Who is affected: anyone adopting loadout onto a machine that already has hand-made agent config — especially first-time users importing a shared bundle.

## Requirements

1. **MUST** treat a `MergeReplace` target (skill/command/hook-script file) that exists on disk but has no matching install record — or whose store and disk have both moved since install — as a **conflict**, reusing the existing `domain.StateConflict` classification rather than inventing a second taxonomy.
2. **MUST** skip conflicts by default: `Plan.Changes()` excludes them, so `Apply` does not write them and the existing confirm gate is unchanged.
3. **MUST** provide a per-run, non-persisted opt-in (`Plan.OverwriteConflicts`, set from a `--force` flag / web `overwriteConflicts` field) that promotes conflicts into `Changes()` so they are written.
4. **MUST NOT** record install state for a skipped conflict — otherwise the next plan would see it as a loadout-installed `drifted-local` file and silently overwrite it on the second sync. This is the single most important correctness invariant of the feature.
5. **MUST NOT** block a file loadout provably installed that has since drifted locally (`drifted-local`): re-converging it to the store is the tool's core contract, and blocking it would train users to always pass `--force`. `MergeBlock` (managed-block) and `MergeJSON` (settings) targets are never blocked — they are non-destructive by construction.
6. **MUST** surface skipped/overwritten conflicts distinctly from ordinary changes in all three frontends (CLI plan output, web sync dock, TUI), never as a plain `update`.
7. **MUST** take a snapshot of the original content of every file an `Apply` or `ApplySwitch` is about to overwrite or delete, **before the first write**, storing it under the machine-local data dir (`<data>/loadout/backups/<id>/`), never inside the shareable store.
8. **MUST** capture whole directories for directory deletes (a skill's `skills/<id>/`), and skip pure creations (nothing on disk to preserve) so a first-time install with no collisions produces no snapshot.
9. **MUST** fail closed: if the snapshot can't be written, the apply aborts before touching anything (a safety feature that fails silently is worse than an aborted run).
10. **MUST** add `loadout restore` to list snapshots and restore one, gated by the exact same confirm-callback invariant as `Apply` (nil/false confirm writes nothing).
11. **MUST** make a restore itself snapshotted (so it's undoable) and **MUST NOT** modify `state.yaml` — after a restore, `status`/`doctor` honestly report whatever drift results and the user decides.
12. **MUST** cap retained snapshots (config `backups.keep`, default 10; negative keeps all) and prune the oldest beyond the cap.
13. **SHOULD** keep the TUI read-only with respect to forcing in v1 (log that conflicts were skipped and how to force from the CLI), rather than adding a TUI overwrite affordance.

## Non-goals

- A TUI `--force` affordance — CLI/web only for v1 (requirement 13).
- Restoring files an apply *created* (a snapshot only preserves what existed before; a create has nothing to preserve). Snapshot metadata could record creates for a future "undo an install" — out of scope here.
- Compressing/deduplicating snapshot storage, or a retention policy richer than "keep newest N".
- Blocking `MergeJSON`/`MergeBlock` writes — they are non-destructive and out of scope.

## Design

### Approach

Conflict detection lives entirely in `buildItem`: after constructing an `Item`, set `it.Conflict = op != OpNoop && merge == MergeReplace && classify(it) == StateConflict`. `Plan` gains an `OverwriteConflicts bool`; `Changes()` skips `Conflict && !OverwriteConflicts` items, a new `Conflicts()` returns them for display, and a new `stateItems()` (used by both `Apply` and `ApplySwitch` for state recording) excludes skipped conflicts. Because every write path already funnels through `Changes()` and every state write through the group builder, this one classification propagates correctly to CLI, TUI and web with no per-surface logic — each just gains conflict *display* and, for CLI/web, a `--force` / checkbox that flips `OverwriteConflicts`.

Snapshots live in a new `internal/engine/snapshot.go`. `takeSnapshot(backupDir, trigger, items, now)` copies the current on-disk content of every `OpUpdate`/`OpDelete` item (whole tree for directory deletes) into `<data>/loadout/backups/<UTC-timestamp>/files/<agent>/<relPath>`, plus a `snapshot.yaml` manifest; it returns `(nil,nil)` when nothing was worth preserving. `Apply`/`ApplySwitch` call it after the confirm gate and before the first write, fail closed on error, then `pruneSnapshots`. `Engine.ApplyRestore(snap, confirm)` re-uses the same confirm invariant, snapshots the current state first (trigger `restore`), writes the captured bytes back, and never touches `state.yaml`. A clock is injected on `Engine` for deterministic snapshot IDs in tests. `loadout restore` lists (newest-first, with relative time and trigger) or restores by ID.

### Alternatives considered

- **Also block `drifted-local` files (loadout-installed, then hand-edited).** Rejected: these were provably installed by loadout, so re-converging them is the core contract; blocking them makes every routine sync on a machine where agents rewrite their own files demand `--force`, training users to always pass it — which destroys the protection exactly where it matters. Recovery for those overwrites is instead guaranteed by the snapshot, which is taken for *every* overwrite, not just conflicts.
- **Warn-and-overwrite in the same run (with an extra ack) instead of skip-by-default.** Rejected: skip-by-default is the safe failure mode. A user who wants the overwrite types `--force` once; a user who didn't loses nothing. The asymmetry favors refusing to write.
- **`.bak` sibling files next to each overwritten file.** Rejected: litters agent homes, only keeps one generation, and pollutes the very directories the tool manages. A single machine-local snapshot tree keeps homes clean, versions naturally by timestamp, and is trivially prunable.
- **Snapshot lazily / warn-and-continue on snapshot failure.** Rejected: fail-closed. A backup that might not exist is not a backup; aborting the apply is recoverable, a missing snapshot after a clobber is not.
- **Record state for skipped conflicts anyway (simpler code).** Rejected outright: it silently reintroduces the exact clobber this feature prevents on the *second* sync (the file would classify as `drifted-local` next time). `stateItems()` exists solely to prevent this, and a dedicated regression test guards it.
- **Have restore rewrite `state.yaml` to match the restored content.** Rejected: a restore is a user escape hatch, not a sync; leaving state untouched means `status` tells the truth (the restored file now differs from the store) and the user consciously chooses to re-sync or keep it.

## Data & API changes

### `internal/engine` (engine.go)

```go
type Item struct { /* … */ Conflict bool } // set in buildItem
type Plan struct { /* … */ OverwriteConflicts bool }
func (p *Plan) Changes() []Item // now skips unresolved conflicts
func (p *Plan) Conflicts() []Item // new: the blocked items, for display
func (p *Plan) stateItems() []Item // new: Changes ∪ noops minus skipped conflicts

type Engine struct { /* … */ BackupDir string; KeepBackups int; Clock func() time.Time }
```

### `internal/engine/snapshot.go` (new)

```go
type SnapshotEntry struct { Agent domain.AgentID; RelPath, AbsPath string; Op Op; Mode fs.FileMode; IsDir bool }
type Snapshot struct { ID string; CreatedAt time.Time; Trigger string; Entries []SnapshotEntry }

func takeSnapshot(backupDir, trigger string, items []Item, now time.Time) (*Snapshot, error)
func pruneSnapshots(backupDir string, keep int) error
func ListSnapshots(backupDir string) ([]Snapshot, error)
func LoadSnapshot(backupDir, id string) (*Snapshot, error)
func (e *Engine) ApplyRestore(ctx, snap *Snapshot, confirm func(*Snapshot) bool) (int, error)
```

### `internal/config/config.go`

- `Config.Backups.Keep int` (`backups.keep`), `Config.BackupsKeep() int` (default `DefaultBackupsKeep = 10`), `BackupsDir() (string, error)` = `DataDir()/backups`.

### CLI

- `--force` on `sync`, `install`, `loadouts switch` → sets `Plan.OverwriteConflicts`.
- `loadout restore [id]` (`internal/cli/restore.go`), registered in `root.go`; completion scripts updated.
- `printChanges` renders conflicts as `! skip …` / `! force …` lines.

### Web API

- `POST /api/sync/plan` response gains `conflicts: []changeJSON`; `GET /api/overview` gains `conflicts: int`.
- `POST /api/sync/apply` accepts `overwriteConflicts bool`.
- SPA: `SyncDock.tsx` lists conflicts, offers an "also overwrite N …" checkbox, and surfaces a conflict-only state; `api.ts` types updated.
Loading
Loading