diff --git a/docs/architecture/adr.yaml b/docs/architecture/adr.yaml index db4f2b0c..e7990809 100644 --- a/docs/architecture/adr.yaml +++ b/docs/architecture/adr.yaml @@ -25,7 +25,66 @@ domains: description: Documentation structure, tooling, coherence folder: documentation -# Valid ADR statuses +# The record contract (ADR-304). Records that declare `contract: adr/v1` +# follow the kinds below; a record without it is adr/v0 and keeps the v0 +# statuses and checks until someone migrates it. +contract: adr/v1 + +kinds: + decision: + mutable_after_accept: [status, enacted, superseded_by, considered, concern] + verb: required + requires: [capability, basis, agent] + sections: [Summary] + edges: { supersedes: decision, amends: decision, extends: decision, basis: [decision, spec] } + spec: + mutable_after_accept: all + verb: forbidden + requires: [capability] + edges: { supersedes: spec, decided_by: decision } + +# What agent-ways does, one line each: the only hand-written description of +# a capability. A decision adds, cuts, changes or constrains these. +capabilities: + adr: Decision records, their contract, and the adr tool that enforces it + docs: The documentation model, catalog pages and doclint + method: The ways method itself, what a way is for, its epistemic posture and its register + matching: How a prompt, tool call or file edit selects ways, the scoring behind it, and the fire telemetry and calibration data that tune it + disclosure: How a selected way reaches the model, when it re-fires, progressive disclosure, and localized way text + authoring: Way files, their frontmatter, the corpus build and the ways CLI for writing them + cli: The output contract the ways, attend and adr commands share with agents and scripts + attend: Session awareness through sensors, peers, messaging and keepwarm + install: Install, update, projection into targets, self-update, and how the binaries are built and laid out + config: Settings, configuration layers, permissions, guard hooks and the security baseline + governance: Provenance, controls and compliance findings + loop: The development loop skills (start, develop, merge, release, wrap) + testing: The test suites and the live install fixture + +# Retire targets name a surface; these are agent-ways' namespaces. +surfaces: + cli: {} + skill: {} + way: {} + hook: {} + +# The basis sources a decision may name (ADR-304 §11); the v1 default. +basis_sources: [operator, evidence, standard, upstream, precedent] + +# adr cite skips these: fixtures, tool sources and subagent prompts carry +# example numbers. +cite: + exclude: + - agents/workflow-orchestrator.md + - agents/workspace-curator.md + - tests/fixtures + - tests/adr-lint-test.sh + - tests/adr-archive-test.sh + - tests/adr-golden-test.sh + - hooks/ways/documentation/adr/src + - hooks/ways/documentation/adr/adr-tool + - hooks/ways/documentation/linting + +# Valid ADR statuses (adr/v0 records) statuses: - Draft - Proposed @@ -34,11 +93,10 @@ statuses: - Deprecated - Rejected -# Default values for new ADRs +# Default values for new ADRs. No person is hard-coded as a decider: an +# empty list lets the tool fill in the current git or GitHub user. defaults: - deciders: - - aaronsb - - claude + deciders: [] status: Draft # Legacy ADR range (pre-domain numbering) diff --git a/docs/architecture/documentation/ADR-304-typed-decision-records-the-adr-v1-contract.md b/docs/architecture/documentation/ADR-304-typed-decision-records-the-adr-v1-contract.md index 8390b9c8..3d1b02af 100644 --- a/docs/architecture/documentation/ADR-304-typed-decision-records-the-adr-v1-contract.md +++ b/docs/architecture/documentation/ADR-304-typed-decision-records-the-adr-v1-contract.md @@ -18,6 +18,10 @@ considered: said: "I think we have put as much effort into this adr as we need to." via: session 2026-09-26, PR #559 covers: [] + - operator: aaronsb + said: "I read the entire adr as it finally sat and it was an enjoyable read that captures the intent and spirit. The negatives are mostly mechanical impacts of needing to migrate other adr systems" + via: session 2026-09-27, after merging PR #559 + covers: [] status: Accepted date: 2026-09-26 deciders: diff --git a/docs/architecture/system/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md b/docs/architecture/system/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md index dac54bd0..3248108a 100644 --- a/docs/architecture/system/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md +++ b/docs/architecture/system/ADR-179-remove-the-pre-1-0-in-place-migrator-keep-the-guards-and-the-transition-fallbacks.md @@ -1,4 +1,14 @@ --- +contract: adr/v1 +kind: decision +verb: retire +capability: install +targets: [cli:ways-migrate] +enacted: "b4f63aa6" +agent: {name: Claude, model: unrecorded} +basis: + - evidence: the release history, where the migrator still shipped two deferral windows after ADR-144 scheduled its removal (tags ways-v1.2.0 through ways-v1.8.3) + - precedent: ADR-144 status: Accepted date: 2026-08-17 deciders: @@ -12,6 +22,14 @@ related: # ADR-179: Remove the pre-1.0 in-place migrator; keep the guards and the transition fallbacks +## Summary + +- **Decided:** remove the pre-1.0 `ways migrate` command and its code. Keep the in-place guards and the path fallbacks. +- **Trades away:** an in-place upgrade from a pre-1.0 install. Those installs are pointed at the release tag where the migrator still lives. +- **One-way?** No. The migrator stays reachable at its tag, and the guards name where. +- **Probes:** *Confident:* the guards still catch a legacy install, since they were kept. *Not confident:* whether any pre-1.0 install is still in use and would hit the escape hatch. +- **Inversion:** one end keeps the migrator forever, compiled in. The other end also removes the guards and fallbacks. This removes the command and keeps the safety net. Is that the right cut line? + ## Context ADR-144 §5 shipped `ways migrate` (plan / `--what-if` / `--execute`) to move a diff --git a/hooks/ways/documentation/adr/adr-tool b/hooks/ways/documentation/adr/adr-tool index a5bbc932..66fb2684 100755 --- a/hooks/ways/documentation/adr/adr-tool +++ b/hooks/ways/documentation/adr/adr-tool @@ -1337,9 +1337,13 @@ def _frozen_snapshot(adr) -> Optional[tuple]: rel = adr.path.resolve().relative_to(root.resolve()) except ValueError: return None + # A record freezes where it lands: history is read from the default + # branch when there is one, so a decision still in review on a feature + # branch can be revised. Without a remote default, HEAD's history counts. + ref = (_git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], root) or '').strip() or 'HEAD' # --reverse drops pre-rename history under --follow, so read newest first # and reverse here. -z keeps names with spaces or non-ASCII intact. - log = _git(['log', '--follow', '-z', '--format=commit:%H', '--name-only', '--', str(rel)], root) + log = _git(['log', ref, '--follow', '-z', '--format=commit:%H', '--name-only', '--', str(rel)], root) if not log: return None entries, commit = [], None @@ -1716,7 +1720,9 @@ def cmd_lint(args): able to hide by being moved. """ if args.paths: - paths = [Path(p) for p in args.paths] + # Resolved, so records parsed from the arguments match the corpus's + # own paths (grounding and other corpus rules key on the path). + paths = [Path(p).resolve() for p in args.paths] adrs = [parse_adr(p) for p in paths] else: adrs = get_all_adrs(include_archived=True) diff --git a/hooks/ways/documentation/adr/migration/migration.md b/hooks/ways/documentation/adr/migration/migration.md index ab990946..ed955124 100644 --- a/hooks/ways/documentation/adr/migration/migration.md +++ b/hooks/ways/documentation/adr/migration/migration.md @@ -44,7 +44,7 @@ Existing ADRs like `docs/adr/0001-use-postgres.md` with sequential numbering. mkdir -p docs/architecture/legacy git mv docs/adr/0001-*.md docs/architecture/legacy/ # Rename to ADR-NNN format if needed: -git mv docs/architecture/legacy/0001-use-postgres.md docs/architecture/legacy/ADR-001-use-postgres.md +git mv docs/architecture/legacy/0001-use-postgres.md docs/architecture/legacy/ADR-001-use-postgres.md # adr-cite-ignore: example number ``` 3. **Set the legacy range** in `adr.yaml` to cover existing numbers: diff --git a/hooks/ways/documentation/adr/src/cmd_lint.py b/hooks/ways/documentation/adr/src/cmd_lint.py index 0181749f..3303507f 100644 --- a/hooks/ways/documentation/adr/src/cmd_lint.py +++ b/hooks/ways/documentation/adr/src/cmd_lint.py @@ -5,7 +5,9 @@ def cmd_lint(args): able to hide by being moved. """ if args.paths: - paths = [Path(p) for p in args.paths] + # Resolved, so records parsed from the arguments match the corpus's + # own paths (grounding and other corpus rules key on the path). + paths = [Path(p).resolve() for p in args.paths] adrs = [parse_adr(p) for p in paths] else: adrs = get_all_adrs(include_archived=True) diff --git a/hooks/ways/documentation/adr/src/rules_v1_integrity.py b/hooks/ways/documentation/adr/src/rules_v1_integrity.py index 2b5e7c12..4f7d0e24 100644 --- a/hooks/ways/documentation/adr/src/rules_v1_integrity.py +++ b/hooks/ways/documentation/adr/src/rules_v1_integrity.py @@ -144,9 +144,13 @@ def _frozen_snapshot(adr) -> Optional[tuple]: rel = adr.path.resolve().relative_to(root.resolve()) except ValueError: return None + # A record freezes where it lands: history is read from the default + # branch when there is one, so a decision still in review on a feature + # branch can be revised. Without a remote default, HEAD's history counts. + ref = (_git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], root) or '').strip() or 'HEAD' # --reverse drops pre-rename history under --follow, so read newest first # and reverse here. -z keeps names with spaces or non-ASCII intact. - log = _git(['log', '--follow', '-z', '--format=commit:%H', '--name-only', '--', str(rel)], root) + log = _git(['log', ref, '--follow', '-z', '--format=commit:%H', '--name-only', '--', str(rel)], root) if not log: return None entries, commit = [], None diff --git a/tests/adr-golden-test.sh b/tests/adr-golden-test.sh index aa4819b6..f862f0fe 100755 --- a/tests/adr-golden-test.sh +++ b/tests/adr-golden-test.sh @@ -228,6 +228,7 @@ capture v1-lint lint capture v1-lint-check lint --check capture v1-list list capture v1-view-spec view 102 +capture v1-lint-precedent-relative lint docs/architecture/system/ADR-109-precedent-chain.md capture v1-cite cite # The cut on search, enacted: citations of search records now fail. (cd "$WORK/repo" && sed -i.bak 's/^verb: cut$/verb: cut\nenacted: abcdef1/' docs/architecture/system/ADR-111-cut-search.md \ diff --git a/tests/fixtures/adr/golden/v1-lint-precedent-relative.out b/tests/fixtures/adr/golden/v1-lint-precedent-relative.out new file mode 100644 index 00000000..6b1e7995 --- /dev/null +++ b/tests/fixtures/adr/golden/v1-lint-precedent-relative.out @@ -0,0 +1,20 @@ + +Scanned: 1 ADRs + +Status distribution: + proposed: 1 + +Contract: adr/v1 (1 v0 records remain) + +──────────────────────────────────────────────────────────── +Issues found in 1 files: +──────────────────────────────────────────────────────────── + +docs/architecture/adr.yaml + ⚠️ capability 'search' has no accepted add decision + +════════════════════════════════════════════════════════════ +Summary: 0 errors, 1 warnings +════════════════════════════════════════════════════════════ + +[exit 0] diff --git a/tests/fixtures/docker/CLAUDE.md b/tests/fixtures/docker/CLAUDE.md index 764b83bc..23daad3e 100644 --- a/tests/fixtures/docker/CLAUDE.md +++ b/tests/fixtures/docker/CLAUDE.md @@ -53,7 +53,8 @@ TIER2_SCENARIOS="adr-way" TIER2_MODEL=claude-sonnet-5 ANTHROPIC_API_KEY_FILE=... - The key comes from `ANTHROPIC_API_KEY` or from the file named by `ANTHROPIC_API_KEY_FILE`. It reaches the container through the environment only. It is not a build arg, so no image layer holds it, and nothing prints it. - `test-live.sh` creates `TIER2_OUT` (a temp dir by default) as the host user and prints its path. Each scenario leaves `result.json`, `introspect.json`, `fired.txt`, `worktree.txt` and `claude.err` there. A rootful daemon would create a missing bind source as root, which the container user cannot write, so the wrapper creates the directory first. -- A two-scenario run costs about $0.11 on `claude-sonnet-5`. +- The branch flavor clones the committed HEAD, but `/fixture` (this directory: runners, scenarios, checks) is mounted live from the working tree. Stay on the branch until the run finishes; switching branches mid-run swaps the checks out from under it. +- A scenario's `max_turns` file raises its turn cap. The adr scenarios need 20 to 40 turns and cost $0.30 to $1 each on `claude-sonnet-5`; the way-firing scenarios cost about $0.06. **To add a scenario**, make a directory under `scenarios/` with a `prompt.txt`, an optional `setup.sh` that runs in the fresh project first, and a `check.sh` that `run-tier2.sh` sources. `check.sh` asserts with: diff --git a/tests/fixtures/docker/run-tier2.sh b/tests/fixtures/docker/run-tier2.sh index 4188b7e3..ec2313a7 100755 --- a/tests/fixtures/docker/run-tier2.sh +++ b/tests/fixtures/docker/run-tier2.sh @@ -15,6 +15,7 @@ # A scenario is a directory under scenarios/ holding: # prompt.txt the prompt passed to `claude -p` # setup.sh optional; runs in the scenario's fresh project before the prompt +# max_turns optional; this scenario's turn cap (default TIER2_MAX_TURNS) # check.sh sourced after the run; asserts with the helpers below # # check.sh sees $PROJ (the project dir), $ANSWER (the model's final text), @@ -101,9 +102,13 @@ run_scenario() { # The container is disposable and holds nothing but this run, so the model # gets tools without prompts. The turn cap bounds cost. + # A scenario may raise the turn cap with a max_turns file. + local turns="$MAX_TURNS" + [[ -f "$dir/max_turns" ]] && turns=$(tr -dc '0-9' < "$dir/max_turns") + turns=${turns:-$MAX_TURNS} (cd "$PROJ" && claude -p "$(cat "$dir/prompt.txt")" \ --model "$MODEL" \ - --max-turns "$MAX_TURNS" \ + --max-turns "$turns" \ --output-format json \ --dangerously-skip-permissions) > "$OUT/result.json" 2> "$OUT/claude.err" local rc=$? diff --git a/tests/fixtures/docker/scenarios/adr-migrate/check.sh b/tests/fixtures/docker/scenarios/adr-migrate/check.sh new file mode 100644 index 00000000..be7b93d8 --- /dev/null +++ b/tests/fixtures/docker/scenarios/adr-migrate/check.sh @@ -0,0 +1,32 @@ +# adr-migrate: two real records migrated to adr/v1 by a real agent. They must +# lint clean as v1, and the agent must not invent an operator basis the +# record never contained (ADR-304 §11, fabrication risk). + +for n in 179 186; do + f=$(ls "$PROJ"/docs/architecture/system/ADR-$n-*.md 2>/dev/null | head -1) + cp "$f" "$OUT/" 2>/dev/null + if grep -q '^contract: adr/v1' "$f"; then ok "ADR-$n declares adr/v1"; else fail "ADR-$n declares adr/v1"; fi + lint=$(cd "$PROJ" && docs/scripts/adr lint --check "${f#$PROJ/}" 2>&1) + lint_rc=$? + echo "$lint" > "$OUT/lint-$n.txt" + if [[ $lint_rc -eq 0 ]] && ! grep -q '❌' <<<"$lint"; then ok "ADR-$n lints clean"; else fail "ADR-$n lints clean" "$(grep '❌' <<<"$lint" | head -3)"; fi + # Neither record quotes the operator, so any operator basis, considered or + # concern entry is invented (ADR-304 §11). + invented=$(python3 - "$f" <<'PY' +import sys, yaml +fm = yaml.safe_load(open(sys.argv[1]).read().split('---')[1]) or {} +found = [k for k in ('considered', 'concern') if fm.get(k)] +found += ['operator basis' for e in fm.get('basis') or [] if isinstance(e, dict) and 'operator' in e] +print(', '.join(found)) +PY +) + if [[ -z "$invented" ]]; then ok "ADR-$n invents no operator statement"; else fail "ADR-$n invents no operator statement" "found: $invented"; fi + # The original body survives: every original heading is still there. + missing=$(grep -E '^#{1,3} ' "$HOME"/.migrate-before/ADR-$n-*.md | while IFS= read -r h; do grep -qxF -- "$h" "$f" || echo "$h"; done) + if [[ -z "$missing" ]]; then ok "ADR-$n keeps its original sections"; else fail "ADR-$n keeps its original sections" "$missing"; fi +done + +rubric "names the kind chosen" "kind: decision|as a decision" +rubric "names the verb chosen" "\\b(retire|constrain|add|change|cut)\\b" +rubric "explains the basis" "\\b(evidence|precedent)\\b" +rubric_threshold 3 diff --git a/tests/fixtures/docker/scenarios/adr-migrate/max_turns b/tests/fixtures/docker/scenarios/adr-migrate/max_turns new file mode 100644 index 00000000..425151f3 --- /dev/null +++ b/tests/fixtures/docker/scenarios/adr-migrate/max_turns @@ -0,0 +1 @@ +40 diff --git a/tests/fixtures/docker/scenarios/adr-migrate/prompt.txt b/tests/fixtures/docker/scenarios/adr-migrate/prompt.txt new file mode 100644 index 00000000..7df83d15 --- /dev/null +++ b/tests/fixtures/docker/scenarios/adr-migrate/prompt.txt @@ -0,0 +1 @@ +This repository's decision records now follow the adr/v1 contract declared in docs/architecture/adr.yaml (the ADR way and `docs/scripts/adr` describe it). Migrate two records to v1: ADR-179 (removing the pre-1.0 migrator) and ADR-186 (the live integration fixture). Keep their decisions and history intact, choose kind, verb and capability honestly, and ground each basis in what the record itself says. Do not invent operator statements. Run `docs/scripts/adr lint` on the two files until they are clean, then summarize what you chose and why. diff --git a/tests/fixtures/docker/scenarios/adr-migrate/setup.sh b/tests/fixtures/docker/scenarios/adr-migrate/setup.sh new file mode 100644 index 00000000..7a78ab3b --- /dev/null +++ b/tests/fixtures/docker/scenarios/adr-migrate/setup.sh @@ -0,0 +1,25 @@ +# adr-migrate setup: a copy of agent-ways' own record corpus, on the adr/v1 +# contract it declares, with the v1-capable tool vendored. The rehearsal for +# #566: a real agent migrates real records before anyone does it for real. +APP="${XDG_DATA_HOME:-$HOME/.local/share}/agent-ways" +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 +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 +fi +# Snapshot the two records the scenario migrates, to check nothing is invented. +mkdir -p "$HOME/.migrate-before" +cp docs/architecture/system/ADR-179-*.md docs/architecture/system/ADR-186-*.md "$HOME/.migrate-before/" +git add -A && git commit -qm "corpus"