Claude Skill

adr-ops

Author, index, and lint Architecture Decision Records — append-only memory that recovers the WHY behind a system's shape. Triggers on: adr, architecture decision record, decision log, record this decision, supersede an adr, why was this decided, adr template, next adr number.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_adr-ops-3dfaf0b.zip · 41 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/adr-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

ADR Ops

An Architecture Decision Record (ADR) captures one architectural decision: what was decided, why, what was rejected, and what it costs. ADRs are append-only project memory — they exist so a future maintainer touching a subsystem can recover the reasoning behind its shape without archaeology through git history or chat logs.

This skill encapsulates a battle-tested ADR protocol and generalizes it to any repo. The default location is docs/adr/, but every script takes --dir so a repo can keep records anywhere (docs/decisions/, architecture/adr/, …).


When to write an ADR

Write one when a change has any of these properties:

  • It constrains future options — a boundary, an invariant, a "we will always / never do X" rule that later work must respect.
  • Multiple alternatives were seriously evaluated and the choice is not obvious in hindsight.
  • The rationale is non-obvious from the code — the code shows what, the ADR preserves why.

Write one decision per ADR. If a change bundles two separable decisions, write two.

When NOT to write one

  • A bug fix, refactor, or feature that follows existing architecture without changing it — that is a commit message.
  • A reversible, low-stakes choice — that is a code comment.
  • A point-in-time event with no forward constraint (a benchmark run, an incident write-up) — that is an audit/log entry, not an ADR.

Rule of thumb: if someone could plausibly undo this next month without re-litigating a trade-off, it is not an ADR.


Naming, location, numbering

  • Path: <adr-dir>/ADR-NNN-slug.md (default <adr-dir> = docs/adr).
  • NNN is zero-padded three digits, assigned sequentially: the next number is highest existing + 1. Numbers are never reused, never reordered — a superseded ADR keeps its number forever.
  • slug is short kebab-case naming the subject (oauth-only-auth, per-trial-container).
  • A protocol/how-to file (e.g. 00_*) sorts above the numbered records and is not part of the sequence.

The directory IS the index

Do not maintain a hand-curated numbered list as the source of truth — it drifts from the filesystem. The authoritative list is the directory itself; adr-index.sh is just a clean parse of it. Any prose list elsewhere (README, AGENTS.md) is a convenience pointer that may lag and must say so.


Canonical format (compact view)

Full template in assets/ADR-template.md; full rules in references/canonical-format.md. The shape:

---
status: accepted
date: YYYY-MM-DD
supersedes: []
superseded-by: []
touches:
  - "path/one.py"
---

# ADR-NNN: Title in Title Case

## Decision (one sentence)
<BLUF — one present-tense sentence stating the standing rule; greppable, stands alone.>

## Context
## Alternatives considered
## Consequences
### Positive / ### Negative / ### Non-goals
## See also

Fixed section order: Decision → Context → Alternatives considered → Consequences → See also. Extra sections (Migration path, Enforcement, Implementation summary) go after Consequences. For a multi-part decision, keep the one-sentence BLUF and add a ## Decision (detail) section lower down.

Frontmatter fields

Field Required Rule
status yes proposed / accepted / superseded / deprecated (lowercase).
date yes Decision date, YYYY-MM-DD.
supersedes yes YAML list of ADR ids this replaces ([] if none).
superseded-by yes YAML list of ADR ids that replace this ([] until superseded).
touches yes YAML list of paths / globs / config keys this governs. Quote each. The grep discovery surface — grep touches: answers "is there an ADR about the thing I'm changing?"
extends optional ADR ids this builds on without replacing.
related optional Companion ADR ids (not parents).
deciders optional Who made the call.

A new field is a protocol change — record it in a new ADR, don't invent per-record keys.


Status lifecycle & immutability

proposed ──► accepted ──► superseded   (superseded-by: [ADR-NNN])
                │
                └────────► deprecated  (withdrawn; nothing replaces it)

ADRs are append-only. Once accepted, you do not rewrite the Decision or Context. Three change modes (full detail in references/lifecycle-and-supersession.md):

  1. Supersede — the rule itself changes. Write a new ADR with supersedes: [ADR-OLD]; flip the old record's frontmatter to status: superseded + superseded-by: [ADR-NEW] in the same commit. Body stays intact (obsolete reasoning is itself a record). Supersession is bidirectional — a one-sided link is a lint error.
  2. Addendum — new facts that refine an in-force decision. A dated ## Addendum — YYYY-MM-DD: <topic> at the end of the body. Never use it to quietly reverse the decision.
  3. In-place edit — typos, dead links, a renamed path in touches:. Preserves meaning; never rewrites rationale.

End-to-end workflow

  1. Scaffold the next record: bash scripts/adr-new.sh --dir docs/adr --title "Your decision title" (computes NNN = highest+1, derives the slug, fills frontmatter).
  2. Fill it in — BLUF first, then Context, Alternatives, Consequences, See also. Keep touches: accurate; it is the discovery surface.
  3. Cross-check the number against the directory to avoid a collision with a parallel session: ls docs/adr/ADR-*.md (or bash scripts/adr-index.sh).
  4. If superseding, flip the old record's frontmatter in the same commit — either by hand or with adr-new.sh --supersedes ADR-OLD --apply-supersede.
  5. Lint before committing: python scripts/adr-lint.py --dir docs/adr.
  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/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

# 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

# Next number, slug derived from the title, frontmatter pre-filled:
bash scripts/adr-new.sh --title "OAuth-only auth"

# Custom dir + explicit slug + proposed status, preview without writing:
bash scripts/adr-new.sh --dir docs/decisions --title "Per-trial container" \
  --slug per-trial-container --status proposed --dry-run

# Supersede an old record and flip its frontmatter automatically:
bash scripts/adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede

Refuses to overwrite an existing file (exit 5). Atomic write. --dry-run prints the path

  • rendered content and writes nothing. --number N forces a specific number (backfilling or coordination) — use sparingly; sequential highest+1 is the discipline.

scripts/adr-index.sh — the directory as a table (read-only)

bash scripts/adr-index.sh                       # number | status | date | title
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.

# 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.

Batched queries: pass several positionals and the ADR set is parsed once — one spawn for N paths, which is what makes it cheap to call from a lint loop (fleetflow's ff-plan lint went from 225 spawns to 35 on a 35-packet plan). The exit code is any-governed (10 if at least one query is governed, 0 only when none is); the per-query split is in the --json envelope's queries list, each entry {query, governing, rc}. data stays the deduped union so .data[].number keeps working, and a single-query call's envelope is unchanged.

python scripts/adr-touching.py --json src/a.py src/b.py lib/ \
  | jq -r '.queries[] | select(.rc==10) | .query'   # which of these are governed

scripts/adr-lint.py — conformance validator

python scripts/adr-lint.py --dir docs/adr        # exit 0 clean, 10 if findings
python scripts/adr-lint.py --strict --json | jq '.data[] | select(.severity=="error")'

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). 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.

# .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.


See also

  • references/canonical-format.md — the full template, field table, and body rules.
  • references/lifecycle-and-supersession.md — status lifecycle + the three change modes.
  • assets/ADR-template.md — copy-ready canonical template.
Files (claude-mods)
  • assets
    • ADR-template.md 1.2 KB
      ---
      status: accepted
      date: YYYY-MM-DD
      supersedes: []
      superseded-by: []
      touches:
        - "path/one.py"
        - "path/two.py"
        - "config.yaml:some.key"
      ---
      
      # ADR-NNN: Title in Title Case
      
      ## Decision (one sentence)
      
      <One present-tense sentence stating the rule. This is the BLUF — it must stand
      alone and be greppable. A reader who reads only this line knows what was decided.>
      
      ## Context
      
      <The forces in play: what problem, what constraints, what made this a decision
      rather than an obvious default. Enough that the decision reads as inevitable given
      the context — not a coin flip.>
      
      ## Alternatives considered
      
      <Each serious option, and the specific reason it lost. "We didn't consider any" is
      a signal the change may not warrant an ADR. Omit this section only for a forced
      decision with no real alternative — and say so explicitly if you omit it.>
      
      ## Consequences
      
      ### Positive
      - <What this buys us.>
      
      ### Negative
      - <The costs and risks we accept.>
      
      ### Non-goals
      - <What this ADR deliberately does NOT decide or constrain — fences against
        scope-creep readings.>
      
      ## See also
      
      - <Links to the enforcing code, tests, related ADRs, audits. An invariant that is
        enforced by a test should link that test — the protocol is the contract is the
        test.>
      
  • references
    • canonical-format.md 4.8 KB
      # ADR Canonical Format
      
      The exact shape every ADR takes. Copy `assets/ADR-template.md` verbatim and fill it
      in, or copy an existing accepted ADR. `adr-lint.py` enforces what is on this page.
      
      ---
      
      ## The template
      
      ```markdown
      ---
      status: accepted
      date: YYYY-MM-DD
      supersedes: []
      superseded-by: []
      touches:
        - "path/one.py"
        - "path/two.py"
        - "config.yaml:some.key"
      ---
      
      # ADR-NNN: Title in Title Case
      
      ## Decision (one sentence)
      
      <One present-tense sentence stating the rule. This is the BLUF — it must stand
      alone and be greppable. A reader who reads only this line knows what was decided.>
      
      ## Context
      
      <The forces in play: what problem, what constraints, what made this a decision
      rather than an obvious default. Enough that the decision reads as inevitable given
      the context — not a coin flip.>
      
      ## Alternatives considered
      
      <Each serious option, and the specific reason it lost. "We didn't consider any" is
      a signal the change may not warrant an ADR. Omit this section only for a forced
      decision with no real alternative — and say so explicitly if you omit it.>
      
      ## Consequences
      
      ### Positive
      - <What this buys us.>
      
      ### Negative
      - <The costs and risks we accept.>
      
      ### Non-goals
      - <What this ADR deliberately does NOT decide or constrain — fences against
        scope-creep readings.>
      
      ## See also
      
      - <Links to the enforcing code, tests, related ADRs, audits. An invariant that is
        enforced by a test should link that test — the protocol is the contract is the
        test.>
      ```
      
      ---
      
      ## Frontmatter fields
      
      | Field | Required | Rule |
      |---|---|---|
      | `status` | yes | One of `proposed` / `accepted` / `superseded` / `deprecated` (lowercase). |
      | `date` | yes | Decision date, `YYYY-MM-DD`. |
      | `supersedes` | yes | YAML list of ADR ids this record replaces (`[]` if none, else `[ADR-002]`). |
      | `superseded-by` | yes | YAML list of ADR ids that replace this one (`[]` until superseded). |
      | `touches` | yes | YAML list of the paths / globs / config keys this decision governs. **Quote every entry.** This is the discovery surface — a future editor greps `touches:` to find "is there an ADR about the thing I'm changing?" Keep it accurate. |
      | `extends` | optional | YAML list of ADR ids this record builds on without replacing. |
      | `related` | optional | YAML list of ADR ids that are companions, not parents. |
      | `deciders` | optional | YAML list of who made the call. |
      
      Keep the frontmatter to these fields. A new field is a protocol change — propose it in
      a new ADR rather than inventing per-record keys.
      
      ---
      
      ## Body rules
      
      - **Title.** `# ADR-NNN: Title` — **colon separator**, Title Case, immediately after the
        closing `---` of the frontmatter (one blank line between them). Backticks for code
        identifiers in the title are fine. The `NNN` in the title MUST match the filename.
      - **Decision-first (BLUF).** `## Decision (one sentence)` comes immediately after the
        title, before `## Context`. A reader skims the decision, then reads context only if
        they need the why.
      - The one-sentence decision is **literally one sentence**, present tense, stating the
        standing rule (not "we decided to…" but "X routes through Y by default…").
      - **Fixed section order:** Decision → Context → Alternatives considered → Consequences →
        See also. Add extra sections (e.g. `## Migration path`, `## Enforcement`,
        `## Implementation summary`) **after** Consequences when useful; never reorder the
        core five.
      - **Consequences** carries three sub-headings: `### Positive`, `### Negative`,
        `### Non-goals`. Non-goals fence the record against scope-creep readings.
      - **Multi-part decisions.** When the decision needs more than one sentence to specify
        (enumerated rules, a comparison table), keep the one-sentence BLUF at the top and put
        the full statement in a `## Decision (detail)` section lower down. Do not drop the
        one-sentence BLUF.
      
      ---
      
      ## Naming, location, numbering
      
      - Path: `<adr-dir>/ADR-NNN-slug.md` (default `<adr-dir>` is `docs/adr`, configurable).
      - `NNN` is zero-padded three digits, assigned sequentially. **The next number is
        `highest existing + 1`. Numbers are never reused, never reordered** — a superseded
        ADR keeps its number forever.
      - `slug` is short kebab-case naming the subject (`oauth-only-auth`,
        `per-trial-container`).
      - A protocol/how-to file (e.g. `00_*`) sorts above the numbered records and is **not**
        part of the sequence.
      
      ### The directory IS the index
      
      Do **not** maintain a hand-curated numbered list of ADRs as a source of truth — that
      list drifts from the filesystem. The authoritative list is the directory itself:
      
      ```bash
      ls <adr-dir>/ADR-*.md          # or: adr-index.sh --dir <adr-dir>
      ```
      
      Any prose list of ADRs elsewhere (a README, an AGENTS.md) is a **convenience pointer
      that may lag** and must say so. Because metadata lives in YAML frontmatter, a fresh
      index is a clean parse — `adr-index.sh` does exactly this.
      
    • lifecycle-and-supersession.md 3 KB
      # ADR Lifecycle and Supersession
      
      ADRs are **append-only project memory**. Once a record is `accepted`, its Decision and
      Context are not rewritten to reflect a new reality — that erases the record. Change comes
      through supersession, addenda, or narrow in-place fixes, never by editing the decision.
      
      ---
      
      ## Status lifecycle
      
      ```
      proposed  ──►  accepted  ──►  superseded   (superseded-by: [ADR-NNN])
                        │
                        └─────────►  deprecated   (withdrawn; nothing replaces it)
      ```
      
      | `status` | Meaning |
      |---|---|
      | `proposed` | Drafted, under discussion, not yet in force. Rare — most ADRs land accepted. |
      | `accepted` | In force. The default landing state. |
      | `superseded` | Replaced by a newer decision; `superseded-by` names it. The record stays. |
      | `deprecated` | The decision no longer applies and nothing replaces it. |
      
      ---
      
      ## The three change modes
      
      When something about a decision needs to change, pick exactly one mode.
      
      ### 1. Supersede — the decision itself changes
      
      A new decision replaces an old one. This is a **record, not a deletion** — `git log`
      should never be the only place a reversal lives.
      
      1. Write a **new** ADR with the next number. Set its frontmatter `supersedes: [ADR-OLD]`.
      2. In the old ADR, edit **frontmatter only**: set `status: superseded` and
         `superseded-by: [ADR-NEW]`. Leave its body intact — the obsolete reasoning is itself
         a record of how thinking changed.
      3. Do both in the **same commit**.
      
      Supersession is **bidirectional** and `adr-lint.py` enforces it: if A lists
      `supersedes: [B]`, then B must have `superseded-by: [A]` AND `status: superseded`, and
      vice versa. A one-sided link is a lint error. `adr-new.sh --supersedes ADR-OLD
      --apply-supersede` performs the flip for you.
      
      ### 2. Addendum — new facts, same decision
      
      A dated `## Addendum — YYYY-MM-DD: <topic>` section at the **end** of the body is for new
      facts that *refine* an in-force decision (an implementation note, a discovered
      constraint). It does not change the decision. Do **not** use an addendum to quietly
      reverse the decision — that is a supersession.
      
      ### 3. In-place edit — typos, dead links, renamed paths
      
      In-place edits for typos, broken links, or a renamed path in `touches:` are fine — they
      preserve the record's meaning. Use judgement: correcting a path is maintenance; rewriting
      the rationale is not. Normalising format across the whole corpus is maintenance — it
      changes presentation, not decisions.
      
      ---
      
      ## Decision tree
      
      ```
      Does the standing rule change?
      ├── YES → Supersede (new ADR + flip the old record's frontmatter, same commit)
      └── NO
          ├── New fact refining the decision?     → Addendum (dated section at the end)
          └── Typo / dead link / renamed path?    → In-place edit (frontmatter or body)
      ```
      
      > Rule of thumb for whether it is even an ADR at all: if someone could plausibly undo
      > the change next month without re-litigating a trade-off, it is not an ADR — it is a
      > commit message or a code comment.
      
  • scripts
    • adr-index.sh 8.2 KB
      #!/usr/bin/env bash
      # Emit the ADR index — one row per record, in number order. Read-only.
      #
      # 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"}}
      #          --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
      #
      # Prefers `yq --front-matter=extract` for frontmatter parsing; degrades to a
      # sed/grep parser when yq is absent (announced on stderr).
      #
      # 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
      set -uo pipefail
      
      readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3
      
      # Terminal design system (skills/_lib/term.sh). The index IS this tool's stdout
      # data product (pipeable rows / --json / --output), so framing rides fd 1 and is
      # only rendered as a full panel when stdout is a TTY (or FORCE_COLOR is set for a
      # render check). Piped or --json/--output stays plain. Degrade if the lib is gone.
      __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
      if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init
      else
        term_init() { :; }; term_color() { shift; printf '%s' "$*"; }
        term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
        term_section() { :; }; term_summary_line() { :; }; term_leaf_line() { :; }
        term_health() { shift; printf '%s' "$*"; }; TERM_DOT="|"
        TERM_TREE_BRANCH="+-"; TERM_TREE_LAST="\`-"
      fi
      
      DIR="docs/adr"
      JSON=0
      OUTPUT=""
      
      usage() {
        cat <<'EOF'
      adr-index.sh — emit the ADR index (number | status | date | title), in order.
      
      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)
        --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
      
      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
      }
      
      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 ;;
          --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
      if command -v yq >/dev/null 2>&1; then HAVE_YQ=1; else
        printf 'note: yq not found — using built-in frontmatter parser.\n' >&2
      fi
      
      # Extract a scalar frontmatter field from a file.
      #   field_of <file> <field>
      field_of() {
        local file="$1" field="$2"
        if [[ "$HAVE_YQ" -eq 1 ]]; then
          local v
          v="$(yq --front-matter=extract ".$field" "$file" 2>/dev/null)"
          [[ "$v" == "null" ]] && v=""
          printf '%s' "$v"
        else
          # Read only the first frontmatter block (between the first two --- lines).
          awk -v f="$field" '
            NR==1 && $0=="---" { infm=1; next }
            infm && $0=="---" { exit }
            infm {
              if ($0 ~ "^" f ":[[:space:]]*") {
                sub("^" f ":[[:space:]]*", "")
                gsub(/^["'"'"']|["'"'"']$/, "")
                print
                exit
              }
            }
          ' "$file"
        fi
      }
      
      title_of() {
        # First "# ADR-NNN: Title" line, with the prefix stripped.
        sed -n 's/^# ADR-[0-9]*:[[:space:]]*//p' "$1" | head -1
      }
      
      # Collect ADR files in number order.
      rows_num=(); rows_status=(); rows_date=(); rows_title=()
      shopt -s nullglob
      mapfile -t files < <(
        for f in "$DIR"/ADR-*.md; do
          base="$(basename "$f")"
          [[ "$base" =~ ^ADR-([0-9]+) ]] || continue
          printf '%010d\t%s\n' "$((10#${BASH_REMATCH[1]}))" "$f"
        done | sort | cut -f2-
      )
      shopt -u nullglob
      
      for f in "${files[@]}"; do
        base="$(basename "$f")"
        [[ "$base" =~ ^(ADR-[0-9]+) ]] || continue
        num="${BASH_REMATCH[1]}"
        rows_num+=("$num")
        rows_status+=("$(field_of "$f" status)")
        rows_date+=("$(field_of "$f" date)")
        rows_title+=("$(title_of "$f")")
      done
      
      count="${#rows_num[@]}"
      
      # Render the index as a full panel (grouped by lifecycle status) for a human at a
      # TTY. Strictly a display layer over the same rows — never the data product.
      render_panel() {
        local indicator
        indicator="$count $([ "$count" -eq 1 ] && echo record || echo records)"
        term_panel_open adr "adr" "$indicator"
        term_panel_vert
        term_summary_line "$DIR"
        term_panel_vert
      
        local st state i j idxs last conn nm
        # Canonical lifecycle order; a trailing pass catches any off-spec status so no
        # row is ever silently dropped from the view.
        for st in proposed accepted superseded deprecated __other__; do
          idxs=()
          for ((i=0; i<count; i++)); do
            case "$st" in
              __other__) case "${rows_status[$i]}" in proposed|accepted|superseded|deprecated) ;; *) idxs+=("$i") ;; esac ;;
              *)         [[ "${rows_status[$i]}" == "$st" ]] && idxs+=("$i") ;;
            esac
          done
          [[ ${#idxs[@]} -eq 0 ]] && continue
          case "$st" in
            accepted)   state=OK ;;       # green — in force
            proposed)   state=PENDING ;;  # yellow — under consideration
            *)          state=RETIRED ;;  # default fg — superseded / deprecated / off-spec
          esac
          local label="$st"; [[ "$st" == "__other__" ]] && label="other"
          term_section "$state" "$label" "${#idxs[@]}"
          last=$(( ${#idxs[@]} - 1 ))
          for j in "${!idxs[@]}"; do
            i="${idxs[$j]}"
            conn="$TERM_TREE_BRANCH"; [[ "$j" -eq "$last" ]] && conn="$TERM_TREE_LAST"
            nm="${rows_num[$i]}  ${rows_title[$i]}"
            term_leaf_line "$conn" "$nm" "" "" "${rows_date[$i]}"
          done
          term_panel_vert
        done
      
        term_panel_close "lint ${TERM_DOT} touching ${TERM_DOT} new" "$(term_health healthy "$indicator")"
      }
      
      # Decide whether the human panel applies: never for --json/--output; only when
      # stdout is a terminal (or FORCE_COLOR forces a render for verification).
      PANEL=0
      if [[ "$JSON" -eq 0 && -z "$OUTPUT" ]] && { [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; }; then PANEL=1; fi
      
      if [[ "$JSON" -eq 1 ]]; then
        # Build JSON without external deps (escape backslash + double-quote).
        esc() { local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; }
        printf '{"data":['
        for ((i=0; i<count; i++)); do
          [[ $i -gt 0 ]] && printf ','
          printf '{"number":"%s","status":"%s","date":"%s","title":"%s"}' \
            "$(esc "${rows_num[$i]}")" "$(esc "${rows_status[$i]}")" \
            "$(esc "${rows_date[$i]}")" "$(esc "${rows_title[$i]}")"
        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
      elif [[ "$PANEL" -eq 1 ]]; then
        render_panel
      else
        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
      fi
      
      exit "$EX_OK"
      
    • adr-init.sh 7.6 KB
      #!/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
      
      # Terminal design system (skills/_lib/term.sh). stdout = paths created (data); the
      # bootstrap summary frames on stderr, so detect color on fd 2. Degrade to plain
      # stderr lines if the shared lib is unreachable.
      __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
      if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
      else
        term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
        term_status_row() { shift; printf '  - %s %s\n' "$1" "${2:-}"; }
        term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|"
      fi
      
      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
        [[ -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"
        {
          term_panel_open adr "adr ${TERM_DOT} init (dry-run)" "$DIR"
          term_panel_vert
          [[ -d "$DIR" ]] || term_status_row skip "would create dir" "$DIR"
          term_status_row skip "would scaffold ADR-001" "title: $FIRST_TITLE"
          term_status_row skip "would write README.md" ""
          term_panel_vert
          term_panel_close "nothing written" ""
        } >&2
        exit "$EX_OK"
      fi
      
      # Bootstrap outcome rows, collected then framed as one status panel on stderr.
      init_rows=()   # "state\tlabel\tvalue"
      
      # ── 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"
        init_rows+=("ok"$'\t'"created dir"$'\t'"$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"
        init_rows+=("ok"$'\t'"scaffolded $(basename "$new_out")"$'\t'"title: $FIRST_TITLE")
      elif [[ "$new_rc" -eq 5 ]]; then
        init_rows+=("skip"$'\t'"ADR-001 already present"$'\t'"left as-is")
      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
        init_rows+=("skip"$'\t'"README.md already present"$'\t'"left as-is")
      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"
        init_rows+=("ok"$'\t'"wrote README.md"$'\t'"$README_PATH")
      fi
      
      {
        term_panel_open adr "adr ${TERM_DOT} init" "$DIR"
        term_panel_vert
        for row in "${init_rows[@]}"; do
          IFS=$'\t' read -r st lbl val <<<"$row"
          term_status_row "$st" "$lbl" "$val"
        done
        term_panel_vert
        term_panel_close "fill in ADR-001 ${TERM_DOT} then adr-lint before committing" ""
      } >&2
      exit "$EX_OK"
      
    • adr-lint.py 21.8 KB
      #!/usr/bin/env python3
      """Conformance linter for Architecture Decision Records.
      
      Validates every ADR-*.md in --dir against the canonical protocol: required
      frontmatter (present + well-typed), the `# ADR-NNN: Title` line matching the
      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] [--repo-root DIR] [--strict] [--json]
      Input:   argv flags only (no stdin).
      Output:  stdout = findings (plain table, or --json envelope). Data only.
               Both streams are pinned to UTF-8 at import so a finding quoting a
               non-ASCII title, path, or field cannot break the exit contract.
      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.
      
      Examples:
        adr-lint.py
        adr-lint.py --dir docs/decisions --strict
        adr-lint.py --json | jq '.data[] | select(.severity=="error")'
      """
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import sys
      from pathlib import Path
      
      
      # Windows consoles default to cp1252; force UTF-8 so em-dashes/arrows in findings
      # don't raise UnicodeEncodeError or print mojibake (matches the repo's standard
      # fix). Load-bearing here, not cosmetic: findings quote ADR titles, touches:
      # entries, filenames, and raw field values, and the crash fired mid-list AFTER
      # linting — truncating the findings and exiting 1, so a caller branching on the
      # documented 0/10 misread a dirty repo as clean.
      for _stream in (sys.stdout, sys.stderr):
          try:
              _stream.reconfigure(encoding="utf-8")  # type: ignore[attr-defined]
          except (AttributeError, ValueError):
              pass
      
      
      class Term:
          """Tiny ANSI helper mirroring skills/_lib/term.sh (term.sh is bash-only; per
          TERMINAL-DESIGN.md §9 the Python port is inline with matching keys/glyphs).
      
          Honors FORCE_COLOR / NO_COLOR / TERM_ASCII (+ legacy FLEET_ASCII). Color tracks
          the bound stream's TTY so piped data stays plain; ASCII mode swaps every glyph
          for its registered proxy (✓✗▲—? -> +x!-?)."""
      
          _C = {
              "green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
              "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m",
          }
          _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "na": "—", "unknown": "?"}
          _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"}
          _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim",
                         "na": "dim", "unknown": "yellow"}
      
          def __init__(self, stream=sys.stdout):
              # ASCII fallback: explicit env, OR the bound stream can't encode UTF —
              # mirrors term.sh's non-UTF-locale rule and prevents a UnicodeEncodeError
              # when a glyph hits a legacy codec. The module-level reconfigure above
              # runs first, so the encoding arm no longer fires on a Windows cp1252 pipe
              # (the stream really is UTF-8 by then); it still covers a caller that binds
              # Term to its own stream. TERM_ASCII stays the operator control for
              # "give me ASCII anyway".
              enc = (getattr(stream, "encoding", "") or "").lower()
              self.ascii = (
                  os.environ.get("TERM_ASCII") == "1"
                  or os.environ.get("FLEET_ASCII") == "1"
                  or "utf" not in enc
              )
              if os.environ.get("FORCE_COLOR"):
                  self.color = True
              elif (os.environ.get("NO_COLOR") is not None
                    or os.environ.get("TERM") == "dumb"
                    or not getattr(stream, "isatty", lambda: False)()):
                  self.color = False
              else:
                  self.color = True
      
          def c(self, name, text):
              if not self.color:
                  return text
              return f"{self._C.get(name, '')}{text}{self._C['off']}"
      
          def mark(self, state):
              glyph = (self._ASCII if self.ascii else self._GLYPH).get(state, "." )
              return self.c(self._MARK_COLOR.get(state, ""), glyph)
      
      
      EX_OK = 0
      EX_USAGE = 2
      EX_NOTFOUND = 3
      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",
          "## Alternatives considered",
          "## Consequences",
          "## See also",
      ]
      
      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 == "":
                  # Possible block list: subsequent "  - item" lines.
                  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 | None:
          """Return value as a list, or None if it is not list-typed."""
          if isinstance(value, list):
              return value
          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()):
              m = TITLE_RE.match(line.strip())
              if m:
                  return idx, m
          return None, None
      
      
      def section_sequence(body: str) -> list[str]:
          """Ordered list of the core `## ` headings that appear (normalised)."""
          seen = []
          for line in body.splitlines():
              s = line.strip()
              if not s.startswith("## "):
                  continue
              for canon in CORE_SECTIONS:
                  if s == canon or s.startswith(canon + " "):
                      seen.append(canon)
                      break
          return seen
      
      
      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
      
          files = sorted(
              p for p in adr_dir.glob("ADR-*.md") if FILENAME_RE.match(p.name)
          )
      
          # number -> list of filenames (duplicate detection)
          by_number: dict[str, list[str]] = {}
          # adr-id -> parsed record (for supersession cross-check)
          records: dict[str, dict] = {}
      
          def add(file: str, severity: str, message: str) -> None:
              findings.append({"file": file, "severity": severity, "message": message})
      
          for path in files:
              name = path.name
              fn_match = FILENAME_RE.match(name)
              if fn_match is None:
                  continue  # files are pre-filtered to ADR-NNN-*.md; defensive guard
              fm_num = fn_match.group(1)
              adr_id = f"ADR-{fm_num}"
              by_number.setdefault(fm_num, []).append(name)
      
              try:
                  text = path.read_text(encoding="utf-8")
              except Exception as exc:
                  add(name, "error", f"could not read file: {exc}")
                  any_unparseable = True
                  continue
      
              try:
                  fm_text, body = split_frontmatter(text)
                  fm = parse_frontmatter(fm_text)
              except FrontmatterError as exc:
                  add(name, "error", f"unparseable frontmatter: {exc}")
                  any_unparseable = True
                  continue
      
              records[adr_id] = {"file": name, "fm": fm, "number": fm_num}
      
              # ── required frontmatter present + typed ──
              for field in REQUIRED_FIELDS:
                  if field not in fm:
                      add(name, "error", f"missing required frontmatter field: {field}")
      
              status = fm.get("status")
              if status is not None and status not in VALID_STATUS:
                  add(
                      name,
                      "error",
                      f"status '{status}' not in {sorted(VALID_STATUS)}",
                  )
      
              date = fm.get("date")
              if date is not None and not DATE_RE.match(str(date)):
                  add(name, "error", f"date '{date}' is not YYYY-MM-DD")
      
              for field in LIST_FIELDS:
                  if field in fm and as_list(fm[field]) is None:
                      add(name, "error", f"{field} must be a YAML list (got {type(fm[field]).__name__})")
      
              if "touches" in fm and as_list(fm["touches"]) is None:
                  add(name, "warning", "touches should be a YAML list of paths/globs/keys")
      
              # ── title line + filename agreement ──
              t_idx, t_match = find_title(body)
              if t_match is None:
                  add(name, "error", "missing '# ADR-NNN: Title' line after frontmatter")
              else:
                  title_num = t_match.group(1)
                  if title_num != fm_num:
                      add(
                          name,
                          "error",
                          f"title number ADR-{title_num} != filename number ADR-{fm_num}",
                      )
      
              # ── BLUF: '## Decision (one sentence)' right after the title ──
              if t_match is not None and t_idx is not None:
                  body_lines = body.splitlines()
                  nxt = None
                  for line in body_lines[t_idx + 1 :]:
                      if line.strip():
                          nxt = line.strip()
                          break
                  if nxt != "## Decision (one sentence)":
                      add(
                          name,
                          "error",
                          "first section after title must be '## Decision (one sentence)' (BLUF)",
                      )
      
              # ── core section order ──
              seq = section_sequence(body)
              present = [s for s in CORE_SECTIONS if s in seq]
              # Filter the observed sequence down to core headings only, dedup-first-occurrence.
              observed = []
              for s in seq:
                  if s in present and s not in observed:
                      observed.append(s)
              expected_order = [s for s in CORE_SECTIONS if s in observed]
              if observed != expected_order:
                  add(
                      name,
                      "error",
                      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:
                  for n in names:
                      add(n, "error", f"duplicate ADR number {num} (also: {[x for x in names if x != n]})")
          if by_number:
              nums = sorted(int(n) for n in by_number)
              full = set(range(min(nums), max(nums) + 1))
              missing = sorted(full - set(nums))
              for gap in missing:
                  add(
                      f"ADR-{gap:03d}",
                      "warning",
                      f"number {gap:03d} is missing — a gap in the sequence (numbers are normally contiguous)",
                  )
      
          # ── supersession bidirectionality (the high-value cross-file check) ──
          for adr_id, rec in records.items():
              fm = rec["fm"]
              name = rec["file"]
              supersedes = as_list(fm.get("supersedes")) or []
              for target in supersedes:
                  if not isinstance(target, str) or not ADR_ID_RE.match(target):
                      add(name, "error", f"supersedes entry '{target}' is not a valid ADR-NNN id")
                      continue
                  other = records.get(target)
                  if other is None:
                      add(name, "error", f"supersedes {target}, but no such record exists")
                      continue
                  o_fm = other["fm"]
                  o_by = as_list(o_fm.get("superseded-by")) or []
                  if adr_id not in o_by:
                      add(
                          other["file"],
                          "error",
                          f"{target} is superseded by {adr_id} but its superseded-by does not list {adr_id}",
                      )
                  if o_fm.get("status") != "superseded":
                      add(
                          other["file"],
                          "error",
                          f"{target} is superseded by {adr_id} but its status is '{o_fm.get('status')}', not 'superseded'",
                      )
      
              superseded_by = as_list(fm.get("superseded-by")) or []
              for target in superseded_by:
                  if not isinstance(target, str) or not ADR_ID_RE.match(target):
                      add(name, "error", f"superseded-by entry '{target}' is not a valid ADR-NNN id")
                      continue
                  other = records.get(target)
                  if other is None:
                      add(name, "error", f"superseded-by {target}, but no such record exists")
                      continue
                  o_sup = as_list(other["fm"].get("supersedes")) or []
                  if adr_id not in o_sup:
                      add(
                          other["file"],
                          "error",
                          f"{target} claims to supersede nothing back to {adr_id} (its supersedes omits {adr_id})",
                      )
      
          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",
              description="Conformance linter for Architecture Decision Records.",
              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"
          )
          parser.add_argument("--json", action="store_true", help="emit a JSON envelope")
          try:
              args = parser.parse_args(argv)
          except SystemExit as exc:
              # argparse exits 2 on usage error already; normalise non-zero to EX_USAGE.
              return EX_USAGE if exc.code not in (0, None) else (exc.code or EX_OK)
      
          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
      
          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"]
      
          if args.json:
              envelope = {
                  "data": findings,
                  "meta": {
                      "count": len(findings),
                      "errors": len(errors),
                      "warnings": len(warnings),
                      "dir": str(adr_dir),
                      "schema": "claude-mods.adr-ops.lint/v1",
                  },
              }
              print(json.dumps(envelope, indent=2))
          else:
              tout = Term(sys.stdout)
              terr = Term(sys.stderr)
              for f in findings:
                  sev = f["severity"]
                  if tout.color:
                      state = "bad" if sev == "error" else "warn"
                      col = "red" if sev == "error" else "orange"
                      print(f"{tout.mark(state)} {tout.c(col, f'{sev.upper():7}')} {f['file']}: {f['message']}")
                  else:
                      # Plain stream stays byte-identical to the legacy data format.
                      print(f"{sev.upper():7} {f['file']}: {f['message']}")
              print(
                  f"--- {terr.c('red', str(len(errors)))} error(s), "
                  f"{terr.c('orange', str(len(warnings)))} warning(s) across {args.dir}",
                  file=sys.stderr,
              )
      
          if any_unparseable:
              return EX_UNPARSEABLE
          if errors or (args.strict and warnings):
              return EX_FINDINGS
          return EX_OK
      
      
      if __name__ == "__main__":
          sys.exit(main(sys.argv[1:]))
      
    • adr-new.sh 12 KB
      #!/usr/bin/env bash
      # Scaffold the next Architecture Decision Record from the canonical template.
      #
      # Usage:   adr-new.sh --title "Title Text" [OPTIONS]
      # Input:   argv flags only (no stdin).
      # Output:  stdout = the created file path (or, under --dry-run, the path then the
      #          full rendered content). Data only.
      # Stderr:  headers, reminders (e.g. supersession flip), warnings, errors.
      # Exit:    0 created (or dry-run rendered), 2 usage, 3 dir not found,
      #          5 precondition (target already exists)
      #
      # Computes the next number as (highest existing ADR-NNN in --dir) + 1, zero-padded
      # to three digits, and writes ADR-NNN-slug.md with frontmatter filled in. Never
      # overwrites an existing file. Atomic write (tmp + mv).
      #
      # Examples:
      #   adr-new.sh --title "OAuth-only auth"
      #   adr-new.sh --dir docs/decisions --title "Per-trial container" --slug per-trial-container
      #   adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede
      #   adr-new.sh --title "Draft idea" --status proposed --dry-run
      set -uo pipefail
      
      # ── exit-code constants ────────────────────────────────────────────────────
      readonly EX_OK=0 EX_USAGE=2 EX_NOTFOUND=3 EX_PRECOND=5
      
      # Terminal design system (skills/_lib/term.sh). stdout = the created file path
      # (data); the creation summary + supersession reminders frame on stderr, so detect
      # color on fd 2. Degrade to plain stderr lines if the shared lib is unreachable.
      __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
      if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
      else
        term_panel_open() { :; }; term_panel_close() { :; }; term_panel_vert() { :; }
        term_status_row() { shift; printf '  - %s %s\n' "$1" "${2:-}"; }
        term_alert() { shift; printf '  ! %s\n' "$*"; }
        term_color() { shift; printf '%s' "$*"; }; TERM_DOT="|"
      fi
      
      # ── defaults ───────────────────────────────────────────────────────────────
      DIR="docs/adr"
      TITLE=""
      SLUG=""
      STATUS="accepted"
      SUPERSEDES=""
      APPLY_SUPERSEDE=0
      DATE=""
      NUMBER=""        # explicit override; default is highest+1
      DRY_RUN=0
      
      # Resolve the bundled template relative to this script (works in repo + installed).
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      TEMPLATE="$HERE/../assets/ADR-template.md"
      
      usage() {
        cat <<'EOF'
      adr-new.sh — scaffold the next ADR from the canonical template.
      
      Usage:
        adr-new.sh --title "Title Text" [OPTIONS]
      
      Options:
        --dir DIR              ADR directory (default: docs/adr)
        --title TEXT           ADR title (required). Used in the `# ADR-NNN: Title` line.
        --slug SLUG            kebab-case slug for the filename. Derived from --title if omitted.
        --status STATUS        proposed|accepted|superseded|deprecated (default: accepted)
        --number N             Force the ADR number instead of computing highest+1
                               (for backfilling or coordination). Sequential discipline
                               is the default; use sparingly. The overwrite guard still
                               applies.
        --supersedes ADR-NNN   Mark this ADR as superseding ADR-NNN. Prints a reminder to
                               flip the old record. Repeatable.
        --apply-supersede      With --supersedes: also flip the OLD file's frontmatter
                               (status: superseded, superseded-by: [this ADR]) in place.
        --date YYYY-MM-DD      Decision date (default: today via `date +%F`).
        --dry-run              Print the target path and full content; write nothing.
        -h, --help             Show this help and exit 0.
      
      Exit codes:
        0 created (or dry-run rendered)   2 usage   3 dir not found   5 target exists
      
      Examples:
        adr-new.sh --title "OAuth-only auth"
        adr-new.sh --dir docs/decisions --title "Per-trial container" --slug per-trial-container
        adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede
        adr-new.sh --title "Draft idea" --status proposed --dry-run
      EOF
      }
      
      die_usage() { printf 'error: %s\n' "$1" >&2; echo >&2; usage >&2; exit "$EX_USAGE"; }
      
      # ── parse args ─────────────────────────────────────────────────────────────
      SUPERSEDES_LIST=()
      while [[ $# -gt 0 ]]; do
        case "$1" in
          --dir)           [[ $# -ge 2 ]] || die_usage "--dir needs a value"; DIR="$2"; shift 2 ;;
          --title)         [[ $# -ge 2 ]] || die_usage "--title needs a value"; TITLE="$2"; shift 2 ;;
          --slug)          [[ $# -ge 2 ]] || die_usage "--slug needs a value"; SLUG="$2"; shift 2 ;;
          --status)        [[ $# -ge 2 ]] || die_usage "--status needs a value"; STATUS="$2"; shift 2 ;;
          --number)        [[ $# -ge 2 ]] || die_usage "--number needs a value"; NUMBER="$2"; shift 2 ;;
          --supersedes)    [[ $# -ge 2 ]] || die_usage "--supersedes needs a value"; SUPERSEDES_LIST+=("$2"); shift 2 ;;
          --apply-supersede) APPLY_SUPERSEDE=1; shift ;;
          --date)          [[ $# -ge 2 ]] || die_usage "--date needs a value"; DATE="$2"; shift 2 ;;
          --dry-run)       DRY_RUN=1; shift ;;
          -h|--help)       usage; exit "$EX_OK" ;;
          -*)              die_usage "unknown flag: $1" ;;
          *)               die_usage "unexpected positional argument: $1" ;;
        esac
      done
      
      # ── validate ───────────────────────────────────────────────────────────────
      [[ -n "$TITLE" ]] || die_usage "--title is required"
      
      case "$STATUS" in
        proposed|accepted|superseded|deprecated) ;;
        *) die_usage "--status must be one of proposed|accepted|superseded|deprecated (got '$STATUS')" ;;
      esac
      
      if [[ -n "$DATE" ]]; then
        [[ "$DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die_usage "--date must be YYYY-MM-DD (got '$DATE')"
      else
        DATE="$(date +%F)"
      fi
      
      [[ -f "$TEMPLATE" ]] || { printf 'error: template not found at %s\n' "$TEMPLATE" >&2; exit "$EX_NOTFOUND"; }
      [[ -d "$DIR" ]] || { printf 'error: ADR directory not found: %s\n' "$DIR" >&2; exit "$EX_NOTFOUND"; }
      
      # Derive slug from title if not supplied: lowercase, non-alnum -> '-', squeeze, trim.
      if [[ -z "$SLUG" ]]; then
        SLUG="$(printf '%s' "$TITLE" \
          | tr '[:upper:]' '[:lower:]' \
          | sed -E 's/[^a-z0-9]+/-/g; s/-+/-/g; s/^-//; s/-$//')"
      fi
      [[ -n "$SLUG" ]] || die_usage "could not derive a slug from --title; pass --slug explicitly"
      [[ "$SLUG" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || die_usage "--slug must be kebab-case (got '$SLUG')"
      
      # ── compute next number ────────────────────────────────────────────────────
      # Default: highest existing ADR-NNN in --dir, +1. Sequential, never reused/reordered.
      # --number N overrides this (for backfilling or cross-session coordination); the
      # overwrite guard below still protects against clobbering an existing record.
      if [[ -n "$NUMBER" ]]; then
        [[ "$NUMBER" =~ ^[0-9]+$ ]] || die_usage "--number must be a non-negative integer (got '$NUMBER')"
        next=$((10#$NUMBER))
      else
        highest=0
        shopt -s nullglob
        for f in "$DIR"/ADR-*.md; do
          base="$(basename "$f")"
          if [[ "$base" =~ ^ADR-([0-9]+) ]]; then
            n=$((10#${BASH_REMATCH[1]}))
            (( n > highest )) && highest=$n
          fi
        done
        shopt -u nullglob
        next=$((highest + 1))
      fi
      NNN="$(printf '%03d' "$next")"
      
      TARGET="$DIR/ADR-$NNN-$SLUG.md"
      
      # Never overwrite (precondition).
      if [[ -e "$TARGET" ]]; then
        printf 'error: target already exists: %s (refusing to overwrite)\n' "$TARGET" >&2
        exit "$EX_PRECOND"
      fi
      
      # ── render content from template ───────────────────────────────────────────
      # Build the supersedes YAML list value.
      supersedes_yaml="[]"
      if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then
        joined=""
        for s in "${SUPERSEDES_LIST[@]}"; do
          [[ "$s" =~ ^ADR-[0-9]+$ ]] || die_usage "--supersedes value must look like ADR-NNN (got '$s')"
          joined+="${joined:+, }$s"
        done
        supersedes_yaml="[$joined]"
      fi
      
      # Render from template via line-exact awk substitution (avoids bash ${//}
      # footguns: a leading '#' anchors the match, and a title with regex metachars
      # would otherwise need escaping). Each placeholder line is matched whole.
      content="$(awk \
        -v status="status: $STATUS" \
        -v date="date: $DATE" \
        -v supersedes="supersedes: $supersedes_yaml" \
        -v title="# ADR-$NNN: $TITLE" '
        $0 == "status: accepted"             { print status;     next }
        $0 == "date: YYYY-MM-DD"             { print date;       next }
        $0 == "supersedes: []"               { print supersedes; next }
        $0 == "# ADR-NNN: Title in Title Case" { print title;    next }
        { print }
      ' "$TEMPLATE")"
      
      # ── dry-run: print and stop ────────────────────────────────────────────────
      if [[ "$DRY_RUN" -eq 1 ]]; then
        printf '%s\n' "$TARGET"
        {
          term_panel_open adr "adr ${TERM_DOT} new (dry-run)" "ADR-$NNN"
          term_panel_vert
          term_status_row skip "would write  $(basename "$TARGET")" "status: $STATUS ${TERM_DOT} $DATE"
          if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then
            term_alert warning "supersedes ${SUPERSEDES_LIST[*]} — flip status: superseded + superseded-by: [ADR-$NNN] in the same commit"
          fi
          term_panel_vert
          term_panel_close "nothing written" ""
        } >&2
        printf '%s\n' "$content"
        exit "$EX_OK"
      fi
      
      # ── atomic write ───────────────────────────────────────────────────────────
      tmp="$TARGET.tmp.$$"
      printf '%s\n' "$content" > "$tmp" || { printf 'error: failed to write %s\n' "$tmp" >&2; exit 1; }
      mv -f "$tmp" "$TARGET" || { rm -f "$tmp"; printf 'error: failed to move into place: %s\n' "$TARGET" >&2; exit 1; }
      
      printf '%s\n' "$TARGET"
      
      # ── supersession handling ──────────────────────────────────────────────────
      # Apply the flips first (collecting outcome rows), then frame the whole event as a
      # single status panel on stderr. stdout already carries the created path (data).
      sup_rows=()    # "state\tlabel\tvalue" — rendered as term_status_row
      sup_alerts=()  # plain text — rendered as term_alert warning
      if [[ ${#SUPERSEDES_LIST[@]} -gt 0 ]]; then
        for old in "${SUPERSEDES_LIST[@]}"; do
          oldfile=""
          shopt -s nullglob
          for f in "$DIR/$old"-*.md; do oldfile="$f"; break; done
          shopt -u nullglob
      
          if [[ "$APPLY_SUPERSEDE" -eq 1 ]]; then
            if [[ -z "$oldfile" || ! -f "$oldfile" ]]; then
              sup_alerts+=("--apply-supersede: could not find $old in $DIR — flip it by hand")
              continue
            fi
            # Flip frontmatter ONLY: status -> superseded, superseded-by -> [ADR-NNN].
            otmp="$oldfile.tmp.$$"
            if sed -E \
              -e "0,/^status: .*/s//status: superseded/" \
              -e "0,/^superseded-by: .*/s//superseded-by: [ADR-$NNN]/" \
              "$oldfile" > "$otmp" && mv -f "$otmp" "$oldfile"; then
              sup_rows+=("ok"$'\t'"flipped $(basename "$oldfile")"$'\t'"-> superseded, superseded-by: [ADR-$NNN]")
            else
              rm -f "$otmp"
              sup_alerts+=("failed to flip $oldfile — do it by hand")
            fi
          else
            sup_alerts+=("supersedes $old — in the SAME commit flip ${oldfile:-$old} to status: superseded + superseded-by: [ADR-$NNN] (or re-run with --apply-supersede)")
          fi
        done
      fi
      
      {
        term_panel_open adr "adr ${TERM_DOT} new" "ADR-$NNN"
        term_panel_vert
        term_status_row ok "created  $(basename "$TARGET")" "status: $STATUS ${TERM_DOT} $DATE"
        for row in "${sup_rows[@]:-}"; do
          [[ -z "$row" ]] && continue
          IFS=$'\t' read -r st lbl val <<<"$row"
          term_status_row "$st" "$lbl" "$val"
        done
        for a in "${sup_alerts[@]:-}"; do
          [[ -z "$a" ]] && continue
          term_alert warning "$a"
        done
        term_panel_vert
        term_panel_close "then: adr-lint before committing" ""
      } >&2
      
      exit "$EX_OK"
      
    • adr-touching.py 16.6 KB
      #!/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 OR MORE positional queries + argv flags (no stdin). The ADR set is
               parsed once and every query is matched against it, so a caller with N
               paths pays one process spawn instead of N (the 2026-09-05 batching:
               fleetflow's plan lint went from 225 spawns to 35 on a 35-packet run).
      Output:  stdout = matching ADRs, data only.
               Single query: "number | status | title | matched-entry" rows — the
               legacy format, byte-identical to before batching existed.
               Multiple queries: "query | number | status | title | matched-entry"
               rows (the query column is prepended so rows stay attributable).
               --json: {"data":[...],"queries":[{"query":Q,"governing":[...],
               "rc":0|10},...],"meta":{...,"schema":"claude-mods.adr-ops.touching/v1"}}.
               "data" is the union of governing ADRs across all queries (deduped by
               number, ordered by first appearance) so `.data[].number` keeps
               working; "queries" carries the per-query verdict a batching caller
               needs. meta.query (string) is present for a single query — unchanged;
               a multi-query call carries meta.queries (list) instead.
               Both streams are pinned to UTF-8 at import so an ADR title carrying an
               em dash or an arrow cannot break the exit contract.
      Stderr:  headers, the PyYAML fallback notice, errors.
      Exit:    0 NO governing ADR found for ANY query, 2 usage (no query, or a blank
               one), 3 dir not found,
               10 at least one governing ADR found for AT LEAST ONE query (domain
               signal — a pre-edit hook or CI can branch on it: "heads up, ADR-NNN
               governs this path"). Multi-query exit is deliberately any-governed so a
               caller that only reads the exit code gets the conservative answer; the
               per-query split lives in the --json "queries" list.
      
      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'
        adr-touching.py --json src/a.py src/b.py lib/ | jq '.queries[] | select(.rc==10) | .query'
      """
      from __future__ import annotations
      
      import argparse
      import fnmatch
      import json
      import os
      import re
      import sys
      from pathlib import Path
      
      
      # Windows consoles default to cp1252; force UTF-8 so em-dashes/arrows in ADR
      # titles don't raise UnicodeEncodeError or print mojibake (matches the repo's
      # standard fix). Load-bearing here, not cosmetic: the crash fired AFTER matching,
      # so the traceback replaced the rows and the process exited 1 — a caller branching
      # on the documented `10` read "no ADR governs this" while several did. A guard that
      # dies encoding its own answer is a false negative on a safety check.
      for _stream in (sys.stdout, sys.stderr):
          try:
              _stream.reconfigure(encoding="utf-8")  # type: ignore[attr-defined]
          except (AttributeError, ValueError):
              pass
      
      
      class Term:
          """Tiny ANSI helper mirroring skills/_lib/term.sh (term.sh is bash-only; per
          TERMINAL-DESIGN.md §9 the Python port is inline with matching keys/glyphs).
      
          Honors FORCE_COLOR / NO_COLOR / TERM_ASCII (+ legacy FLEET_ASCII). Color tracks
          the bound stream's TTY so piped data stays plain; ASCII mode swaps every glyph
          for its registered proxy (✓✗▲—? -> +x!-?)."""
      
          _C = {
              "green": "\033[32m", "yellow": "\033[33m", "orange": "\033[38;5;208m",
              "red": "\033[31m", "cyan": "\033[36m", "dim": "\033[2m", "off": "\033[0m",
          }
          _GLYPH = {"ok": "✓", "bad": "✗", "warn": "▲", "skip": "—", "na": "—", "unknown": "?"}
          _ASCII = {"ok": "+", "bad": "x", "warn": "!", "skip": "-", "na": "-", "unknown": "?"}
          _MARK_COLOR = {"ok": "green", "bad": "red", "warn": "orange", "skip": "dim",
                         "na": "dim", "unknown": "yellow"}
      
          def __init__(self, stream=sys.stdout):
              # ASCII fallback: explicit env, OR the bound stream can't encode UTF —
              # mirrors term.sh's non-UTF-locale rule and prevents a UnicodeEncodeError
              # when a glyph hits a legacy codec. The module-level reconfigure above
              # runs first, so the encoding arm no longer fires on a Windows cp1252 pipe
              # (the stream really is UTF-8 by then); it still covers a caller that binds
              # Term to its own stream. TERM_ASCII stays the operator control for
              # "give me ASCII anyway".
              enc = (getattr(stream, "encoding", "") or "").lower()
              self.ascii = (
                  os.environ.get("TERM_ASCII") == "1"
                  or os.environ.get("FLEET_ASCII") == "1"
                  or "utf" not in enc
              )
              if os.environ.get("FORCE_COLOR"):
                  self.color = True
              elif (os.environ.get("NO_COLOR") is not None
                    or os.environ.get("TERM") == "dumb"
                    or not getattr(stream, "isatty", lambda: False)()):
                  self.color = False
              else:
                  self.color = True
      
          def c(self, name, text):
              if not self.color:
                  return text
              return f"{self._C.get(name, '')}{text}{self._C['off']}"
      
          def mark(self, state):
              glyph = (self._ASCII if self.ascii else self._GLYPH).get(state, ".")
              return self.c(self._MARK_COLOR.get(state, ""), glyph)
      
      
      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 load_adrs(adr_dir: Path) -> list[dict]:
          """Parse every ADR-*.md once. Returns records sorted by number, each carrying
          the parsed `touches:` list; a file that fails to parse is skipped with a
          stderr warning (not fatal — one broken record must not blind the guard)."""
          adrs: 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
              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
              adrs.append(
                  {
                      "number": f"ADR-{fn.group(1)}",
                      "status": str(fm.get("status", "")),
                      "title": find_title(body),
                      "file": path.name,
                      "touches": as_list(fm.get("touches")),
                  }
              )
          return adrs
      
      
      def scan(adrs: list[dict], query: str) -> list[dict]:
          """Return the ADR records (from load_adrs) whose touches: govern `query`.
          Separated from parsing so a batched call matches N queries against one
          parsed set — the whole point of accepting several positionals."""
          results: list[dict] = []
          for adr in adrs:
              matched = next((t for t in adr["touches"] if matches(query, t)), None)
              if matched is not None:
                  results.append(
                      {
                          "number": adr["number"],
                          "status": adr["status"],
                          "title": adr["title"],
                          "matched": matched,
                          "file": adr["file"],
                      }
                  )
          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(s), glob(s), or config key(s) 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)
      
          # A blank query anywhere in the list is a usage error for the WHOLE call,
          # not a silent skip: a caller that built its argv from a bad split would
          # otherwise get a confident "ungoverned" verdict for a path it never asked
          # about.
          queries: list[str] = list(args.query)
          if not queries or any(q.strip() == "" for q in queries):
              print("error: at least one non-blank 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
      
          adrs = load_adrs(adr_dir)
          per_query: list[dict] = []
          for q in queries:
              res = scan(adrs, q)
              per_query.append({"query": q, "governing": res, "rc": EX_FOUND if res else EX_OK})
          # Union across queries, deduped by ADR number, first appearance wins (so a
          # single-query call's "data" is exactly that query's result list — the
          # pre-batching envelope, unchanged).
          seen: set[str] = set()
          union: list[dict] = []
          for pq in per_query:
              for r in pq["governing"]:
                  if r["number"] not in seen:
                      seen.add(r["number"])
                      union.append(r)
          single = len(queries) == 1
          any_found = any(pq["rc"] == EX_FOUND for pq in per_query)
      
          if args.json:
              meta: dict = {"count": len(union)}
              if single:
                  meta["query"] = queries[0]
              else:
                  meta["queries"] = queries
              meta["dir"] = str(adr_dir)
              meta["schema"] = "claude-mods.adr-ops.touching/v1"
              envelope = {"data": union, "queries": per_query, "meta": meta}
              print(json.dumps(envelope, indent=2))
          else:
              tout = Term(sys.stdout)
              terr = Term(sys.stderr)
              status_color = {"accepted": "green", "proposed": "yellow"}
              for pq in per_query:
                  # Multi-query rows carry the query as a leading column; single-query
                  # rows do not, so the legacy plain format stays byte-identical.
                  lead = "" if single else f"{pq['query']} | "
                  for r in pq["governing"]:
                      if tout.color:
                          num = tout.c("cyan", r["number"])
                          st = tout.c(status_color.get(r["status"], "dim"), r["status"])
                          print(f"{tout.mark('warn')} {lead}{num} | {st} | {r['title']} | {r['matched']}")
                      else:
                          print(f"{lead}{r['number']} | {r['status']} | {r['title']} | {r['matched']}")
                  n = len(pq["governing"])
                  if n:
                      print(f"--- {terr.c('orange', str(n))} ADR(s) govern '{pq['query']}'", file=sys.stderr)
                  else:
                      print(f"--- no ADR governs '{pq['query']}'", file=sys.stderr)
      
          return EX_FOUND if any_found else EX_OK
      
      
      if __name__ == "__main__":
          sys.exit(main(sys.argv[1:]))
      
  • tests
    • run.sh 24.2 KB
      #!/usr/bin/env bash
      # Self-test for adr-ops scripts (adr-new.sh, adr-index.sh, adr-lint.py).
      #
      # Offline-deterministic (no network). Builds throwaway ADR fixtures, asserts the
      # documented exit codes and key output of each script, then cleans up. Resolves
      # paths relative to itself so it works both in the repo and once installed to
      # ~/.claude/skills/adr-ops/.
      #
      # Usage:   bash tests/run.sh
      # Exit:    0 all pass, 1 one or more failures
      
      set -uo pipefail
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SKILL="$(dirname "$HERE")"
      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=""
      for c in python python3 py; do
        if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
      done
      [[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
      
      SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
      
      PASS=0; FAIL=0
      ok() { PASS=$((PASS+1)); printf '  PASS  %s\n' "$1"; }
      no() { FAIL=$((FAIL+1)); printf '  FAIL  %s\n' "$1"; }
      expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
      expect_has()  { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
      
      # Write a minimal conformant ADR.
      #   make_adr <dir> <NNN> <slug> <status> <date> <supersedes-yaml> <superseded-by-yaml>
      make_adr() {
        local dir="$1" nnn="$2" slug="$3" status="$4" date="$5" sup="$6" supby="$7"
        cat > "$dir/ADR-$nnn-$slug.md" <<EOF
      ---
      status: $status
      date: $date
      supersedes: $sup
      superseded-by: $supby
      touches:
        - "src/x.py"
      ---
      
      # ADR-$nnn: $slug Title
      
      ## Decision (one sentence)
      
      The system does the $slug thing by default.
      
      ## Context
      
      Some forces were in play.
      
      ## Alternatives considered
      
      We considered nothing and it lost.
      
      ## Consequences
      
      ### Positive
      - Good.
      
      ### Negative
      - Cost.
      
      ### Non-goals
      - Not that.
      
      ## See also
      
      - src/x.py
      EOF
      }
      
      echo "=== adr-ops self-test (python: $PYTHON) ==="
      
      # ── --help contracts (exit 0) ──────────────────────────────────────────────
      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 --"
      CLEAN="$SB/clean"; mkdir -p "$CLEAN"
      make_adr "$CLEAN" 001 alpha accepted 2026-01-01 "[]" "[]"
      make_adr "$CLEAN" 002 beta  accepted 2026-01-02 "[]" "[]"
      "$PYTHON" "$LINT" --dir "$CLEAN" >/dev/null 2>&1; expect_exit "clean pair -> 0" 0 $?
      
      # ── adr-lint.py: missing dir -> 3 ──────────────────────────────────────────
      "$PYTHON" "$LINT" --dir "$SB/no-such-dir" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $?
      
      # ── adr-lint.py: missing required field -> 10 ──────────────────────────────
      echo "-- adr-lint: findings --"
      MISS="$SB/missing"; mkdir -p "$MISS"
      cat > "$MISS/ADR-001-x.md" <<'EOF'
      ---
      status: accepted
      date: 2026-01-01
      superseded-by: []
      touches:
        - "a"
      ---
      
      # ADR-001: X
      
      ## Decision (one sentence)
      
      Rule.
      
      ## Context
      ## Alternatives considered
      ## Consequences
      ## See also
      EOF
      out="$("$PYTHON" "$LINT" --dir "$MISS" 2>&1)"; rc=$?
      expect_exit "missing 'supersedes' field -> 10" 10 "$rc"
      expect_has  "names missing field" "supersedes" "$out"
      
      # ── adr-lint.py: bad status -> 10 ──────────────────────────────────────────
      BADS="$SB/badstatus"; mkdir -p "$BADS"
      make_adr "$BADS" 001 x bogus 2026-01-01 "[]" "[]"
      out="$("$PYTHON" "$LINT" --dir "$BADS" 2>&1)"; rc=$?
      expect_exit "bad status -> 10" 10 "$rc"
      expect_has  "flags bad status" "not in" "$out"
      
      # ── adr-lint.py: broken supersession (one-sided) -> 10 ─────────────────────
      BROKE="$SB/broken-sup"; mkdir -p "$BROKE"
      # 002 supersedes 001, but 001 was NOT flipped (still accepted, empty superseded-by).
      make_adr "$BROKE" 001 old accepted 2026-01-01 "[]" "[]"
      make_adr "$BROKE" 002 new accepted 2026-01-02 "[ADR-001]" "[]"
      out="$("$PYTHON" "$LINT" --dir "$BROKE" 2>&1)"; rc=$?
      expect_exit "broken supersession -> 10" 10 "$rc"
      expect_has  "flags one-sided supersession" "superseded-by" "$out"
      
      # ── adr-lint.py: a properly-flipped supersession pair is clean -> 0 ─────────
      GOODSUP="$SB/good-sup"; mkdir -p "$GOODSUP"
      make_adr "$GOODSUP" 001 old superseded 2026-01-01 "[]" "[ADR-002]"
      make_adr "$GOODSUP" 002 new accepted   2026-01-02 "[ADR-001]" "[]"
      "$PYTHON" "$LINT" --dir "$GOODSUP" >/dev/null 2>&1; expect_exit "valid supersession pair -> 0" 0 $?
      
      # ── adr-lint.py: duplicate number -> 10 ────────────────────────────────────
      DUP="$SB/dup"; mkdir -p "$DUP"
      make_adr "$DUP" 001 a accepted 2026-01-01 "[]" "[]"
      make_adr "$DUP" 001 b accepted 2026-01-02 "[]" "[]"
      out="$("$PYTHON" "$LINT" --dir "$DUP" 2>&1)"; rc=$?
      expect_exit "duplicate number -> 10" 10 "$rc"
      expect_has  "flags duplicate" "duplicate ADR number" "$out"
      
      # ── adr-lint.py: unparseable frontmatter -> 4 ──────────────────────────────
      BADFM="$SB/badfm"; mkdir -p "$BADFM"
      printf '# ADR-001: No Frontmatter\n\n## Decision (one sentence)\nRule.\n' > "$BADFM/ADR-001-x.md"
      "$PYTHON" "$LINT" --dir "$BADFM" >/dev/null 2>&1; expect_exit "no frontmatter fence -> 4" 4 $?
      
      # ── adr-lint.py: --json envelope ───────────────────────────────────────────
      out="$("$PYTHON" "$LINT" --dir "$DUP" --json 2>/dev/null)"
      expect_has "json envelope schema" "claude-mods.adr-ops.lint/v1" "$out"
      
      # ── adr-new.sh: computes next number, derives slug ─────────────────────────
      echo "-- adr-new --"
      NEWDIR="$SB/newdir"; mkdir -p "$NEWDIR"
      make_adr "$NEWDIR" 001 alpha accepted 2026-01-01 "[]" "[]"
      make_adr "$NEWDIR" 007 gamma accepted 2026-01-02 "[]" "[]"
      out="$(bash "$NEW" --dir "$NEWDIR" --title "Cache The Things" --date 2026-02-02 2>/dev/null)"; rc=$?
      expect_exit "adr-new -> 0" 0 "$rc"
      expect_has  "next number is highest+1 (008)" "ADR-008-cache-the-things.md" "$out"
      [[ -f "$NEWDIR/ADR-008-cache-the-things.md" ]] && ok "file written" || no "file not written"
      # the written file passes the linter
      "$PYTHON" "$LINT" --dir "$NEWDIR" >/dev/null 2>&1; expect_exit "scaffolded file lints clean -> 0" 0 $?
      
      # ── adr-new.sh: refuses to overwrite -> 5 ──────────────────────────────────
      # A pre-existing target must never be clobbered. Create one (ADR-004 already on
      # disk), then ask adr-new to write that exact slot via --number — the precondition
      # guard must refuse. (--number also exercises the explicit-number override path.)
      OWDIR="$SB/overwrite"; mkdir -p "$OWDIR"
      make_adr "$OWDIR" 004 collide accepted 2026-01-01 "[]" "[]"
      bash "$NEW" --dir "$OWDIR" --title "Collide" --slug collide --number 4 --date 2026-02-02 >/dev/null 2>&1
      expect_exit "refuse overwrite -> 5" 5 $?
      # --number on a free slot writes there (and lints clean).
      bash "$NEW" --dir "$OWDIR" --title "Backfill Two" --slug backfill-two --number 2 --date 2026-02-02 >/dev/null 2>&1
      expect_exit "--number free slot writes -> 0" 0 $?
      [[ -f "$OWDIR/ADR-002-backfill-two.md" ]] && ok "--number wrote ADR-002" || no "--number did not write ADR-002"
      
      # ── adr-new.sh: --dry-run writes nothing ───────────────────────────────────
      DRY="$SB/dry"; mkdir -p "$DRY"
      before="$(ls "$DRY" | wc -l)"
      out="$(bash "$NEW" --dir "$DRY" --title "Dry Run Test" --dry-run 2>/dev/null)"; rc=$?
      expect_exit "dry-run -> 0" 0 "$rc"
      after="$(ls "$DRY" | wc -l)"
      [[ "$before" == "$after" ]] && ok "dry-run wrote nothing" || no "dry-run wrote a file"
      expect_has "dry-run prints target path" "ADR-001-dry-run-test.md" "$out"
      
      # ── adr-new.sh: bad status -> 2, missing title -> 2 ────────────────────────
      bash "$NEW" --dir "$NEWDIR" --title "X" --status nonsense >/dev/null 2>&1; expect_exit "bad status -> 2" 2 $?
      bash "$NEW" --dir "$NEWDIR" >/dev/null 2>&1; expect_exit "missing title -> 2" 2 $?
      bash "$NEW" --dir "$SB/no-such" --title "X" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $?
      
      # ── adr-new.sh: --apply-supersede flips the old record ─────────────────────
      SUPDIR="$SB/supdir"; mkdir -p "$SUPDIR"
      make_adr "$SUPDIR" 001 router accepted 2026-01-01 "[]" "[]"
      bash "$NEW" --dir "$SUPDIR" --title "New Router" --supersedes ADR-001 --apply-supersede --date 2026-03-03 >/dev/null 2>&1
      expect_exit "apply-supersede -> 0" 0 $?
      "$PYTHON" "$LINT" --dir "$SUPDIR" >/dev/null 2>&1
      expect_exit "auto-flipped pair lints clean -> 0" 0 $?
      
      # ── adr-index.sh: one row per ADR, in order ────────────────────────────────
      echo "-- adr-index --"
      out="$(bash "$INDEX" --dir "$CLEAN" 2>/dev/null)"; rc=$?
      expect_exit "adr-index -> 0" 0 "$rc"
      lines="$(printf '%s\n' "$out" | grep -c '^ADR-')"
      [[ "$lines" == 2 ]] && ok "two ADRs -> two rows" || no "expected 2 rows, got $lines"
      expect_has "row carries status" "accepted" "$out"
      bash "$INDEX" --dir "$SB/no-such-dir" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $?
      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"
      # single-query envelope keeps its pre-batching shape: meta.query is the string
      printf '%s' "$out" | jq -e '.meta.query=="src/auth.py" and (.meta|has("queries")|not) and (.queries|length)==1' >/dev/null 2>&1 \
        && ok "touching single-query envelope unchanged (meta.query string, one queries[] entry)" \
        || no "touching single-query envelope drifted"
      
      # ── adr-touching.py: batched queries (2026-09-05) ───────────────────────────
      # One spawn, N queries, per-query verdicts. Exit is any-governed: 10 if at least
      # one query is governed, 0 only when none is. fleetflow's plan lint depends on
      # this to make one call per packet instead of one per owned path.
      "$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py other/unrelated.txt >/dev/null 2>&1
      expect_exit "touching multi-query any-governed -> 10" 10 $?
      "$PYTHON" "$TOUCHING" --dir "$TCH" other/unrelated.txt nope/x.txt >/dev/null 2>&1
      expect_exit "touching multi-query none-governed -> 0" 0 $?
      "$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py "" >/dev/null 2>&1
      expect_exit "touching multi-query with a blank entry -> 2" 2 $?
      out="$("$PYTHON" "$TOUCHING" --dir "$TCH" --json src/auth.py other/unrelated.txt lib/deep/thing.go 2>/dev/null)"
      printf '%s' "$out" | jq -e '
          (.queries|length)==3
          and (.queries[0]|.query=="src/auth.py" and .rc==10 and (.governing|length)==1)
          and (.queries[1]|.query=="other/unrelated.txt" and .rc==0 and (.governing|length)==0)
          and (.queries[2]|.query=="lib/deep/thing.go" and .rc==10)
          and (.data|length)==1 and .data[0].number=="ADR-001"
          and (.meta.queries|length)==3 and (.meta|has("query")|not)' >/dev/null 2>&1 \
        && ok "touching multi-query json: per-query verdicts, deduped data union, meta.queries" \
        || no "touching multi-query json envelope wrong: $out"
      out="$("$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py other/unrelated.txt 2>/dev/null)"
      expect_has "touching multi-query text rows carry the query column" "src/auth.py | ADR-001 |" "$out"
      
      # ── non-Latin-1 text must not break the exit contract (regression) ──────────
      # Both Python tools print ADR titles / touches: entries / field values verbatim.
      # On Windows those streams default to cp1252 whenever redirected, so a title
      # carrying an em dash or an arrow raised UnicodeEncodeError *after* the work was
      # done: the traceback replaced the findings and the process exited 1, making a
      # governed path look ungoverned and a dirty repo look clean. PYTHONIOENCODING is
      # pinned to cp1252 here so the regression is reproduced deterministically on every
      # platform, not just on a Windows box — the import-time reconfigure must win over
      # it. Assert the documented codes AND that the text actually survives.
      echo "-- non-Latin-1 titles --"
      U8="$SB/utf8"; mkdir -p "$U8/src"; : > "$U8/src/auth.py"
      cat > "$U8/ADR-001-write-path.md" <<'EOF'
      ---
      status: accepted
      date: 2026-01-01
      supersedes: []
      superseded-by: []
      touches:
        - "src/auth.py"
      ---
      
      # ADR-001: Propose→Approve Is The Only Chat→Profile Write Path
      
      ## Decision (one sentence)
      
      Writes reach the profile only via propose→approve — never a direct chat write.
      
      ## Context
      C — an em dash lives here too.
      
      ## Alternatives considered
      A.
      
      ## Consequences
      ### Positive
      - G.
      
      ## See also
      - x
      EOF
      
      out="$(PYTHONIOENCODING=cp1252 "$PYTHON" "$TOUCHING" --dir "$U8" src/auth.py 2>/dev/null)"; rc=$?
      expect_exit "touching: non-Latin-1 title still exits 10" 10 "$rc"
      expect_has  "touching: non-Latin-1 title survives to stdout" "Chat→Profile" "$out"
      
      out="$(PYTHONIOENCODING=cp1252 "$PYTHON" "$TOUCHING" --dir "$U8" --json src/auth.py 2>/dev/null)"; rc=$?
      expect_exit "touching --json: non-Latin-1 title still exits 10" 10 "$rc"
      expect_has  "touching --json: envelope intact" "claude-mods.adr-ops.touching/v1" "$out"
      
      # A conformant record with a non-Latin-1 title lints clean -> 0.
      PYTHONIOENCODING=cp1252 "$PYTHON" "$LINT" --dir "$U8" --repo-root "$U8" >/dev/null 2>&1
      expect_exit "lint: non-Latin-1 title lints clean -> 0" 0 $?
      
      # A finding that QUOTES non-Latin-1 text must still reach stdout, and still exit 10.
      cat > "$U8/ADR-002-boundary.md" <<'EOF'
      ---
      status: accepted
      date: 2026-01-01
      supersedes: ["ADR-001 → replaced"]
      superseded-by: []
      touches:
        - "src/auth.py"
      ---
      
      # ADR-002: Chat→Profile Boundary
      
      ## Decision (one sentence)
      
      The boundary is one-way.
      
      ## Context
      C.
      
      ## Alternatives considered
      A.
      
      ## Consequences
      ### Positive
      - G.
      
      ## See also
      - x
      EOF
      out="$(PYTHONIOENCODING=cp1252 "$PYTHON" "$LINT" --dir "$U8" --repo-root "$U8" 2>/dev/null)"; rc=$?
      expect_exit "lint: finding quoting non-Latin-1 text still exits 10" 10 "$rc"
      expect_has  "lint: non-Latin-1 finding survives to stdout" "ADR-001 → replaced" "$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 $?
      
      # ── terminal design system (term.sh adoption + ASCII fallback) ─────────────
      echo "-- terminal design system --"
      
      # Bash scripts adopt the shared toolkit (full panel grammar), not hand-rolled ANSI.
      for s in "$INDEX" "$NEW" "$INIT"; do
        b="$(basename "$s")"
        if grep -q '_lib/term.sh' "$s"; then ok "$b sources _lib/term.sh"; else no "$b does not source _lib/term.sh"; fi
      done
      # Python scripts carry the inline Term helper (term.sh is bash-only; spec §9).
      for s in "$LINT" "$TOUCHING"; do
        b="$(basename "$s")"
        if grep -q 'class Term' "$s"; then ok "$b carries inline Term helper"; else no "$b missing inline Term helper"; fi
      done
      
      # term.sh primitives are pure ASCII under TERM_ASCII=1 (design principle #3).
      LIBTERM="$SKILL/../_lib/term.sh"
      if [[ -f "$LIBTERM" ]]; then
        ok "term.sh present"
        prims="$(TERM_ASCII=1 LT="$LIBTERM" bash -c '. "$LT"; term_init; printf "%s%s%s%s%s" \
          "$(term_mark ok)" "$(term_mark bad)" "$(term_status_row ok lbl val)" \
          "$TERM_DOT" "$(term_panel_open adr adr x)"')"
        if printf '%s' "$prims" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
          no "term.sh TERM_ASCII=1 primitives still emit non-ASCII bytes"
        else ok "term.sh TERM_ASCII=1 primitives are pure ASCII"; fi
      else
        no "term.sh missing at $LIBTERM"
      fi
      
      # adr-index panel: full bordered frame under FORCE_COLOR, pure ASCII under TERM_ASCII=1.
      PB="$SB/panel"; mkdir -p "$PB"
      make_adr "$PB" 001 alpha accepted 2026-01-01 "[]" "[]"
      pout="$(TERM_ASCII=1 FORCE_COLOR=1 bash "$INDEX" --dir "$PB" 2>/dev/null)"
      if printf '%s' "$pout" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
        no "adr-index panel emits non-ASCII under TERM_ASCII=1"
      else ok "adr-index panel is pure ASCII under TERM_ASCII=1"; fi
      case "$pout" in *"+-- "*) ok "adr-index renders the full panel frame";; *) no "adr-index panel frame missing";; esac
      # Piped (non-TTY, no FORCE_COLOR) stays plain data rows — the stdout contract.
      pp="$(bash "$INDEX" --dir "$PB" 2>/dev/null)"
      case "$pp" in
        *$'\033'*) no "piped adr-index leaked ANSI into the data stream";;
        *"ADR-001 | accepted"*) ok "piped adr-index stays plain data rows";;
        *) no "piped adr-index lost its data row";;
      esac
      
      # Python inline Term: colorizes under FORCE_COLOR, pure ASCII under TERM_ASCII=1,
      # byte-plain when piped (the data contract).
      pylint="$(TERM_ASCII=1 FORCE_COLOR=1 "$PYTHON" "$LINT" --dir "$DUP" 2>/dev/null)"
      if printf '%s' "$pylint" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
        no "adr-lint colored output emits non-ASCII under TERM_ASCII=1"
      else ok "adr-lint colored output is pure ASCII under TERM_ASCII=1"; fi
      case "$pylint" in *$'\033'*) ok "adr-lint colorizes under FORCE_COLOR";; *) no "adr-lint did not colorize under FORCE_COLOR";; esac
      plain_lint="$("$PYTHON" "$LINT" --dir "$DUP" 2>/dev/null)"
      case "$plain_lint" in *$'\033'*) no "piped adr-lint leaked ANSI";; *) ok "piped adr-lint stays plain data";; esac
      
      # ── summary ────────────────────────────────────────────────────────────────
      echo "=== $PASS passed, $FAIL failed ==="
      [[ "$FAIL" -eq 0 ]] || exit 1
      
  • SKILL.md 13.1 KB
    ---
    name: adr-ops
    description: "Author, index, and lint Architecture Decision Records — append-only memory that recovers the WHY behind a system's shape. Triggers on: adr, architecture decision record, decision log, record this decision, supersede an adr, why was this decided, adr template, next adr number."
    license: MIT
    allowed-tools: "Read Write Edit Bash Glob Grep"
    metadata:
      author: claude-mods
      related-skills: "git-ops, doc-scanner"
    ---
    
    # ADR Ops
    
    An **Architecture Decision Record (ADR)** captures one architectural decision: what was
    decided, why, what was rejected, and what it costs. ADRs are **append-only project
    memory** — they exist so a future maintainer touching a subsystem can recover the
    *reasoning* behind its shape without archaeology through git history or chat logs.
    
    This skill encapsulates a battle-tested ADR protocol and generalizes it to **any repo**.
    The default location is `docs/adr/`, but every script takes `--dir` so a repo can keep
    records anywhere (`docs/decisions/`, `architecture/adr/`, …).
    
    ---
    
    ## When to write an ADR
    
    Write one when a change has **any** of these properties:
    
    - It **constrains future options** — a boundary, an invariant, a "we will always / never
      do X" rule that later work must respect.
    - **Multiple alternatives were seriously evaluated** and the choice is not obvious in
      hindsight.
    - The **rationale is non-obvious from the code** — the code shows *what*, the ADR
      preserves *why*.
    
    Write one decision per ADR. If a change bundles two separable decisions, write two.
    
    ### When NOT to write one
    
    - A bug fix, refactor, or feature that follows existing architecture without changing it
      — that is a commit message.
    - A reversible, low-stakes choice — that is a code comment.
    - A point-in-time event with no forward constraint (a benchmark run, an incident
      write-up) — that is an audit/log entry, not an ADR.
    
    > **Rule of thumb:** if someone could plausibly undo this next month without
    > re-litigating a trade-off, it is **not** an ADR.
    
    ---
    
    ## Naming, location, numbering
    
    - Path: `<adr-dir>/ADR-NNN-slug.md` (default `<adr-dir>` = `docs/adr`).
    - `NNN` is zero-padded three digits, assigned **sequentially**: the next number is
      `highest existing + 1`. **Numbers are never reused, never reordered** — a superseded
      ADR keeps its number forever.
    - `slug` is short kebab-case naming the subject (`oauth-only-auth`, `per-trial-container`).
    - A protocol/how-to file (e.g. `00_*`) sorts above the numbered records and is **not**
      part of the sequence.
    
    ### The directory IS the index
    
    Do **not** maintain a hand-curated numbered list as the source of truth — it drifts from
    the filesystem. The authoritative list is the directory itself; `adr-index.sh` is just a
    clean parse of it. Any prose list elsewhere (README, AGENTS.md) is a convenience pointer
    that **may lag** and must say so.
    
    ---
    
    ## Canonical format (compact view)
    
    Full template in `assets/ADR-template.md`; full rules in
    `references/canonical-format.md`. The shape:
    
    ```markdown
    ---
    status: accepted
    date: YYYY-MM-DD
    supersedes: []
    superseded-by: []
    touches:
      - "path/one.py"
    ---
    
    # ADR-NNN: Title in Title Case
    
    ## Decision (one sentence)
    <BLUF — one present-tense sentence stating the standing rule; greppable, stands alone.>
    
    ## Context
    ## Alternatives considered
    ## Consequences
    ### Positive / ### Negative / ### Non-goals
    ## See also
    ```
    
    **Fixed section order:** Decision → Context → Alternatives considered → Consequences →
    See also. Extra sections (Migration path, Enforcement, Implementation summary) go *after*
    Consequences. For a multi-part decision, keep the one-sentence BLUF and add a
    `## Decision (detail)` section lower down.
    
    ### Frontmatter fields
    
    | Field | Required | Rule |
    |---|---|---|
    | `status` | yes | `proposed` / `accepted` / `superseded` / `deprecated` (lowercase). |
    | `date` | yes | Decision date, `YYYY-MM-DD`. |
    | `supersedes` | yes | YAML list of ADR ids this replaces (`[]` if none). |
    | `superseded-by` | yes | YAML list of ADR ids that replace this (`[]` until superseded). |
    | `touches` | yes | YAML list of paths / globs / config keys this governs. **Quote each.** The grep discovery surface — `grep touches:` answers "is there an ADR about the thing I'm changing?" |
    | `extends` | optional | ADR ids this builds on without replacing. |
    | `related` | optional | Companion ADR ids (not parents). |
    | `deciders` | optional | Who made the call. |
    
    A new field is a protocol change — record it in a new ADR, don't invent per-record keys.
    
    ---
    
    ## Status lifecycle & immutability
    
    ```
    proposed ──► accepted ──► superseded   (superseded-by: [ADR-NNN])
                    │
                    └────────► deprecated  (withdrawn; nothing replaces it)
    ```
    
    ADRs are **append-only**. Once `accepted`, you do not rewrite the Decision or Context.
    Three change modes (full detail in `references/lifecycle-and-supersession.md`):
    
    1. **Supersede** — the rule itself changes. Write a new ADR with `supersedes: [ADR-OLD]`;
       flip the old record's frontmatter to `status: superseded` + `superseded-by: [ADR-NEW]`
       in the **same commit**. Body stays intact (obsolete reasoning is itself a record).
       Supersession is **bidirectional** — a one-sided link is a lint error.
    2. **Addendum** — new facts that refine an in-force decision. A dated
       `## Addendum — YYYY-MM-DD: <topic>` at the **end** of the body. Never use it to
       quietly reverse the decision.
    3. **In-place edit** — typos, dead links, a renamed path in `touches:`. Preserves
       meaning; never rewrites rationale.
    
    ---
    
    ## End-to-end workflow
    
    1. **Scaffold** the next record:
       `bash scripts/adr-new.sh --dir docs/adr --title "Your decision title"`
       (computes `NNN = highest+1`, derives the slug, fills frontmatter).
    2. **Fill it in** — BLUF first, then Context, Alternatives, Consequences, See also.
       Keep `touches:` accurate; it is the discovery surface.
    3. **Cross-check the number** against the directory to avoid a collision with a parallel
       session: `ls docs/adr/ADR-*.md` (or `bash scripts/adr-index.sh`).
    4. **If superseding**, flip the old record's frontmatter in the **same commit** — either
       by hand or with `adr-new.sh --supersedes ADR-OLD --apply-supersede`.
    5. **Lint** before committing: `python scripts/adr-lint.py --dir docs/adr`.
    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/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
    
    ```bash
    # Next number, slug derived from the title, frontmatter pre-filled:
    bash scripts/adr-new.sh --title "OAuth-only auth"
    
    # Custom dir + explicit slug + proposed status, preview without writing:
    bash scripts/adr-new.sh --dir docs/decisions --title "Per-trial container" \
      --slug per-trial-container --status proposed --dry-run
    
    # Supersede an old record and flip its frontmatter automatically:
    bash scripts/adr-new.sh --title "Replace router" --supersedes ADR-002 --apply-supersede
    ```
    
    Refuses to overwrite an existing file (exit 5). Atomic write. `--dry-run` prints the path
    + rendered content and writes nothing. `--number N` forces a specific number (backfilling
    or coordination) — use sparingly; sequential `highest+1` is the discipline.
    
    ### `scripts/adr-index.sh` — the directory as a table (read-only)
    
    ```bash
    bash scripts/adr-index.sh                       # number | status | date | title
    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.
    
    **Batched queries:** pass several positionals and the ADR set is parsed once —
    one spawn for N paths, which is what makes it cheap to call from a lint loop
    (fleetflow's `ff-plan lint` went from 225 spawns to 35 on a 35-packet plan). The
    exit code is any-governed (`10` if at least one query is governed, `0` only when
    none is); the per-query split is in the `--json` envelope's `queries` list, each
    entry `{query, governing, rc}`. `data` stays the deduped union so `.data[].number`
    keeps working, and a single-query call's envelope is unchanged.
    
    ```bash
    python scripts/adr-touching.py --json src/a.py src/b.py lib/ \
      | jq -r '.queries[] | select(.rc==10) | .query'   # which of these are governed
    ```
    
    ### `scripts/adr-lint.py` — conformance validator
    
    ```bash
    python scripts/adr-lint.py --dir docs/adr        # exit 0 clean, 10 if findings
    python scripts/adr-lint.py --strict --json | jq '.data[] | select(.severity=="error")'
    ```
    
    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). 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.
    
    ---
    
    ## See also
    
    - `references/canonical-format.md` — the full template, field table, and body rules.
    - `references/lifecycle-and-supersession.md` — status lifecycle + the three change modes.
    - `assets/ADR-template.md` — copy-ready canonical template.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related