Prechádzať zdrojové kódy

feat(adr-ops): Add touches-query, init, lint nuance, index --output, CI note

Rounds adr-ops out from managing ADRs to adopting + governing them.

New tools:
- adr-touching.py — query the touches: discovery surface ('what ADRs
  govern this path before I change it?'); exit 10 when a governing ADR
  exists, so it works as a pre-edit/CI guard. exact/glob/prefix matching.
- adr-init.sh — bootstrap ADRs in a repo cold (dir + lint-clean ADR-001
  + generated README); refuses a populated dir without --force.

adr-lint.py gains: stale-touches warning (a literal touches: path that no
longer resolves under --repo-root), and lifecycle-consistency checks
(superseded must name a successor; deprecated must not; in-force must not).

adr-index.sh gains --output (generated Markdown index, self-labeled).
SKILL.md documents all of it + a CI-integration section. 36 -> 72 assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 týždeň pred
rodič
commit
614d7e80c5

+ 16 - 6
CHANGELOG.md

@@ -12,12 +12,22 @@ feature releases live in the README "Recent Updates" section.
   generalized from a mature in-house ADR protocol: when-to-write / when-NOT
   decision rule, the canonical format (BLUF-first `## Decision`, fixed section
   order, frontmatter field set), the proposed→accepted→superseded/deprecated
-  lifecycle, and append-only supersession discipline. Ships three tools to the
-  Skill Resource Protocol - `adr-new.sh` (scaffold the next sequential ADR,
-  atomic, no-clobber, `--apply-supersede` flips the old record), `adr-index.sh`
-  (read-only index table from frontmatter), and `adr-lint.py` (validates required
-  fields, status enum, numbering, section order, and cross-file supersession
-  bidirectionality). 36-assertion offline self-test.
+  lifecycle, and append-only supersession discipline. Five tools to the Skill
+  Resource Protocol:
+  - `adr-init.sh` - bootstrap ADRs in a repo adopting them cold (dir +
+    lint-clean ADR-001 + generated README)
+  - `adr-new.sh` - scaffold the next sequential ADR (atomic, no-clobber,
+    `--apply-supersede` flips the superseded record's frontmatter)
+  - `adr-index.sh` - read-only index table from frontmatter; `--output` writes
+    a generated Markdown index
+  - `adr-touching.py` - query the `touches:` discovery surface ("what ADRs
+    govern this path before I change it?"); exit 10 when a governing ADR exists,
+    a usable pre-edit/CI guard
+  - `adr-lint.py` - validates required fields, status enum, numbering, section
+    order, cross-file supersession bidirectionality, lifecycle consistency
+    (status vs `superseded-by`), and stale-`touches` paths
+  Includes a CI-integration section (gate `adr-lint --strict` on exit 10).
+  72-assertion offline self-test.
 
 ## [3.0.0] - 2026-06-10
 

+ 88 - 4
skills/adr-ops/SKILL.md

@@ -154,13 +154,34 @@ Three change modes (full detail in `references/lifecycle-and-supersession.md`):
 6. **Commit** with a `docs(adr):` conventional-commit subject, e.g.
    `docs(adr): ADR-020 — <subject>`.
 
+Adopting ADRs in a fresh repo? Run `bash scripts/adr-init.sh --first-title "…"` once to
+bootstrap the directory + a lint-clean ADR-001. Before changing an existing subsystem, run
+`python scripts/adr-touching.py <path>` to surface any decision already governing it.
+
 ---
 
 ## Tools
 
 All scripts take `--dir` (default `docs/adr`), `--help`, and follow semantic exit codes
-(`0` ok, `2` usage, `3` not-found, `5` precondition, `10` findings). Pair with the
-`git-ops` skill for the commit/PR step.
+(`0` ok, `2` usage, `3` not-found, `5` precondition, `10` findings/domain-signal). Pair
+with the `git-ops` skill for the commit/PR step. The three read tools form the legs of a
+stool: **lint** = integrity, **index** = overview, **touching** = "what governs this file
+before I change it".
+
+### `scripts/adr-init.sh` — bootstrap a repo adopting ADRs cold
+
+```bash
+# Create docs/adr/, scaffold a lint-clean ADR-001, write a generated README:
+bash scripts/adr-init.sh --first-title "Adopt ADRs"
+
+# Custom dir + preview without writing:
+bash scripts/adr-init.sh --dir docs/decisions --first-title "OAuth-only auth" --dry-run
+```
+
+Refuses to run in a directory that already holds `ADR-*.md` (exit 5) unless `--force`. The
+ADR-001 it scaffolds is rendered by `adr-new.sh`, so it lints clean immediately. The
+generated `<dir>/README.md` is self-labeled "generated — do not hand-edit; the directory
+is the index" and says to run `adr-index` to regenerate. `--dry-run` writes nothing.
 
 ### `scripts/adr-new.sh` — scaffold the next ADR
 
@@ -188,6 +209,32 @@ bash scripts/adr-index.sh --json | jq '.data[] | select(.status=="accepted")'
 ```
 
 Prefers `yq`; degrades to a built-in parser when yq is absent (announced on stderr).
+Pass `--output FILE` to write a **generated Markdown index** (heading + a `do not
+hand-edit` marker + the `| # | Status | Date | Title |` table) atomically to a file
+instead of stdout — for a README pointer that you regenerate rather than hand-curate.
+
+### `scripts/adr-touching.py` — what governs this file? (the discovery surface)
+
+The `touches:` frontmatter is the grep target answering "is there an ADR about the thing
+I'm changing?". This tool *is* that grep, done properly — match a path, glob, or config
+key against every ADR's `touches:` list.
+
+```bash
+# Before editing src/auth.py, ask what decisions constrain it:
+python scripts/adr-touching.py src/auth.py        # exit 10 if an ADR governs it
+python scripts/adr-touching.py 'src/**'           # glob query
+python scripts/adr-touching.py --json src/ | jq '.data[].number'
+```
+
+Matching is bidirectional and pragmatic: exact equality; fnmatch glob either direction
+(touches `src/**` matches query `src/auth.py`; query `src/*` matches touches
+`src/auth.py`); path-prefix containment (query `src/` governs touches `src/auth.py`, and
+vice-versa); config keys (`file.yaml:key`) by exact-or-prefix.
+
+**Guard contract (the load-bearing bit):** exit **0 = no governing ADR found**, exit
+**10 = at least one ADR governs the query**. A pre-edit hook or CI step branches on it —
+"heads up, ADR-010 governs this path; read it before changing." Exit `3` dir not found,
+`2` usage.
 
 ### `scripts/adr-lint.py` — conformance validator
 
@@ -198,8 +245,45 @@ python scripts/adr-lint.py --strict --json | jq '.data[] | select(.severity=="er
 
 Checks required + well-typed frontmatter, the `# ADR-NNN:` title matching the filename,
 the BLUF placement, core section order, **no duplicate numbers** (gaps are a warning),
-and **supersession bidirectionality** (the high-value cross-file check). `--strict` makes
-warnings count toward exit 10. Exit 4 if a file's frontmatter is unparseable.
+and **supersession bidirectionality** (the high-value cross-file check). Plus:
+
+- **Lifecycle consistency** (errors): `superseded` with an empty `superseded-by`;
+  `deprecated` with a non-empty `superseded-by`; an in-force (`accepted`/`proposed`) ADR
+  carrying a `superseded-by`. These complement the bidirectionality check without
+  double-reporting.
+- **Stale `touches`** (warning): a `touches:` entry that is a literal filesystem path
+  (not a glob, not a config key) which no longer resolves under `--repo-root` (default:
+  git toplevel, else cwd) — the discovery surface may have drifted. Warning-tier only;
+  counts toward exit 10 under `--strict`.
+
+`--strict` makes warnings count toward exit 10. Exit 4 if a file's frontmatter is
+unparseable.
+
+---
+
+## CI integration
+
+ADRs only stay trustworthy if the integrity contract is machine-enforced. Gate the lint
+in CI; `--strict` turns the stale-`touches` drift warning into a hard signal.
+
+```yaml
+# .github/workflows/adr-lint.yml
+name: adr-lint
+on: [pull_request]
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Lint ADRs
+        run: python skills/adr-ops/scripts/adr-lint.py --strict --dir docs/adr
+        # exit 10 (findings, incl. stale-touches under --strict) fails the build
+```
+
+**Local pre-commit gate:** add `python scripts/adr-lint.py --strict --dir docs/adr` to a
+pre-commit hook so a one-sided supersession or a stale discovery surface is caught before
+the commit lands. A pre-edit hook can additionally call `adr-touching.py <changed-path>`
+and surface the governing ADR (exit 10) before a subsystem is modified.
 
 ---
 

+ 29 - 5
skills/adr-ops/scripts/adr-index.sh

@@ -1,11 +1,13 @@
 #!/usr/bin/env bash
 # Emit the ADR index — one row per record, in number order. Read-only.
 #
-# Usage:   adr-index.sh [--dir DIR] [--json]
+# Usage:   adr-index.sh [--dir DIR] [--json] [--output FILE]
 # Input:   argv flags only (no stdin).
 # Output:  stdout = the index. Plain: "number | status | date | title" rows.
 #          --json: {"data":[...],"meta":{...,"schema":"claude-mods.adr-ops.index/v1"}}
-#          Data only — the directory IS the index; this is just a parse of it.
+#          --output FILE: write a generated Markdown index (heading + marker +
+#          table) to FILE atomically instead of stdout. Data only — the directory
+#          IS the index; this is just a parse of it.
 # Stderr:  headers, warnings (e.g. yq absent -> fallback parser), errors.
 # Exit:    0 ok, 2 usage, 3 dir not found
 #
@@ -16,12 +18,14 @@
 #   adr-index.sh
 #   adr-index.sh --dir docs/decisions
 #   adr-index.sh --json | jq '.data[] | select(.status=="accepted")'
+#   adr-index.sh --output docs/adr/INDEX.md
 set -uo pipefail
 
 readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3
 
 DIR="docs/adr"
 JSON=0
+OUTPUT=""
 
 usage() {
   cat <<'EOF'
@@ -31,9 +35,10 @@ Usage:
   adr-index.sh [--dir DIR] [--json]
 
 Options:
-  --dir DIR    ADR directory (default: docs/adr)
-  --json       Emit a JSON envelope (schema claude-mods.adr-ops.index/v1)
-  -h, --help   Show this help and exit 0.
+  --dir DIR      ADR directory (default: docs/adr)
+  --json         Emit a JSON envelope (schema claude-mods.adr-ops.index/v1)
+  --output FILE  Write a generated Markdown index to FILE (atomic) instead of stdout
+  -h, --help     Show this help and exit 0.
 
 Exit codes:
   0 ok   2 usage   3 dir not found
@@ -42,6 +47,7 @@ Examples:
   adr-index.sh
   adr-index.sh --dir docs/decisions
   adr-index.sh --json | jq '.data[] | select(.status=="accepted")'
+  adr-index.sh --output docs/adr/INDEX.md
 EOF
 }
 
@@ -51,12 +57,15 @@ while [[ $# -gt 0 ]]; do
   case "$1" in
     --dir)     [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;;
     --json)    JSON=1; shift ;;
+    --output)  [[ $# -ge 2 ]] || die_usage "--output needs a value"; OUTPUT="$2"; shift 2 ;;
     -h|--help) usage; exit "$EX_OK" ;;
     -*)        die_usage "unknown flag: $1" ;;
     *)         die_usage "unexpected positional argument: $1" ;;
   esac
 done
 
+[[ "$JSON" -eq 1 && -n "$OUTPUT" ]] && die_usage "--json and --output are mutually exclusive"
+
 [[ -d "$DIR" ]] || { printf 'error: ADR directory not found: %s\n' "$DIR" >&2; exit "$EX_NOTFOUND"; }
 
 HAVE_YQ=0
@@ -131,6 +140,21 @@ if [[ "$JSON" -eq 1 ]]; then
   done
   printf '],"meta":{"count":%d,"dir":"%s","schema":"claude-mods.adr-ops.index/v1"}}\n' \
     "$count" "$(esc "$DIR")"
+elif [[ -n "$OUTPUT" ]]; then
+  # Generated Markdown index, written atomically to $OUTPUT.
+  tmp="$OUTPUT.tmp.$$"
+  {
+    printf '# ADR Index\n\n'
+    printf '<!-- generated by adr-index.sh — do not hand-edit; the directory is the index -->\n\n'
+    printf '| # | Status | Date | Title |\n'
+    printf '|---|---|---|---|\n'
+    for ((i=0; i<count; i++)); do
+      printf '| %s | %s | %s | %s |\n' \
+        "${rows_num[$i]}" "${rows_status[$i]}" "${rows_date[$i]}" "${rows_title[$i]}"
+    done
+  } > "$tmp" || { rm -f "$tmp"; printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; }
+  mv -f "$tmp" "$OUTPUT" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$OUTPUT" >&2; exit 1; }
+  printf 'wrote %d-row index to %s\n' "$count" "$OUTPUT" >&2
 else
   for ((i=0; i<count; i++)); do
     printf '%s | %s | %s | %s\n' \

+ 159 - 0
skills/adr-ops/scripts/adr-init.sh

@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+# Bootstrap an ADR directory in a repo adopting Architecture Decision Records cold.
+#
+# Usage:   adr-init.sh [--dir DIR] [--first-title "TEXT"] [--dry-run] [--force]
+# Input:   argv flags only (no stdin).
+# Output:  stdout = paths created (or, under --dry-run, the actions it would take).
+#          Data only.
+# Stderr:  headers, reminders, warnings, errors.
+# Exit:    0 created (or dry-run rendered), 2 usage, 5 precondition
+#          (dir already contains ADR-*.md and --force not given)
+#
+# Creates <dir> if missing, scaffolds a lint-clean ADR-001 (by invoking adr-new.sh
+# so it shares the canonical template), and writes a short generated <dir>/README.md
+# pointing at the directory-as-index discipline. Refuses to run in a directory that
+# already holds ADRs unless --force. Atomic writes; never clobbers existing files.
+#
+# Examples:
+#   adr-init.sh
+#   adr-init.sh --dir docs/decisions --first-title "Adopt ADRs"
+#   adr-init.sh --first-title "OAuth-only auth" --dry-run
+set -uo pipefail
+
+readonly EX_OK=0 EX_USAGE=2 EX_PRECOND=5
+
+DIR="docs/adr"
+FIRST_TITLE="Record architecture decisions"
+DRY_RUN=0
+FORCE=0
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ADR_NEW="$HERE/adr-new.sh"
+
+usage() {
+  cat <<'EOF'
+adr-init.sh — bootstrap an ADR directory in a repo adopting ADRs cold.
+
+Usage:
+  adr-init.sh [--dir DIR] [--first-title "TEXT"] [--dry-run] [--force]
+
+Options:
+  --dir DIR            ADR directory to create/populate (default: docs/adr)
+  --first-title TEXT   Title for the scaffolded ADR-001
+                       (default: "Record architecture decisions")
+  --dry-run            Print what would happen; write nothing.
+  --force              Proceed even if DIR already contains ADR-*.md.
+  -h, --help           Show this help and exit 0.
+
+Exit codes:
+  0 created (or dry-run)   2 usage   5 dir already has ADRs (without --force)
+
+Examples:
+  adr-init.sh
+  adr-init.sh --dir docs/decisions --first-title "Adopt ADRs"
+  adr-init.sh --first-title "OAuth-only auth" --dry-run
+EOF
+}
+
+die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; }
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --dir)         [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;;
+    --first-title) [[ $# -ge 2 ]] || die_usage "--first-title needs a value"; FIRST_TITLE="$2"; shift 2 ;;
+    --dry-run)     DRY_RUN=1; shift ;;
+    --force)       FORCE=1; shift ;;
+    -h|--help)     usage; exit "$EX_OK" ;;
+    -*)            die_usage "unknown flag: $1" ;;
+    *)             die_usage "unexpected positional argument: $1" ;;
+  esac
+done
+
+[[ -f "$ADR_NEW" ]] || { printf 'error: adr-new.sh not found beside this script: %s\n' "$ADR_NEW" >&2; exit 1; }
+
+# ── precondition: refuse a populated dir unless --force ─────────────────────
+if [[ -d "$DIR" ]]; then
+  shopt -s nullglob
+  existing=("$DIR"/ADR-*.md)
+  shopt -u nullglob
+  if [[ ${#existing[@]} -gt 0 && "$FORCE" -ne 1 ]]; then
+    printf 'error: %s already contains %d ADR file(s); refusing to bootstrap (use --force to override)\n' \
+      "$DIR" "${#existing[@]}" >&2
+    exit "$EX_PRECOND"
+  fi
+fi
+
+README_PATH="$DIR/README.md"
+
+readme_content() {
+  cat <<EOF
+<!-- generated by adr-init.sh — do not hand-edit; the directory is the index -->
+# Architecture Decision Records
+
+This directory holds **Architecture Decision Records (ADRs)** — append-only
+project memory recording *why* the system is shaped the way it is. Each
+\`ADR-NNN-slug.md\` captures one decision: what was decided, the alternatives,
+and the consequences.
+
+## When to write one
+
+Write an ADR when a change **constrains future options**, **seriously weighed
+alternatives**, or has **rationale the code can't show**. A reversible,
+low-stakes choice is a code comment, not an ADR.
+
+## Format
+
+Numbered sequentially (\`highest + 1\`), never reused or reordered. Frontmatter
+carries \`status\`, \`date\`, \`supersedes\`, \`superseded-by\`, and \`touches:\`
+(the discovery surface). Body: Decision (one sentence) → Context → Alternatives
+considered → Consequences → See also.
+
+## The directory IS the index
+
+Do not hand-maintain a numbered list here — it drifts. The authoritative list is
+the filesystem; run \`adr-index\` to regenerate a table view.
+EOF
+}
+
+# ── dry-run: describe, write nothing ────────────────────────────────────────
+if [[ "$DRY_RUN" -eq 1 ]]; then
+  printf '%s\n' "----- 8< ----- (dry-run: nothing written) -----" >&2
+  [[ -d "$DIR" ]] || printf 'would create directory: %s\n' "$DIR"
+  printf 'would scaffold: %s\n' "$DIR/ADR-001-<slug-from-title>.md"
+  printf 'would write:    %s\n' "$README_PATH"
+  printf '(first ADR title: %s)\n' "$FIRST_TITLE" >&2
+  exit "$EX_OK"
+fi
+
+# ── create the directory ────────────────────────────────────────────────────
+if [[ ! -d "$DIR" ]]; then
+  mkdir -p "$DIR" || { printf 'error: failed to create %s\n' "$DIR" >&2; exit 1; }
+  printf '%s\n' "$DIR"
+fi
+
+# ── scaffold ADR-001 via adr-new.sh (shares the canonical template) ─────────
+# Force number 001 so a --force re-run into a dir with higher numbers still
+# bootstraps the first record; adr-new's own guard refuses to clobber.
+new_out="$(bash "$ADR_NEW" --dir "$DIR" --number 001 --title "$FIRST_TITLE" 2>/dev/null)"
+new_rc=$?
+if [[ "$new_rc" -eq 0 ]]; then
+  printf '%s\n' "$new_out"
+elif [[ "$new_rc" -eq 5 ]]; then
+  printf 'note: ADR-001 already present — left as-is.\n' >&2
+else
+  printf 'error: adr-new.sh failed (exit %d) scaffolding ADR-001\n' "$new_rc" >&2
+  exit 1
+fi
+
+# ── write the generated README (atomic; never clobber) ──────────────────────
+if [[ -e "$README_PATH" ]]; then
+  printf 'note: %s already exists — left as-is.\n' "$README_PATH" >&2
+else
+  tmp="$README_PATH.tmp.$$"
+  readme_content > "$tmp" || { rm -f "$tmp"; printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; }
+  mv -f "$tmp" "$README_PATH" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$README_PATH" >&2; exit 1; }
+  printf '%s\n' "$README_PATH"
+fi
+
+printf 'bootstrapped ADRs in %s — fill in ADR-001, then run adr-lint before committing.\n' "$DIR" >&2
+exit "$EX_OK"

+ 107 - 3
skills/adr-ops/scripts/adr-lint.py

@@ -7,13 +7,18 @@ filename, the BLUF `## Decision (one sentence)` right after the title, the fixed
 core section order, no duplicate numbers, and — the high-value cross-file check —
 supersession bidirectionality.
 
-Usage:   adr-lint.py [--dir DIR] [--strict] [--json]
+Usage:   adr-lint.py [--dir DIR] [--repo-root DIR] [--strict] [--json]
 Input:   argv flags only (no stdin).
 Output:  stdout = findings (plain table, or --json envelope). Data only.
 Stderr:  headers, the yq/PyYAML fallback notice, errors.
 Exit:    0 conformant, 2 usage, 3 dir not found, 4 a file's frontmatter
          unparseable, 10 findings present (errors; or warnings too under --strict)
 
+Beyond format/order/duplicate/supersession-bidirectionality, also checks:
+lifecycle consistency (status vs superseded-by), and — when a touches: entry is a
+literal filesystem path — whether it still resolves under --repo-root (a stale
+discovery surface), reported as a warning.
+
 Prefers PyYAML for frontmatter; falls back to a minimal parser when it is absent
 (announced on stderr). The supersession cross-check is the one most worth running.
 
@@ -37,12 +42,15 @@ EX_UNPARSEABLE = 4
 EX_FINDINGS = 10
 
 VALID_STATUS = {"proposed", "accepted", "superseded", "deprecated"}
+IN_FORCE_STATUS = {"proposed", "accepted"}
 LIST_FIELDS = ("supersedes", "superseded-by")
 REQUIRED_FIELDS = ("status", "date", "supersedes", "superseded-by", "touches")
 DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
 ADR_ID_RE = re.compile(r"^ADR-\d+$")
 FILENAME_RE = re.compile(r"^ADR-(\d+)-.+\.md$")
 TITLE_RE = re.compile(r"^# ADR-(\d+):\s+\S")
+GLOB_CHARS_RE = re.compile(r"[*?\[]")
+EXT_RE = re.compile(r"\.[A-Za-z0-9]{1,8}$")
 CORE_SECTIONS = [
     "## Decision",  # may be "## Decision (one sentence)"
     "## Context",
@@ -142,6 +150,23 @@ def as_list(value) -> list | None:
     return None
 
 
+def is_literal_path(entry: str) -> bool:
+    """True if a touches: entry is a literal filesystem path we can check on disk.
+
+    A literal path contains a '/' or a file extension; is NOT a glob (no * ? [);
+    and is NOT a config-key (no `file:key` colon segment — but a Windows drive
+    letter `C:` at position 1 doesn't count as a marker).
+    """
+    s = entry.strip()
+    if not s:
+        return False
+    if GLOB_CHARS_RE.search(s):
+        return False
+    if s.find(":") > 1:  # config-key marker (drive letters live at index 1)
+        return False
+    return ("/" in s) or bool(EXT_RE.search(s))
+
+
 def find_title(body: str):
     """Return (line_number_in_body, match) for the first ADR title, or (None, None)."""
     for idx, line in enumerate(body.splitlines()):
@@ -165,7 +190,7 @@ def section_sequence(body: str) -> list[str]:
     return seen
 
 
-def lint_dir(adr_dir: Path) -> tuple[list[dict], bool]:
+def lint_dir(adr_dir: Path, repo_root: Path | None = None) -> tuple[list[dict], bool]:
     """Return (findings, any_unparseable)."""
     findings: list[dict] = []
     any_unparseable = False
@@ -276,6 +301,48 @@ def lint_dir(adr_dir: Path) -> tuple[list[dict], bool]:
                 f"core sections out of order: {observed} (expected {expected_order})",
             )
 
+        # ── lifecycle consistency (status vs superseded-by) ──
+        # Complements the bidirectionality cross-check below: these are local,
+        # single-record contradictions and never double-report with it.
+        superseded_by_here = as_list(fm.get("superseded-by")) or []
+        has_successor = len(superseded_by_here) > 0
+        if status == "superseded" and not has_successor:
+            add(
+                name,
+                "error",
+                "status is 'superseded' but superseded-by is empty "
+                "(a superseded ADR must name its successor in superseded-by)",
+            )
+        elif status == "deprecated" and has_successor:
+            add(
+                name,
+                "error",
+                "status is 'deprecated' but superseded-by is non-empty "
+                "(deprecated means nothing replaces it; if something does, use 'superseded')",
+            )
+        elif status in IN_FORCE_STATUS and has_successor:
+            add(
+                name,
+                "error",
+                f"status is '{status}' (in force) but superseded-by is non-empty "
+                "(an in-force ADR cannot list a superseded-by)",
+            )
+
+        # ── stale touches: a literal path that no longer exists (warning) ──
+        if repo_root is not None:
+            touches_here = as_list(fm.get("touches")) or []
+            for entry in touches_here:
+                if not isinstance(entry, str) or not is_literal_path(entry):
+                    continue
+                target = (repo_root / entry).resolve()
+                if not target.exists():
+                    add(
+                        name,
+                        "warning",
+                        f"touches path no longer exists: {entry} "
+                        "(discovery surface may be stale)",
+                    )
+
     # ── duplicate numbers (error) / gaps (warning) ──
     for num, names in sorted(by_number.items()):
         if len(names) > 1:
@@ -340,6 +407,32 @@ def lint_dir(adr_dir: Path) -> tuple[list[dict], bool]:
     return findings, any_unparseable
 
 
+def resolve_repo_root(explicit: str | None) -> Path | None:
+    """Resolve the repo root for touches-path checks.
+
+    Explicit --repo-root wins (must be a directory). Otherwise try `git
+    rev-parse --show-toplevel`; fall back to cwd. Returns None only if an
+    explicit path was given but is not a directory (caller treats as usage).
+    """
+    if explicit is not None:
+        p = Path(explicit)
+        return p if p.is_dir() else None
+    import subprocess  # local: only needed when no explicit root
+
+    try:
+        out = subprocess.run(
+            ["git", "rev-parse", "--show-toplevel"],
+            capture_output=True,
+            text=True,
+            timeout=5,
+        )
+        if out.returncode == 0 and out.stdout.strip():
+            return Path(out.stdout.strip())
+    except (OSError, subprocess.SubprocessError):
+        pass
+    return Path.cwd()
+
+
 def main(argv: list[str]) -> int:
     parser = argparse.ArgumentParser(
         prog="adr-lint.py",
@@ -347,6 +440,12 @@ def main(argv: list[str]) -> int:
         add_help=True,
     )
     parser.add_argument("--dir", default="docs/adr", help="ADR directory (default: docs/adr)")
+    parser.add_argument(
+        "--repo-root",
+        default=None,
+        help="repo root for resolving literal touches: paths "
+        "(default: git toplevel if in a git repo, else cwd)",
+    )
     parser.add_argument(
         "--strict", action="store_true", help="count warnings toward the exit-10 signal"
     )
@@ -365,7 +464,12 @@ def main(argv: list[str]) -> int:
         print(f"error: ADR directory not found: {adr_dir}", file=sys.stderr)
         return EX_NOTFOUND
 
-    findings, any_unparseable = lint_dir(adr_dir)
+    repo_root = resolve_repo_root(args.repo_root)
+    if args.repo_root is not None and repo_root is None:
+        print(f"error: --repo-root is not a directory: {args.repo_root}", file=sys.stderr)
+        return EX_USAGE
+
+    findings, any_unparseable = lint_dir(adr_dir, repo_root)
 
     errors = [f for f in findings if f["severity"] == "error"]
     warnings = [f for f in findings if f["severity"] == "warning"]

+ 297 - 0
skills/adr-ops/scripts/adr-touching.py

@@ -0,0 +1,297 @@
+#!/usr/bin/env python3
+"""Find which ADRs govern a given path, glob, or config key via `touches:`.
+
+The third leg of the toolkit: adr-lint checks integrity, adr-index gives an
+overview, and adr-touching answers the pre-edit question — "is there a decision
+record governing the thing I'm about to change?". It reads every ADR's `touches:`
+list and reports the records whose discovery surface matches the query.
+
+A query matches a `touches:` entry by any of: exact string equality; fnmatch glob
+in EITHER direction (touches `src/**` matches query `src/auth.py`; query `src/*`
+matches touches `src/auth.py`); or path-prefix containment (touches `src/auth.py`
+is governed by query `src/`; touches `src/` governs query `src/auth.py`).
+Config-key entries (`file.yaml:db.host`) match by exact-or-prefix on the whole
+string. Pragmatic, not exhaustive.
+
+Usage:   adr-touching.py [--dir DIR] [--json] <path-or-glob-or-key>
+Input:   one positional query + argv flags (no stdin).
+Output:  stdout = matching ADRs, "number | status | title | matched-entry" rows.
+         Data only. --json: {"data":[...],"meta":{...,"schema":
+         "claude-mods.adr-ops.touching/v1"}}.
+Stderr:  headers, the PyYAML fallback notice, errors.
+Exit:    0 NO governing ADR found, 2 usage, 3 dir not found,
+         10 at least one governing ADR found (domain signal — a pre-edit hook or
+         CI can branch on it: "heads up, ADR-NNN governs this path").
+
+Prefers PyYAML for frontmatter; falls back to a minimal parser when absent
+(announced on stderr).
+
+Examples:
+  adr-touching.py src/auth.py
+  adr-touching.py 'src/**'
+  adr-touching.py --dir docs/decisions config.yaml:db.host
+  adr-touching.py --json src/ | jq '.data[].number'
+"""
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import json
+import re
+import sys
+from pathlib import Path
+
+EX_OK = 0
+EX_USAGE = 2
+EX_NOTFOUND = 3
+EX_FOUND = 10
+
+FILENAME_RE = re.compile(r"^ADR-(\d+)-.+\.md$")
+TITLE_RE = re.compile(r"^# ADR-(\d+):\s+(\S.*)$")
+GLOB_CHARS_RE = re.compile(r"[*?\[]")
+
+try:
+    import yaml  # type: ignore
+
+    _HAVE_YAML = True
+except Exception:  # pragma: no cover - environment dependent
+    yaml = None  # type: ignore
+    _HAVE_YAML = False
+
+
+class FrontmatterError(Exception):
+    """Frontmatter block is absent or structurally unparseable."""
+
+
+def split_frontmatter(text: str) -> tuple[str, str]:
+    """Return (frontmatter_text, body_text). Raises FrontmatterError if absent."""
+    lines = text.splitlines()
+    if not lines or lines[0].strip() != "---":
+        raise FrontmatterError("no opening '---' frontmatter fence")
+    for i in range(1, len(lines)):
+        if lines[i].strip() == "---":
+            return "\n".join(lines[1:i]), "\n".join(lines[i + 1 :])
+    raise FrontmatterError("no closing '---' frontmatter fence")
+
+
+def parse_frontmatter(fm_text: str) -> dict:
+    """Parse the frontmatter block to a dict. PyYAML if present, else minimal."""
+    _yaml = yaml  # local alias narrows cleanly (module global won't)
+    if _yaml is not None:
+        try:
+            data = _yaml.safe_load(fm_text)
+        except Exception as exc:  # malformed YAML
+            raise FrontmatterError(f"YAML parse error: {exc}") from exc
+        if data is None:
+            return {}
+        if not isinstance(data, dict):
+            raise FrontmatterError("frontmatter is not a mapping")
+        return data
+    return _minimal_parse(fm_text)
+
+
+def _minimal_parse(fm_text: str) -> dict:
+    """Tiny frontmatter parser for `key: scalar` and `key: [a, b]` / block lists."""
+    out: dict = {}
+    lines = fm_text.splitlines()
+    i = 0
+    while i < len(lines):
+        raw = lines[i]
+        if not raw.strip() or raw.lstrip().startswith("#"):
+            i += 1
+            continue
+        m = re.match(r"^(\S[^:]*):\s*(.*)$", raw)
+        if not m:
+            i += 1
+            continue
+        key, val = m.group(1).strip(), m.group(2).strip()
+        if val == "":
+            items = []
+            j = i + 1
+            while j < len(lines) and re.match(r"^\s*-\s+", lines[j]):
+                item = re.sub(r"^\s*-\s+", "", lines[j]).strip()
+                item = item.strip("\"'")
+                items.append(item)
+                j += 1
+            if items:
+                out[key] = items
+                i = j
+                continue
+            out[key] = ""
+            i += 1
+            continue
+        if val.startswith("[") and val.endswith("]"):
+            inner = val[1:-1].strip()
+            out[key] = (
+                [x.strip().strip("\"'") for x in inner.split(",") if x.strip()]
+                if inner
+                else []
+            )
+        else:
+            out[key] = val.strip("\"'")
+        i += 1
+    return out
+
+
+def as_list(value) -> list:
+    """Return value coerced to a list of strings (best-effort)."""
+    if isinstance(value, list):
+        return [str(x) for x in value]
+    if value is None or value == "":
+        return []
+    return [str(value)]
+
+
+def _norm(p: str) -> str:
+    """Normalise a path-ish string for comparison: backslashes -> /, no trailing /."""
+    s = p.strip().replace("\\", "/")
+    while len(s) > 1 and s.endswith("/"):
+        s = s[:-1]
+    return s
+
+
+def _is_glob(s: str) -> bool:
+    return bool(GLOB_CHARS_RE.search(s))
+
+
+def _is_config_key(s: str) -> bool:
+    """A `file.ext:dotted.key` entry — a colon segment that isn't a drive letter."""
+    # Treat any ':' not at position 1 (Windows drive like C:) as a config-key marker.
+    idx = s.find(":")
+    return idx > 1
+
+
+def _prefix_governs(prefix: str, child: str) -> bool:
+    """True if `prefix` is a directory-prefix of `child` (or equal)."""
+    prefix = _norm(prefix)
+    child = _norm(child)
+    if prefix == child:
+        return True
+    return child.startswith(prefix + "/")
+
+
+def matches(query: str, entry: str) -> bool:
+    """Does `query` select the ADR carrying `touches:` entry `entry`?"""
+    q = _norm(query)
+    e = _norm(entry)
+
+    if q == e:
+        return True
+
+    # Config-key entries: match by exact-or-prefix on the whole string only.
+    if _is_config_key(entry) or _is_config_key(query):
+        # exact handled above; allow prefix containment either direction
+        if e.startswith(q) or q.startswith(e):
+            return True
+        return False
+
+    # Glob in either direction.
+    if _is_glob(entry) and fnmatch.fnmatch(q, e):
+        return True
+    if _is_glob(query) and fnmatch.fnmatch(e, q):
+        return True
+    # Recursive-glob convenience: fnmatch treats ** like * (no path awareness),
+    # which already lets `src/**` match `src/auth.py`. Nothing more needed.
+
+    # Path-prefix containment in either direction.
+    if not _is_glob(entry) and not _is_glob(query):
+        if _prefix_governs(query, entry) or _prefix_governs(entry, query):
+            return True
+
+    return False
+
+
+def find_title(body: str) -> str:
+    for line in body.splitlines():
+        m = TITLE_RE.match(line.strip())
+        if m:
+            return m.group(2).strip()
+    return ""
+
+
+def scan(adr_dir: Path, query: str) -> list[dict]:
+    """Return the list of matching ADR records (sorted by number)."""
+    results: list[dict] = []
+    files = sorted(p for p in adr_dir.glob("ADR-*.md") if FILENAME_RE.match(p.name))
+    for path in files:
+        fn = FILENAME_RE.match(path.name)
+        if fn is None:
+            continue
+        number = f"ADR-{fn.group(1)}"
+        try:
+            text = path.read_text(encoding="utf-8")
+            fm_text, body = split_frontmatter(text)
+            fm = parse_frontmatter(fm_text)
+        except (OSError, FrontmatterError) as exc:
+            print(f"warning: skipping {path.name}: {exc}", file=sys.stderr)
+            continue
+        touches = as_list(fm.get("touches"))
+        matched = next((t for t in touches if matches(query, t)), None)
+        if matched is not None:
+            results.append(
+                {
+                    "number": number,
+                    "status": str(fm.get("status", "")),
+                    "title": find_title(body),
+                    "matched": matched,
+                    "file": path.name,
+                }
+            )
+    return results
+
+
+def main(argv: list[str]) -> int:
+    parser = argparse.ArgumentParser(
+        prog="adr-touching.py",
+        description="Find which ADRs govern a path/glob/config-key via touches:.",
+        add_help=True,
+    )
+    parser.add_argument("--dir", default="docs/adr", help="ADR directory (default: docs/adr)")
+    parser.add_argument("--json", action="store_true", help="emit a JSON envelope")
+    parser.add_argument("query", nargs="?", help="path, glob, or config key to look up")
+    try:
+        args = parser.parse_args(argv)
+    except SystemExit as exc:
+        return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
+
+    if args.query is None or args.query.strip() == "":
+        print("error: a path/glob/config-key query is required", file=sys.stderr)
+        return EX_USAGE
+
+    if not _HAVE_YAML:
+        print("note: PyYAML not found — using built-in minimal frontmatter parser.", file=sys.stderr)
+
+    adr_dir = Path(args.dir)
+    if not adr_dir.is_dir():
+        print(f"error: ADR directory not found: {adr_dir}", file=sys.stderr)
+        return EX_NOTFOUND
+
+    results = scan(adr_dir, args.query)
+
+    if args.json:
+        envelope = {
+            "data": results,
+            "meta": {
+                "count": len(results),
+                "query": args.query,
+                "dir": str(adr_dir),
+                "schema": "claude-mods.adr-ops.touching/v1",
+            },
+        }
+        print(json.dumps(envelope, indent=2))
+    else:
+        for r in results:
+            print(f"{r['number']} | {r['status']} | {r['title']} | {r['matched']}")
+        if results:
+            print(
+                f"--- {len(results)} ADR(s) govern '{args.query}'",
+                file=sys.stderr,
+            )
+        else:
+            print(f"--- no ADR governs '{args.query}'", file=sys.stderr)
+
+    return EX_FOUND if results else EX_OK
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))

+ 138 - 0
skills/adr-ops/tests/run.sh

@@ -17,6 +17,8 @@ SCRIPTS="$SKILL/scripts"
 NEW="$SCRIPTS/adr-new.sh"
 INDEX="$SCRIPTS/adr-index.sh"
 LINT="$SCRIPTS/adr-lint.py"
+TOUCHING="$SCRIPTS/adr-touching.py"
+INIT="$SCRIPTS/adr-init.sh"
 
 # Pick a python that actually executes — skips the Windows Store `python3` stub.
 PYTHON=""
@@ -85,6 +87,8 @@ echo "-- --help --"
 bash "$NEW"   --help >/dev/null 2>&1; expect_exit "adr-new --help" 0 $?
 bash "$INDEX" --help >/dev/null 2>&1; expect_exit "adr-index --help" 0 $?
 "$PYTHON" "$LINT" --help >/dev/null 2>&1; expect_exit "adr-lint --help" 0 $?
+"$PYTHON" "$TOUCHING" --help >/dev/null 2>&1; expect_exit "adr-touching --help" 0 $?
+bash "$INIT" --help >/dev/null 2>&1; expect_exit "adr-init --help" 0 $?
 
 # ── adr-lint.py: clean conformant pair -> 0 ────────────────────────────────
 echo "-- adr-lint: clean --"
@@ -220,6 +224,140 @@ bash "$INDEX" --dir "$SB/no-such-dir" >/dev/null 2>&1; expect_exit "missing dir
 out="$(bash "$INDEX" --dir "$CLEAN" --json 2>/dev/null)"
 expect_has "json envelope schema" "claude-mods.adr-ops.index/v1" "$out"
 
+# ── adr-lint.py: lifecycle consistency checks ──────────────────────────────
+echo "-- adr-lint: lifecycle --"
+# superseded with empty superseded-by -> error -> 10
+LCS="$SB/lc-sup-empty"; mkdir -p "$LCS"
+make_adr "$LCS" 001 a superseded 2026-01-01 "[]" "[]"
+out="$("$PYTHON" "$LINT" --dir "$LCS" 2>&1)"; rc=$?
+expect_exit "superseded w/ empty superseded-by -> 10" 10 "$rc"
+expect_has  "names the lifecycle error" "must name its successor" "$out"
+
+# deprecated with non-empty superseded-by -> error -> 10
+LCD="$SB/lc-dep"; mkdir -p "$LCD"
+make_adr "$LCD" 001 a deprecated 2026-01-01 "[]" "[ADR-002]"
+make_adr "$LCD" 002 b accepted   2026-01-02 "[]" "[]"
+out="$("$PYTHON" "$LINT" --dir "$LCD" 2>&1)"; rc=$?
+expect_exit "deprecated w/ superseded-by -> 10" 10 "$rc"
+expect_has  "names the deprecated error" "nothing replaces it" "$out"
+
+# accepted (in force) with superseded-by -> error -> 10. Pair it with a valid
+# back-reference so ONLY the lifecycle error fires (no bidirectionality noise),
+# proving the two checks don't double-report.
+LCA="$SB/lc-accepted"; mkdir -p "$LCA"
+make_adr "$LCA" 001 a accepted 2026-01-01 "[]"          "[ADR-002]"
+make_adr "$LCA" 002 b accepted 2026-01-02 "[ADR-001]"   "[]"
+out="$("$PYTHON" "$LINT" --dir "$LCA" 2>&1)"; rc=$?
+expect_exit "accepted w/ superseded-by -> 10" 10 "$rc"
+expect_has  "names the in-force error" "in force" "$out"
+
+# ── adr-lint.py: stale touches: warning ────────────────────────────────────
+echo "-- adr-lint: stale touches --"
+# An ADR whose touches: lists a literal path absent under --repo-root. Warning
+# tier: exit 0 normally, exit 10 under --strict. (make_adr writes touches src/x.py;
+# the sandbox repo-root has no such file, so it's stale by construction.)
+STALE="$SB/stale"; mkdir -p "$STALE"
+make_adr "$STALE" 001 a accepted 2026-01-01 "[]" "[]"
+out="$("$PYTHON" "$LINT" --dir "$STALE" --repo-root "$SB" 2>&1)"; rc=$?
+expect_exit "stale touches normally -> 0" 0 "$rc"
+expect_has  "warns on stale touches path" "no longer exists" "$out"
+"$PYTHON" "$LINT" --dir "$STALE" --repo-root "$SB" --strict >/dev/null 2>&1
+expect_exit "stale touches --strict -> 10" 10 $?
+# When the path DOES exist under repo-root, no stale warning.
+mkdir -p "$STALE/repo/src"; : > "$STALE/repo/src/x.py"
+out="$("$PYTHON" "$LINT" --dir "$STALE" --repo-root "$STALE/repo" 2>&1)"
+case "$out" in *"no longer exists"*) no "existing touches path still flagged";; *) ok "existing touches path not flagged";; esac
+
+# ── adr-touching.py: exact / prefix / glob / config-key / no-match ──────────
+echo "-- adr-touching --"
+TCH="$SB/touching"; mkdir -p "$TCH"
+cat > "$TCH/ADR-001-auth.md" <<'EOF'
+---
+status: accepted
+date: 2026-01-01
+supersedes: []
+superseded-by: []
+touches:
+  - "src/auth.py"
+  - "lib/**"
+  - "config.yaml:db.host"
+---
+
+# ADR-001: Auth Title
+
+## Decision (one sentence)
+
+Rule.
+
+## Context
+C.
+
+## Alternatives considered
+A.
+
+## Consequences
+### Positive
+- G.
+
+## See also
+- x
+EOF
+
+# exact match -> 10, names the ADR
+out="$("$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py 2>/dev/null)"; rc=$?
+expect_exit "touching exact match -> 10" 10 "$rc"
+expect_has  "touching names the ADR" "ADR-001" "$out"
+# prefix query (dir governs file) -> 10
+"$PYTHON" "$TOUCHING" --dir "$TCH" src/ >/dev/null 2>&1; expect_exit "touching prefix query -> 10" 10 $?
+# glob query matches a literal touches entry -> 10
+"$PYTHON" "$TOUCHING" --dir "$TCH" 'src/*.py' >/dev/null 2>&1; expect_exit "touching glob query -> 10" 10 $?
+# touches glob matches a concrete query path -> 10
+"$PYTHON" "$TOUCHING" --dir "$TCH" lib/deep/thing.go >/dev/null 2>&1; expect_exit "touching matched by touches-glob -> 10" 10 $?
+# config-key exact -> 10
+"$PYTHON" "$TOUCHING" --dir "$TCH" config.yaml:db.host >/dev/null 2>&1; expect_exit "touching config-key -> 10" 10 $?
+# no governing ADR -> 0
+"$PYTHON" "$TOUCHING" --dir "$TCH" other/unrelated.txt >/dev/null 2>&1; expect_exit "touching no match -> 0" 0 $?
+# dir not found -> 3
+"$PYTHON" "$TOUCHING" --dir "$SB/no-such-dir" src/auth.py >/dev/null 2>&1; expect_exit "touching missing dir -> 3" 3 $?
+# missing query -> 2
+"$PYTHON" "$TOUCHING" --dir "$TCH" >/dev/null 2>&1; expect_exit "touching missing query -> 2" 2 $?
+# --json envelope schema
+out="$("$PYTHON" "$TOUCHING" --dir "$TCH" --json src/auth.py 2>/dev/null)"
+expect_has "touching json envelope schema" "claude-mods.adr-ops.touching/v1" "$out"
+
+# ── adr-init.sh: bootstrap, refuse populated, dry-run ──────────────────────
+echo "-- adr-init --"
+INITD="$SB/init"
+bash "$INIT" --dir "$INITD/docs/adr" --first-title "Adopt ADRs" >/dev/null 2>&1
+expect_exit "adr-init -> 0" 0 $?
+[[ -f "$INITD/docs/adr/ADR-001-adopt-adrs.md" ]] && ok "init scaffolded ADR-001" || no "init did not scaffold ADR-001"
+[[ -f "$INITD/docs/adr/README.md" ]] && ok "init wrote README.md" || no "init did not write README.md"
+case "$(cat "$INITD/docs/adr/README.md" 2>/dev/null)" in
+  *"generated by adr-init.sh"*) ok "init README carries generated marker";;
+  *) no "init README missing generated marker";;
+esac
+# the scaffolded ADR-001 lints clean (repo-root = init root; touches paths are template placeholders -> warnings only, exit 0)
+"$PYTHON" "$LINT" --dir "$INITD/docs/adr" --repo-root "$INITD" >/dev/null 2>&1
+expect_exit "init ADR-001 lints clean -> 0" 0 $?
+# refuses a populated dir -> 5
+bash "$INIT" --dir "$INITD/docs/adr" >/dev/null 2>&1; expect_exit "init refuses populated dir -> 5" 5 $?
+# --dry-run writes nothing into a fresh location
+DRYI="$SB/init-dry"
+bash "$INIT" --dir "$DRYI/docs/adr" --dry-run >/dev/null 2>&1; expect_exit "init dry-run -> 0" 0 $?
+[[ -e "$DRYI" ]] && no "init dry-run created files" || ok "init dry-run wrote nothing"
+
+# ── adr-index.sh: --output generated file ──────────────────────────────────
+echo "-- adr-index --output --"
+OUTF="$SB/index-out.md"
+bash "$INDEX" --dir "$CLEAN" --output "$OUTF" >/dev/null 2>&1; expect_exit "adr-index --output -> 0" 0 $?
+[[ -f "$OUTF" ]] && ok "--output wrote a file" || no "--output wrote no file"
+outc="$(cat "$OUTF" 2>/dev/null)"
+expect_has "--output has the table header" "| # | Status | Date | Title |" "$outc"
+expect_has "--output carries the generated marker" "do not hand-edit" "$outc"
+expect_has "--output lists a row" "ADR-001" "$outc"
+# --json + --output is a usage error -> 2
+bash "$INDEX" --dir "$CLEAN" --json --output "$SB/x.md" >/dev/null 2>&1; expect_exit "index --json+--output -> 2" 2 $?
+
 # ── summary ────────────────────────────────────────────────────────────────
 echo "=== $PASS passed, $FAIL failed ==="
 [[ "$FAIL" -eq 0 ]] || exit 1