Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .claude/skills/radar-decision-log/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
name: radar-decision-log
description: Use when the user wants a timeline, history, decision log, or movement report for tech radar entries — for example "show the history of React on the radar", "decision log for terraform", "which techs moved last year", "generate an ADR index", "what changed between snapshots", or "build me a movement table". Reads radar/<date>/ snapshots, reconstructs the per-tech ring history across time, and emits a chronological decision log similar to an ADR index. Can scope to a single tech, a quadrant, or the whole radar. Trigger when the user asks about radar history or wants to see how decisions evolved.
---

# radar-decision-log

Reconstruct the temporal decision log for INFO tech radar entries.

## When this fires

- "What's the history of Terraform on the radar?"
- "Show me everything that moved into hold since 2023."
- "Generate the ADR index for the radar."
- "Which techs were added in 2024?"
- "Diff the last two snapshots."

## How to read snapshots

`radar/` contains dated dirs (`YYYY-MM-DD/`). Each dir holds *only the entries that were created or changed on that date*. To reconstruct a tech's history:

1. List every dated dir in chronological order.
2. For each tech (filename slug), find every snapshot that contains it.
3. Read frontmatter from each occurrence to capture `ring` over time.
4. The first occurrence = creation. Subsequent occurrences = ring changes or prose updates. The last occurrence = current state.

A tech that appears once and never again is still active — its state is whatever the last snapshot said.

## Modes

### Single-tech timeline

Input: one slug (e.g. `terraform`). Output: a table of every snapshot that touched it.

```markdown
## Terraform — decision log

| Date | Ring | Quadrant | Note |
|------------|-------|---------------------------|------|
| 2022-09-22 | trial | platforms-and-operations | Added |
| 2022-12-05 | trial | platforms-and-operations | Prose update |
| 2024-12-01 | adopt | platforms-and-operations | Promoted |

**Current ring:** adopt (as of 2024-12-01)
```

If the user wants the *why*, also surface the supersession note or the diff of body text between consecutive entries.

### Quadrant or whole-radar movement report

Input: a date range, optionally a quadrant filter. Output: every ring change in that range.

```markdown
## Movement report — 2024-01-01 to 2025-01-22

### platforms-and-operations
- **Terraform**: trial → adopt (2024-12-01)
- **Pulumi**: → assess (2024-12-01, new)

### tools
- **React Testing Library**: → adopt (2025-01-22, new)
- **Cypress**: → hold (2024-12-01)
```

Group by quadrant; within a quadrant, order by snapshot date then alphabetical.

### ADR index

Input: none. Output: a flat list of every entry with its current ring, sorted by quadrant, formatted like an ADR index. Useful as a published-artifact rendering of the radar.

```markdown
## INFO Tech Radar — Decision Index

### Languages & Frameworks
- [React](/languages-and-frameworks/react) — adopt (since 2020-09-01, last touched 2023-03-01)
- ...

### Methods & Patterns
- ...
```

## Procedure

1. Confirm scope: single tech / quadrant / whole radar / date range.
2. Run `scripts/scan_radar.py` (bundled with this skill, mirrored from `radar-entry-audit`) to get a JSON index. It already builds `slug → [(date, ring, quadrant, ...), ...]` for you — no need to re-walk the tree in the conversation.

```bash
# Whole-radar dump:
python3 .claude/skills/radar-decision-log/scripts/scan_radar.py --root radar > /tmp/radar.json

# Single tech:
python3 .claude/skills/radar-decision-log/scripts/scan_radar.py --slug terraform
```

3. Use the JSON's `entries.<slug>` for timelines and `current` for "what's the current ring".
4. Render the requested view.
5. If the user asks for *why* a transition happened, read the supersession note (if any) from the entry on that date, or diff the body against the previous occurrence. Note: the repo's idiomatic move-style is prose, not a `> **Status:**` blockquote — so look at the opening paragraph of the new entry, not just metadata.

## Output rules

- Always include the date a transition happened — the radar's value is the timeline, not just the current state.
- For "current ring", trust the most recent snapshot containing the slug.
- If a slug appears in only one snapshot, label it "Added <date>" and use that ring as current.
- Don't infer a "removed" status from absence — the radar doesn't model deletion. If a tech genuinely should be removed, that's a `hold` entry, not a missing file.

## Anti-patterns

- Don't assume snapshots are full re-statements. They aren't — they're differential. Reading only the latest snapshot misses every tech that hasn't changed recently.
- Don't sort by file path. Always sort by snapshot date for timelines.
- Don't generate the report by hand for large scans. Script it.
26 changes: 26 additions & 0 deletions .claude/skills/radar-decision-log/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"skill_name": "radar-decision-log",
"evals": [
{
"id": 0,
"name": "react-history",
"prompt": "Show me the full history of the React entry on the radar.",
"expected_output": "report.md with a chronological table of every snapshot containing react.md, columns: date, ring, quadrant, note. Current ring stated.",
"files": []
},
{
"id": 1,
"name": "movement-report",
"prompt": "Generate a movement report of every ring change between snapshots from 2024-01-01 onward.",
"expected_output": "report.md grouping ring changes by quadrant, listing tech, transition (old → new), date.",
"files": []
},
{
"id": 2,
"name": "adr-index",
"prompt": "Build me an ADR-style index of every current radar entry, grouped by quadrant.",
"expected_output": "report.md with each quadrant as a section, each entry listed with its current ring and the date it was last touched.",
"files": []
}
]
}
117 changes: 117 additions & 0 deletions .claude/skills/radar-decision-log/scripts/scan_radar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
scan_radar.py — walk radar/<YYYY-MM-DD>/<slug>.md, parse frontmatter, emit JSON.

Designed to be shared by radar-entry-audit and radar-decision-log. Reading the
whole tree once and emitting a structured index avoids each skill reinventing
the file walk + frontmatter parser.

Output shape:

{
"snapshots": ["2020-09-01", ...], # chronological
"entries": { # one record per (date, slug)
"react": [
{"date": "2020-09-01", "ring": "adopt", "quadrant": "...", "title": "...",
"featured": true, "path": "radar/2020-09-01/react.md",
"body_chars": 1234, "internal_links": ["/tools/vitest/", ...]},
...
]
},
"current": { # latest occurrence per slug
"react": {"date": "...", "ring": "...", "quadrant": "...", "path": "..."}
}
}

Usage:
python scan_radar.py [--root radar/] [--quadrant tools]
# prints JSON to stdout
"""
from __future__ import annotations
import argparse, json, re, sys
from pathlib import Path

DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
LINK_RE = re.compile(r"\]\((/[a-z0-9-]+/[a-z0-9-]+/?)\)")
FM_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)

VALID_RINGS = {"adopt", "trial", "assess", "hold"}
VALID_QUADRANTS = {
"languages-and-frameworks", "methods-and-patterns",
"platforms-and-operations", "tools",
}


def parse_frontmatter(text: str) -> tuple[dict, str]:
m = FM_RE.match(text)
if not m:
return {}, text
fm_text, body = m.groups()
fm: dict = {}
for line in fm_text.splitlines():
if ":" not in line:
continue
k, _, v = line.partition(":")
v = v.strip().strip('"').strip("'")
if v in ("true", "false"):
v = v == "true"
fm[k.strip()] = v
return fm, body


def scan(root: Path) -> dict:
snapshots = sorted(
p.name for p in root.iterdir() if p.is_dir() and DATE_RE.match(p.name)
)
entries: dict[str, list[dict]] = {}
for date in snapshots:
for md in sorted((root / date).glob("*.md")):
slug = md.stem
text = md.read_text(encoding="utf-8")
fm, body = parse_frontmatter(text)
entries.setdefault(slug, []).append({
"date": date,
"slug": slug,
"ring": fm.get("ring"),
"quadrant": fm.get("quadrant"),
"title": fm.get("title"),
"featured": fm.get("featured"),
"path": str(md),
"body_chars": len(body.strip()),
"internal_links": LINK_RE.findall(body),
})
current = {
slug: occurrences[-1]
for slug, occurrences in entries.items()
}
return {"snapshots": snapshots, "entries": entries, "current": current}


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", default="radar", type=Path)
ap.add_argument("--quadrant", help="filter current entries by quadrant")
ap.add_argument("--slug", help="dump only this slug's history")
args = ap.parse_args()

if not args.root.is_dir():
print(f"error: {args.root} not a directory", file=sys.stderr)
return 1

data = scan(args.root)

if args.slug:
data = {"slug": args.slug, "history": data["entries"].get(args.slug, [])}
elif args.quadrant:
data["current"] = {
slug: rec for slug, rec in data["current"].items()
if rec.get("quadrant") == args.quadrant
}

json.dump(data, sys.stdout, indent=2, default=str)
print()
return 0


if __name__ == "__main__":
sys.exit(main())
100 changes: 100 additions & 0 deletions .claude/skills/radar-entry-audit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
name: radar-entry-audit
description: Use when the user wants to review, audit, lint, or quality-check radar entries — for example "audit the radar", "review my radar entries", "check the radar for issues", "are these entries ADR-compliant", "find broken links in the radar", "which entries are missing rationale", or after a batch of new entries has been added. Scans markdown files under radar/ and reports on frontmatter completeness, ADR-shape rationale (context / decision / consequences), internal-link integrity, slug/title consistency, and orphan entries (present in old snapshots but never updated). Also flags entries that read like marketing rather than decisions. Trigger proactively after radar-entry-new or radar-entry-move runs.
---

# radar-entry-audit

Lint INFO tech radar entries against ADR-style quality criteria.

## When this fires

- "Audit the radar."
- "Review the latest snapshot."
- "Are there broken links in radar/?"
- "Which entries are missing a rationale?"
- After bulk additions or before publishing.

## What "good" looks like

A good radar entry is a small ADR. It has:

1. **Complete frontmatter** — `title` (quoted), `ring` (one of adopt/trial/assess/hold), `quadrant` (one of the four ids), `featured` (bool).
2. **A decision, not a description** — the body explains *why we chose this here*, not just what the tech does.
3. **Consequences captured** — a "Considerations at INFO" (or equivalent) section noting trade-offs, current focus, or constraints.
4. **Working internal links** — links of the form `/[quadrant]/[slug]/` resolve to a file under some `radar/*/<slug>.md`.
5. **Slug ↔ title coherence** — filename slug clearly matches the `title` field.

## Audit dimensions

Run these checks across all entries (or a user-specified subset like "the latest snapshot only"):

### 1. Frontmatter
- Missing fields → flag.
- `ring` outside the allowed set → flag.
- `quadrant` outside the allowed set → flag.
- Title not quoted, or empty → flag.

### 2. ADR shape
- Body shorter than ~80 words → likely a stub. Flag with severity *info* (some legitimate stubs exist, e.g. `superblocks.md`).
- No "why" framing — no occurrence of words like "because", "we decided", "in the context", "current focus", "considerations" — flag as *missing rationale*.
- Reads like marketing — heavy use of "powerful", "seamless", "best-in-class" without concrete INFO context — flag as *too promotional*.

### 3. Internal links
- For each `[text](/quadrant/slug/)` or `[text](/quadrant/slug)` link in the body:
- `quadrant` segment must be one of the four valid quadrant ids.
- At least one file `radar/*/<slug>.md` must exist.
- External links (http/https) — optionally check, but don't block on flakiness.

### 4. Cross-snapshot coherence
- For each slug, find every snapshot containing it. The latest occurrence is the "current" entry.
- If an entry's `featured: true` but the tech only appears in snapshots older than ~2 years → flag *possibly stale*.
- If an entry was moved to `hold` and a newer entry replaces it without a supersession note → flag *missing supersession link*.

### 5. Slug / title
- Slug should be the kebab-case lowercase form of the title (allowing for minor abbreviations like "and" → "and", "&" handled). Mismatches like `title: "Postgres"` in `postgresql.md` → flag.

## Procedure

1. Decide scope. Default = all entries under `radar/`. If the user names a snapshot, restrict to that dir.
2. Run `scripts/scan_radar.py` (bundled with this skill) to get a JSON index of every entry — frontmatter, body length, internal links, current ring per slug. Don't re-parse files one-by-one in the conversation; pipe the JSON through `jq`/Python for the actual checks. The script is also shared with `radar-decision-log` so the two skills produce comparable indices.

```bash
python3 .claude/skills/radar-entry-audit/scripts/scan_radar.py --root radar > /tmp/radar.json
```

The output's `current` map is what you want for "is this the latest entry for the slug"; the per-slug `entries` list is the history.
3. Group findings by severity:
- **Error** — frontmatter invalid, broken internal link.
- **Warning** — missing rationale, missing supersession note.
- **Info** — short stubs, possibly stale, slug/title mismatch.
4. Report as a table grouped by file. For each finding give: file path, dimension, what's wrong, suggested fix. Keep it scannable.
5. Offer to auto-fix the trivial ones (frontmatter quoting, slug case) but ask before editing.

## Output shape

```markdown
## Radar audit — <scope>, <N> entries

### Errors (must fix)
| File | Issue | Fix |
| ---- | ----- | --- |
| radar/.../foo.md | ring "adopted" not in {adopt,trial,assess,hold} | change to `adopt` |

### Warnings
...

### Info
...

### Summary
- Errors: X
- Warnings: Y
- Info: Z
```

## Anti-patterns

- Don't auto-rewrite prose. "Reads like marketing" is a flag, not a fix — surface it, let a human rephrase.
- Don't enforce a strict template on body sections. The "Why X / Considerations at INFO" pattern is common but not universal; allow Nygard-sentence entries (see `radar/2020-09-01/react.md`).
- Don't treat `featured: false` as a defect. Some entries are deliberately unfeatured (industry givens).
26 changes: 26 additions & 0 deletions .claude/skills/radar-entry-audit/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"skill_name": "radar-entry-audit",
"evals": [
{
"id": 0,
"name": "snapshot-audit",
"prompt": "Audit the latest snapshot at radar/2025-01-22 for ADR quality issues.",
"expected_output": "A report.md with findings grouped by severity (errors/warnings/info) for each file in radar/2025-01-22, covering frontmatter, rationale, internal links, slug/title.",
"files": []
},
{
"id": 1,
"name": "broken-links",
"prompt": "Run an audit across the whole radar/ tree and report broken internal links.",
"expected_output": "report.md listing each /quadrant/slug/ link in any radar entry that does not resolve to an existing file under radar/*/<slug>.md. Indicate file + line + bad link.",
"files": []
},
{
"id": 2,
"name": "slug-mismatch",
"prompt": "Find any radar entries where the slug filename doesn't match the title field.",
"expected_output": "report.md listing files where the kebab-case form of `title:` does not equal the filename slug.",
"files": []
}
]
}
Loading
Loading