diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..00db232
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,18 @@
+name: test
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ installer:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - run: ./tests/install.sh
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..c37578d
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,27 @@
+# Contributing
+
+Peter is pre-release. Bug reports, failed runs, narrow fixes, and reproducible
+examples are more useful than new orchestration features.
+
+## Report a run
+
+Include:
+
+- Peter commit, Claude Code version, model, operating system, and target repository commit
+- Exact goal and project gate commands
+- Completed, blocked, and discovered task counts
+- Human interventions, failed gates, and relevant redacted logs
+- Expected behavior and what happened instead
+
+Never include credentials, private source, or unredacted model transcripts from
+repositories you cannot share.
+
+## Change Peter
+
+1. Keep the skill dependency-free and the graph contract append-only.
+2. Update the nearest reference document when changing a gate or state rule.
+3. Run `./tests/install.sh` after installer changes.
+4. Exercise orchestration changes in a disposable repository and attach the
+ resulting graph, commits, and gate results to the pull request.
+
+Small, evidence-backed changes are preferred.
diff --git a/README.md b/README.md
index e4517ad..ecd8d9e 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
You describe it. He graphs it. He ships it.
- Develop like Peter Steinberger — and cut the token bill doing it.
+ Persistent work graphs and independently checked gates for Claude Code.
@@ -16,7 +16,7 @@
- One goal in · one gated commit per task · zero babysitting
+ One goal in · one gated commit per task · bounded autonomy Autonomous epic builds for Claude Code — graph engineering with the bars built in.
Not a framework, not a runtime: a skill, four agent files, and a JSONL contract.
The parent session is the runtime.
@@ -24,18 +24,18 @@
---
-You know him. You describe a feature at nine; by noon there's a branch with a
-commit per task, each one green. He doesn't ask whether you want tests. There
-are tests. There's an OWASP pass. The contrast ratios check out.
+Peter is a Claude Code skill for work that is too large or failure-prone for one
+prompt-and-hope loop. It decomposes a goal into a persistent dependency graph,
+runs each task through project checks, and produces one traceable commit per
+completed task. Repeated failures, missing credentials, and unresolved product
+decisions return control to the operator instead of retrying forever.
-Peter puts him inside Claude Code.
+> **Pre-release:** the orchestration contract may change before `v0.1.0`. A
+> reproducible public example run is still required before launch.
-## Before / after
+## Output shape
-You ask for a checkout flow. Your agent writes 800 lines, says "All done! 🎉",
-and the first click 500s. No tests, no migrations, `main` is broken.
-
-With peter:
+This is illustrative output, not a benchmark or completed public run:
```
> /peter checkout flow with Stripe test mode
@@ -50,20 +50,19 @@ f3a91c2 task-9: e2e — happy path + declined card
## How it works
-Every task — loop or epic — goes through the same enforced sequence. No
-implementation code before the bars exist:
+No implementation code starts before its pass/fail bars exist:
```
1. Spec + pass/fail bars → written first, or nothing gets built
2. Implement → the minimum that meets the bars
3. Machine gates → unit, typecheck, lint, build, e2e
(real server, real database — no mocks)
-4. Security audit → OWASP Top 10:2025, read-only verdict
-5. UI audit → WCAG 2.2 AA + visual fidelity, read-only verdict
-6. Loop until green → then exactly one commit
+4. Conditional audit → security for trust boundaries; UI for rendered work
+5. Loop until green → then exactly one commit
```
-Small changes run that loop once and stop.
+Small changes run that loop once and stop. Completed epics finish with the full
+test suite and both read-only audit passes when their prerequisites are available.
Big goals become an **epic**: the goal is decomposed into a persistent work
graph (`runs//graph.jsonl`, append-only) of dependency-ordered tasks,
@@ -73,7 +72,7 @@ most the task in flight), discovered work filed as new tasks instead of
scope-creeping the current one, until the epic closes or a stop condition hands
control back.
-Implementation is zone-fenced across two builders: `backend-builder` owns
+Implementation is zone-fenced across two builders. `backend-builder` owns
everything that doesn't render (API, CLI, library, pipeline, infra),
`frontend-builder` owns everything that does. The write fence is what makes a
parallel pair safe — a shared file has no fence, so co-located code goes to one
@@ -122,15 +121,30 @@ ESON is the message format only — `graph.jsonl` stays JSONL.
## Install
-The most effort peter will ever ask of you:
+Inspect the planned writes, then install:
```bash
-git clone https://github.com/robertkeus/peter && cd peter && ./install.sh
+git clone https://github.com/robertkeus/peter
+cd peter
+./install.sh --dry-run
+./install.sh
```
-That's it. `~/.claude` is not version-controlled; this repo is the tracked
-copy. `./install.sh pull` copies the other way, `./install.sh check` reports
-drift.
+The installer refuses to overwrite existing `skills/peter`, generated
+`skills/build`, or Peter agent files. `./install.sh --force` preserves collisions
+for restoration; `./install.sh uninstall` restores them. Forced replacement of
+files changed after installation also writes a timestamped copy under
+`~/.claude/peter-backups/`. Set `CLAUDE_CONFIG_DIR` to install somewhere other
+than `~/.claude`; the legacy `CLAUDE_HOME` variable remains supported.
+
+| Command | Effect |
+|---------|--------|
+| `./install.sh --dry-run` | Preview an install without writing files. |
+| `./install.sh` | Install, or update an unchanged Peter installation. |
+| `./install.sh --force` | Back up and replace reported collisions. |
+| `./install.sh check` | Report drift between the repository and installation. |
+| `./install.sh pull` | Copy installed Peter files back into the repository. |
+| `./install.sh uninstall` | Remove Peter and restore pre-install files. |
## Commands
@@ -146,8 +160,28 @@ skills/peter/references/ work-graph, state, eson wire format, e2e/securi
agents/{backend,frontend}-builder.md zone-fenced implementers
agents/{security,ui}-auditor.md read-only verdict-only auditors
install.sh sync with ~/.claude
+tests/install.sh installer integration coverage
```
+## Requirements and limitations
+
+- A current Claude Code release with custom skills and subagents, plus Git and
+ Bash 3.2 or newer. The installer targets macOS and Linux; native Windows is
+ not tested.
+- The builders select Sonnet and the auditors select Opus. Your Claude plan must
+ provide those models. UI audits additionally require the `Claude_Browser` MCP
+ tools named in `agents/ui-auditor.md`; without them the UI verdict is not run.
+- Peter is prompt-level orchestration, not an operating-system sandbox. Run it
+ only in repositories and environments you are willing to let Claude Code
+ modify.
+- Autonomy is bounded. Ambiguous requirements, unavailable services, missing
+ credentials, operator-rejected dispatches, and repeated gate failures stop or
+ block work for human review.
+- Gates depend on the repository exposing runnable test, lint, build, E2E, and
+ audit prerequisites. Missing prerequisites are reported, not counted as passes.
+- Fewer handoff tokens do not guarantee a cheaper total run. Epics execute more
+ checks than a one-shot coding prompt; publish costs with the workload and model.
+
## FAQ
**Why "peter"?**
@@ -178,12 +212,4 @@ builders already write minimal code — it's the house standard.
[MIT](LICENSE). Use it, fork it, ship with it.
-## Star History
-
-
-
-
-
-
-
-
+Contributions start with [CONTRIBUTING.md](CONTRIBUTING.md).
diff --git a/docs/launch-checklist.md b/docs/launch-checklist.md
new file mode 100644
index 0000000..551c8b3
--- /dev/null
+++ b/docs/launch-checklist.md
@@ -0,0 +1,45 @@
+# Launch checklist
+
+## Required before Hacker News
+
+- [x] Installer detects existing skill and agent filename collisions.
+- [x] Forced replacement preserves originals and uninstall restores them.
+- [x] Dry-run, uninstall, drift detection, and installer integration tests exist.
+- [x] README distinguishes bounded autonomy from unconditional hands-off operation.
+- [x] Limitations and contribution instructions are public.
+- [ ] Run Peter end-to-end against a small public repository at a pinned commit.
+- [ ] Complete `docs/launch-run-template.md` with unedited observed results.
+- [ ] Publish the work graph, commits, gate logs, human interventions, and cost data.
+- [ ] Record a short terminal demo from the same reproducible run.
+- [ ] Replace the illustrative README transcript with the public run.
+- [ ] Confirm the documented Claude Code version on a clean account or machine.
+- [ ] Create and smoke-test the `v0.1.0` release archive.
+
+## GitHub metadata
+
+Description:
+
+> Persistent work graphs and independently checked quality gates for autonomous Claude Code builds.
+
+Topics:
+
+`claude-code`, `agent-orchestration`, `ai-agents`, `developer-tools`, `open-source`
+
+Before launch, add the description and topics, confirm Issues are enabled, and
+leave the website blank unless a useful demo or documentation URL exists.
+
+## Release notes
+
+The `v0.1.0` notes should contain:
+
+- The problem and Peter's graph-and-gates approach
+- Supported environment and exact installation command
+- Link to the reproducible launch run
+- Known limitations and upgrade/uninstall instructions
+- SHA-256 checksum for the source archive if distributing one separately
+
+## Launch gate
+
+Do not submit to Hacker News until every required item is complete. Write the HN
+title and first comment personally; HN currently asks authors not to publish
+LLM-written or LLM-edited text.
diff --git a/docs/launch-run-template.md b/docs/launch-run-template.md
new file mode 100644
index 0000000..bb64e76
--- /dev/null
+++ b/docs/launch-run-template.md
@@ -0,0 +1,63 @@
+# Peter launch run
+
+Status: **not yet run**. Replace every placeholder with observed data; do not
+publish this file as evidence while any result field is incomplete.
+
+## Environment
+
+| Field | Observed value |
+|-------|----------------|
+| Peter commit | `` |
+| Target repository and commit | `` |
+| Claude Code version | `` |
+| Model | `` |
+| Operating system | `` |
+| Started / finished | `` |
+
+## Goal
+
+Exact prompt:
+
+```text
+
+```
+
+Project gate commands:
+
+```text
+
+```
+
+## Results
+
+| Metric | Observed value | Evidence |
+|--------|----------------|----------|
+| Elapsed time | `` | `` |
+| Tasks completed / blocked / discovered | `` | `` |
+| Commits produced | `` | `` |
+| Gate failures repaired | `` | `` |
+| Human interventions | `` | `` |
+| Input / output tokens | `` | `` |
+| Reported cost | `` | `` |
+| Final test result | `` | `` |
+| Security / UI verdict | `` | `` |
+
+## Artifacts
+
+- Work graph: ``
+- Specification: ``
+- Commit history: ``
+- Gate logs: ``
+- Audit returns: ``
+- Terminal recording: ``
+
+## Failures and limitations
+
+Record what Peter got wrong, work it could not complete, misleading status,
+unexpected cost, and every place a person intervened. Do not remove failed
+attempts that materially affect the result.
+
+## Reproduce
+
+Provide the exact checkout, installation, target setup, and `/peter` commands.
+State any credential or paid-service requirement without publishing secrets.
diff --git a/install.sh b/install.sh
index c4ec64d..17332db 100755
--- a/install.sh
+++ b/install.sh
@@ -1,45 +1,315 @@
#!/usr/bin/env bash
-# Sync the tracked /peter skill and its agents with ~/.claude.
-# ~/.claude/skills/build is a generated alias of peter (only name: differs);
-# it is rebuilt on install and never pulled.
-#
-# ./install.sh repo -> ~/.claude (default)
-# ./install.sh pull ~/.claude -> repo
-# ./install.sh check diff both ways, non-zero on drift
-#
-# ~/.claude is not version-controlled; this repo is the tracked copy.
+# Install, update, verify, or remove Peter's Claude Code files.
+# The /build skill is generated from /peter during installation.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-dest="${CLAUDE_HOME:-$HOME/.claude}"
+dest="${CLAUDE_CONFIG_DIR:-${CLAUDE_HOME:-$HOME/.claude}}"
+dest="${dest%/}"
+state="$dest/.peter-install"
+backup_root="$dest/peter-backups"
+targets=(
+ "skills/peter"
+ "agents/backend-builder.md"
+ "agents/frontend-builder.md"
+ "agents/security-auditor.md"
+ "agents/ui-auditor.md"
+)
+managed_targets=("${targets[@]}" "skills/build")
-case "${1:-install}" in
-install)
- mkdir -p "$dest/skills" "$dest/agents"
- rm -rf "${dest:?}/skills/peter" "${dest:?}/skills/build"
- cp -R "$repo/skills/peter" "$dest/skills/peter"
- cp -R "$repo/skills/peter" "$dest/skills/build"
- sed 's/^name: peter$/name: build/' "$repo/skills/peter/SKILL.md" > "$dest/skills/build/SKILL.md"
- cp "$repo"/agents/*.md "$dest/agents/"
- echo "installed -> $dest"
- ;;
-pull)
- rm -rf "${repo:?}/skills/peter"
- cp -R "$dest/skills/peter" "$repo/skills/peter"
- cp "$dest"/agents/{backend-builder,frontend-builder,security-auditor,ui-auditor}.md "$repo/agents/"
- echo "pulled <- $dest"
+action=install
+action_set=0
+dry_run=0
+force=0
+
+usage() {
+ cat <<'EOF'
+usage: ./install.sh [install|pull|check|uninstall] [--dry-run] [--force]
+
+ install copy this repository into ~/.claude (default)
+ pull copy the installed files back into this repository
+ check report drift without changing files
+ uninstall remove Peter and restore files backed up during first install
+
+ --dry-run print changes without making them
+ --force back up and replace conflicting files
+EOF
+}
+
+for arg in "$@"; do
+ case "$arg" in
+ install | pull | check | uninstall)
+ if [ "$action_set" -eq 1 ]; then
+ echo "choose one action" >&2
+ usage >&2
+ exit 2
+ fi
+ action="$arg"
+ action_set=1
+ ;;
+ --dry-run) dry_run=1 ;;
+ --force) force=1 ;;
+ -h | --help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "unknown argument: $arg" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+case "$dest" in
+"" | "/")
+ echo "refusing unsafe configuration directory: ${dest:-}" >&2
+ exit 2
;;
-check)
- diff -r "$repo/skills/peter" "$dest/skills/peter"
- diff -r --exclude=SKILL.md "$repo/skills/peter" "$dest/skills/build"
- diff <(sed 's/^name: peter$/name: build/' "$repo/skills/peter/SKILL.md") "$dest/skills/build/SKILL.md"
- for f in "$repo"/agents/*.md; do
- diff "$f" "$dest/agents/$(basename "$f")"
- done
- echo "in sync"
+esac
+
+exists() {
+ [ -e "$1" ] || [ -L "$1" ]
+}
+
+validate_write_layout() {
+ for path in "$dest/skills" "$dest/agents" "$state"; do
+ if [ -L "$path" ]; then
+ echo "refusing to write through symlink: $path" >&2
+ echo "set CLAUDE_CONFIG_DIR to the real configuration directory" >&2
+ exit 1
+ fi
+ done
+}
+
+same_path() {
+ left=$1
+ right=$2
+ exists "$left" && exists "$right" || return 1
+ if [ -L "$left" ] || [ -L "$right" ]; then
+ [ -L "$left" ] && [ -L "$right" ] &&
+ [ "$(readlink "$left")" = "$(readlink "$right")" ]
+ elif [ -d "$left" ] && [ -d "$right" ]; then
+ diff -qr "$left" "$right" >/dev/null
+ elif [ -f "$left" ] && [ -f "$right" ]; then
+ cmp -s "$left" "$right"
+ else
+ return 1
+ fi
+}
+
+remove_path() {
+ path=$1
+ exists "$path" || return 0
+ if [ "$dry_run" -eq 1 ]; then
+ printf 'would remove %s\n' "$path"
+ elif [ -d "$path" ] && [ ! -L "$path" ]; then
+ rm -rf -- "$path"
+ else
+ rm -f -- "$path"
+ fi
+}
+
+copy_path() {
+ source_path=$1
+ target_path=$2
+ if [ "$dry_run" -eq 1 ]; then
+ printf 'would copy %s -> %s\n' "$source_path" "$target_path"
+ return
+ fi
+ mkdir -p "$(dirname "$target_path")"
+ remove_path "$target_path"
+ if [ -d "$source_path" ] && [ ! -L "$source_path" ]; then
+ cp -R "$source_path" "$target_path"
+ else
+ cp -P "$source_path" "$target_path"
+ fi
+}
+
+validate_sources() {
+ root=$1
+ for rel in "${targets[@]}"; do
+ if ! exists "$root/$rel"; then
+ echo "missing required path: $root/$rel" >&2
+ exit 1
+ fi
+ done
+}
+
+validate_state() {
+ if [ ! -f "$state/version" ] || [ "$(cat "$state/version")" != "1" ]; then
+ echo "invalid install state: $state" >&2
+ exit 1
+ fi
+ for rel in "${managed_targets[@]}"; do
+ if ! exists "$state/original/$rel" && ! grep -Fqx "$rel" "$state/original-absent"; then
+ echo "incomplete install state for $rel" >&2
+ exit 1
+ fi
+ done
+}
+
+find_initial_collisions() {
+ collisions=()
+ for rel in "${managed_targets[@]}"; do
+ exists "$dest/$rel" && collisions+=("$rel")
+ done
+ return 0
+}
+
+find_modified_targets() {
+ collisions=()
+ for rel in "${managed_targets[@]}"; do
+ if exists "$dest/$rel" && ! same_path "$dest/$rel" "$state/installed/$rel"; then
+ collisions+=("$rel")
+ fi
+ done
+ return 0
+}
+
+print_collisions() {
+ message=$1
+ printf '%s\n' "$message" >&2
+ for rel in "${collisions[@]}"; do
+ printf ' %s\n' "$dest/$rel" >&2
+ done
+ printf 'rerun with --force to back them up and continue\n' >&2
+}
+
+backup_conflicts() {
+ [ "${#collisions[@]}" -gt 0 ] || return 0
+ conflict_backup="$backup_root/$(date -u +%Y%m%dT%H%M%SZ)-$$"
+ for rel in "${collisions[@]}"; do
+ copy_path "$dest/$rel" "$conflict_backup/$rel"
+ done
+ printf '%s\n' "conflicts backed up -> $conflict_backup"
+}
+
+install_build_alias() {
+ target_path=$1
+ copy_path "$repo/skills/peter" "$target_path"
+ if [ "$dry_run" -eq 1 ]; then
+ printf 'would set skill name to build in %s/SKILL.md\n' "$target_path"
+ else
+ sed 's/^name: peter$/name: build/' "$repo/skills/peter/SKILL.md" >"$target_path/SKILL.md"
+ fi
+}
+
+same_build_alias() {
+ target_path=$1
+ exists "$target_path" || return 1
+ diff -qr --exclude=SKILL.md "$repo/skills/peter" "$target_path" >/dev/null &&
+ diff <(sed 's/^name: peter$/name: build/' "$repo/skills/peter/SKILL.md") "$target_path/SKILL.md" >/dev/null
+}
+
+install_files() {
+ validate_sources "$repo"
+
+ if exists "$state"; then
+ validate_state
+ find_modified_targets
+ if [ "${#collisions[@]}" -gt 0 ] && [ "$force" -eq 0 ]; then
+ print_collisions "installed files contain local changes:"
+ exit 1
+ fi
+ [ "$force" -eq 1 ] && backup_conflicts
+ else
+ find_initial_collisions
+ if [ "${#collisions[@]}" -gt 0 ] && [ "$force" -eq 0 ]; then
+ print_collisions "installation would replace existing files:"
+ exit 1
+ fi
+
+ if [ "$dry_run" -eq 0 ]; then
+ mkdir -p "$state/original" "$state/installed"
+ : >"$state/original-absent"
+ for rel in "${managed_targets[@]}"; do
+ if exists "$dest/$rel"; then
+ copy_path "$dest/$rel" "$state/original/$rel"
+ else
+ printf '%s\n' "$rel" >>"$state/original-absent"
+ fi
+ done
+ printf '1\n' >"$state/version"
+ if [ "${#collisions[@]}" -gt 0 ]; then
+ printf 'original files saved for uninstall -> %s\n' "$state/original"
+ fi
+ else
+ printf 'would create restore state in %s\n' "$state"
+ fi
+ fi
+
+ for rel in "${targets[@]}"; do
+ copy_path "$repo/$rel" "$dest/$rel"
+ copy_path "$repo/$rel" "$state/installed/$rel"
+ done
+ install_build_alias "$dest/skills/build"
+ copy_path "$dest/skills/build" "$state/installed/skills/build"
+ printf '%s\n' "installed -> $dest"
+}
+
+pull_files() {
+ validate_sources "$dest"
+ if [ "$force" -eq 0 ] && [ -n "$(git -C "$repo" status --porcelain -- skills/peter agents)" ]; then
+ echo "repository copies contain local changes; rerun with --force to replace them" >&2
+ exit 1
+ fi
+ for rel in "${targets[@]}"; do
+ copy_path "$dest/$rel" "$repo/$rel"
+ done
+ printf '%s\n' "pulled <- $dest"
+}
+
+check_files() {
+ validate_sources "$repo"
+ validate_sources "$dest"
+ drift=0
+ for rel in "${targets[@]}"; do
+ if ! same_path "$repo/$rel" "$dest/$rel"; then
+ printf 'drift: %s\n' "$rel" >&2
+ drift=1
+ fi
+ done
+ if ! same_build_alias "$dest/skills/build"; then
+ printf 'drift: %s\n' "skills/build" >&2
+ drift=1
+ fi
+ [ "$drift" -eq 0 ] || exit 1
+ printf '%s\n' "in sync"
+}
+
+uninstall_files() {
+ if ! exists "$state"; then
+ echo "Peter is not installed by this installer: $state not found" >&2
+ exit 1
+ fi
+ validate_state
+ find_modified_targets
+ if [ "${#collisions[@]}" -gt 0 ] && [ "$force" -eq 0 ]; then
+ print_collisions "installed files contain local changes:"
+ exit 1
+ fi
+ [ "$force" -eq 1 ] && backup_conflicts
+
+ for rel in "${managed_targets[@]}"; do
+ remove_path "$dest/$rel"
+ if exists "$state/original/$rel"; then
+ copy_path "$state/original/$rel" "$dest/$rel"
+ fi
+ done
+ remove_path "$state"
+ printf '%s\n' "uninstalled -> $dest"
+}
+
+case "$action" in
+install)
+ validate_write_layout
+ install_files
;;
-*)
- echo "usage: ${0##*/} [install|pull|check]" >&2
- exit 2
+pull) pull_files ;;
+check) check_files ;;
+uninstall)
+ validate_write_layout
+ uninstall_files
;;
esac
diff --git a/tests/install.sh b/tests/install.sh
new file mode 100755
index 0000000..516023d
--- /dev/null
+++ b/tests/install.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+test_root="$(mktemp -d)"
+trap 'rm -rf -- "$test_root"' EXIT
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+assert_exists() {
+ [ -e "$1" ] || fail "expected $1"
+}
+
+assert_missing() {
+ [ ! -e "$1" ] || fail "expected $1 to be absent"
+}
+
+fresh_dest="$test_root/fresh"
+CLAUDE_CONFIG_DIR="$fresh_dest" "$repo/install.sh" --dry-run >/dev/null
+assert_missing "$fresh_dest"
+
+CLAUDE_CONFIG_DIR="$fresh_dest" "$repo/install.sh" >/dev/null
+CLAUDE_CONFIG_DIR="$fresh_dest" "$repo/install.sh" >/dev/null
+CLAUDE_CONFIG_DIR="$fresh_dest" "$repo/install.sh" check >/dev/null
+assert_exists "$fresh_dest/skills/peter/SKILL.md"
+grep -Fqx 'name: build' "$fresh_dest/skills/build/SKILL.md"
+assert_exists "$fresh_dest/.peter-install/version"
+
+CLAUDE_CONFIG_DIR="$fresh_dest" "$repo/install.sh" uninstall >/dev/null
+assert_missing "$fresh_dest/skills/peter"
+assert_missing "$fresh_dest/skills/build"
+assert_missing "$fresh_dest/agents/backend-builder.md"
+assert_missing "$fresh_dest/.peter-install"
+
+collision_dest="$test_root/collision"
+mkdir -p "$collision_dest/skills/peter" "$collision_dest/agents"
+printf 'original skill\n' >"$collision_dest/skills/peter/SKILL.md"
+mkdir -p "$collision_dest/skills/build"
+printf 'original build\n' >"$collision_dest/skills/build/SKILL.md"
+printf 'original agent\n' >"$collision_dest/agents/backend-builder.md"
+
+if CLAUDE_CONFIG_DIR="$collision_dest" "$repo/install.sh" >/dev/null 2>&1; then
+ fail "install accepted collisions without --force"
+fi
+CLAUDE_CONFIG_DIR="$collision_dest" "$repo/install.sh" --force >/dev/null
+grep -Fqx 'original skill' "$collision_dest/.peter-install/original/skills/peter/SKILL.md"
+grep -Fqx 'original build' "$collision_dest/.peter-install/original/skills/build/SKILL.md"
+grep -Fqx 'original agent' "$collision_dest/.peter-install/original/agents/backend-builder.md"
+
+printf 'user modification\n' >"$collision_dest/agents/backend-builder.md"
+if CLAUDE_CONFIG_DIR="$collision_dest" "$repo/install.sh" uninstall >/dev/null 2>&1; then
+ fail "uninstall discarded a local change without --force"
+fi
+CLAUDE_CONFIG_DIR="$collision_dest" "$repo/install.sh" uninstall --force >/dev/null
+grep -Fqx 'original skill' "$collision_dest/skills/peter/SKILL.md"
+grep -Fqx 'original build' "$collision_dest/skills/build/SKILL.md"
+grep -Fqx 'original agent' "$collision_dest/agents/backend-builder.md"
+assert_missing "$collision_dest/agents/frontend-builder.md"
+assert_missing "$collision_dest/.peter-install"
+assert_exists "$(find "$collision_dest/peter-backups" -type f -name backend-builder.md -print -quit)"
+
+symlink_dest="$test_root/symlink"
+outside_agents="$test_root/outside-agents"
+mkdir -p "$symlink_dest" "$outside_agents"
+ln -s "$outside_agents" "$symlink_dest/agents"
+if CLAUDE_CONFIG_DIR="$symlink_dest" "$repo/install.sh" --force >/dev/null 2>&1; then
+ fail "install wrote through a configuration symlink"
+fi
+assert_missing "$outside_agents/backend-builder.md"
+
+echo "install tests passed"