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
16 changes: 7 additions & 9 deletions tests/fixtures/docker/scenarios/adr-migrate/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ mkdir -p docs/scripts
cp -r "$APP/docs/architecture" docs/
cp "$HOME/.claude/hooks/ways/documentation/adr/adr-tool" docs/scripts/adr
chmod +x docs/scripts/adr
# The records under test start from their v0 form on main, so the rehearsal
# migrates them even when the branch under test already has.
base=$(git -C "$APP" merge-base HEAD origin/main 2>/dev/null || true)
if [[ -n "$base" ]]; then
for n in 179 186; do
f=$(ls docs/architecture/system/ADR-$n-*.md)
git -C "$APP" show "$base:$f" > "$f" 2>/dev/null || true
done
fi
# The records under test start from their v0 form, snapshotted beside this
# script from main before #581 migrated them. The release flavor clones with
# --depth 1, so the history to restore them from is not there.
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
for f in "$here"/v0/ADR-*.md; do
cp "$f" docs/architecture/system/
done
if grep -q '^contract:' docs/architecture/system/ADR-179-*.md docs/architecture/system/ADR-186-*.md; then
echo "setup: the records under test are already v1; the rehearsal would test nothing" >&2
exit 1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
---
status: Accepted
date: 2026-08-17
deciders:
- aaronsb
- claude
related:
- ADR-142
- ADR-144
- ADR-153
---

# ADR-179: Remove the pre-1.0 in-place migrator; keep the guards and the transition fallbacks

## Context

ADR-144 §5 shipped `ways migrate` (plan / `--what-if` / `--execute`) to move a
pre-1.0 in-place `~/.claude` clone onto the 1.0 projection model, and gave it an
explicit deprecation lifecycle: dormant at 1.0.0, escalating SessionStart
pressure across 1.0.x, removed at 1.1.0. The escape hatch was named in the same
section — the migrator lives forever at the last tag that ships it, so removal
never means a user cannot migrate.

The removal has been deferred twice. 1.1.0 shipped with the migrator still
present; 1.2.0 (`691b0e6`) corrected the docs and moved the window to 1.3.0.
That target passed too. The line is now at **1.8.3** and the migrator is still
compiled in — roughly 1,000 lines across `migrate.rs` and `migrate_exec.rs`,
plus the CLI variant, a reconcile parameter that exists only for it, and
migration instructions in the README, the install guide, `install.sh`, the
`deployment` way, and the `ways-update` skill.

The deferrals bought adopter time. They have also left the escalation curve's
end state unreached: eight minor releases past the announced window, every
surface still presents `ways migrate` as a live command, and the deprecation
notices in those surfaces name removal targets (1.1, 1.3) that came and went.

ADR-144 §5 also drew a second line that the removal must respect. Its "the cliff
is a ramp" clause says removing the migrator removes *assisted migration*, not
*function*: a post-removal binary on an un-migrated `~/.claude` still reads
correctly through the transition fallbacks — cache from the legacy
`claude-ways` dir, events from `~/.claude/stats`. Those fallbacks live in
`ways-core/src/paths.rs` and are cheap, non-destructive, and inert on a current
install.

Two guards share the migrator's detector, `reconcile::is_legacy_in_place`.
`ways reconcile` refuses to run against an in-place clone because projecting
symlinks over a live repo would strand the user's checkout; `ways update`
refuses for the same reason. Both currently route the user to `ways migrate`.
ADR-144 §5 listed "migrator **and in-place check** removed" as one step. Removing
the checks would let `ways reconcile` clobber an in-place clone — an actively
destructive outcome for exactly the users the never-strand clause protects.

## Decision

Remove the migrator. Keep the in-place guards and the transition fallbacks.

**Removed:**

- `tools/ways-cli/src/cmd/migrate.rs` and `tools/ways-cli/src/cmd/migrate_exec.rs`,
their `mod` declarations, the `Commands::Migrate` clap variant, and its
dispatch arm.
- `ways_core::paths::cache_root_canonical`, which exists solely as the
migrator's rename destination. Runtime reads use the fallback-aware
`cache_root`.
- `reconcile::run`'s `allow_in_place` parameter and the bypass it gates. The
migrator is the only caller that ever passed `true`; with it gone the guard is
unconditional.
- The migration walkthrough in `docs/migration-1.0.md`, the `ways migrate`
invocations in `scripts/install.sh`, `skills/ways-update/SKILL.md`, the
`deployment` way, README, and `docs/install-guide.md`.

**Kept:**

- `reconcile::is_legacy_in_place` and both guards. Their messages retarget from
"run `ways migrate`" to the tag escape hatch. A user who reaches these guards
is told what their install is and where the migrator still lives.
- The `paths.rs` transition fallbacks — `LEGACY_CACHE` resolution in
`resolve_cache`, the `~/.claude/stats/events.jsonl` fallback in
`resolve_events`, and the `events_log_sources` union (ADR-153 §1). These are
the ramp ADR-144 §5 promised. The union also recovers orphaned `session_start`
lines on *migrated* installs whose shell hooks predated the path fix, so it
earns its keep independent of the in-place case.
- The legacy `~/.claude/ways.json` disabled layer, a lower-precedence config
read with no coupling to the migrator.

**Escape hatch:** `ways-v1.8.3` is the last tag shipping the migrator. Migrating
after removal means `git clone --branch ways-v1.8.3 … && ways migrate --execute`,
then updating. `docs/migration-1.0.md` is rewritten around that route rather than
deleted, and the guards point at it.

Removal lands in **1.9.0**.

## Consequences

### Positive

- About 1,000 lines of transitional code leave the binary, along with a
destructive code path (whole-`~/.claude` relocation) that no current install
exercises.
- `reconcile::run` loses a boolean parameter whose only non-default caller is
being deleted, so the in-place guard becomes unconditional and the function's
contract simplifies.
- Every user-facing surface stops advertising a deprecation target that has
already passed. The migration story becomes one route (the tag) instead of a
live command shadowed by stale removal dates.

### Negative

- A pre-1.0 adopter who has not migrated by 1.9.0 now has a two-step path (clone
the tag, migrate, update) instead of one command. This is the cost ADR-144 §5
accepted when it named the tag as the escape hatch.
- The migrator's crash-safe phase machinery and its tests go with it. Reviving
the capability would mean recovering it from the tag.

### Neutral

- ADR-144 §5's lifecycle is executed, not superseded — its escalation dates were
the cadence, not the contract. This ADR records the divergence on one point:
the in-place *check* stays.
- The `deployment` way keeps its legacy-in-place branch. The detection guidance
is still correct; only the remedy changes.

## Alternatives Considered

- **Remove the guards too, as ADR-144 §5 literally specified.** Rejected: with
the guard gone, `ways reconcile` on an in-place clone projects over a live git
repo and strands the checkout. Deleting assistance is within the lifecycle;
adding a destructive path is not.
- **Remove the `paths.rs` transition fallbacks in the same change.** Rejected for
now: they are inert on a current install, and dropping them turns "reads
correctly, unassisted" into "silently re-fetches the model and stops reading
its own stats." The `events_log_sources` union has a second justification that
outlives the in-place case. If these come out, it is as their own decision with
its own reasoning.
- **Defer again to 2.0.0.** Rejected: two deferrals have already passed with no
signal that a third would be used differently, and each one leaves the shipped
docs quoting a removal date that has expired.
- **Keep the migrator indefinitely as a dormant command.** Rejected: it carries a
destructive code path and a phase machine that no test run outside its own
fixtures exercises, and its presence is why the docs still carry a transition
narrative eight releases past 1.0.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
status: Accepted
date: 2026-09-17
deciders:
- aaronsb
- claude
related:
- ADR-142
- ADR-144
- ADR-184
- ADR-185
---

# ADR-186: Live integration fixture: install-path test levels and the tier 2 gate

## Context

The install path is the installer script, `make setup` with its prebuilt downloads, `ways reconcile` into a config directory that already holds a user's own files, and the hook scripts that Claude Code runs from the merged `settings.json`. The reviews of PRs #501, #502, #504 and #508 each found a defect on that path by reading the code, and each said the same thing: none of it had been run end to end on a clean machine.

The tests that exist stop short of it. The Rust unit tests and `session_sim` exercise the binary against fixture ways in a temporary directory. The reconcile tests put a fake source and a fake destination in a sandbox. The sandbox transcripts in the PR comments were run by hand and are not repeatable. Nothing runs the installer, downloads a release asset, seeds a home with the shape from #501 (a real `skills/` directory, three hand-written hooks, a second config directory), or feeds a hook script the payload Claude Code sends.

ADR-184 increment 2 (#509) changes the installer's last act from projecting into `~/.claude` to handing off to the targets bootstrap. Building that against no fixture repeats the pattern the reviews named.

## Decision

**The install path is tested at two levels. Tier 1 installs and configures with no API key and runs on every pull request that touches the path. Tier 2 exercises a model with a key, runs on manual dispatch or a schedule, and never runs unattended on a pull request.**

1. **Tier 1: install and configure, no key.** A Debian container with Claude Code installed at a pinned version. The home is seeded before the installer runs: a real `~/.claude/skills/` with a user's own skill, a `settings.json` with three user hooks and a `model` key, and a second config directory with its own settings and skills. The runner asserts:
- the installer runs unattended and refuses the real `skills/` directory with nothing deleted and `settings.json` byte-identical;
- the documented recovery (`ways reconcile --force`) moves the directory aside with the user's skill intact, links the projection roots, and keeps every user hook by identity in its event;
- the target is recorded in the user config, `ways config targets` reports one enabled target, and `ways status` reports the active state with the embedding engine up;
- `ways reconcile --dry-run` run twice prints identical output and reports no change;
- the second config directory is byte-identical to its seed;
- `way-embed` arrived as a release download. The image carries no C++ toolchain, so a source-build fallback is a failed assertion;
- `attend status` and `claude --version` exit zero, and the version is the pinned one;
- the hooks fire the way Claude Code fires them: the runner reads the hook commands out of the merged `settings.json`, pipes a synthetic `SessionStart`, `UserPromptSubmit`, and `PreToolUse` payload to each, and asserts on the additional-context output. Disclosure is proven with no model.

2. **Two flavors of tier 1.** The `branch` flavor mounts the checkout and the binaries CI built for the same commit, so a pull request is tested against its own binary and its own hooks. It is the gate, and it runs on every pull request that touches the path and on every push to `main`. The `release` flavor runs the documented one-liner, which clones `main` and downloads the latest release assets. It runs on dispatch and on the nightly schedule, with Claude Code at its `latest` version, to catch drift on either side.

3. **Tier 2: exercise with a key.** `claude -p` runs non-interactively under `CLAUDE_CONFIG_DIR` with prompts chosen to trigger named ways. The evidence is the transcript and the events log read through `ways introspect`, plus the answer scored against a rubric with a stated pass threshold. Two containers on one compose network carry the attend peer test: send from one, assert the other's inbox. Keepwarm is out of scope.

4. **The gate.** Tier 2 reads the key from a repository secret. The workflow runs it on `workflow_dispatch` and on `schedule` only. A pull request never triggers it, and a fork pull request cannot see the secret. A change to tier 2 is verified by dispatching it against the branch.

5. **Shape.** Everything lives under `tests/fixtures/docker/`: the compose file, one Dockerfile parameterized by Claude Code version and installer, the seed, the payloads, and one runner per tier. `make test-live TIER=1|2` is the entry point on a workstation and in CI. Tier 1 is a job in `portability.yml`.

6. **Sequence.** Tier 1 lands before #509. Tier 2 follows tier 1 as its own increment.

Reversibility: cheap. The fixture is additive. Removing the job removes the gate and nothing else.

## Consequences

### Positive

- A change to the installer, the reconciler, the settings merge, or a hook script is run on a clean machine before review reads it.
- The #501 shape is a fixture, not a memory. The refusal, the recovery, and the kept hooks are asserted on every pull request.
- #509 is built against a fixture that fails when it wires the wrong directory.
- A pinned Claude Code on the gate and `latest` on the schedule separate our regressions from upstream drift.

### Negative

- Tier 1 needs network: the Claude Code installer, the way-embed and model downloads, and the release assets through `gh`. A network fault fails the job. The branch flavor keeps the four suite binaries off the network; way-embed, mmaid, and the model stay on it.
- Docker on the runner adds minutes to the portability workflow.
- Tier 2 on a schedule spends tokens on a fixed cadence. The threshold and the prompt set have to be maintained.

### Neutral

- The runner drives hooks from `settings.json` rather than by path, so a hook that the merge drops is a failed assertion rather than a silent skip.
- `portability.yml` gains a job that builds all four suite binaries before the fixture runs, since the branch flavor needs `ways-audit` and `attend-chat` beside `ways` and `attend`. The existing cross-platform matrix is unchanged.
- ADR-185's `--json` views are what the runner parses.
- The first run of the fixture found two defects on the download path, both fixed on the same branch. The download scripts listed 20 or 30 releases before grepping for a component prefix, and the newest tags of three components sat past that window. The way-embed download script's capability probe ran under `pipefail` and rejected every binary, since a supporting binary also exits nonzero when asked for `match --batch` without a corpus. #516 had diagnosed that as a release that predates the primitive. The release supports it; the probe was wrong. The image carries no C++ toolchain, so the fixture fails if either defect returns.

## Alternatives Considered

- **Run the installer on the GitHub runner's own home.** Rejected: the runner's home is not clean, and the seeded state would have to be undone between steps. A container starts empty every time.
- **Mock Claude Code.** Rejected: `claude --version`, the installer path, and the settings file are the things under test. The binary is a pinned download and costs one step.
- **Tier 2 on every pull request.** Rejected: a fork pull request has no secret, so the check would pass by absence, and every pull request would spend tokens. Dispatch and schedule keep the run deliberate.
- **One tier with the key optional.** Rejected: a runner that skips assertions when the key is missing reports green for two different things. Two runners with two names keep the report honest.
- **A virtual machine per run.** Rejected: slower to start, and the container already isolates the home, the XDG roots, and the PATH.
Loading