add bundled git-archaeology skill, auto-installed for opencode and claude (#565)
* 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 <home>/.agents/skills/<name>/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: <actionRoot>/skills/<name>/SKILL.md (relative to utils/skills.ts) - bundled mode: <distDir>/skills/<name>/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
This commit is contained in:
committed by
pullfrog[bot]
parent
3bacf01e48
commit
57f54e37c5
+3
-1
@@ -21,7 +21,7 @@ import { getIdleMs, markActivity } from "../utils/activity.ts";
|
|||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { installFromNpmTarball } from "../utils/install.ts";
|
import { installFromNpmTarball } from "../utils/install.ts";
|
||||||
import { detectProviderError } from "../utils/providerErrors.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 { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||||
import { ThinkingTimer } from "../utils/timer.ts";
|
import { ThinkingTimer } from "../utils/timer.ts";
|
||||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||||
@@ -604,6 +604,8 @@ export const claude = agent({
|
|||||||
agent: "claude",
|
agent: "claude",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
installBundledSkills({ home: homeEnv.HOME });
|
||||||
|
|
||||||
const mcpConfigPath = writeMcpConfig(ctx);
|
const mcpConfigPath = writeMcpConfig(ctx);
|
||||||
const effort = resolveEffort(model);
|
const effort = resolveEffort(model);
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -21,7 +21,7 @@ import { getIdleMs, markActivity } from "../utils/activity.ts";
|
|||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { installFromNpmTarball } from "../utils/install.ts";
|
import { installFromNpmTarball } from "../utils/install.ts";
|
||||||
import { detectProviderError } from "../utils/providerErrors.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 { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||||
import { ThinkingTimer } from "../utils/timer.ts";
|
import { ThinkingTimer } from "../utils/timer.ts";
|
||||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||||
@@ -665,6 +665,8 @@ export const opencode = agent({
|
|||||||
agent: "opencode",
|
agent: "opencode",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
installBundledSkills({ home: homeEnv.HOME });
|
||||||
|
|
||||||
// base args shared between initial run and continue runs
|
// base args shared between initial run and continue runs
|
||||||
const baseArgs = ["run", "--format", "json", "--print-logs"];
|
const baseArgs = ["run", "--format", "json", "--print-logs"];
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,7 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { build } from "esbuild";
|
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"));
|
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
||||||
|
|
||||||
@@ -96,4 +96,9 @@ const cliPath = "./dist/cli.mjs";
|
|||||||
const cliContent = readFileSync(cliPath, "utf8");
|
const cliContent = readFileSync(cliPath, "utf8");
|
||||||
writeFileSync(cliPath, `#!/usr/bin/env node\n${cliContent}`);
|
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");
|
console.log("» build completed successfully");
|
||||||
|
|||||||
@@ -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 <sha> -- <path>` for the diff of one specific
|
||||||
|
commit.
|
||||||
|
3. **Scope the search.** Add `--since="3 months ago"`, `-n 20`, or a path
|
||||||
|
restriction (`-- <path>`) 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'<exact-string>' --oneline -- <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
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'<regex>' --oneline -- <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 :<function-name>:<file>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <N>,<M>:<file>
|
||||||
|
```
|
||||||
|
|
||||||
|
### "What's the full history of this file, including across renames?"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --follow --oneline -- <file> # overview
|
||||||
|
git log --follow -p -- <file> # 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'<deleted-string>' --oneline --all -- <file>
|
||||||
|
|
||||||
|
# 2. reverse-blame from that commit to find the last commit it survived in
|
||||||
|
git blame --reverse <old-sha>..HEAD -- <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 -- <deleted-path>
|
||||||
|
|
||||||
|
# the most recent of those is usually the deletion. confirm:
|
||||||
|
git show <sha> --stat
|
||||||
|
|
||||||
|
# view the file's contents at any commit where it existed
|
||||||
|
git show <sha>^:<deleted-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 '<filename>'
|
||||||
|
|
||||||
|
# or glob across all branches
|
||||||
|
git log --all --oneline -- '**/<filename>.*'
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Who deleted it, in one shot?"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-list -n 1 HEAD -- <deleted-path> # the deletion commit
|
||||||
|
git show $(git rev-list -n 1 HEAD -- <deleted-path>) -- <deleted-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Restore a deleted file (locally, no commit)"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git restore --source=<deletion-sha>^ -- <deleted-path>
|
||||||
|
# or, on older git:
|
||||||
|
git checkout <deletion-sha>^ -- <deleted-path>
|
||||||
|
```
|
||||||
|
|
||||||
|
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='<text>' --oneline
|
||||||
|
git log --all --grep='<text>' -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 -- <file>` — overview of commits touching it.
|
||||||
|
2. If a recent commit looks suspicious: `git show <sha> -- <file>`.
|
||||||
|
3. If you expected to find something and it's missing:
|
||||||
|
`git log -S'<expected-string>' --oneline -- <file>`.
|
||||||
|
4. For a specific function's full lifecycle:
|
||||||
|
`git log -L :<fn>:<file>`.
|
||||||
|
5. For the deletion point of a known string: pickaxe to find an old commit
|
||||||
|
that contained it, then `git blame --reverse <old-sha>..HEAD -- <file>`.
|
||||||
|
|
||||||
|
## 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 <count>` | 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 -- <path>` shows nothing if the path never existed
|
||||||
|
on the current branch. When in doubt, add `--all`.
|
||||||
|
- `git log --full-history -- <path>` alone has had bugs in some git versions
|
||||||
|
for deleted files; pair with `--all` for reliability.
|
||||||
|
- For files that were renamed, `git log -- <new-path>` only shows post-rename
|
||||||
|
history. Use `--follow` (one file) or `git log --all -- <old-path>` when
|
||||||
|
hunting across rename events.
|
||||||
@@ -1,10 +1,76 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
import { getDevDependencyVersion } from "./version.ts";
|
import { getDevDependencyVersion } from "./version.ts";
|
||||||
|
|
||||||
const skillsVersion = getDevDependencyVersion("skills");
|
const skillsVersion = getDevDependencyVersion("skills");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* skills bundled with the action runtime. the SKILL.md files live in
|
||||||
|
* `action/skills/<name>/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`): `<actionRoot>/skills/<name>/SKILL.md`,
|
||||||
|
* reached as `../skills/...` from `utils/skills.ts`.
|
||||||
|
* - bundled mode (npx published package): `<distDir>/skills/<name>/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.
|
* install a skill globally via the `skills` CLI.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user