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 - - - - - - Star History Chart - - +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"