diff --git a/skills/lineage/SKILL.md b/skills/lineage/SKILL.md new file mode 100644 index 0000000..bf206d4 --- /dev/null +++ b/skills/lineage/SKILL.md @@ -0,0 +1,163 @@ +--- +name: lineage +surfaces: [claude-code, library] +summary: "Backward decision archaeology — was this tried before, and does the reason it failed still hold" +use_when: "Before proposing something that feels new; when an idea keeps resurfacing; when you inherit code whose reason is gone" +description: Backward decision archaeology. Find out whether an idea was already tried here, why it was dropped, and whether that reason still applies — using git history, decision records and the issue tracker rather than asking whoever is still around. Use before proposing a change that feels novel, when inheriting unexplained code, or when the same idea keeps being re-litigated. +--- + +# Lineage + +## Overview + +Most "new" ideas in an established codebase are revivals. The valuable question is never +"is this a good idea" in the abstract — it is **"was this tried, what killed it, and is +the killer still alive?"** + +A past failure is not evidence against trying again. It is context for trying again +differently, and the difference between those two readings is usually one commit message. + +## When to Use + +- Before proposing something that feels new in a codebase older than you +- When an idea keeps resurfacing and being re-argued from scratch +- When you inherit code and cannot tell whether a constraint is load-bearing or vestigial +- Before writing an ADR — the reason a thing was dropped belongs in your Context section + +**Do not use for:** code you wrote this week, or a question the current code answers. +Archaeology on a live construction site is just reading. + +## The tool + +```sh +skills/lineage/lineage.sh [repo-path] +``` + +Runs the searches people skip because they are fiddly to type. In particular +`git log -S`, which finds commits where the term's **occurrence count changed** — +so it surfaces the *removal* as well as the introduction. A plain `--grep` never finds a +deletion, and the deletion is the whole point. + +It also lists files that once carried the term and no longer exist, and names the two +sources it cannot search for you. + +**A no-match is not a finding.** The first version of this tool said a null meant "this is +new here"; a pressure test caught it asserting exactly that about the most-discussed +removed feature in this repo — the search term merely had a space in it. One string +against one repo proves nothing. Try at least three variants (the identifier name, a +one-word form, the name it would have had) and the brain and tracker searches before +telling anyone an idea is novel. This is the same rule as the anti-patterns table below, +and it is there because the tool broke it. + +Note also that `--grep` takes a regex and `-S` takes a literal string, so a term +containing `.`, `[`, `*` or `+` matches differently in the two halves of the output. + +## The sequence + +**1. Search on the identifier, not the concept.** If the thing had a name in the code — +a function, a flag, a config key — `git log --all -S` typically returns exactly two +commits, its birth and its death, on one screen. That is the fastest path to the answer in +this whole sequence. + +Path-scoped log (`git log --all -- `) is the weaker move and it is the one people +reach for: a function deleted from a file that still exists is the *dominant* removal +shape, and the path search buries it under every other commit that touched the file. + +Concept words are the fallback, and they are lossy — a term with a space in it will miss +the record entirely. Try several spellings before you conclude anything. + +**2. Read the removal commit's full body, not its subject.** + +```sh +git log -1 --format=%B +git show --stat +``` + +This is where the answer almost always is. A subject line says *what*; the body says +*why*, and the why is what you came for. Read the diff too — what was removed alongside +it tells you how load-bearing it was. + +**3. Search outside git — that is where this estate keeps its reasoning.** Two sources, +both routinely decisive, neither reachable from a commit log: + +- **The brain** (`brain_query`). Postmortems and generalized rules live there. In the run + that shaped this skill, the single best artifact — the rule abstracted from the incident, + with detection one-liners — came from the brain and from nowhere else. +- **The issue tracker, including comments.** The commit records *that* something was + removed; the argument for it is usually in a comment. Search closed issues especially — + a closed issue is where a decision usually is. + +A git-only search finds the what and misses the why. + +**4. Test whether the failure conditions still hold — empirically, not from the record.** +This is the step that makes the whole exercise worth doing, and it is the one that gets +hand-waved. Write the conditions down as separate claims, then **run a command per claim**: + +| Claim from the record | How you check it today | +|---|---| +| "absolute links break across two path roots" | `readlink` one, `test -e` it on this host | +| "it is gitignored, so nobody sees the rot" | `grep` the ignore file | +| "the detector does not cover it" | `grep` the detector for the path it scans | + +Twenty seconds each, and it converts *"the record claims X"* into *"X is true right now"* — +which is the only form of the answer worth giving. A constraint that has since been +removed turns a dead idea live; one that is still true kills a revival on the spot. + +**5. Separate the mechanism from the goal.** A revival proposal usually bundles both, and +they can have opposite verdicts: the goal is still worth wanting, the mechanism is the one +that failed. Splitting them is often the whole finding — you end up agreeing with the +proposer and rejecting their implementation, which is a far more useful answer than yes +or no. + +## What "it was tried and failed" can mean + +| Reading | Tell | What to do | +|---|---|---| +| **Structurally impossible** | The removal commit says *why* it cannot work, not that it broke | Believe it. Look for a different mechanism for the same goal | +| **Failed under conditions that changed** | The blocker names a version, a host, a tool that has since moved | Re-open it, citing what changed | +| **Failed on execution** | It broke, was reverted, nobody diagnosed it | Genuinely undecided. The revival is legitimate; budget for the diagnosis | +| **Never actually tried** | Discussed, an issue exists, no commit | There is no evidence either way. Say so rather than inheriting someone's pessimism | +| **Still there, in a different shape** | The idea returned under another name | The most missable one — check before you build it twice | + +## Output + +Answer the question that was asked, then show your work: + +- **Verdict** — tried / not tried / tried and later restored differently +- **The decisive artifact** — commit sha, issue number, or ADR, so the reader can check +- **What killed it**, quoted from the source rather than paraphrased +- **Whether that still applies**, per condition, not as a single yes or no + +If the decisive evidence is one commit message, quote it. A conclusion that makes the +reader re-run your search has not saved them anything. + +## Anti-patterns + +| Anti-pattern | Why it fails | +|---|---| +| `--grep` only | Finds discussion, misses deletions. The deletion is the evidence | +| Reading the commit subject and stopping | Subjects say what; you need why | +| Treating a past failure as a verdict | Conditions change. That is the entire question | +| Git-only search | The argument is usually in the issue tracker | +| "It was tried and it failed" with no sha | Unverifiable, and it will be re-litigated next quarter | +| Assuming absence means it was never considered | It may have been rejected in a conversation. Say what you searched | + +## Escalation + +Read-only. Reports what the record says; changes nothing and revives nothing. + +Stop and hand back when: the record is genuinely silent — no commit, no issue, no ADR. +State what you searched and that nothing exists, rather than inferring intent from +absence. Someone deciding not to build a thing and nobody ever considering it look +identical in git, and only one of them is an argument. + +Also stop when the answer turns on why a *person* did something and the record does not +say. Ask them. Reconstructing a colleague's reasoning from a diff is how a guess acquires +a citation. + +## Cross-References + +- `adr` — what you find here belongs in the Context section of the ADR you are about to + write. If the thing was decided before, your ADR supersedes that one; say so. +- `debug` — the same discipline aimed at a failure rather than a decision: evidence + before hypothesis, and the removal commit is evidence. diff --git a/skills/lineage/lineage.sh b/skills/lineage/lineage.sh new file mode 100755 index 0000000..da27123 --- /dev/null +++ b/skills/lineage/lineage.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# lineage.sh — decision archaeology for one term. +# +# Answers "why does X exist, was it tried before, and is this 'new' idea a +# revival?" +# +# Its real value is that it makes you run `git log -S` at all — that is a nudge, +# not a capability. -S finds commits where the term's occurrence COUNT changed, +# so it surfaces a removal; --grep only finds people talking about one. On an +# identifier name (`wire_claude_repo`) -S typically returns exactly two commits, +# birth and death, which is the fastest path to the answer in this whole file. +# +# Note the two searches do not match the same way: --grep takes a regex, -S takes +# a literal string. A term containing . [ * + behaves differently in each. +# +# Usage: lineage.sh [repo-path] +# +# Read-only: nothing here writes, and it never touches the index or worktree. + +set -u + +[ $# -ge 1 ] || { echo "usage: lineage.sh [repo-path]" >&2; exit 2; } +TERM_=$1 +REPO=${2:-.} + +git -C "$REPO" rev-parse --git-dir >/dev/null 2>&1 || { + echo "lineage: $REPO is not a git repository" >&2; exit 2; } + +# Say which repo, always. The default is the cwd, and in a worktree-heavy setup +# that is routinely not the repo the caller means — a confident null against the +# wrong tree is the worst thing this tool can produce. +TOPLEVEL=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$REPO") +printf 'searching: %s term: "%s"\n' "$TOPLEVEL" "$TERM_" + +FMT='%h %ad %an %s' +DATE='--date=short' +found=0 + +section() { printf '\n== %s ==\n' "$1"; } + +# 1. Commits whose MESSAGE mentions it — the stated reasoning. +msgs=$(git -C "$REPO" log --all -i --grep="$TERM_" --format="$FMT" $DATE 2>/dev/null || true) +if [ -n "$msgs" ]; then + found=1 + section "Commits that talk about it (message)" + printf '%s\n' "$msgs" +fi + +# 2. Commits where the term was ADDED or REMOVED — the actual history. +# -S counts occurrences, so a deletion shows up here and nowhere else. +picks=$(git -C "$REPO" log --all -S"$TERM_" --format="$FMT" $DATE 2>/dev/null || true) +if [ -n "$picks" ]; then + found=1 + section "Commits where it appeared or disappeared (-S)" + printf '%s\n' "$picks" + printf '\n Oldest is the introduction. Anything after it that removed the term is\n' + printf ' the interesting one: read that commit before proposing this again.\n' +fi + +# 3. Files that once contained it and no longer exist. +deleted=$(git -C "$REPO" log --all --diff-filter=D --name-only --format='%h' -S"$TERM_" 2>/dev/null \ + | grep -v '^[0-9a-f]\{7,\}$' | grep -v '^$' | sort -u || true) +if [ -n "$deleted" ]; then + found=1 + section "Files deleted that carried it" + printf '%s\n' "$deleted" + printf '\n Recover one with: git -C %s show ^:\n' "$REPO" +fi + +# 4. Decision records naming it. +docs=$(git -C "$REPO" grep -l -i "$TERM_" -- 'docs/adr/*' 'docs/decisions/*' 'docs/*.md' 'ADR*' 2>/dev/null || true) +if [ -n "$docs" ]; then + found=1 + section "Decision records naming it" + printf '%s\n' "$docs" +fi + +# This estate keeps most of its reasoning OUTSIDE the repo, so the two searches +# this tool cannot run are usually the decisive ones. Say so every time rather +# than letting their absence read as a null result. +section "Not searched here — do these by hand" +printf 'brain: brain_query "%s" — decisions and postmortems live there, not in docs/\n' "$TERM_" +printf 'gitea: closed issues and their COMMENTS. The argument for a removal is\n' +printf ' usually in a comment; the commit only records that it happened.\n' + +if [ "$found" -eq 0 ]; then + printf '\nno match for the exact string "%s" in %s.\n' "$TERM_" "$TOPLEVEL" + printf 'This is NOT evidence the idea is new. It is one string against one repo.\n' + printf 'Before concluding anything, try: the identifier or function name; a\n' + printf 'one-word variant (spaces and punctuation break the match); the name it\n' + printf 'would have had; and the brain and issue searches above.\n' + exit 0 +fi + +printf '\n-- what this cannot tell you --------------------------------------------\n' +printf 'Why it was removed, if it was. The commit subject is a summary; read the\n' +printf 'body, the PR, and the issue it closed. A past failure under conditions that\n' +printf 'no longer hold is not evidence against trying again — it is context for\n' +printf 'trying again differently.\n' diff --git a/tests/lineage_test.sh b/tests/lineage_test.sh new file mode 100644 index 0000000..040678e --- /dev/null +++ b/tests/lineage_test.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Tests for skills/lineage/lineage.sh — the decision-archaeology helper. +# +# The techniques this automates are the ones people skip because they are +# fiddly to type: `git log -S` to find when a thing was introduced AND removed, +# --diff-filter=D to find files that once existed, and grepping decision records +# for the term. A skill that only *describes* those is a skill that gets read and +# not run (#11, #19's "small robust tools over prose"). + +set -u + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +SUT="$ROOT/skills/lineage/lineage.sh" + +pass=0 +fail=0 +WORK=${TMPDIR:-/tmp}/lineage_test.$$ + +if [ ! -f "$SUT" ]; then + printf 'FAIL: %s does not exist\n' "$SUT" + exit 1 +fi + +cleanup() { rm -rf "$WORK"; } +trap cleanup EXIT INT TERM + +# A repo where a thing was built, written about, and then removed. +mkdir -p "$WORK/repo/docs/adr" +cd "$WORK/repo" || exit 1 +git init -q . +git config user.email t@example.com +git config user.name test + +printf 'func WidgetCache() {}\n' > cache.go +git add -A && git commit -qm "feat: add WidgetCache for lookup speed" + +printf -- '---\nadr: 0001\ntitle: use a WidgetCache\n---\nWe chose WidgetCache over a database index.\n' > docs/adr/0001-widgetcache.md +git add -A && git commit -qm "docs: record the WidgetCache decision" + +git rm -q cache.go +git commit -qm "revert: drop WidgetCache, it went stale under concurrent writes" + +cd - >/dev/null || exit 1 +out=$(sh "$SUT" WidgetCache "$WORK/repo" 2>&1) + +check() { + if printf '%s' "$out" | grep -q "$1"; then + pass=$((pass + 1)); printf ' ok %s\n' "$2" + else + fail=$((fail + 1)); printf ' FAIL %s\n output had no %s\n%s\n' "$2" "$1" "$out" + fi +} + +check 'add WidgetCache' 'finds the commit that introduced it' +check 'drop WidgetCache' 'finds the commit that removed it — the revival question' +check '0001-widgetcache.md' 'finds the decision record naming it' +check 'cache.go' 'names the file that no longer exists' + +# A no-match must NOT be reported as evidence the idea is new. The first version +# of this tool said "it means this is new here" and a pressure test caught it +# asserting exactly that about the most-discussed removed feature in this repo — +# the search term simply had a space in it. That is the inference the skill's own +# anti-pattern table forbids, printed in the tool's voice. +out=$(sh "$SUT" NeverExisted "$WORK/repo" 2>&1) +if printf '%s' "$out" | grep -q 'NOT evidence the idea is new' \ + && printf '%s' "$out" | grep -qi 'identifier'; then + pass=$((pass + 1)); printf ' ok a no-match refuses to imply novelty and suggests other terms\n' +else + fail=$((fail + 1)); printf ' FAIL a no-match refuses to imply novelty and suggests other terms\n%s\n' "$out" +fi + +# The repo actually searched is always stated — a confident null against the +# wrong tree is this tool's worst failure mode, and the default is the cwd. +out=$(sh "$SUT" WidgetCache "$WORK/repo" 2>&1) +if printf '%s' "$out" | grep -q "searching: "; then + pass=$((pass + 1)); printf ' ok names the repo it searched\n' +else + fail=$((fail + 1)); printf ' FAIL names the repo it searched\n%s\n' "$out" +fi + +# The sources it cannot reach are named every run, so their absence never reads +# as a null result. +if printf '%s' "$out" | grep -q 'brain_query' && printf '%s' "$out" | grep -qi 'closed issues'; then + pass=$((pass + 1)); printf ' ok names the sources it cannot search\n' +else + fail=$((fail + 1)); printf ' FAIL names the sources it cannot search\n%s\n' "$out" +fi + +printf '\nlineage: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ]