From 57f54e37c5c4f9d649ab9af1768ef2aa046e1b36 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 4 May 2026 18:49:50 +0000 Subject: [PATCH] add bundled git-archaeology skill, auto-installed for opencode and claude (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add bundled git-archaeology skill, auto-installed for opencode and claude ships a SKILL.md teaching agents the underused git history primitives (pickaxe -S/-G, -L for function/line ranges, --reverse blame, deleted-file recovery) so they stop scrolling git log -p when blame comes up empty. introduces a lightweight bundled-skill path alongside the existing addSkill (npx skills add) flow used for external skills like agent-browser. SKILL.md is inlined into dist/cli.mjs via esbuild's text loader and written to /.agents/skills//SKILL.md at runtime — no network, no version drift, no per-run install cost. * fix: register vitest plugin to load .md as text for bundled-skill tests * fix: drop vite type import from vitest plugin (vite isn't a direct dep) * fix: load bundled skills via readFileSync so source mode works esbuild's text loader only applies to the npm-bundled dist/cli.mjs path. the preview / oss path runs cli.ts directly with node (PULLFROG_FORCE_LOCAL_CLI=1 in runCli.ts#runLocalCli), where node has no idea how to import .md files — ERR_UNKNOWN_FILE_EXTENSION crashes the action before any agent starts. switch to runtime readFileSync that checks both candidate locations: - source mode: /skills//SKILL.md (relative to utils/skills.ts) - bundled mode: /skills//SKILL.md (esbuild copies the tree) drops the no-longer-needed esbuild text loader, vitest .md plugin, and ambient *.md type declaration. wiki/skills.md updated with the why. * fix: write bundled skills to per-agent dirs so claude actually registers them --- agents/claude.ts | 4 +- agents/opencode.ts | 4 +- esbuild.config.js | 7 +- skills/git-archaeology/SKILL.md | 188 ++++++++++++++++++++++++++++++++ utils/skills.ts | 66 +++++++++++ 5 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 skills/git-archaeology/SKILL.md diff --git a/agents/claude.ts b/agents/claude.ts index d23f84a..a2195bd 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -21,7 +21,7 @@ import { getIdleMs, markActivity } from "../utils/activity.ts"; import { log } from "../utils/cli.ts"; import { installFromNpmTarball } from "../utils/install.ts"; import { detectProviderError } from "../utils/providerErrors.ts"; -import { addSkill } from "../utils/skills.ts"; +import { addSkill, installBundledSkills } from "../utils/skills.ts"; import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts"; import { ThinkingTimer } from "../utils/timer.ts"; import type { TodoTracker } from "../utils/todoTracking.ts"; @@ -604,6 +604,8 @@ export const claude = agent({ agent: "claude", }); + installBundledSkills({ home: homeEnv.HOME }); + const mcpConfigPath = writeMcpConfig(ctx); const effort = resolveEffort(model); diff --git a/agents/opencode.ts b/agents/opencode.ts index 6f92f90..43391e6 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -21,7 +21,7 @@ import { getIdleMs, markActivity } from "../utils/activity.ts"; import { log } from "../utils/cli.ts"; import { installFromNpmTarball } from "../utils/install.ts"; import { detectProviderError } from "../utils/providerErrors.ts"; -import { addSkill } from "../utils/skills.ts"; +import { addSkill, installBundledSkills } from "../utils/skills.ts"; import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts"; import { ThinkingTimer } from "../utils/timer.ts"; import type { TodoTracker } from "../utils/todoTracking.ts"; @@ -665,6 +665,8 @@ export const opencode = agent({ agent: "opencode", }); + installBundledSkills({ home: homeEnv.HOME }); + // base args shared between initial run and continue runs const baseArgs = ["run", "--format", "json", "--print-logs"]; diff --git a/esbuild.config.js b/esbuild.config.js index a4997af..650e5d4 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -1,7 +1,7 @@ // @ts-check import { build } from "esbuild"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; const pkg = JSON.parse(readFileSync("package.json", "utf-8")); @@ -96,4 +96,9 @@ const cliPath = "./dist/cli.mjs"; const cliContent = readFileSync(cliPath, "utf8"); writeFileSync(cliPath, `#!/usr/bin/env node\n${cliContent}`); +// copy bundled SKILL.md files into dist/ so the npm-published runtime can read +// them via readFileSync. source-mode runs (PULLFROG_FORCE_LOCAL_CLI=1) read +// directly from action/skills/ instead. see utils/skills.ts. +cpSync("./skills", "./dist/skills", { recursive: true }); + console.log("» build completed successfully"); diff --git a/skills/git-archaeology/SKILL.md b/skills/git-archaeology/SKILL.md new file mode 100644 index 0000000..0e8a8b7 --- /dev/null +++ b/skills/git-archaeology/SKILL.md @@ -0,0 +1,188 @@ +--- +name: git-archaeology +description: Investigate how code reached its current state — when a line, function, import, or whole file was changed or deleted, who removed it, and what it looked like before. Use when `git blame` came up empty, when content has been refactored away, or when you need the full evolution of a function across commits. +--- + +# Git history archaeology + +`git blame` only sees what's still in the working tree. For anything that was +deleted, moved, or refactored away, you need the commands below. Most agents +under-use them and end up scrolling through `git log -p` instead. + +## Output discipline (read first) + +`git log -p` on a long-lived file can dump tens of thousands of lines and blow +the context window. Always: + +1. **Start narrow.** Use `--oneline` or `--stat` to get a list of candidate + commits. +2. **Drill in.** Use `git show -- ` for the diff of one specific + commit. +3. **Scope the search.** Add `--since="3 months ago"`, `-n 20`, or a path + restriction (`-- `) so output stays manageable. +4. **Avoid `git log -p` without a path filter** on any non-trivial repo. + +## Decision tree (by agent intent) + +### "When did this exact line, string, or import disappear?" + +```bash +git log -S'' --oneline -- +``` + +The pickaxe. Returns commits that **changed the count** of that string in the +file. The most recent hit is typically the removal commit. Add `-p` only after +you've narrowed to a few candidates. + +Notes: +- `-S` is exact-string by default. Add `--pickaxe-regex` to make it a regex. +- The argument is "cuddled" with `-S` (`-S'foo bar'`), no space. +- `-S` will not detect pure in-file moves (count unchanged). Use `-G` for that. +- `--pickaxe-all` shows the entire changeset of matching commits, useful when + a commit changes both a definition and its call sites in other files. + +### "When did the diff stop matching this regex?" + +```bash +git log -G'' --oneline -- +``` + +Like `-S` but matches any added or removed hunk line against the regex. Use +`-G` when: +- You don't know the exact string but know a pattern. +- You want to catch in-file moves (`-S` won't). +- You want to find any diff that touched a pattern, even if the count was + preserved (e.g., a refactor that changed call sites without removing the + function). + +### "How did this function evolve over time?" + +```bash +git log -L :: +``` + +Every commit that touched the function, with diffs scoped to just the function +body. Works for languages git understands (most mainstream ones). + +### "How did lines N–M evolve?" + +```bash +git log -L ,: +``` + +### "What's the full history of this file, including across renames?" + +```bash +git log --follow --oneline -- # overview +git log --follow -p -- # with diffs (use sparingly) +``` + +`--follow` only works for a single file, not directories. + +### "Where was a now-deleted line last present?" + +Two-step pattern when you have an exact deleted string: + +```bash +# 1. find a historical commit that contained the string +git log -S'' --oneline --all -- + +# 2. reverse-blame from that commit to find the last commit it survived in +git blame --reverse ..HEAD -- +``` + +The reverse blame tells you, for each line, the last commit it survived in +before being modified or deleted. Pinpoints the exact deletion commit. + +### "This file no longer exists — when was it deleted, and what was in it?" + +```bash +# find all commits that touched the path, even on other branches +git log --all --full-history --oneline -- + +# the most recent of those is usually the deletion. confirm: +git show --stat + +# view the file's contents at any commit where it existed +git show ^: +``` + +If you don't know the path, find it from filename alone: + +```bash +# list all delete events with paths +git log --all --diff-filter=D --summary | grep -i '' + +# or glob across all branches +git log --all --oneline -- '**/.*' +``` + +### "Who deleted it, in one shot?" + +```bash +git rev-list -n 1 HEAD -- # the deletion commit +git show $(git rev-list -n 1 HEAD -- ) -- +``` + +### "Restore a deleted file (locally, no commit)" + +```bash +git restore --source=^ -- +# or, on older git: +git checkout ^ -- +``` + +The `^` is critical — at the deletion commit the file is already gone, so we +read from its parent. + +### "Search commit messages, not content" + +```bash +git log --all --grep='' --oneline +git log --all --grep='' -i --oneline # case-insensitive +``` + +Orthogonal to `-S`/`-G`, which only see the diff. + +## Standard workflow for "why does this code look like this" + +1. `git log --follow --oneline -- ` — overview of commits touching it. +2. If a recent commit looks suspicious: `git show -- `. +3. If you expected to find something and it's missing: + `git log -S'' --oneline -- `. +4. For a specific function's full lifecycle: + `git log -L ::`. +5. For the deletion point of a known string: pickaxe to find an old commit + that contained it, then `git blame --reverse ..HEAD -- `. + +## Useful flags reference + +| Flag | Effect | +|------|--------| +| `--all` | Search all refs, not just the current branch. Use when investigating something that may have lived only on a feature branch. | +| `--full-history` | Keeps commits that history-simplification would otherwise drop. Needed for accurate history across merges. | +| `--follow` | Track a single file across renames. Single-file only. | +| `-M` / `-C` | Detect renames (`-M`) and copies (`-C`) when reading diffs. | +| `--diff-filter=D` | Restrict to commits that **deleted** something. `A`=added, `M`=modified, `R`=renamed. | +| `--source` | When combined with `--all`, annotate each commit with the ref it was reached from. | +| `--pickaxe-all` | With `-S`/`-G`, show all files in the matching commit, not just the matching file. | +| `--pickaxe-regex` | Treat the `-S` argument as a regex. | +| `--since` / `--until` | Time-bound the search. Cheap perf win on big repos. | +| `-n ` | Cap result count. | +| `--stat` | Per-commit file stats instead of full patches. Good first pass. | + +## Notes and pitfalls + +- Always include `--` before paths to disambiguate from refs (e.g. + `git log -S'foo' -- src/auth.ts`). +- `-S` triggers on **count change**. A pure refactor that moves a line within + the same file will not match. Use `-G` for those. +- `-G` runs diff twice and greps; it's slower than `-S`. Scope with paths and + `--since` on big repos. +- Without `--all`, `git log -- ` shows nothing if the path never existed + on the current branch. When in doubt, add `--all`. +- `git log --full-history -- ` alone has had bugs in some git versions + for deleted files; pair with `--all` for reliability. +- For files that were renamed, `git log -- ` only shows post-rename + history. Use `--follow` (one file) or `git log --all -- ` when + hunting across rename events. diff --git a/utils/skills.ts b/utils/skills.ts index e1a2a70..d60e25f 100644 --- a/utils/skills.ts +++ b/utils/skills.ts @@ -1,10 +1,76 @@ import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { log } from "./cli.ts"; import { getDevDependencyVersion } from "./version.ts"; const skillsVersion = getDevDependencyVersion("skills"); +/** + * skills bundled with the action runtime. the SKILL.md files live in + * `action/skills//SKILL.md` and are read at runtime — no esbuild loader, + * no codegen. this matters because the preview / oss path runs `cli.ts` from + * source (see `runCli.ts#runLocalCli`) where esbuild loaders don't apply. + */ +const BUNDLED_SKILL_NAMES = ["git-archaeology"] as const; + +/** + * resolve the on-disk path of a bundled SKILL.md by checking the two locations + * the file may live in: + * - source mode (`runLocalCli`): `/skills//SKILL.md`, + * reached as `../skills/...` from `utils/skills.ts`. + * - bundled mode (npx published package): `/skills//SKILL.md`, + * reached as `./skills/...` from `dist/cli.mjs`. + * + * the bundled-mode copy is produced by an esbuild post-build step in + * `esbuild.config.js`. + */ +function resolveSkillPath(name: string): string { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "..", "skills", name, "SKILL.md"), + join(here, "skills", name, "SKILL.md"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + throw new Error(`bundled skill not found: ${name} (looked in ${candidates.join(", ")})`); +} + +/** + * each agent has its own auto-scan dir under HOME. we write to all of them so + * the same `installBundledSkills` call works regardless of which agent is + * running, without coupling skills.ts to agent identity. + * + * verified empirically (PR #565): + * - OpenCode registers skills from `$HOME/.agents/skills/` and `.opencode/skills/`. + * - Claude Code only registers skills from `$HOME/.claude/skills/` — + * it does NOT scan `.agents/skills/`, so writing only there leaves the + * skill on disk but invisible to Claude's `Skill` tool. + */ +const SKILL_TARGET_DIRS = [".opencode/skills", ".claude/skills", ".agents/skills"] as const; + +/** + * write all bundled skills into the fake HOME so OpenCode / Claude Code discover + * them via their auto-scan directories. + * + * called once per agent run from each agent's `run()`. cheap (small file + * writes), no network, idempotent. + */ +export function installBundledSkills(params: { home: string }): void { + for (const name of BUNDLED_SKILL_NAMES) { + const content = readFileSync(resolveSkillPath(name), "utf8"); + for (const targetDir of SKILL_TARGET_DIRS) { + const skillDir = join(params.home, targetDir, name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), content); + } + } + log.info(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`); +} + /** * install a skill globally via the `skills` CLI. *