返回 Skills
skymavis/skills· MIT 内容可用

decision-records

>-

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: decision-records description: >- Draft, promote, archive, and supersede ADR-style decision records (types are open: architecture, product, security, policy, legal, …) and keep INDEX.md and cross-links generated, via the bundled scripts/decisions.py tool. Use when creating or promoting decision drafts, superseding or archiving a decision, fixing a promotion breach, or running build, check, promote, rename-draft-id, or install.

Decision records

This skill owns the registry tool decisions.py, symlinked into the repo at scripts/decisions.py (run decisions.py install to set up the symlink + a CI check). The tool finds the repo's docs/ by walking up from the CWD, so run it from anywhere in the repo:

python scripts/decisions.py build [--relink]            # regenerate INDEX.md (+ refresh links)
python scripts/decisions.py check                       # validate (CI-safe; exit 1 if stale)
python scripts/decisions.py promote <name…> [--deref] [--allow-replace]   # draft(s) -> accepted/
python scripts/decisions.py rename-draft-id <name> <NEW>                  # re-ID a draft
python scripts/decisions.py install [repo]              # adopt in a repo: symlink + pre-commit

Layout

Everything the convention owns lives under docs/decisions/:

docs/
  decisions/              # the convention's namespace (the umbrella)
    INDEX.md              # GENERATED registry over accepted/ + archived/
    README.md             # human guide to the convention (scaffolded by install)
    AGENTS.md             # agent rules: decisions are binding here (scaffolded by install)
    accepted/<type>/      # ACCEPTED numbered records; <type> = any lowercase slug you define
    archived/             # RETIRED records (superseded | deprecated) — flat
    drafts/               # WIP candidates — flat, 4-UPPERCASE-letter IDs, NOT in INDEX
  threat-model.md         # other repo docs stay siblings — still cross-reference decisions
StageDirIDStatus
candidate (WIP)drafts/4 UPPERCASE letters, mnemonic (CONF)draft
decisionaccepted/<type>/global counter (0001…)accepted
retiredarchived/(keeps its counter)superseded / deprecated

Types are open<type> is any lowercase slug, and your accepted/<type>/ subdirs are the set (software: architecture, product, security; governance: policy, legal, finance, people, compliance, operations). A new type's directory is created on promotion. The tool enforces that a decision sits in the subdir matching its type — not a fixed list.

There is no proposed status — "proposing" is the act of opening a PR that promotes a draft. Mint a draft ID yourself (a mnemonic of the topic); check enforces format + uniqueness. Cross-reference by writing the bare ID as inline code — `0006` (decision) or `CONF` (draft); never hand-author a path — build --relink generates and self-heals every link across every docs/*.md (records, drafts, and other docs like threat-model.md).

Promoting drafts

Promotion requires explicit human sign-off. Promoting is a finalizing, semi-irreversible act (accepted records are held firm — changing course requires supersession, never a rewrite; decider-approved maintenance edits such as clarity, staleness, and cross-record consistency are allowed). Author, edit, and validate drafts freely; but never run promote — or its downstream steps (replacing naming placeholders, resolving threads, regenerating INDEX.md) — without the user's explicit go-ahead in the current turn. Don't infer approval from an adjacent choice (a scope answer, a cleared checklist); when unsure, ask.

An accepted decision may never reference a draft. promote enforces this: it refuses a set that would breach and prints exactly how to fix it (co-promote, --deref, or --allow-replace) with a copy-paste prompt. Before any promotion the tool refuses — or any supersession — read references/promotion.md for the mechanics.

Adopting this in a repo

Run this skill's decisions.py install [repo] from the target repo. (repo defaults to the current dir; install sets up there — it does not search upward.) It is idempotent: it fills in only what's missing and regenerates INDEX.md. What it does:

  • Symlinks <repo>/scripts/decisions.py to this skill's copy, and gitignores that path (creating .gitignore if absent) — the symlink is machine-specific, so each clone recreates it with install rather than committing it.
  • Scaffolds docs/decisions/: accepted/, archived/, drafts/, the two record templates, a human README.md, and an agent-facing AGENTS.md.
  • Generates INDEX.md (a build artifact, not a starter).
  • Wires the root entry points — when the repo has no root README.md or AGENTS.md (a fresh or empty repo), creates each as a placeholder linking the scaffold so people and agents discover it. An existing file is left untouched (see below).
  • In a git repo, adds a pre-commit hook running decisions.py check; run that same command in CI.

If the repo already has these entry points, install leaves them alone — wire the scaffold in yourself so people and agents discover it:

  • Link the scaffolded docs/decisions/README.md from the repo's contributor-facing docs — CONTRIBUTING.md, or the README.md only if it addresses contributors (skip a user-facing README) — and point contributors at docs/decisions/INDEX.md to browse the accepted decisions.
  • In the repo's root AGENTS.md/CLAUDE.md, link docs/decisions/AGENTS.md so agents pick up that decisions are binding here.

Keep each link to a one-line note on what it is.

附带文件

references/promotion.md
# Promoting drafts — mechanics

Promotion turns a draft into an accepted decision. **An accepted decision must never reference a
draft** (a breach). When promoting a draft that references other drafts, each reference to a draft
*outside the promoted set* is one of:

| Reference                      | Direction | Resolution                                                                                   |
| :----------------------------- | :-------- | :------------------------------------------------------------------------------------------- |
| `relates_to` (front-matter)    | symmetric | **dereference** — drop here; the referenced draft gains your new counter in its `relates_to` |
| `superseded_by` (front-matter) | forward   | **dereference** — drop here; the referenced draft gains your new counter in its `supersedes` |
| `supersedes` → a draft         | backward  | **blocking** — promote that draft first                                                      |
| prose `` `DRFT` `` in the body | content   | **blocking** — promote that draft first                                                      |

`supersedes` → an existing **decision** is a real supersession, gated behind `--allow-replace`.

## What `promote` does

`promote <name…>` is advisory — it never silently promotes extras or rewrites refs:

| Situation                             | Result                                                                                                            |
| :------------------------------------ | :---------------------------------------------------------------------------------------------------------------- |
| self-contained                        | promotes it                                                                                                       |
| only **dereferenceable** refs         | refuses; re-run `promote <name…> --deref` (shows the moves)                                                       |
| any **blocking** ref                  | refuses; prints the minimal blocking set to co-promote (your draft highlighted) **and a copy-paste agent prompt** |
| `supersedes` an existing **decision** | refuses; re-run `promote <name…> --allow-replace`                                                                 |

- `promote <name…> --deref` inverts the dereferenceable edges, then promotes — refused if any
  blocking ref is present.
- `promote <name…> --allow-replace` confirms archiving the decisions the draft `supersedes` (the IDs
  are shown in the preview, so the flag is just intent).
- Promote several at once, space- or comma-separated. Refs *within* the set become counter↔counter
  automatically.

**Invariant:** after any promotion, everything the promoted record pointed at refers back to it —
via the inverted edge (`--deref`) or a counter rewrite (co-promoted).
requirements-dev.txt
# Test deps for the decision-records skill. CI always installs pytest; the tool
# itself is dependency-free (standard library only).
pytest
scripts/decisions.py
#!/usr/bin/env python3
"""
Decision-records registry tool — generate docs/decisions/INDEX.md and the cross-reference
path links across the docs tree, validate it, mint draft ids, and promote a draft
into a numbered decision.

Repository layout — everything the convention owns lives under docs/decisions/:

    docs/
      decisions/                    # the convention's namespace (the umbrella)
        INDEX.md                    # GENERATED registry over accepted/ + archived/
        README.md                   # human guide to the convention (scaffolded by install)
        AGENTS.md                   # agent rules: decisions are binding (scaffolded by install)
        _template.md                # decision-record template (numbered)
        accepted/                   # ACCEPTED numbered decisions
          architecture/  product/  security/   # one subdir per type (alphabetical)
        archived/                   # RETIRED numbered decisions (superseded | deprecated) — flat
        drafts/                     # WIP candidates — flat, 4-UPPERCASE-letter ids, NOT in INDEX
          _template.md
      threat-model.md               # other repo docs stay siblings; still cross-ref decisions

Lifecycle (there is NO "proposed" status — proposing is the *act* of opening a PR):
  decisions/drafts/<AAAA-title>.md
    --(promote: a PR assigns the next counter)--> decisions/accepted/<type>/NNNN-title.md (accepted)
  decisions/accepted/<type>/NNNN   --(supersede/deprecate)-->  decisions/archived/NNNN-title.md

Conventions this tool encodes and enforces:
  * Identity is the global counter ID (`0001`, …) for decisions; a 4-UPPERCASE-letter
    ID (`CONF`) for drafts — mint a mnemonic of the draft's topic; `check` enforces
    format + uniqueness. Permanent and canonical; the only thing cross-refs use.
  * `type` is any lowercase slug — the set is OPEN; your accepted/<type>/ subdirs are the
    suggested set (architecture/product/security, or policy/legal/finance for governance).
    It lives in front-matter and, for a decision, equals its directory. `status` -> lifecycle.
  * Cross-reference by writing the bare ID as inline code — `0006` or `CONF`. NEVER
    hand-author a path. `build --relink` rewrites every such ID (in every docs/*.md —
    records, drafts, and other docs) into a correct relative link and self-heals on moves.
  * An accepted decision may NEVER reference a draft (a breach); `promote` therefore
    promotes a whole reference-closure together and refuses a set that would breach.
  * INDEX.md and every path link are GENERATED build artifacts.

Dependency-free (no PyYAML). Usage (a bare invocation = `build`; draft IDs are 4 UPPERCASE letters):
    python scripts/decisions.py build                  # write docs/decisions/INDEX.md
    python scripts/decisions.py build --relink         # also refresh path links everywhere
    python scripts/decisions.py check                  # validate only; exit 1 if stale (CI)
    python scripts/decisions.py rename-draft-id <name> <NEW>   # re-ID a draft, repoint refs
    python scripts/decisions.py promote CONF [TIER ...]        # promote draft(s) -> accepted/
    python scripts/decisions.py promote CONF --deref           # invert refs, promote alone
    python scripts/decisions.py promote CONF --allow-replace   # also archive what it supersedes
    python scripts/decisions.py install [repo]                 # adopt: symlink + scaffold + check
"""

from __future__ import annotations

import argparse
import difflib
import os
import re
import sys
from pathlib import Path


def find_docs(start: Path | None = None) -> Path:
    """Locate the repo's docs/ by walking up from the CWD (not from __file__ — the
    script may be a symlink installed by the decision-records skill). Falls back to a
    path relative to this file for the unusual case of no match."""
    cur = (start or Path.cwd()).resolve()
    for p in (cur, *cur.parents):
        if (p / "docs" / "decisions").is_dir():
            return p / "docs"
    return Path(__file__).resolve().parent.parent / "docs"


# A type is any lowercase slug — the set is OPEN. The repo's accepted/<type>/ subdirs are
# the suggested set (e.g. architecture, product, security; or policy, legal, finance,
# people, compliance, operations for governance repos). New types create their dir on promotion.
TYPE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
ACTIVE_STATUS = {"accepted"}  # -> decisions/accepted/
RETIRED_STATUS = {"superseded", "deprecated"}  # -> decisions/archived/

BLOCK_SCALAR_RE = re.compile(r"^([>|])[0-9+-]*$")  # folded (>) / literal (|), chomp/indent mods

RECORD_RE = re.compile(r"^\d{4}-.+\.md$")  # NNNN-kebab-title.md (decisions, archived)
DRAFT_ID_RE = re.compile(r"^[A-Z]{4}$")  # 4-uppercase-letter draft ID
# An inline-code ID — a counter (`0006`) or a draft tag (`CONF`) — not already linked.
ID_RE = re.compile(r"(?<!\[)`(\d{4}|[A-Z]{4})`")
COUNTER_RE = re.compile(r"(?<!\[)`(\d{4})`")  # numeric-only, for prose validation
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
LABEL_ID_RE = re.compile(r"`?(\d{4}|[A-Z]{4})`?$")  # link label that is just an ID
FILE_ID_RE = re.compile(r"^(\d{4}|[A-Z]{4})-")  # leading ID in a filename
STATUS_ICON = {"accepted": "🟢", "deprecated": "⚪", "superseded": "🔵"}

FENCE_RE = re.compile(r"^\s*(```|~~~)")
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")


def strip_fences(text: str) -> str:
    """Blank out fenced code blocks, preserving line structure."""
    out: list[str] = []
    fence = None
    for line in text.splitlines():
        m = FENCE_RE.match(line)
        if fence is not None:
            if m and m.group(1) == fence:
                fence = None
            out.append("")
        elif m:
            fence = m.group(1)
            out.append("")
        else:
            out.append(line)
    return "\n".join(out)


def strip_code(text: str) -> str:
    """Blank out fenced blocks and inline code spans — a link inside code is syntax on
    display, not a live link, so it must not be link-checked."""
    text = strip_fences(text)
    text = re.sub(r"``[^`]*``", "", text)
    return re.sub(r"`[^`\n]*`", "", text)


def heading_anchors(text: str) -> set[str]:
    """GitHub-style anchor slugs for every heading, -N suffixes for duplicates."""
    slugs: set[str] = set()
    seen: dict[str, int] = {}
    for line in strip_fences(text).splitlines():
        m = HEADING_RE.match(line)
        if not m:
            continue
        s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", m.group(2))  # links -> label
        s = s.replace("`", "").strip().lower()
        s = re.sub(r"[^\w\- ]", "", s).replace(" ", "-")
        n = seen.get(s, 0)
        seen[s] = n + 1
        slugs.add(s if n == 0 else f"{s}-{n}")
    return slugs


# ── layout ──────────────────────────────────────────────────────────────────
# Everything the convention owns nests under docs/decisions/ (the umbrella) so the three
# lifecycle dirs and the INDEX never collide with the repo's other docs/. `root` is docs/;
# other repo docs (threat-model.md, …) stay its direct children and keep their auto-links.
def decisions_dir(root: Path) -> Path:
    return root / "decisions"


def accepted_dir(root: Path) -> Path:
    return root / "decisions" / "accepted"


def archived_dir(root: Path) -> Path:
    return root / "decisions" / "archived"


def drafts_dir(root: Path) -> Path:
    return root / "decisions" / "drafts"


def index_path(root: Path) -> Path:
    return root / "decisions" / "INDEX.md"


# ── parsing ─────────────────────────────────────────────────────────────────
def _consume_block_scalar(
    lines: list[str], start: int, key_indent: int, folded: bool
) -> tuple[str, int]:
    """Consume a YAML block scalar (folded `>` / literal `|`) whose `key:` line sits at
    `key_indent`. Continuation lines are those indented past `key_indent`; the first
    such line sets the block's indentation, which is stripped from every line. Returns
    (joined text, index of the first line after the block)."""
    block: list[str] = []
    indent: int | None = None
    idx = start
    while idx < len(lines):
        raw = lines[idx]
        if not raw.strip():
            block.append("")
            idx += 1
            continue
        cur_indent = len(raw) - len(raw.lstrip(" "))
        if cur_indent <= key_indent:
            break
        if indent is None:
            indent = cur_indent
        block.append(raw[indent:])
        idx += 1
    while block and block[-1] == "":
        block.pop()
    if not folded:
        return "\n".join(block), idx
    paragraphs, para = [], []
    for line in block:
        if line == "":
            if para:
                paragraphs.append(" ".join(para))
                para = []
        else:
            para.append(line)
    if para:
        paragraphs.append(" ".join(para))
    return "\n\n".join(paragraphs), idx


def parse_front_matter(text: str) -> dict:
    if not text.startswith("---"):
        raise ValueError("missing front-matter")
    end = text.index("\n---", 3)
    lines = text[3:end].splitlines()
    data: dict = {}
    idx = 0
    while idx < len(lines):
        raw = lines[idx]
        line = raw.split(" #", 1)[0].rstrip()
        if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
            idx += 1
            continue
        key, _, val = line.partition(":")
        key, val = key.strip(), val.strip()
        block = BLOCK_SCALAR_RE.match(val)
        if block:
            key_indent = len(raw) - len(raw.lstrip(" "))
            folded = block.group(1) == ">"
            data[key], idx = _consume_block_scalar(lines, idx + 1, key_indent, folded)
            continue
        if val in ("", "null", "~"):
            data[key] = None if val in ("null", "~") else ""
        elif val.startswith("[") and val.endswith("]"):
            inner = val[1:-1].strip()
            data[key] = [i.strip().strip('"').strip("'") for i in inner.split(",") if i.strip()]
        else:
            data[key] = val.strip('"').strip("'")
        idx += 1
    return data


def front_matter_errors(recs: list[dict], drafts: list[dict]) -> list[str]:
    """Plain-scalar YAML hazards that a spec-compliant parser (e.g. GitHub's renderer)
    rejects but the tolerant parser above accepts — chiefly an unquoted ": " inside a
    value, which YAML reads as a nested mapping and errors on mid-scalar."""
    errs = []
    for r in recs + drafts:
        path = r["_path"]
        text = path.read_text(encoding="utf-8")
        if not text.startswith("---"):
            continue
        lines = text[3 : text.index("\n---", 3)].splitlines()
        idx = 0
        while idx < len(lines):
            raw = lines[idx]
            line = raw.split(" #", 1)[0].rstrip()
            idx += 1
            if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
                continue
            key, _, val = line.partition(":")
            key, val = key.strip(), val.strip()
            if BLOCK_SCALAR_RE.match(val):  # block scalars may contain anything
                key_indent = len(raw) - len(raw.lstrip(" "))
                while idx < len(lines):
                    nxt = lines[idx]
                    if nxt.strip() and (len(nxt) - len(nxt.lstrip(" "))) <= key_indent:
                        break
                    idx += 1
                continue
            if not val or val in ("null", "~") or val[0] in "[{":
                continue
            if val[0] in "\"'":
                if len(val) < 2 or val[-1] != val[0]:
                    errs.append(f"{path.name}: front-matter '{key}' has an unbalanced quote")
                continue
            if ": " in val or val.endswith(":"):
                errs.append(
                    f"{path.name}: front-matter '{key}' has an unquoted ':' in its value —"
                    " strict YAML parsers (GitHub) reject this; quote the value or rephrase"
                )
    return errs


def split_front_matter(text: str) -> tuple[str, str]:
    """(head, body). Files without front-matter (e.g. threat-model.md) are all body."""
    if not text.startswith("---"):
        return "", text
    fence = text.index("\n---", 3)
    nl = text.index("\n", fence + 1)
    return text[: nl + 1], text[nl + 1 :]


def set_field(text: str, key: str, value: str) -> str:
    head, body = split_front_matter(text)
    line = f"{key}: {value}" if value != "" else f"{key}:"  # empty -> no trailing space
    pat = re.compile(rf"^{re.escape(key)}:.*$", re.M)
    if pat.search(head):
        head = pat.sub(lambda _: line, head, count=1)
    else:
        head = head[:-4] + line + "\n---\n"
    return head + body


def drop_field(text: str, key: str) -> str:
    head, body = split_front_matter(text)
    return re.sub(rf"^{re.escape(key)}:.*\n", "", head, flags=re.M) + body


# ── loading ─────────────────────────────────────────────────────────────────
def _meta(p: Path, **extra) -> dict:
    text = p.read_text(encoding="utf-8")
    fm = parse_front_matter(text)
    fm.update(_file=p.name, _path=p, _text=text, **extra)
    return fm


def load_records(root: Path) -> list[dict]:
    """Numbered records: accepted/<type>/*.md (typed) and archived/*.md (flat)."""
    recs = []
    for p in sorted(accepted_dir(root).rglob("*.md")):
        if RECORD_RE.match(p.name):
            recs.append(_meta(p, _lifecycle="decisions", _typedir=p.parent.name))
    base = archived_dir(root)
    if base.exists():
        for p in sorted(base.glob("*.md")):
            if RECORD_RE.match(p.name):
                recs.append(_meta(p, _lifecycle="archived", _typedir=None))
    return recs


def load_drafts(root: Path) -> list[dict]:
    base = drafts_dir(root)
    return (
        [_meta(p) for p in sorted(base.glob("*.md")) if not p.name.startswith("_")]
        if base.exists()
        else []
    )


def ref_map(recs: list[dict], drafts: list[dict]) -> dict:
    """ID -> path, for both counters and draft IDs (the universe of cross-ref targets)."""
    m = {r["id"]: r["_path"] for r in recs}
    for d in drafts:
        m.setdefault(d.get("id"), d["_path"])
    return m


def reference_targets(
    root: Path, recs: list[dict], drafts: list[dict]
) -> list[tuple[Path, str | None]]:
    """Every docs/*.md whose body carries generated cross-reference links — records,
    drafts, and any other doc (threat-model, roadmap, …). Pairs (path, own_id); own_id is
    the record/draft ID (so it doesn't self-link), else None."""
    own = {r["_path"]: r["id"] for r in recs}
    own.update({d["_path"]: d.get("id") for d in drafts})
    return [(p, own.get(p)) for p in _md_files(root)]


def next_counter(recs: list[dict]) -> str:
    nums = [int(r["id"]) for r in recs if str(r.get("id", "")).isdigit()]
    return f"{(max(nums) + 1) if nums else 1:04d}"


# ── links ───────────────────────────────────────────────────────────────────
def rel(target: Path, start: Path) -> str:
    return os.path.relpath(target, start).replace(os.sep, "/")


def relink(text: str, refs: dict, own_id: str | None, self_dir: Path) -> str:
    """Render inline-code IDs (counters and draft tags) as relative links, looked up
    from `refs`. Refresh existing links first (moves self-heal), then linkify bare
    ones. Front-matter untouched. Idempotent."""
    head, body = split_front_matter(text)

    def fix_existing(m: re.Match) -> str:
        lm = LABEL_ID_RE.fullmatch(m.group(1).strip())
        if lm and lm.group(1) in refs and lm.group(1) != own_id:
            rid = lm.group(1)
            return f"[`{rid}`]({rel(refs[rid], self_dir)})"
        return m.group(0)

    def add_link(m: re.Match) -> str:
        rid = m.group(1)
        if rid == own_id or rid not in refs:
            return m.group(0)
        return f"[`{rid}`]({rel(refs[rid], self_dir)})"

    body = LINK_RE.sub(fix_existing, body)
    body = ID_RE.sub(add_link, body)
    return head + body


def check_links(root: Path, recs: list[dict], drafts: list[dict], refs: dict) -> list[str]:
    errs = []
    files = [
        (p.name, p, p.read_text(encoding="utf-8")) for p, _ in reference_targets(root, recs, drafts)
    ]
    index = index_path(root)
    if index.exists():
        files.append((index.name, index, index.read_text(encoding="utf-8")))
    anchors_cache: dict[Path, set[str]] = {}

    def anchors_of(p: Path) -> set[str]:
        rp = p.resolve()
        if rp not in anchors_cache:
            anchors_cache[rp] = heading_anchors(rp.read_text(encoding="utf-8"))
        return anchors_cache[rp]

    for name, path, text in files:
        for _, target in LINK_RE.findall(strip_code(text)):
            t, _sep, frag = target.partition("#")
            t = t.strip()
            if re.match(r"[a-z][a-z0-9+.-]*://", t) or t.startswith("mailto:"):
                continue
            if "…" in target or " " in t:
                continue
            if not t and not frag:
                continue
            dest = path.parent / t if t else path
            if not dest.exists():
                errs.append(f"{name}: broken link -> {target}")
                continue
            if frag and dest.suffix == ".md" and frag not in anchors_of(dest):
                errs.append(f"{name}: broken anchor -> {target}")
                continue
            if not t:
                continue
            cm = FILE_ID_RE.match(os.path.basename(t))
            if cm:
                rid = cm.group(1)
                if rid not in refs:
                    errs.append(f"{name}: link to unknown ID {rid} ({target})")
                elif dest.resolve() != refs[rid].resolve():
                    errs.append(f"{name}: stale link for {rid} -> {target}")
    return errs


# ── validation ──────────────────────────────────────────────────────────────
def validate_records(recs: list[dict], refs: dict) -> list[str]:
    errs, ids = [], {}
    for r in recs:
        rid = r.get("id")
        if rid in ids:
            errs.append(f"duplicate ID {rid}: {r['_file']} and {ids[rid]['_file']}")
        ids[rid] = r
        summary = r.get("summary")
        if summary and BLOCK_SCALAR_RE.match(str(summary).strip()):
            errs.append(f"{r['_file']}: summary is an unresolved YAML scalar indicator {summary!r}")
        if r["_lifecycle"] == "decisions":
            if not TYPE_RE.match(str(r.get("type") or "")):
                errs.append(f"{r['_file']}: type {r.get('type')!r} must be a lowercase slug")
            elif r["_typedir"] != r.get("type"):
                errs.append(
                    f"{r['_file']}: in accepted/{r['_typedir']}/ but type is {r.get('type')}"
                )
            if r.get("status") not in ACTIVE_STATUS:
                errs.append(
                    f"{r['_file']}: in accepted/ but status {r.get('status')} is not accepted"
                )
        elif r.get("status") not in RETIRED_STATUS:
            errs.append(f"{r['_file']}: in archived/ but status {r.get('status')} is not retired")
    nums = sorted(int(r["id"]) for r in recs if str(r.get("id", "")).isdigit())
    if nums:
        missing = sorted(set(range(1, nums[-1] + 1)) - set(nums))
        if missing:
            errs.append("gap in counters — missing " + ", ".join(f"{n:04d}" for n in missing))
    for r in recs:
        for ref in (r.get("relates_to") or []) + [r.get("supersedes"), r.get("superseded_by")]:
            if ref and ref not in refs:
                errs.append(f"{r['_file']}: dangling reference {ref}")
    return errs


def validate_drafts(root: Path, refs: dict, drafts: list[dict]) -> list[str]:
    errs, seen = [], {}
    for d in drafts:
        did = d.get("id")
        if not did or not DRAFT_ID_RE.match(str(did)):
            errs.append(f"drafts/{d['_file']}: ID must be 4 UPPERCASE letters (got {did!r})")
        elif did in seen:
            errs.append(f"drafts/{d['_file']}: duplicate draft ID {did} (also {seen[did]})")
        else:
            seen[did] = d["_file"]
        if not TYPE_RE.match(str(d.get("type") or "")):
            errs.append(f"drafts/{d['_file']}: type {d.get('type')!r} must be a lowercase slug")
        for ref in (d.get("relates_to") or []) + [d.get("supersedes"), d.get("superseded_by")]:
            if ref and ref not in refs:
                errs.append(f"drafts/{d['_file']}: references unknown ID {ref}")
    return errs


def warn_unknown_types(root: Path, drafts: list[dict]) -> list[str]:
    """Non-blocking: a draft whose type has no accepted/<type>/ dir yet — likely a typo,
    or a deliberately new type (its dir is created on promotion)."""
    base = accepted_dir(root)
    known = {p.name for p in base.iterdir() if p.is_dir()} if base.exists() else set()
    return [
        f"WARN drafts/{d['_file']}: new type {d['type']!r} — no accepted/{d['type']}/ yet "
        "(typo? otherwise it's created on promotion)"
        for d in drafts
        if TYPE_RE.match(str(d.get("type") or "")) and d.get("type") not in known
    ]


def draft_references(d: dict, draft_ids: set) -> set:
    """The draft IDs this record points at — and that would survive into a decision:
    front-matter relates_to/supersedes/superseded_by, plus body inline-code IDs whether
    bare (`CONF`) or already markdown-linked ([`CONF`](...)). Linked refs are counted too,
    so a relinked decision body cannot smuggle a draft reference past the breach check."""
    refs = set(d.get("relates_to") or [])
    for k in ("supersedes", "superseded_by"):
        if d.get(k):
            refs.add(d[k])
    _, body = split_front_matter(d["_text"])
    refs |= set(ID_RE.findall(body))  # bare inline-code IDs
    for label, _ in LINK_RE.findall(body):  # ...and markdown-linked IDs
        m = LABEL_ID_RE.fullmatch(label.strip())
        if m:
            refs.add(m.group(1))
    return refs & draft_ids


def validate_no_breach(recs: list[dict], drafts: list[dict]) -> list[str]:
    """An accepted/retired decision must NOT reference a draft — that would bind a
    finalized record to unfinalized WIP. Promote the draft first."""
    draft_ids = {d.get("id") for d in drafts}
    errs = []
    for r in recs:
        for b in sorted(draft_references(r, draft_ids)):
            errs.append(f"{r['_file']}: decision references draft {b} (breach — promote {b} first)")
    return errs


def prose_unknown_counters(
    root: Path, recs: list[dict], drafts: list[dict], refs: dict
) -> list[str]:
    """Un-linkified numeric counters in prose must resolve (4-letter tokens are not
    flagged — they are ordinary words unless they match a known draft ID)."""
    errs = []
    for path, own in reference_targets(root, recs, drafts):
        _, body = split_front_matter(path.read_text(encoding="utf-8"))
        for cid in COUNTER_RE.findall(body):
            if cid != own and cid not in refs:
                errs.append(f"{path.name}: prose references unknown record {cid}")
    return errs


# ── render ──────────────────────────────────────────────────────────────────
def render_index(recs: list[dict], root: Path) -> str:
    recs = sorted(recs, key=lambda r: r["id"])
    base = decisions_dir(root)  # INDEX.md lives here; links are relative to it
    rows = (
        "\n".join(
            f"| [{r['id']}]({rel(r['_path'], base)}) | {r.get('type', '')} | {r.get('summary', '')}"
            f" | {STATUS_ICON.get(r.get('status', ''), '—')} | {', '.join(r.get('tags') or [])} |"
            for r in recs
        )
        or "| _none_ | | _no decisions yet — promote a draft_ | | |"
    )
    return f"""# Decision Records — Index

<!-- GENERATED by scripts/decisions.py — do not edit by hand. -->

**Agents read this file first**, then load only the records relevant to the task
(filter on `type`, `status`, `tags`). Skip `archived/` (`superseded`/`deprecated`) unless
tracing why a decision changed. Records are held firm once `accepted` — course changes only by
supersession; decider-approved maintenance edits are allowed.

## Identity & references
- Identity is the global counter (`0001`, …), assigned in creation order. Permanent;
  the only thing a human writes to cross-reference. Drafts use a 4-UPPERCASE-letter ID
  instead — a mnemonic of the draft's topic.
- Reference by ID. Path links — each ID rendered as a markdown link to its file — are
  **generated** by `scripts/decisions.py build --relink` and refreshed on every move,
  so they stay correct; never **hand-author** a path.
- `type` is an open lowercase slug — the `accepted/<type>/` subdirs are the set (e.g.
  architecture, product, security; or policy, legal, finance). For a decision it equals its
  directory and `status` selects the lifecycle dir; the tool enforces placement, not a fixed list.

## Status: 🟢 accepted · ⚪ deprecated · 🔵 superseded

| ID | Type | Summary | Status | Tags |
|:---|:-----|:--------|:------:|:-----|
{rows}

## Layout
Everything lives under `docs/decisions/`:

- `accepted/<type>/`: accepted numbered records, one subdir per type.
- `archived/`: retired records (`superseded`/`deprecated`), flat.
- `drafts/`: 4-letter WIP candidates, flat and not indexed.

Moving a record never breaks references, whether you reclassify it or retire it into `archived/`.
Records are keyed by ID, not path, and `build --relink` refreshes the generated links.
"""


# ── promote / rename ────────────────────────────────────────────────────────
def _md_files(root: Path) -> list[Path]:
    """Every markdown file under docs/ whose links the tool manages — all `*.md` EXCEPT
    template files (`_*.md`) and the generated `INDEX.md`."""
    return [
        p for p in sorted(root.rglob("*.md")) if not p.name.startswith("_") and p.name != "INDEX.md"
    ]


def rewrite_reference(root: Path, old: str, new: str) -> None:
    """Repoint every reference from ID `old` to ID `new` across the tree — front-matter
    ref fields (de-duplicated) and inline-code body refs. Used when promotion changes a
    draft's ID so inbound references don't dangle. `build --relink` then re-paths them."""
    link = re.compile(r"\[`" + re.escape(old) + r"`\]\([^)]*\)")
    bare = re.compile(r"`" + re.escape(old) + r"`")

    def fix_field(m: re.Match) -> str:
        key, val = m.group(1), m.group(2).strip()
        if val.startswith("[") and val.endswith("]"):
            out: list[str] = []
            for i in (x.strip().strip('"').strip("'") for x in val[1:-1].split(",") if x.strip()):
                i = new if i == old else i
                if i not in out:
                    out.append(i)
            return f"{key} [" + ", ".join(f'"{x}"' for x in out) + "]"
        return f"{key} " + (f'"{new}"' if val.strip('"').strip("'") == old else val)

    for p in _md_files(root):
        t = p.read_text(encoding="utf-8")
        head, body = split_front_matter(t)
        head = re.sub(
            r"^(relates_to:|supersedes:|superseded_by:)(.*)$", fix_field, head, flags=re.M
        )
        body = bare.sub(f"`{new}`", link.sub(f"`{new}`", body))
        if head + body != t:
            p.write_text(head + body, encoding="utf-8")


def match_drafts(root: Path, query: str) -> list[Path]:
    drafts = (
        [p for p in sorted(drafts_dir(root).glob("*.md")) if not p.name.startswith("_")]
        if drafts_dir(root).exists()
        else []
    )
    q = query.lower().strip()
    for p in drafts:  # an exact draft-ID match wins outright (no substring noise)
        if q == str(parse_front_matter(p.read_text(encoding="utf-8")).get("id", "")).lower():
            return [p]
    hits = []
    for p in drafts:
        fm = parse_front_matter(p.read_text(encoding="utf-8"))
        hay = f"{fm.get('id', '')} {fm.get('title', '')} {p.stem}".lower()
        if q in hay:
            hits.append(p)
    if not hits:
        names = {p.stem.lower(): p for p in drafts}
        hits = [names[m] for m in difflib.get_close_matches(q, list(names), n=5, cutoff=0.4)]
    seen, out = set(), []
    for p in hits:
        if p not in seen:
            seen.add(p)
            out.append(p)
    return out


def select_draft(root: Path, query: str, input_fn=input) -> tuple[Path | None, str | None]:
    """Resolve a draft from a fuzzy query, prompting to disambiguate if several match."""
    matches = match_drafts(root, query)
    if not matches:
        return None, f"no draft matches {query!r}"
    if len(matches) == 1:
        return matches[0], None
    for i, p in enumerate(matches, 1):
        print(f"  [{i}] {p.name}", file=sys.stderr)
    try:
        raw = input_fn(f"pick a candidate [1-{len(matches)}]: ").strip()
    except EOFError:
        return None, "ambiguous match and no input — pass the exact draft ID"
    if not raw.isdigit() or not (1 <= int(raw) <= len(matches)):
        return None, "no candidate selected"
    return matches[int(raw) - 1], None


def add_to_list_field(text: str, key: str, value: str) -> str:
    head, _ = split_front_matter(text)
    m = re.search(rf"^{key}:(.*)$", head, re.M)
    items: list[str] = []
    if m:
        cur = m.group(1).strip()
        if cur.startswith("[") and cur.endswith("]"):
            items = [x.strip().strip('"').strip("'") for x in cur[1:-1].split(",") if x.strip()]
        elif cur not in ("", "null", "~"):
            items = [cur.strip('"').strip("'")]
    if value not in items:
        items.append(value)
    return set_field(text, key, "[" + ", ".join(f'"{i}"' for i in items) + "]")


def remove_from_list_field(text: str, key: str, value: str) -> str:
    head, _ = split_front_matter(text)
    m = re.search(rf"^{key}:(.*)$", head, re.M)
    if not m:
        return text
    cur = m.group(1).strip()
    if cur.startswith("[") and cur.endswith("]"):
        items = [x.strip().strip('"').strip("'") for x in cur[1:-1].split(",") if x.strip()]
        items = [i for i in items if i != value]
        return set_field(text, key, "[" + ", ".join(f'"{i}"' for i in items) + "]")
    if cur.strip('"').strip("'") == value:
        return set_field(text, key, "")  # blank, not null (mdformat-stable; reads the same)
    return text


def classify_refs(d: dict, draft_ids: set, decision_ids: set, in_set: set) -> dict:
    """Bucket a draft's references to things OUTSIDE the promote set:
    deref          - front-matter relates_to/superseded_by to a draft (invertible)
    block          - `supersedes` to a draft, or a prose `DRFT` ref (can't invert)
    supersedes_dec - `supersedes` to an existing decision (the --allow-replace case)"""
    sup = d.get("supersedes")
    relates = (set(d.get("relates_to") or []) & draft_ids) - in_set
    superby = (({d["superseded_by"]} if d.get("superseded_by") else set()) & draft_ids) - in_set
    supdraft = (({sup} if sup else set()) & draft_ids) - in_set
    _, body = split_front_matter(d["_text"])
    prose = (set(ID_RE.findall(body)) & draft_ids) - in_set
    block = supdraft | prose
    return {
        "deref": (relates | superby) - block,
        "block": block,
        "supersedes_dec": ({sup} & decision_ids) if sup else set(),
    }


def blocking_closure(by_id: dict, seeds: set, decision_ids: set) -> set:
    """Seeds + every draft reachable by BLOCKING edges — the minimal set that must be
    promoted together (dereferenceable edges no longer pull drafts in)."""
    draft_ids, seen, stack = set(by_id), set(seeds), list(seeds)
    while stack:
        for ref in classify_refs(by_id[stack.pop()], draft_ids, decision_ids, set())["block"]:
            if ref not in seen:
                seen.add(ref)
                stack.append(ref)
    return seen


def _highlight(ids: set, seeds: set) -> str:
    return "\n".join(f"  → {i}  (requested)" if i in seeds else f"    {i}" for i in sorted(ids))


def _blocking_message(bclosure: set, seeds: set, block: set) -> str:
    cmd = "python scripts/decisions.py promote " + " ".join(sorted(bclosure))
    prompt = (
        f"Promote draft(s) {', '.join(sorted(seeds))} with scripts/decisions.py. They are "
        f"blocked: as accepted decisions they would reference draft(s) "
        f"{', '.join(sorted(block))} via prose or `supersedes`, which cannot be "
        f"dereferenced. Either co-promote the whole set — `{cmd}` — or restructure the "
        f"draft(s) to cite only accepted decisions (counters). Then run "
        f"`python scripts/decisions.py check`."
    )
    return (
        "blocked: a promoted decision would reference draft(s) via prose or `supersedes`,\n"
        "which can't be dereferenced. Promote the whole blocking set together:\n"
        + _highlight(bclosure, seeds)
        + f"\n\nRun:\n  {cmd}\n"
        + "\nOr copy this prompt to an agent to fix it:\n"
        + "─" * 70
        + "\n"
        + prompt
        + "\n"
        + "─" * 70
    )


def _deref_message(deref: set, seeds: set) -> str:
    cmd = "python scripts/decisions.py promote " + " ".join(sorted(seeds)) + " --deref"
    return (
        "dereferenceable: the only cross-draft refs are front-matter "
        f"relates_to/superseded_by to {', '.join(sorted(deref))}.\n"
        "Re-run with --deref to move those links onto the referenced draft(s) (they point\n"
        f"back once promoted):\n  {cmd}"
    )


def _allow_replace_message(need: set, seeds: set) -> str:
    cmd = "python scripts/decisions.py promote " + " ".join(sorted(seeds)) + " --allow-replace"
    return (
        f"promoting {', '.join(sorted(seeds))} would supersede existing decision(s) "
        f"{', '.join(sorted(need))},\nwhich will be archived. Re-run with --allow-replace "
        f" to confirm:\n  {cmd}"
    )


def _do_deref(by_id: dict, seeds: set, mapping: dict) -> None:
    """Invert each dereferenceable edge: drop it from the promoted draft and record the
    draft's new counter on the referenced draft, restoring the link when that draft is
    later promoted. Mutates _text; writes the referenced (still-draft) files."""
    draft_ids, touched = set(by_id), set()
    for s in seeds:
        d, nid = by_id[s], mapping[s]
        for t in (set(d.get("relates_to") or []) & draft_ids) - seeds:
            d["_text"] = remove_from_list_field(d["_text"], "relates_to", t)
            by_id[t]["_text"] = add_to_list_field(by_id[t]["_text"], "relates_to", nid)
            touched.add(t)
        if d.get("superseded_by") in draft_ids and d.get("superseded_by") not in seeds:
            t = d["superseded_by"]
            d["_text"] = set_field(d["_text"], "superseded_by", "")  # blank, not null
            by_id[t]["_text"] = set_field(by_id[t]["_text"], "supersedes", f'"{nid}"')
            touched.add(t)
    for t in touched:
        by_id[t]["_path"].write_text(by_id[t]["_text"], encoding="utf-8")


def promote(
    root: Path, queries: list[str], deref: bool = False, allow_replace: bool = False, input_fn=input
) -> tuple[list[Path] | None, str | None]:
    """Promote draft(s) into decisions/ as the next counters. Refuses with actionable
    guidance when the set isn't self-contained; `--deref` inverts front-matter edges so a
    draft promotes alone; `--allow-replace` confirms archiving the decisions it supersedes."""
    recs = load_records(root)
    by_id = {d["id"]: d for d in load_drafts(root)}
    decision_ids = {r["id"] for r in recs}

    seeds = set()
    for q in queries:
        src, err = select_draft(root, q, input_fn)
        if err:
            return None, err
        seeds.add(parse_front_matter(src.read_text(encoding="utf-8")).get("id"))
    bad = [by_id[i]["_file"] for i in seeds if not TYPE_RE.match(str(by_id[i].get("type") or ""))]
    if bad:
        return None, f"invalid type in {', '.join(bad)}"

    deref_t, block_t, supersedes_dec = set(), set(), set()
    for s in seeds:
        c = classify_refs(by_id[s], set(by_id), decision_ids, seeds)
        deref_t |= c["deref"]
        block_t |= c["block"]
        supersedes_dec |= c["supersedes_dec"]
    deref_t -= block_t

    if block_t:
        msg = _blocking_message(blocking_closure(by_id, seeds, decision_ids), seeds, block_t)
        if deref:
            msg = (
                f"--deref can't proceed — blocked by prose/`supersedes` refs to "
                f"{', '.join(sorted(block_t))}.\n" + msg
            )
        return None, msg
    if supersedes_dec and not allow_replace:
        return None, _allow_replace_message(supersedes_dec, seeds)
    if deref_t and not deref:
        return None, _deref_message(deref_t, seeds)

    base = int(next_counter(recs))
    mapping = {did: f"{base + i:04d}" for i, did in enumerate(sorted(seeds))}
    if deref:
        _do_deref(by_id, seeds, mapping)

    dests = []
    for did, nid in mapping.items():
        d = by_id[did]
        slug = re.sub(r"^[A-Z]{4}-", "", d["_path"].stem)
        dest = accepted_dir(root) / d["type"] / f"{nid}-{slug}.md"
        text = set_field(d["_text"], "id", f'"{nid}"')
        text = set_field(text, "status", "accepted")  # the PR proposes; merge accepts
        for f in ("change_kind", "author"):  # draft-only fields
            text = drop_field(text, f)
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_text(text, encoding="utf-8")
        d["_path"].unlink()
        dests.append(dest)
    for old, new in mapping.items():  # repoint refs (intra-set -> counters; inbound too)
        rewrite_reference(root, old, new)

    for s in seeds:  # --allow-replace: archive each decision a promoted draft supersedes
        sup = by_id[s].get("supersedes")
        if allow_replace and sup in supersedes_dec:
            r = next(x for x in recs if x["id"] == sup)
            text = set_field(
                set_field(r["_text"], "status", "superseded"), "superseded_by", f'"{mapping[s]}"'
            )
            archived_dir(root).mkdir(parents=True, exist_ok=True)
            (archived_dir(root) / r["_file"]).write_text(text, encoding="utf-8")
            r["_path"].unlink()
    return dests, None


def rename_draft(
    root: Path, query: str, new_id: str, input_fn=input
) -> tuple[Path | None, str | None]:
    """Change a draft's 4-letter ID (e.g. a random one -> a mnemonic), renaming the
    file and repointing every inbound reference. Counters (decisions) are immutable."""
    new = new_id.upper()
    if not DRAFT_ID_RE.match(new):
        return None, f"{new_id!r} is not a 4-letter ID (A-Z)"
    if new in {str(d.get("id", "")).upper() for d in load_drafts(root)}:
        return None, f"draft ID {new} is already in use"
    src, err = select_draft(root, query, input_fn)
    if err:
        return None, err
    old = parse_front_matter(src.read_text(encoding="utf-8")).get("id")
    if old == new:
        return None, f"draft already has ID {new}"
    slug = re.sub(r"^[A-Z]{4}-", "", src.stem)
    dest = src.with_name(f"{new}-{slug}.md")
    text = set_field(src.read_text(encoding="utf-8"), "id", new)
    src.unlink()  # remove first (case-insensitive FS safe)
    dest.write_text(text, encoding="utf-8")
    rewrite_reference(root, old, new)
    return dest, None


def ensure_gitignored(repo: Path, pattern: str) -> None:
    """Add `pattern` to <repo>/.gitignore (creating the file if absent) unless already
    listed. The symlinked scripts/decisions.py is a machine-specific relative symlink into
    the skill's copy — each clone recreates it via `install`, so it must not be committed."""
    gi = repo / ".gitignore"
    existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
    if pattern in existing.splitlines():
        print(f".gitignore already ignores {pattern}")
        return
    comment = "# decision-records: machine-specific symlink — recreate via `decisions.py install`"
    head = existing if not existing or existing.endswith("\n") else existing + "\n"
    sep = "\n" if existing.strip() else ""
    gi.write_text(head + sep + comment + "\n" + pattern + "\n", encoding="utf-8")
    print(f"{'created' if not existing else 'updated'} .gitignore — ignoring {pattern}")


def wire_entry_point(repo: Path, name: str, body: str) -> None:
    """Create a root entry-point file (README.md / AGENTS.md) as a placeholder linking the
    scaffold, but only when it's MISSING — a fresh or empty repo. An existing file is left
    untouched: the agent adopting the skill wires the link into it instead (see SKILL.md)."""
    f = repo / name
    if f.exists():
        return
    f.write_text(body, encoding="utf-8")
    print(f"created {name} (placeholder linking the decision records)")


def install(repo: Path) -> None:
    """Set up the convention in a repo: symlink the tool (and gitignore that symlink),
    scaffold the docs/ skeleton, wire a root README.md/AGENTS.md when absent, and add a
    pre-commit `check` hook in a git repo. `repo` is the project root — install does NOT
    search upward; it sets up exactly where you point it (CLI default: the CWD). Idempotent:
    only creates what's missing, never overwrites. Run the first time via the skill's own copy
    (`python .../decision-records/scripts/decisions.py install`); the symlink works after."""
    canonical = Path(__file__).resolve()
    skill = canonical.parent.parent  # scripts/ -> skill dir
    repo = repo.resolve()
    scripts = repo / "scripts"
    scripts.mkdir(parents=True, exist_ok=True)
    link, rel = scripts / "decisions.py", os.path.relpath(canonical, scripts)
    if link.is_symlink() or link.exists():
        link.unlink()
    link.symlink_to(rel)
    print(f"symlinked scripts/decisions.py -> {rel}")
    ensure_gitignored(repo, "scripts/decisions.py")

    # scaffold docs/decisions/ (idempotent — never overwrites)
    docs = repo / "docs"
    for d in (accepted_dir(docs), archived_dir(docs), drafts_dir(docs)):
        d.mkdir(parents=True, exist_ok=True)
    for keep in (accepted_dir(docs) / ".gitkeep", archived_dir(docs) / ".gitkeep"):
        keep.exists() or keep.write_text("", encoding="utf-8")
    for src, dst in (
        (skill / "templates" / "README.md", decisions_dir(docs) / "README.md"),
        (skill / "templates" / "AGENTS.md", decisions_dir(docs) / "AGENTS.md"),
        (skill / "templates" / "_template.md", decisions_dir(docs) / "_template.md"),
        (skill / "templates" / "drafts" / "_template.md", drafts_dir(docs) / "_template.md"),
    ):
        if src.exists() and not dst.exists():
            dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
            print(f"created {dst.relative_to(repo)}")
    main(["build"], root=docs)  # INDEX.md is GENERATED, not scaffolded — build it

    # Wire the root entry points so people/agents discover the scaffold — only when missing
    # (a fresh/empty repo); an existing README/AGENTS is the adopter's to wire by hand.
    wire_entry_point(
        repo,
        "README.md",
        f"# {repo.name}\n\n"
        "<!-- TODO: describe this repo. -->\n\n"
        "## Decision records\n\n"
        "Significant decisions live under [`docs/decisions/`](docs/decisions/README.md) — "
        "browse the [decision index](docs/decisions/INDEX.md).\n",
    )
    wire_entry_point(
        repo,
        "AGENTS.md",
        f"# AGENTS.md — {repo.name}\n\n"
        "<!-- TODO: project-wide agent guidance. -->\n\n"
        "## Decision records\n\n"
        "Decisions under `docs/decisions/` are **binding** here — read "
        "[docs/decisions/AGENTS.md](docs/decisions/AGENTS.md) before changing what they cover.\n",
    )

    hook, line = (
        repo / ".git" / "hooks" / "pre-commit",
        "python scripts/decisions.py check || exit 1",
    )
    if (repo / ".git").is_dir():
        existing = hook.read_text(encoding="utf-8") if hook.exists() else ""
        if line in existing:
            print("pre-commit check already present")
        else:
            hook.write_text((existing or "#!/usr/bin/env bash\n") + line + "\n", encoding="utf-8")
            hook.chmod(0o755)
            print("installed pre-commit check")
    else:
        print("no .git here — add 'python scripts/decisions.py check' to CI")


# ── CLI ─────────────────────────────────────────────────────────────────────
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    raw = list(sys.argv[1:] if argv is None else argv)
    p = argparse.ArgumentParser(
        prog="decisions.py",
        description="Decision-records registry: build the INDEX, validate, rename/promote drafts.",
    )
    sub = p.add_subparsers(dest="cmd", metavar="{build,check,rename-draft-id,promote,install}")
    build = sub.add_parser(
        "build", help="write docs/decisions/INDEX.md (optionally relink everything)"
    )
    build.add_argument(
        "--relink",
        action="store_true",
        help="also refresh generated path links in every docs/*.md (records, drafts, docs)",
    )
    sub.add_parser("check", help="validate only; exit 1 if INDEX or any link is stale (CI-safe)")
    rn = sub.add_parser("rename-draft-id", help="change a draft's ID and repoint all references")
    rn.add_argument("query", help="draft ID, title, or filename fragment (fuzzy)")
    rn.add_argument("new", help="the new 4-letter ID (UPPERCASE)")
    pr = sub.add_parser(
        "promote", help="move one or more drafts into decisions/ as the next counters"
    )
    pr.add_argument(
        "query", nargs="+", help="one or more draft IDs/names (space- or comma-separated)"
    )
    pr.add_argument(
        "--deref",
        action="store_true",
        help="invert dereferenceable front-matter refs so a draft can promote alone",
    )
    pr.add_argument(
        "--allow-replace",
        dest="allow_replace",
        action="store_true",
        help="confirm archiving any decisions this promotion supersedes",
    )
    ins = sub.add_parser("install", help="symlink the tool into a repo + add a pre-commit check")
    ins.add_argument("repo", nargs="?", default=".", help="repo root (default: current dir)")
    known = ("build", "check", "rename-draft-id", "promote", "install", "-h", "--help")
    if not raw or raw[0] not in known:
        raw = ["build"] + raw
    return p.parse_args(raw)


def main(argv: list[str] | None = None, root: Path | None = None) -> int:
    args = parse_args(argv)
    if args.cmd == "install":
        install(Path(args.repo))
        return 0
    root = root or find_docs()
    index = index_path(root)

    if args.cmd == "rename-draft-id":
        dest, err = rename_draft(root, args.query, args.new)
        if err:
            print(err, file=sys.stderr)
            return 1
        print(f"renamed -> {dest.relative_to(root)}")
        main(["build", "--relink"], root=root)
        return 0

    if args.cmd == "promote":
        queries = [tok for q in args.query for tok in re.split(r"[,\s]+", q) if tok]
        dests, err = promote(root, queries, deref=args.deref, allow_replace=args.allow_replace)
        if err:
            print(err, file=sys.stderr)
            return 1
        for d in dests:
            print(f"promoted -> {d.relative_to(root)}")
        main(["build", "--relink"], root=root)
        print("finalize each record's front-matter (summary, supersedes/relates_to) in the PR")
        return 0

    recs = load_records(root)
    drafts = load_drafts(root)
    refs = ref_map(recs, drafts)
    for w in warn_unknown_types(root, drafts):  # non-blocking: typo / new-type heads-up
        print(w, file=sys.stderr)
    struct_errs = validate_records(recs, refs)
    out = render_index(recs, root)

    if args.cmd == "check":
        problems = list(struct_errs)
        problems += front_matter_errors(recs, drafts)
        problems += validate_no_breach(recs, drafts)
        problems += check_links(root, recs, drafts, refs)
        problems += validate_drafts(root, refs, drafts)
        problems += prose_unknown_counters(root, recs, drafts, refs)
        current = index.read_text(encoding="utf-8") if index.exists() else ""
        if current.strip() != out.strip():
            problems.append("INDEX.md is stale — run scripts/decisions.py build")
        stale = [
            p.name
            for p, own in reference_targets(root, recs, drafts)
            if relink(p.read_text(encoding="utf-8"), refs, own, p.parent)
            != p.read_text(encoding="utf-8")
        ]
        if stale:
            problems.append(
                "links stale — run scripts/decisions.py build --relink: " + ", ".join(stale)
            )
        if problems:
            print("\n".join(problems), file=sys.stderr)
            return 1
        return 0

    if struct_errs:
        print("\n".join(struct_errs), file=sys.stderr)
        return 1

    index.write_text(out, encoding="utf-8")
    print(f"wrote {index} ({len(recs)} records)")

    if getattr(args, "relink", False):
        n = 0
        for path, own in reference_targets(root, recs, drafts):
            text = path.read_text(encoding="utf-8")
            new = relink(text, refs, own, path.parent)
            if new != text:
                path.write_text(new, encoding="utf-8")
                print(f"relinked {path.name}")
                n += 1
        print(f"relinked {n} file(s)")
        residual = check_links(root, recs, drafts, refs)
        if residual:
            print("\n".join(residual), file=sys.stderr)
            return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
templates/_template.md
---
# Decision record — front-matter is machine-readable; keep it accurate.
# Filename:  NNNN-kebab-title.md  (the counter, then a kebab title — no type in the name).
# Identity   = the counter `id` below: permanent, the only thing cross-references use.
# Authoring, promotion, linking, and validation are the decision-records skill's job —
# this template is just the record's shape.
id: "0000"                        # global counter, quoted to keep the zero-pad
title: Short imperative title
type: architecture                # a lowercase slug = the directory this record lives in
status: accepted                  # accepted | deprecated | superseded
date: YYYY-MM-DD
deciders: [trung]
summary: One sentence describing the decision.
tags: []                          # e.g. [access-control, retrieval, orchestrator]
relates_to: []                    # related record IDs, e.g. ["0001", "0003"]
supersedes:                       # ID this replaces, or blank
superseded_by:                    # ID that replaces this, or blank
---

# {id} — {title}

## Context

What forces are at play? What problem or tension prompted this decision? State the constraints as
they actually were at decision time. Don't sanitize.

## Decision

The decision in one or two sentences, active voice ("We will …"). Binding once `accepted` — course
changes only by supersession.

## Rationale

Why this option over the others, given the context above.

## Alternatives considered

| Option | Why not |
| :----- | :------ |
| …      | …       |

## Consequences

**Positive**

- …

**Negative / accepted costs**

- …

**Risks & open questions**

- …

## References

- Threads, specs, external sources, and related records by ID (e.g. `0001`).
templates/AGENTS.md
# AGENTS.md — decision records

This file governs how an agent works with the **decision records** under `docs/decisions/`. These
records are **binding** for work in this repo.

The records live here; **how** to author, promote, link, and validate them is owned by the
**decision-records skill** (the `scripts/decisions.py` tool installed from it). The skill is
authoritative — when this file and the skill disagree, the skill wins. If the tool is missing,
install it before changing any record.

## Read order (every task)

1. This file.
1. `docs/decisions/INDEX.md` — the generated registry. Read it first, then load only the records
   whose `type`/`tags` match the task. **Skip `archived/` by default** — consult it only when
   tracing why a decision changed.
1. The specific record(s); their front-matter is authoritative.

## Decision governance — highest priority

1. **Review the relevant accepted records before acting** on anything they cover.
1. **If a request conflicts with an accepted record, STOP and surface it** — do not quietly proceed.
   Only flag real conflicts; stay silent when aligned.
1. **Never rewrite an accepted decision to change course — supersede it** with a new record that
   replaces the old. Refer to records by their counter (e.g. 0001), never by a hand-written path.

## Boundaries

- `accepted/<type>/` and `archived/` — the decision record. **Held firm once accepted**: changing
  course requires supersession, never a rewrite; decider-approved maintenance edits (clarity,
  staleness, cross-record consistency, typography) are allowed. Firm, not frozen.
- `drafts/` — work-in-progress candidates; mutable until promoted. Promotion is a finalizing step
  that needs explicit human sign-off, so never promote on your own initiative.
- Other `docs/*.md` — living docs that may cross-reference records by ID.
- `docs/decisions/INDEX.md` is **generated** — never hand-edit it; regenerate with
  `scripts/decisions.py build`.

## Working style

Be precise. Cross-reference before producing, and verify claims against the source-of-truth record,
not a summary. Keep the active decision set clean: one accepted record per decision, retired ones in
`archived/`.
templates/drafts/_template.md
---
# Draft — a decision candidate (WIP). Same shape as a decision record, so promotion is
# mechanical; it just carries a 4-letter ID and status: draft, and lives in drafts/.
# Filename:  AAAA-kebab-title.md  (AAAA = the 4 UPPERCASE letters of `id` below).
# Promotion, linking, and validation are the decision-records skill's job.
id: ABCD                          # REQUIRED: 4 UPPERCASE letters, a mnemonic of the topic
title: Short imperative title
type: architecture                # a lowercase slug = the directory it lands in on promotion
status: draft                     # draft | under-review
date: YYYY-MM-DD
deciders: [trung]
summary: One sentence describing the decision.
tags: []                          # e.g. [access-control, retrieval, orchestrator]
relates_to: []                    # related record IDs — counters and/or draft IDs
supersedes:                       # a decision this replaces, or blank
superseded_by:                    # ID that replaces this, or blank
---

# {id} — {title}

## Context

What forces are at play? What problem or tension prompts this decision? State the constraints as
they actually are. Don't sanitize.

## Decision

The proposed decision in one or two sentences, active voice ("We will …"). Binding once promoted and
`accepted` — course changes only by supersession.

## Rationale

Why this option over the others, given the context above.

## Alternatives considered

| Option | Why not |
| :----- | :------ |
| …      | …       |

## Consequences

**Positive**

- …

**Negative / accepted costs**

- …

**Risks & open questions**

- …

## References

- Threads, specs, external sources, and related records by ID (e.g. `0001`, `TIER`).
templates/README.md
# Decision records

This repo records significant decisions as short, numbered documents (ADR-style), held firm once
accepted. Everything lives under `docs/decisions/`, so nothing here is confused with the repo's
other docs.

## Working with an agent (the easy way)

If you use an agent that has the **decision-records** skill installed, just ask — it knows the
lifecycle, mints IDs, runs the tool, and keeps the index and links correct. For example:

- "Capture what we just discussed into a decision draft."
- "Draft an architecture decision: we'll use X for Y."
- "Promote that draft." / "Promote CONF."
- "Supersede our caching decision with a new one that does Z instead."
- "What have we decided about authentication?"
- "Regenerate the decision index."

Promoting and superseding finalize a record, so the agent confirms with you first.

## The lifecycle

A decision moves through three stages, one directory each:

| Stage    | Directory          | Identified by                         | Meaning                                       |
| :------- | :----------------- | :------------------------------------ | :-------------------------------------------- |
| Draft    | `drafts/`          | a 4-uppercase-letter mnemonic (CONF)  | a work-in-progress candidate; edit freely     |
| Accepted | `accepted/<type>/` | a zero-padded counter (0001, 0002, …) | a finalized decision, **held firm**           |
| Retired  | `archived/`        | (keeps its counter)                   | superseded or deprecated; kept for the record |

There is no "proposed" stage — *proposing* a decision is the act of opening a pull request that
promotes a draft. Once a draft lands in `accepted/` it is held firm: changing course means
superseding it with a newer decision, never rewriting it; decider-approved maintenance edits
(clarity, staleness, consistency) are allowed. `<type>` is an open lowercase label you choose
(architecture, product, security, policy, legal, …); each type is its own subdirectory under
`accepted/`.

## Reading the records

Start at [INDEX.md](./INDEX.md) — a generated table of every accepted and retired decision with its
type, summary, status, and tags. Open the records you need; skip `archived/` unless you're tracing
why a decision changed.

## Authoring & cross-referencing

- Copy `_template.md` for a decision, or `drafts/_template.md` for a draft, and fill it in.
- Mint a draft's 4-letter ID yourself (a mnemonic of the topic). Accepted records get the next
  global counter automatically when a draft is promoted.
- Refer to another record by writing its ID inline — the tooling renders IDs as links and keeps them
  correct when files move. Never hand-write a path to a record.

## Promotion & superseding

Promotion turns a draft into the next accepted decision; it is a finalizing step that needs human
sign-off. To replace an existing decision, set the draft's `supersedes` front-matter to that
decision's counter. On promotion (confirmed with `--allow-replace`) the tool **automatically retires
the superseded record for you** — it moves the old record to `archived/`, flips its status to
`superseded`, and records the new counter in the old record's `superseded_by`. You never hand-edit
the record being replaced.

## The tooling

`scripts/decisions.py` generates the index and cross-links, validates the tree, and promotes drafts:

```sh
python scripts/decisions.py build [--relink]            # regenerate INDEX.md (+ refresh links)
python scripts/decisions.py check                       # validate (CI-safe; exit 1 if stale)
python scripts/decisions.py promote <name…> [--deref] [--allow-replace]   # draft(s) -> accepted/
python scripts/decisions.py rename-draft-id <name> <NEW>                  # re-ID a draft
python scripts/decisions.py install [repo]              # adopt in a repo: symlink + pre-commit
```

Run `python scripts/decisions.py --help` for the rest. This convention is provided by the
**decision-records** skill, which is the source of truth for how the tooling behaves; this file is a
local orientation guide.
tests/conftest.py
import sys
from pathlib import Path

# Tests live in the skill; the tool is in the sibling scripts/ dir. Put that on the path
# so `import decisions` loads the tool directly — no symlink needed.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
tests/test_decisions.py
"""
Tests for scripts/decisions.py — the decision-records registry tool.

Option B: the tool takes an injectable `root` (the docs/ dir), so every test runs
against a synthetic tree under tmp_path and never touches the real repo.
"""

import decisions
import pytest


# ── fixture helpers ─────────────────────────────────────────────────────────
def record_text(
    counter,
    typ,
    title,
    *,
    body="",
    status="accepted",
    relates_to="[]",
    supersedes="null",
    superseded_by="null",
    summary="one-line summary",
    tags="[]",
):
    return (
        f'---\nid: "{counter}"\ntitle: {title}\ntype: {typ}\nstatus: {status}\n'
        f"summary: {summary}\ntags: {tags}\nrelates_to: {relates_to}\n"
        f"supersedes: {supersedes}\nsuperseded_by: {superseded_by}\n---\n\n"
        f"# {counter} — {title}\n\n{body}\n"
    )


def place(root, counter, typ, title, *, lifecycle="decisions", subdir=None, **kw):
    d = (
        root / "decisions" / "accepted" / (subdir or typ)
        if lifecycle == "decisions"
        else root / "decisions" / lifecycle
    )
    d.mkdir(parents=True, exist_ok=True)
    p = d / f"{counter}-{title}.md"
    p.write_text(record_text(counter, typ, title, **kw), encoding="utf-8")
    return p


def place_draft(
    root,
    did,
    typ,
    title,
    *,
    status="draft",
    relates_to="[]",
    supersedes="null",
    superseded_by="null",
    body="",
):
    d = root / "decisions" / "drafts"
    d.mkdir(parents=True, exist_ok=True)
    p = d / f"{did}-{title}.md"
    p.write_text(
        f"---\nid: {did}\ntitle: {title}\ntype: {typ}\nstatus: {status}\n"
        f"relates_to: {relates_to}\nsupersedes: {supersedes}\nsuperseded_by: {superseded_by}\n"
        f"---\n\n# {did} — {title}\n\n{body}\n",
        encoding="utf-8",
    )
    return p


def write_threat_model(root, body):
    root.mkdir(parents=True, exist_ok=True)
    (root / "threat-model.md").write_text(f"# Threat Model\n\n{body}\n", encoding="utf-8")


@pytest.fixture
def root(tmp_path):
    """docs/ with contiguous accepted 0001..0003; 0003 (security) cites 0001 & 0002."""
    docs = tmp_path / "docs"
    place(docs, "0001", "architecture", "alpha")
    place(docs, "0002", "architecture", "beta")
    place(docs, "0003", "security", "gamma", body="builds on `0001` and `0002`.")
    return docs


@pytest.fixture
def built(root):
    assert decisions.main(["build", "--relink"], root=root) == 0
    return root


# ── front-matter parsing: block scalars ─────────────────────────────────────
def test_folded_scalar_summary_joins_lines():
    text = (
        '---\nid: "0001"\nsummary: >-\n  A summary written as a folded block scalar\n'
        "  that continues across several indented lines.\nstatus: accepted\n---\n\nbody\n"
    )
    fm = decisions.parse_front_matter(text)
    assert fm["summary"] == (
        "A summary written as a folded block scalar that continues across several indented lines."
    )
    assert fm["status"] == "accepted"  # the field after the block is still parsed


def test_literal_scalar_summary_preserves_newlines():
    text = '---\nid: "0001"\nsummary: |\n  line one\n  line two\nstatus: accepted\n---\n\nbody\n'
    fm = decisions.parse_front_matter(text)
    assert fm["summary"] == "line one\nline two"


def test_block_scalar_with_no_continuation_is_empty():
    text = '---\nid: "0001"\nsummary: >-\nstatus: accepted\n---\n\nbody\n'
    fm = decisions.parse_front_matter(text)
    assert fm["summary"] == ""
    assert fm["status"] == "accepted"


def test_folded_summary_renders_in_index(root):
    p = root / "decisions/accepted/architecture/0001-alpha.md"
    p.write_text(
        p.read_text().replace(
            "summary: one-line summary",
            "summary: >-\n  A summary written as a folded block scalar\n  across two lines.",
        )
    )
    assert decisions.main(["build"], root=root) == 0
    index = (root / "decisions" / "INDEX.md").read_text()
    assert "A summary written as a folded block scalar across two lines." in index
    assert ">-" not in index


def test_check_flags_bare_scalar_indicator_summary(root):
    p = root / "decisions/accepted/architecture/0001-alpha.md"
    p.write_text(p.read_text().replace("summary: one-line summary", 'summary: ">-"'))
    assert decisions.main(["check"], root=root) == 1


# ── build / render ──────────────────────────────────────────────────────────
def test_build_writes_index_sorted(root):
    assert decisions.main(["build"], root=root) == 0
    index = (root / "decisions" / "INDEX.md").read_text()
    assert index.index("0001") < index.index("0002") < index.index("0003")
    assert "(accepted/architecture/0001-alpha.md)" in index
    assert "| architecture |" in index and "| security |" in index


def test_index_empty_when_no_decisions(tmp_path):
    docs = tmp_path / "docs"
    place_draft(docs, "ABCD", "security", "wip")
    assert decisions.main(["build"], root=docs) == 0
    assert "no decisions yet" in (docs / "decisions" / "INDEX.md").read_text()


def test_no_proposed_status_allowed(root):
    place(root, "0004", "architecture", "wip", status="proposed")
    assert decisions.main(["check"], root=root) == 1


# ── relink ──────────────────────────────────────────────────────────────────
def test_relink_cross_directory(built):
    body = (built / "decisions/accepted/security/0003-gamma.md").read_text()
    assert "[`0001`](../architecture/0001-alpha.md)" in body


def test_relink_idempotent(built):
    p = built / "decisions/accepted/security/0003-gamma.md"
    before = p.read_text()
    assert decisions.main(["build", "--relink"], root=built) == 0
    assert p.read_text() == before


def test_move_self_heals(built):
    src = built / "decisions/accepted/architecture/0001-alpha.md"
    src.rename(src.with_name("0001-alpha-renamed.md"))
    assert decisions.main(["check"], root=built) == 1
    assert decisions.main(["build", "--relink"], root=built) == 0
    assert decisions.main(["check"], root=built) == 0
    gamma = (built / "decisions/accepted/security/0003-gamma.md").read_text()
    assert "0001-alpha-renamed.md" in gamma


# ── drafts: UPPERCASE IDs, draft↔draft links ────────────────────────────────
def test_draft_to_draft_link(root):
    place_draft(root, "AAAA", "security", "one")
    place_draft(root, "BBBB", "architecture", "two", body="relates to `AAAA`.")
    assert decisions.main(["build", "--relink"], root=root) == 0
    assert "[`AAAA`](AAAA-one.md)" in (root / "decisions/drafts/BBBB-two.md").read_text()


def test_lowercase_draft_id_rejected(built):
    place_draft(built, "abcd", "security", "lower")
    assert decisions.main(["check"], root=built) == 1


def test_draft_requires_4letter_id(built):
    place_draft(built, "AB", "security", "shorty")
    assert decisions.main(["check"], root=built) == 1


def test_draft_duplicate_id(built):
    place_draft(built, "ABCD", "security", "one")
    place_draft(built, "ABCD", "architecture", "two")
    assert decisions.main(["check"], root=built) == 1


# ── threat-model.md in the link system ──────────────────────────────────────
def test_threat_model_gets_linked(built):
    write_threat_model(built, "Vector X addressed by `0001`.")
    assert decisions.main(["build", "--relink"], root=built) == 0
    link = "[`0001`](decisions/accepted/architecture/0001-alpha.md)"
    assert link in (built / "threat-model.md").read_text()
    assert decisions.main(["check"], root=built) == 0


def test_threat_model_broken_ref_flagged(built):
    write_threat_model(built, "Addressed by `0099`.")
    assert decisions.main(["check"], root=built) == 1


def test_any_doc_is_linked_and_checked(built):
    (built / "roadmap.md").write_text(
        "# Roadmap\n\nMilestone builds on `0001`.\n", encoding="utf-8"
    )
    assert decisions.main(["build", "--relink"], root=built) == 0
    link = "[`0001`](decisions/accepted/architecture/0001-alpha.md)"
    assert link in (built / "roadmap.md").read_text()
    (built / "roadmap.md").write_text("# Roadmap\n\nBuilds on `0099`.\n", encoding="utf-8")
    assert decisions.main(["check"], root=built) == 1


def test_templates_and_index_excluded_from_linkcheck(built):
    (built / "_scratch.md").write_text(
        "# scratch\n\nrefs `0099`.\n", encoding="utf-8"
    )  # _-prefixed
    assert decisions.main(["check"], root=built) == 0  # excluded, so its bad ref is ignored


# ── placement, duplicates, skips ────────────────────────────────────────────
def test_dir_must_match_type(root):
    place(root, "0004", "architecture", "misplaced", subdir="security")
    assert decisions.main(["check"], root=root) == 1


def test_custom_type_is_open(built):
    place_draft(built, "LEGL", "legal", "retention-policy")  # a type outside the usual set
    assert decisions.main(["promote", "LEGL"], root=built) == 0
    # the type's dir is auto-created on promotion
    assert (built / "decisions/accepted/legal/0004-retention-policy.md").exists()
    assert decisions.main(["check"], root=built) == 0


def test_invalid_type_slug_rejected(built):
    place_draft(built, "ABCD", "Security", "bad-slug")  # uppercase -> not a slug
    assert decisions.main(["check"], root=built) == 1


def test_new_type_warns_but_passes(built, capsys):
    place_draft(built, "LEGL", "legal", "thing")  # no decisions/legal/ yet
    assert decisions.main(["check"], root=built) == 0  # non-blocking
    assert "new type" in capsys.readouterr().err  # but warned


def test_archived_status_must_be_retired(root):
    place(root, "0004", "architecture", "notretired", lifecycle="archived", status="accepted")
    assert decisions.main(["check"], root=root) == 1


def test_superseded_in_archived_ok(built):
    place(built, "0004", "architecture", "retired", lifecycle="archived", status="superseded")
    assert decisions.main(["build", "--relink"], root=built) == 0
    assert decisions.main(["check"], root=built) == 0


def test_duplicate_counter(root):
    place(root, "0001", "security", "clash")
    assert decisions.main(["check"], root=root) == 1


def test_gap_in_counters(root):
    place(root, "0005", "architecture", "skipper")
    assert decisions.main(["check"], root=root) == 1


# ── decision → draft breach ─────────────────────────────────────────────────
def test_decision_referencing_draft_is_breach(built):
    place(built, "0004", "architecture", "leaky", body="depends on `WXYZ`.")
    place_draft(built, "WXYZ", "security", "candidate")
    assert decisions.main(["check"], root=built) == 1


def test_decision_relates_to_draft_is_breach(built):
    place(built, "0004", "architecture", "leaky2", relates_to='["WXYZ"]')
    place_draft(built, "WXYZ", "security", "candidate")
    assert decisions.main(["check"], root=built) == 1


def test_decision_linked_draft_ref_is_breach(built):
    # After relink a body ref becomes a markdown LINK; it must STILL be a breach, not only
    # bare inline-code refs (regression guard for the linked-ref blind spot).
    place_draft(built, "WXYZ", "security", "candidate")
    place(built, "0004", "architecture", "leaky3", body="builds on `WXYZ`.")
    assert decisions.main(["build", "--relink"], root=built) == 0  # refresh INDEX + linkify the ref
    body = (built / "decisions/accepted/architecture/0004-leaky3.md").read_text()
    assert "[`WXYZ`](" in body  # ref is now a markdown link
    assert decisions.main(["check"], root=built) == 1  # ...and is still flagged


# ── rename-draft-id ─────────────────────────────────────────────────────────
def test_rename_draft_repoints_refs(built):
    place_draft(built, "ABCD", "security", "target")
    place_draft(
        built, "EFGH", "architecture", "referrer", relates_to='["ABCD"]', body="depends on `ABCD`."
    )
    assert decisions.main(["rename-draft-id", "ABCD", "ZZZZ"], root=built) == 0
    ref = (built / "decisions/drafts/EFGH-referrer.md").read_text()
    assert "ZZZZ" in ref and "ABCD" not in ref
    assert decisions.main(["check"], root=built) == 0


def test_rename_draft_auto_uppercases(built):
    place_draft(built, "ABCD", "security", "thing")
    dest, err = decisions.rename_draft(built, "ABCD", "wxyz")
    assert err is None and dest.name == "WXYZ-thing.md"


def test_rename_draft_rejects_conflict(built):
    place_draft(built, "AAAA", "security", "one")
    place_draft(built, "BBBB", "architecture", "two")
    assert decisions.main(["rename-draft-id", "AAAA", "BBBB"], root=built) == 1


def test_rename_draft_rejects_invalid_id(built):
    place_draft(built, "AAAA", "security", "one")
    dest, err = decisions.rename_draft(built, "AAAA", "AB12")
    assert dest is None and err


# ── promote: single, closure/breach, multi, cycle ───────────────────────────
def test_promote_assigns_next_counter_accepted(built):
    place_draft(built, "QWER", "security", "new-idea")
    assert decisions.main(["promote", "QWER"], root=built) == 0
    dest = built / "decisions/accepted/security/0004-new-idea.md"
    assert dest.exists()
    assert 'id: "0004"' in dest.read_text() and "status: accepted" in dest.read_text()
    assert not (built / "decisions/drafts/QWER-new-idea.md").exists()
    assert decisions.main(["check"], root=built) == 0


def test_promote_rewrites_inbound_refs(built):
    place_draft(built, "ABCD", "security", "target")
    place_draft(
        built, "EFGH", "architecture", "referrer", relates_to='["ABCD"]', body="depends on `ABCD`."
    )
    assert decisions.main(["promote", "ABCD"], root=built) == 0
    ref = (built / "decisions/drafts/EFGH-referrer.md").read_text()
    assert "`0004`" in ref and "ABCD" not in ref
    assert decisions.main(["check"], root=built) == 0


def test_promote_breach_refused_with_closure(built):
    place_draft(built, "ABCD", "security", "needs-dep", body="see `EFGH`.")
    place_draft(built, "EFGH", "architecture", "dep")
    # promoting ABCD alone would leave a decision pointing at draft EFGH -> refused
    dests, err = decisions.promote(built, ["ABCD"])
    assert dests is None and err
    assert "EFGH" in err and "ABCD" in err and "requested" in err
    assert decisions.main(["promote", "ABCD"], root=built) == 1


def test_promote_closed_set_succeeds(built):
    place_draft(built, "ABCD", "security", "a", body="see `EFGH`.")
    place_draft(built, "EFGH", "architecture", "b")
    assert decisions.main(["promote", "ABCD", "EFGH"], root=built) == 0
    assert not list((built / "decisions" / "drafts").glob("[A-Z]*.md"))  # both consumed
    assert decisions.main(["check"], root=built) == 0  # no decision -> draft


def test_promote_cycle_needs_both(built):
    place_draft(built, "ABCD", "security", "a", body="see `EFGH`.")
    place_draft(built, "EFGH", "architecture", "b", body="see `ABCD`.")
    assert decisions.main(["promote", "ABCD"], root=built) == 1  # cycle -> breach
    assert decisions.main(["promote", "ABCD", "EFGH"], root=built) == 0  # together works
    assert decisions.main(["check"], root=built) == 0


def test_promote_comma_separated(built):
    place_draft(built, "ABCD", "security", "a", body="see `EFGH`.")
    place_draft(built, "EFGH", "architecture", "b")
    assert decisions.main(["promote", "ABCD,EFGH"], root=built) == 0
    assert decisions.main(["check"], root=built) == 0


def test_promote_disambiguates(built):
    place_draft(built, "QWER", "architecture", "alpha-cand")
    place_draft(built, "QWES", "security", "beta-cand")
    dests, err = decisions.promote(built, ["qwe"], input_fn=lambda _: "1")
    assert err is None and dests[0].name == "0004-alpha-cand.md"


def test_promote_no_match(built):
    assert decisions.main(["promote", "ZZZZ"], root=built) == 1


# ── deref / blocking / replace ──────────────────────────────────────────────
def test_promote_deref_inverts_frontmatter_edge(built):
    place_draft(built, "AAAA", "architecture", "alpha", relates_to='["BBBB"]')
    place_draft(built, "BBBB", "security", "beta")
    dests, err = decisions.promote(built, ["AAAA"])  # front-matter-only ref
    assert dests is None and "--deref" in err
    assert decisions.main(["promote", "--deref", "AAAA"], root=built) == 0
    assert (built / "decisions/accepted/architecture/0004-alpha.md").exists()
    assert '"0004"' in (built / "decisions/drafts/BBBB-beta.md").read_text()  # link moved onto BBBB
    assert decisions.main(["check"], root=built) == 0


def test_promote_deref_inverts_superseded_by_edge(built):
    # superseded_by is a scalar edge: inverting it must set BBBB's *single* `supersedes`
    # counter, not coerce it into a list (which would crash check on the unhashable value).
    place_draft(built, "AAAA", "architecture", "alpha", superseded_by='"BBBB"')
    place_draft(built, "BBBB", "security", "beta")
    dests, err = decisions.promote(built, ["AAAA"])  # front-matter-only ref
    assert dests is None and "--deref" in err
    assert decisions.main(["promote", "--deref", "AAAA"], root=built) == 0
    assert (built / "decisions/accepted/architecture/0004-alpha.md").exists()
    bbbb = decisions.parse_front_matter((built / "decisions/drafts/BBBB-beta.md").read_text())
    assert bbbb["supersedes"] == "0004"  # scalar counter, not a list
    assert decisions.main(["check"], root=built) == 0


def test_prose_ref_blocks_and_beats_deref(built):
    place_draft(built, "AAAA", "architecture", "alpha", relates_to='["BBBB"]', body="see `BBBB`.")
    place_draft(built, "BBBB", "security", "beta")
    dests, err = decisions.promote(built, ["AAAA"])
    assert dests is None and "blocked" in err and "--deref" not in err  # blocking wins


def test_deref_rejected_when_blocking(built):
    place_draft(built, "AAAA", "architecture", "alpha", body="see `BBBB`.")
    place_draft(built, "BBBB", "security", "beta")
    assert decisions.main(["promote", "--deref", "AAAA"], root=built) == 1


def test_supersedes_decision_needs_replace_then_archives(built):
    place_draft(built, "CCCC", "architecture", "newer", supersedes='"0001"')
    assert decisions.main(["promote", "CCCC"], root=built) == 1  # needs --allow-replace
    assert decisions.main(["promote", "CCCC", "--allow-replace"], root=built) == 0
    archived = built / "decisions/archived/0001-alpha.md"
    assert archived.exists() and "superseded" in archived.read_text()
    assert decisions.main(["check"], root=built) == 0


# ── check semantics & CLI ───────────────────────────────────────────────────
def test_check_clean(built):
    assert decisions.main(["check"], root=built) == 0


def test_check_read_only(built):
    p = built / "decisions/accepted/security/0003-gamma.md"
    before = (p.read_text(), (built / "decisions" / "INDEX.md").read_text())
    assert decisions.main(["check"], root=built) == 0
    assert (p.read_text(), (built / "decisions" / "INDEX.md").read_text()) == before


def test_check_detects_stale_index(built):
    p = built / "decisions/accepted/architecture/0001-alpha.md"
    p.write_text(p.read_text().replace("one-line summary", "changed"))
    assert decisions.main(["check"], root=built) == 1


def test_bare_relink_folds_into_build(root):
    assert decisions.main(["--relink"], root=root) == 0
    assert "[`0001`]" in (root / "decisions/accepted/security/0003-gamma.md").read_text()


def test_unknown_subcommand_exits_2(root):
    with pytest.raises(SystemExit) as e:
        decisions.main(["bogus"], root=root)
    assert e.value.code == 2


def test_relink_not_valid_on_check(root):
    with pytest.raises(SystemExit) as e:
        decisions.main(["check", "--relink"], root=root)
    assert e.value.code == 2


def test_promote_requires_query(root):
    with pytest.raises(SystemExit) as e:
        decisions.main(["promote"], root=root)
    assert e.value.code == 2


def test_rename_requires_two_args(root):
    with pytest.raises(SystemExit) as e:
        decisions.main(["rename-draft-id", "ABCD"], root=root)
    assert e.value.code == 2


def test_install_scaffolds_fresh_repo(tmp_path):
    decisions.install(tmp_path)  # repo root = tmp_path (no upward search)
    docs = tmp_path / "docs"
    assert (tmp_path / "scripts" / "decisions.py").is_symlink()
    for p in (
        "decisions/accepted/.gitkeep",
        "decisions/archived/.gitkeep",
        "decisions/README.md",
        "decisions/AGENTS.md",
        "decisions/_template.md",
        "decisions/drafts/_template.md",
        "decisions/INDEX.md",
    ):
        assert (docs / p).exists(), p
    assert decisions.main(["check"], root=docs) == 0  # scaffolded repo is valid immediately

    # gitignores the machine-specific symlink (creating .gitignore in an empty repo)
    assert "scripts/decisions.py" in (tmp_path / ".gitignore").read_text().splitlines()

    # wires root entry points (absent in a fresh repo) with placeholders linking the scaffold
    readme, agents = (tmp_path / "README.md").read_text(), (tmp_path / "AGENTS.md").read_text()
    assert "docs/decisions/README.md" in readme and "docs/decisions/INDEX.md" in readme
    assert "docs/decisions/AGENTS.md" in agents

    decisions.install(tmp_path)  # idempotent re-run doesn't raise


def test_install_preserves_existing_entry_points(tmp_path):
    (tmp_path / "README.md").write_text("# Existing\n")
    (tmp_path / "AGENTS.md").write_text("# Existing agents\n")
    (tmp_path / ".gitignore").write_text("node_modules/\n")
    decisions.install(tmp_path)
    assert (tmp_path / "README.md").read_text() == "# Existing\n"  # never overwritten
    assert (tmp_path / "AGENTS.md").read_text() == "# Existing agents\n"
    gi = (tmp_path / ".gitignore").read_text().splitlines()
    assert "node_modules/" in gi and "scripts/decisions.py" in gi  # appended, not clobbered
    decisions.install(tmp_path)  # idempotent: a second run adds no duplicate ignore line
    assert (tmp_path / ".gitignore").read_text().count("scripts/decisions.py") == 1


# ── link check: code spans and anchors ──────────────────────────────────────
def test_heading_anchors_github_slugs():
    text = (
        "# Appendix — Harnesses evaluated (research notes, non-binding)\n\n"
        '## "dreaming"\n\n## Risks & open questions\n\n## dup\n\n## dup\n\n'
        "```\n## not a heading\n```\n"
    )
    assert decisions.heading_anchors(text) == {
        "appendix--harnesses-evaluated-research-notes-non-binding",
        "dreaming",
        "risks--open-questions",
        "dup",
        "dup-1",
    }


def test_link_inside_code_span_is_not_checked(built):
    p = built / "decisions/accepted/architecture/0001-alpha.md"
    p.write_text(p.read_text() + "\nUse the form `[term](../../glossary.md#term)` when linking.\n")
    assert decisions.main(["check"], root=built) == 0


def test_link_inside_fenced_block_is_not_checked(built):
    p = built / "decisions/accepted/architecture/0001-alpha.md"
    p.write_text(p.read_text() + "\n```md\n[term](../../nowhere.md#x)\n```\n")
    assert decisions.main(["check"], root=built) == 0


def test_broken_cross_file_anchor_is_flagged(built, capsys):
    (built / "glossary.md").write_text("# Glossary\n\n## sandbox\n\nbody\n")
    p = built / "decisions/accepted/architecture/0001-alpha.md"
    body = p.read_text()
    p.write_text(body + "\nsee [sandbox](../../../glossary.md#sandbox).\n")
    assert decisions.main(["check"], root=built) == 0
    p.write_text(body + "\nsee [sandbox](../../../glossary.md#sandbxo).\n")
    assert decisions.main(["check"], root=built) == 1
    assert "broken anchor" in capsys.readouterr().err


def test_intra_doc_anchor_is_validated(built, capsys):
    p = built / "decisions/accepted/architecture/0001-alpha.md"
    body = p.read_text() + "\n## Context\n\nsee [Context](#context).\n"
    p.write_text(body)
    assert decisions.main(["check"], root=built) == 0
    p.write_text(body.replace("(#context)", "(#contxt)"))
    assert decisions.main(["check"], root=built) == 1
    assert "broken anchor" in capsys.readouterr().err


# ── front matter: strict-YAML hazards ───────────────────────────────────────
def test_unquoted_colon_in_summary_fails_check(root, capsys):
    place(root, "0004", "product", "delta", summary="Two modes: channel and principal")
    assert decisions.main(["check"], root=root) == 1
    assert "unquoted ':'" in capsys.readouterr().err


def test_quoted_colon_in_summary_passes_check(root):
    place(root, "0004", "product", "delta", summary='"Two modes: channel and principal"')
    assert decisions.main(["build", "--relink"], root=root) == 0
    assert decisions.main(["check"], root=root) == 0


def test_block_scalar_colon_passes_check(root):
    place(root, "0004", "product", "delta", summary=">-\n  Two modes: channel and principal")
    assert decisions.main(["build", "--relink"], root=root) == 0
    assert decisions.main(["check"], root=root) == 0


def test_unbalanced_quote_fails_check(root, capsys):
    place(root, "0004", "product", "delta", summary='"half open')
    assert decisions.main(["check"], root=root) == 1
    assert "unbalanced quote" in capsys.readouterr().err