| name | adr-generator |
| description | Retroactively generate MADR (Markdown Architectural Decision Records) from existing codebases by analyzing git commit history and diffs. Use this skill when the user wants to document architectural decisions for an existing codebase, generate ADRs from git history, add decision tags to code, annotate functions or classes with ADR references, reverse-engineer why a codebase looks the way it does, or onboard new developers by surfacing historical decisions. Trigger when the user mentions "ADR", "architectural decisions", "decision records", "document our codebase decisions", "why was this built this way", or wants to annotate existing code with decision context. Works on full repos or specific modules/folders. |
ADR Generator
Generates MADR-format Architectural Decision Records from an existing codebase's git history, and annotates relevant code with inline @ADR reference tags.
Modes
Interaction Mode
Always ask the user which mode they want upfront if not specified:
- Autonomous — detects everything automatically, generates ADRs and places tags end-to-end with no interruptions
- Assisted — pauses at each major step, shows findings, waits for user confirmation before proceeding
Scope
Always clarify scope before starting (or auto-detect in autonomous mode):
History scope:
full — entire git history
since:<commit-or-tag> — from a specific commit/tag onward (e.g. since:v2.0.0)
Code scope:
repo — entire repository
module:<path> — specific folder/module only (e.g. module:src/payments)
Note on module scope: architectural decisions for a module are often partially implemented outside it (e.g. shared event bus, config changes). When scoping to a module path, also scan -- <module-path> diffs AND flag any commits that touch both the module path and shared paths (lib/, core/, shared/, common/) — include those as candidates even if they partially fall outside scope.
Output Location (scope-aware)
Where ADRs are written depends on code scope. Do NOT dump module ADRs into the global pool — co-locate them with the module so they stay discoverable and numbering does not collide across modules.
| Code scope | Output directory | Numbering |
|---|
repo | docs/decisions/ | continues from highest existing global ADR |
module:<path> | <path>/docs/decisions/ (co-located) | numbered independently per module, continues from highest existing ADR in that folder |
Boundary-crossing decisions: when a module-scoped decision also touches shared paths (lib/, core/, etc.), write the ADR in the module's folder, but place the @ADR tag in the shared file too — pointing back across the boundary to the module's decision file. This keeps the single source of truth in the module while making the decision discoverable from the shared code.
Throughout the rest of this skill, docs/decisions/ refers to the scope-resolved output directory from this table, not necessarily the repo root.
Core Loop
0. PREFLIGHT → validate repo state before doing anything
1. DETECT → chunking strategy (merge / squash / rebase / direct)
2. CHUNK → group commits within boundaries into logical units
2.5 CLUSTER → merge related chunks ACROSS boundaries into decision candidates
3. CLASSIFY → filter architectural decisions from noise + route record type
4. GENERATE → produce MADR (decision) or workaround log per candidate
5. TAG → place @ADR / @WA inline markers in code
Quick mode (default for repos under ~500 commits)
The full loop above earns its weight on large histories with many interleaved decisions. For a small/medium repo it is overkill — analyze.py already does the heavy lifting deterministically, so use it as a scored classifier and write from its output:
- Run once:
python3 scripts/analyze.py <repo> [--code module:<path>] --json. With no --config, edge specificity auto-tunes from commit count, so clustering is sane out-of-box (no mega-cluster).
- Triage from the JSON directly — each candidate carries
score, classification, record_type (decision | workaround), and a diff_summary (files added/deleted/modified), so you can rank without running git diff by hand. Candidates with record_type: "workaround" are generated from the workaround template regardless of their score class — a borderline fix: commit carrying a HACK marker is exactly what the workaround log exists for.
- Still honour the Step 4 hard gate: before writing each MADR, read the actual diff for that candidate (
diff_summary is for triage, not a substitute). Write straight from the MADR template.
- Verify with
judge.py verify-adr / check-tags as the safety net.
- Skip CHUNK/CLUSTER hand-curation and batch-generation choreography — they add no value at this scale.
Reach for the full loop only when a repo is large (≈500+ commits), has heavy merge/squash boundaries, or when analyze.py candidates clearly span multiple decisions that need manual splitting/merging.
Step 0: Preflight Checks
Run these before anything else. Surface warnings to the user and halt if critical.
git rev-list HEAD --count 2>/dev/null
git rev-parse --is-shallow-repository
If shallow clone detected (--is-shallow-repository returns true):
- STOP. Tell the user: "This is a shallow clone. History is incomplete — ADRs generated would be misleading. Run
git fetch --unshallow first, then re-run."
- Do not proceed in autonomous mode. In assisted mode, ask if they want to continue with the caveat that output will be incomplete.
ls docs/decisions/ 2>/dev/null | sort | tail -5
- Note the highest existing ADR number — new ADRs continue from there
- Note existing ADR slugs — skip any chunk whose diff is already covered by an existing ADR (match by commit hash in the Links section)
git log --oneline | wc -l
If commit count > 500: warn the user that full history analysis may be very slow and suggest using since:<tag> scope instead. In autonomous mode, default to analyzing the last 200 commits unless full was explicitly requested.
In assisted mode, show preflight summary before proceeding:
Preflight
─────────
Clone depth: full (not shallow) ✓
Existing ADRs: 3 found (next: 0004)
Commits in scope: 142
Estimated chunks: ~15-25
Proceed?
Step 1: Detect Chunking Strategy
Run all three checks — repos can mix strategies:
git log --oneline --merges -- [module-path] | wc -l
git log --oneline --no-merges --pretty=format:"%s" | grep -E "\(#[0-9]+\)" | head -10
git log --oneline | head -20
Strategy selection:
| Condition | Mode |
|---|
| ≥3 merge commits | merge-boundary — use merge commits as chunk boundaries |
Few/no merges but commits with (#NNN) pattern | squash-boundary — each squash commit is one chunk |
| Linear history, no PR refs | direct-commit — group by file-path affinity + commit topic |
| Mixed | use merge-boundary for the merge portion, squash-boundary for the rest |
Note: rebase workflows destroy original author timestamps. Do NOT use temporal proximity (48h window) for grouping in linear/rebase repos — it will be wrong. Use file-path affinity only.
In assisted mode, show detection result and let user override before proceeding.
Step 2: Chunk Commits
Merge-boundary mode
git log --merges --pretty=format:"%H %s" -- [module-path]
MERGE_COMMIT=<hash>
PARENT1=$(git log --pretty=format:"%P" -n 1 $MERGE_COMMIT | awk '{print $1}')
PARENT2=$(git log --pretty=format:"%P" -n 1 $MERGE_COMMIT | awk '{print $2}')
BASE=$(git merge-base $PARENT1 $PARENT2)
git diff $BASE $MERGE_COMMIT -- [module-path]
git log $BASE..$MERGE_COMMIT --oneline -- [module-path]
Squash-boundary mode
git log --no-merges --pretty=format:"%H %s" -- [module-path]
git diff <hash>^..<hash> -- [module-path]
Direct-commit mode
git log --oneline --stat -- [module-path]
Group commits by file-path affinity only (no temporal grouping):
- Extract the top-level directories touched by each commit
- Commits sharing ≥2 top-level dirs AND similar message topics → one chunk
- Keep chunks to max ~10 commits; if a natural group is larger, split by sub-path
Context window management: do NOT load all diffs into context at once. Process chunks sequentially — load diff, generate MADR, write file, unload. For repos with many chunks (>20), process in batches of 5 and write to disk between batches.
In assisted mode, show proposed chunk list (commit count per chunk, not full diffs) and let user merge/split before generating.
Step 2.5: Cluster Across Boundaries
Do not assume one PR/merge = one decision. A single architectural decision often lands across several PRs over weeks, interleaved with unrelated work (e.g. an event-driven migration spread across PR #210, #230, #255). One ADR per boundary shatters that into three weak, redundant records. Step 2 groups within a boundary; this step merges across boundaries into decision candidates.
This pass is deterministic by design — no embeddings, no temporal proximity. It uses signals already extracted in Step 2 so that re-runs are reproducible (which is what makes the skill eval-able and regression-testable).
Merge two chunks (even non-adjacent ones) into one candidate when BOTH hold:
- File overlap — they touch an overlapping set of files or share ≥2 top-level directories, OR one chunk adds a file/symbol the other modifies.
- Topic affinity — their commit/PR subjects share a distinctive token (a library name, subsystem, feature flag, or migration name like
kafka, grpc, hexagonal) — not generic words like fix, add, update.
Guards:
- A merged candidate may span non-sequential PRs/commits — that is the point. Order its links by author date.
- Cap a candidate at ~8 source chunks; if larger, it is probably several decisions — keep the strongest file-overlap cluster and split the rest.
- Do NOT merge on topic affinity alone (two unrelated
cache PRs) or file overlap alone (everyone edits config.ts). Require both.
- A chunk that clusters with nothing stays a singleton candidate — that is fine.
The output of this step is a list of decision candidates, each backed by one or more chunks. Steps 3–5 operate on candidates, not raw chunks.
Mega-cluster guard (small / hot-file repos). Clustering is connected-components over a pairwise-affinity graph, so even sparse overlap collapses into one giant cluster once a "hot" file or directory (touched by a large fraction of commits) or a recurring word binds otherwise-unrelated commits — relatedness is not transitive. Watch for it:
v2 note (not implemented here): semantic clustering via embeddings + graph community detection would also catch conceptually-related PRs that share no files. It is deliberately out of scope: it is non-deterministic, needs a vector store, and would make output impossible to regression-test. Prototype it offline and prove it beats this deterministic pass before adopting it.
In assisted mode, show the proposed candidates (which chunks merged into each, and why) and let the user split/merge before classifying.
Step 3: Classify Chunks
For each chunk, classify using diff-first scoring — commit messages are a weak signal, diff content is the primary signal.
Diff signals (check these first):
git diff $BASE $COMMIT --name-status | grep "^A"
git diff $BASE $COMMIT -- package.json requirements.txt go.mod Cargo.toml pom.xml build.gradle
git diff $BASE $COMMIT --name-only | grep -iE "schema|migration|interface|contract|proto|api"
Scoring:
score = 0
# Diff signals (primary)
+3 if new non-test files added in src/lib/core/app paths
+3 if dependency file changed (package.json, go.mod, etc.)
+3 if schema/migration/proto file changed
+2 if >5 files changed spanning ≥2 top-level directories
+2 if an existing file was deleted (replacement pattern)
# Message signals (secondary, can boost but not solely qualify)
+1 if message contains: migrate, replace, adopt, switch, introduce, implement
-1 if message starts with: fix, chore, style, bump
-2 if message starts with: test, docs, lint, format
# Override rules (applied after score)
FORCE_INCLUDE if: security-related path changed (auth/, certs/, secrets/)
FORCE_INCLUDE if: CI/CD or infra config changed (Dockerfile, k8s/, .github/workflows/)
FORCE_EXCLUDE if: diff is 100% test files only
FORCE_EXCLUDE if: diff is 100% docs/comments only
FORCE_EXCLUDE if: only version numbers changed in dependency files (bump with no structural change)
if score >= 3 → ARCHITECTURAL
if score 1-2 → BORDERLINE (show to user in assisted; skip in autonomous)
if score <= 0 → SKIP
In assisted mode, show the full classification table including BORDERLINEs with scores so the user can promote/demote before generating.
Step 4: Generate MADR
Process one chunk at a time. Write each file to disk before moving to the next — do not batch in context.
Hard gate: diff before draft
Before writing ANY MADR, you MUST have loaded and read the chunk's actual diff (git diff $BASE $COMMIT -- [module-path]). The commit subject or PR/merge title is NOT sufficient — it may seed the title only. This is the most common failure mode: for merge/squash commits the subject line is right there ("Merge PR #214: add caching") and it is tempting to write the ADR straight from it. Do not. If you have not read the diff for this chunk, go back to Step 2 and load it before generating.
Thin-message rule (mirror of the Considered-Options rule): when the commit/PR message is uninformative (wip, fix, Merge #214, empty), the Context, Decision, and Outcome MUST be reconstructed from the diff — new files, deleted code, dependency/schema changes — not paraphrased from the subject line. If the diff is also uninformative, say so explicitly ("Implementation inferred from diff; no clear rationale recoverable") rather than restating the PR title as if it were a decision.
Numbering and deduplication
ls docs/decisions/ 2>/dev/null | grep -E "^[0-9]{4}-" | sort | tail -1
Number sequentially from the next available. Order by author date (--format="%ad"), not committer date (%cd), since rebase/cherry-pick rewrites committer timestamps while preserving original author dates.
MADR Template
Title rule: the title states the decision, not the PR. Merge #214 or add caching is a PR title; Adopt Redis for session cache is a decision title. Derive it from what the diff actually did, not from the commit subject.
# <short decision title>
## Status
Accepted
> Note: This ADR was retroactively generated from commit history by adr-generator.
> Rationale accuracy depends on commit message quality. See linked commits to verify.
## Context and Problem Statement
<What problem or need forced this change? Infer from: what existed before (deleted code in diff),
what broke or was missing, what the surrounding code suggests. Be specific about what changed,
not just what was added.>
## Decision Drivers
<Only include drivers evidenced by the diff or commit , >
<!-- evidence: deleted: -->` (whole file for the old approach removed)
(new approach added)
(file moved/renamed)
(a specific deleted line — for code replaced a modified file)
(phrase in commit msg, e.g. "instead of redis")
The MUST be one of this candidate's commits, and the cited change must be
real — the verifier checks each citation against git and DROPS any option whose
citation does not hold up. Prefer over a vague
claim: if an alternative is only evidenced by code changed inside a file, cite the
actual line that was deleted. Every option needs a citation — an option you cannot
pin to a specific commit and change does not belong here. If none are evidenced,
write exactly "No alternatives recorded in commit history." and leave the list
empty. DO NOT populate this with plausible alternatives from general knowledge.>
Chosen option: , because .
If rationale is not captured: write "Rationale not explicitly recorded. Implementation inferred from diff."
Commits: , , ...
Merge/PR:
Generated by: adr-generator [autonomous|assisted] on
MADR generation discipline
The most likely hallucination point is "Considered Options." Enforce this mentally before writing:
"Can I point to a specific line in the diff or commit message that shows this alternative existed? If not, it goes in the empty list with the 'No alternatives recorded' note."
In assisted mode, show the draft and wait for [write / edit / skip] before touching disk.
Verify Considered Options (deterministic citation check)
After writing an ADR, run the verifier. It checks every option's inline citation against git and rewrites the section to keep only what holds up. No model is involved — same repo + text always yields the same result:
python3 scripts/judge.py verify-adr docs/decisions/<NNNN-slug>.md \
--repo . --commits "<sha1> <sha2> ..." --write --json
Pass the candidate's commit SHAs via --commits; without it the verifier falls back to the ADR's own Links → Commits: line. Either way the check is scoped: a citation pointing at a commit outside the candidate set is dropped, even if the cited change is real elsewhere in the repo (frontmatter records verification: citation-structural; if no commit set is available at all, it degrades to citation-structural-unscoped).
For each option's citation it confirms the SHA belongs to the candidate and exists, then confirms the claimed change is real: deleted/added/renamed against name-status, removed:"line" by grepping the commit's diff for that deleted line, message: by searching the commit message. An option is dropped if its citation fails, if its evidence type is not one of these (uncheckable), or if it has no citation at all. Because the git history is a closed corpus, every check is an exact lookup — a fabricated SHA fails git cat-file immediately.
With --write, the verifier rewrites the section (dropping unevidenced options, collapsing to "No alternatives recorded" if none survive) and stamps the frontmatter (see below). Run without --write to preview the kept/dropped table first; in assisted mode, preview, then apply.
An option that can only be argued semantically — not pinned to a specific commit and change — is dropped, not adjudicated by an LLM. For a retroactive ADR that is the correct call: if you cannot point at the bytes, it should not be asserted as a considered option.
Provenance and the downstream trust boundary
verify-adr --write stamps the ADR's YAML frontmatter:
generation:
method: gen-adr/v1
evidence-verified: true
verification: citation-structural
options-kept: 2
options-dropped: 1
The per-option <!-- evidence: ... --> comments are kept (invisible when rendered) as an audit trail. This is a trust boundary, not an invitation to re-check. A downstream coding agent reading an ADR with evidence-verified: true should trust the Considered Options prose as already cross-checked against git, and must NOT re-investigate the cited commits. The citations exist for a human auditor or a re-run of verify-adr, not for the consuming agent to chase. Treat the listed options as the verified historical decision space.
Step 5: Place Inline @ADR Tags
Tag format
@ADR-<NNN>-<slug>: <one-line summary> — see docs/decisions/<NNN>-<slug>.md
Placement strategy
For each ADR, find the primary entry point(s) by running:
git diff $BASE $COMMIT -- [module-path] | grep -E "^(\+){1}[^+].*(def |class |func |function |interface |type |struct )"
Entry point selection rules:
- Pick the function/class that is the outermost public interface of the decision — the thing callers use, not internal helpers
- If the decision is about a data store/infra component, tag the constructor or factory function
- If the decision is cross-cutting (e.g. "adopt hexagonal architecture"), tag the file-level header with a module-scope comment instead of a specific function
- Cap at 3 tags per ADR (raised from 2 to handle cross-cutting decisions better)
- Skip tagging for: JSON/YAML/TOML/config files (no comment syntax), generated files, migration files (tag the migration runner instead), test files
Language comment syntax:
| Language | Function/method | Class/interface |
|---|
| Python | # @ADR-... | # @ADR-... |
| JS/TS | // @ADR-... | /** @ADR-... */ |
| Go | // @ADR-... | // @ADR-... |
| Java/Kotlin | // @ADR-... | /** @ADR-... */ |
| Rust | // @ADR-... | // @ADR-... |
| Ruby | # @ADR-... | # @ADR-... |
| Shell | # @ADR-... | n/a |
| JSON | SKIP — no comment syntax | |
| YAML/TOML | SKIP — supports # but fragile; formatters/tools may strip inline comments. Tag the code that reads the config instead. | |
In assisted mode, show proposed placements with file + line before writing:
ADR-0004 tag placements:
src/events/processor.py:42 → process_events()
src/events/queue.py:10 → class EventQueue
Confirm? [yes / adjust / skip tagging]
Verify tag syntax (after placement)
A misplaced comment can break a file. After writing tags, parse-check every file you touched:
python3 scripts/judge.py check-tags . <file1> <file2> ... --json
ok → the tag parsed cleanly, keep it.
fail → the tag broke the file. Revert that one insertion (or move it to a safer line — e.g. above the def/func rather than inside a signature) and re-check.
skipped → no syntax checker for that language, or the tool isn't installed. The tag is kept un-verified; note it in the run summary.
Syntax-check coverage is a fixed set: Python, JavaScript (.js/.mjs/.cjs), Ruby, Shell (.sh/.bash), and Go (needs gofmt on PATH). TypeScript, Java/Kotlin, and Rust get tags placed but are not parse-checked yet — they return skipped. For those, eyeball the insertion point.
Workaround Logs (record_type: workaround)
analyze.py flags candidates whose diffs carry workaround evidence — HACK/WORKAROUND/KLUDGE/XXX markers, monkeypatch/polyfill/shim wording, FIXME/TODO with until/upstream/temporary, or a workaround-shaped file (shim/adapter/polyfill, patches/, *.patch) — as record_type: "workaround" with the matched lines in workaround_signals. These get a workaround log, not a MADR. An architectural candidate that also carries markers stays a MADR (signal: WORKAROUND: markers present) — mention the workaround inside that ADR instead.
Routing rules:
| Aspect | Rule |
|---|
| Template | references/workaround-log-template.md (Trigger / Workaround / Evidence / Scope / Removal Condition) |
| Output | same scope-resolved docs/decisions/ pool, same numbering sequence |
| Filename | NNNN-wa-<slug>.md (the -wa- infix is the convention; type: frontmatter is the contract) |
| Frontmatter | type: workaround, status: active |
| Tag | @WA-NNNN-<slug>: <summary> — see docs/decisions/NNNN-wa-<slug>.md — ONE tag, at the workaround site itself |
| Verification | same judge.py verify-adr --commits ... call — for type: workaround it verifies the Evidence section instead of Considered Options; cite added lines with marker:"<exact line>" |
The Removal Condition is the point. A decision records why; a workaround records until when. Always extract a mechanical removal condition from the marker comment or commit message ("remove when aws-sdk >= 3.500"); if none is recoverable, write "No removal condition recorded — review periodically." Never invent one.
Lifecycle (re-runs and CI):
python3 scripts/judge.py check-workarounds <repo> [--check-upstream] [--json]
Scans all type: workaround records: an active record whose cited marker: line is gone from the working tree is flagged "possibly removed — update status"; with --check-upstream (needs gh), a closed upstream issue linked in the record is flagged "removal condition may be met". The tool only reports — update status: to removed/superseded by hand after confirming.
Autonomous Mode Summary
Print at the end:
ADR Generation Complete
───────────────────────
Mode: Autonomous / squash-boundary
Scope: src/payments (since v2.1.0)
Clone depth: full ✓
Commits scoped: 142
Chunks found: 23
Architectural: 8
Borderline: 4 (skipped — use assisted mode to review)
Skipped: 11
ADRs written: 8 (docs/decisions/0004–0011)
Workaround logs: 2 (docs/decisions/0012-wa-*, 0013-wa-*)
Tags placed: 19 @ADR + 2 @WA markers across 13 files
Skipped tagging: 2 files (JSON — no comment syntax)
Failure Modes to Handle Explicitly
| Situation | Action |
|---|
| Shallow clone | STOP, tell user to unshallow |
| >500 commits, no scope given | Warn, default to last 200, suggest since: |
| Chunk diff > ~50KB | Summarize file list only, note "diff too large to fully analyze" in MADR |
| Commit message is empty or just "." | Rely entirely on diff signals; note in MADR |
| Binary files in diff | Skip binary files, note them in MADR links section |
| docs/decisions/ doesn't exist | Create it silently |
| ADR already exists for these commits | Skip, log "already covered by ADR-NNNN" |
Reference Files
references/madr-examples.md — 2 full worked examples: one with evidenced alternatives, one with none
references/language-tag-patterns.md — comment syntax edge cases (decorators, JSDoc, annotation processors)
references/workaround-log-template.md — workaround log template, worked example, status lifecycle, @WA tagging