Skip to content

Instantly share code, notes, and snippets.

@jelenv
Created June 27, 2026 21:30
Show Gist options
  • Select an option

  • Save jelenv/d370fda262b49c0337047f1f961d505e to your computer and use it in GitHub Desktop.

Select an option

Save jelenv/d370fda262b49c0337047f1f961d505e to your computer and use it in GitHub Desktop.
amp-review — a deterministic Amp review skill + invoker plugin

amp-review — a deterministic Amp review skill + invoker plugin

A nice, quick way to launch precise amp review code reviews. Created for Amp.

Files

  • SKILL.md — the skill body. Drop it into any agent skills directory (e.g. ~/.config/agents/skills/amp-review/SKILL.md).
  • skill-invoker.ts — an Amp plugin that lets you trigger a skill from the command palette (or via the $skill-name token in a message). It injects an explicit prompt to properly nudge the agent to invoke the selected skill(s). Tested in deep and glm agent modes.

Why a skill + plugin instead of plain prose?

To put it simply: it's quicker for me to run Ctrl+O -> amp-review than writing or dictating short prompts to invoke the skill or describe what I want to review.

Natural-language prompting of "review my code" is slow, less deterministic and sometimes unreliable across models. Forcing an explicit skill invocation defines:

  • the target-selection algorithm
  • the discovery + mainline-resolution sequence
  • amp review invocation with --thinking high and a review-focus preamble
  • the output shape

Install

  1. Copy SKILL.md to ~/.config/agents/skills/amp-review/SKILL.md.
  2. Drop skill-invoker.ts into your Amp plugins folder and register it (see the Amp plugin docs).
  3. Reload Amp. Open command palette (Ctrl+O), type amp-review, select it. Or type $amp-review in your next message.

Usage

  1. Invoke the skill.
  2. Optional: You can always add more instructions to better define the review target and focus.
import type {
AgentStartEvent,
CommandSubscription,
PluginAPI,
PluginCommandContext,
PluginEventContext,
StatusItem,
} from '@ampcode/plugin';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
type Skill = {
name: string;
description: string;
path: string;
body: string;
};
const SKILL_DIRS = [
'~/.agents/skills',
'~/.config/amp/skills',
'~/.config/agents/skills',
'~/.codex/skills',
'~/.claude/skills',
'.agents/skills',
'.claude/skills',
];
const SKILL_TOKEN = /(?:^|\s)\$([a-zA-Z0-9][a-zA-Z0-9_-]*)(?=\s|$)/g;
const pendingSkillsByThread = new Map<string, string[]>();
function homePath(path: string): string {
if (path === '~') {
return process.env.HOME || path;
}
if (path.startsWith('~/')) {
const home = process.env.HOME;
return home ? join(home, path.slice(2)) : path;
}
return path;
}
function frontmatterValue(frontmatter: string, key: string): string | undefined {
const value = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim();
return value?.replace(/^["']|["']$/g, '');
}
function unique<T>(values: T[]): T[] {
return [...new Set(values)];
}
function parseSkillFile(path: string, fallbackName: string): Skill | null {
const content = readFileSync(path, 'utf8');
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
const frontmatter = frontmatterMatch?.[1] ?? '';
const body = (frontmatterMatch?.[2] ?? content).trim();
const name = frontmatterValue(frontmatter, 'name') ?? fallbackName;
const description = frontmatterValue(frontmatter, 'description') ?? firstMeaningfulLine(body);
if (!name) {
return null;
}
return { name, description, path, body };
}
function firstMeaningfulLine(content: string): string {
return (
content
.split('\n')
.map((line) => line.trim().replace(/^#+\s*/, ''))
.find((line) => line.length > 0) ?? 'Invoke this skill.'
);
}
function loadSkills(): Skill[] {
const skills = new Map<string, Skill>();
for (const configuredDir of SKILL_DIRS) {
const dir = homePath(configuredDir);
if (!existsSync(dir)) {
continue;
}
for (const entry of readdirSync(dir)) {
const skillDir = join(dir, entry);
const skillFile = join(skillDir, 'SKILL.md');
if (!existsSync(skillFile) || !statSync(skillFile).isFile()) {
continue;
}
const skill = parseSkillFile(skillFile, basename(skillDir));
if (skill && !skills.has(skill.name)) {
skills.set(skill.name, skill);
}
}
}
return [...skills.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function selectedSkillNames(skillsByName: Map<string, Skill>, message: string, pending: string[]): string[] {
const names = [...pending];
for (const match of message.matchAll(SKILL_TOKEN)) {
const name = match[1];
if (skillsByName.has(name)) {
names.push(name);
}
}
return unique(names);
}
function buildSkillInvocationRequest(skills: Skill[]): string {
return [
`The user explicitly invoked ${skills.length === 1 ? 'this skill' : 'these skills'} for this turn: ${skills.map((skill) => skill.name).join(', ')}.`,
"Use Amp's native skill invocation mechanism for the named skill(s) before working on the user request. Do not treat this as ordinary prose; load/invoke the skill(s) so the invocation appears in the agent actions when the runtime supports it.",
...skills.map((skill) => `${skill.name}: ${skill.description}`),
].join('\n\n');
}
function statusText(names: string[]): string {
if (names.length === 0) {
return '';
}
return `- Skills: ${names.map((name) => `$${name}`).join(' ')}`;
}
async function chooseSkill(ctx: PluginCommandContext, skills: Skill[]): Promise<string | undefined> {
const options = skills.map((skill, index) => `${index + 1}. ${skill.name} — ${skill.description}`);
const selected = await ctx.ui.select({
title: 'Select skill for next message',
message: 'The selected skill is injected into the next user message in the active thread.',
options,
});
const selectedIndex = selected ? options.indexOf(selected) : -1;
return selectedIndex >= 0 ? skills[selectedIndex]?.name : undefined;
}
export default function (amp: PluginAPI) {
let skills = loadSkills();
let skillsByName = new Map(skills.map((skill) => [skill.name, skill]));
let pendingSkillsForNextThread: string[] = [];
let cancelCommand: CommandSubscription | undefined;
let showPendingCommand: CommandSubscription | undefined;
let skillCommands: CommandSubscription[] = [];
let statusItem: StatusItem | undefined;
function refreshSkills() {
skills = loadSkills();
skillsByName = new Map(skills.map((skill) => [skill.name, skill]));
registerSkillCommands();
}
function addPendingSkill(threadID: string | undefined, skillName: string): string[] {
if (!threadID) {
pendingSkillsForNextThread = unique([...pendingSkillsForNextThread, skillName]);
updatePendingCommandAvailability();
updatePendingStatus();
return pendingSkillsForNextThread;
}
const pending = pendingSkillsByThread.get(threadID) ?? [];
const nextPending = unique([...pending, skillName]);
pendingSkillsByThread.set(threadID, nextPending);
updatePendingCommandAvailability();
updatePendingStatus();
return nextPending;
}
function activeThreadID(ctx: PluginCommandContext): string | undefined {
return ctx.thread?.id ?? amp.activeThread.current?.id;
}
function pendingSkills(ctx?: PluginCommandContext): string[] {
const threadID = ctx ? activeThreadID(ctx) : amp.activeThread.current?.id;
return unique([
...pendingSkillsForNextThread,
...(threadID ? (pendingSkillsByThread.get(threadID) ?? []) : []),
]);
}
function updatePendingCommandAvailability() {
const hasPending = pendingSkills().length > 0;
const availability = hasPending
? { type: 'enabled' as const }
: { type: 'disabled' as const, reason: 'No pending skills' };
cancelCommand?.setAvailability(availability);
showPendingCommand?.setAvailability(availability);
}
function updatePendingStatus() {
if (!amp.experimental) {
return;
}
const pending = pendingSkills();
if (pending.length === 0) {
statusItem?.unsubscribe();
statusItem = undefined;
return;
}
if (!statusItem && pending.length > 0) {
statusItem = amp.experimental.createStatusItem();
}
statusItem?.update({
text: statusText(pending),
url: 'command:skill-invoker.show-pending',
});
}
function registerSkillCommands() {
for (const command of skillCommands) {
command.unsubscribe();
}
skillCommands = [];
for (const skill of skills) {
skillCommands.push(
amp.registerCommand(
`skill-invoker.invoke.${skill.name}`,
{
title: skill.name,
category: 'Skill',
description: skill.description,
},
async (ctx) => {
const threadID = activeThreadID(ctx);
const pending = addPendingSkill(threadID, skill.name);
await ctx.ui.notify(
`${statusText(pending)} selected for next message. Run “Skill: Cancel pending skills” to cancel.`,
);
},
),
);
}
}
amp.activeThread.subscribe(() => updatePendingStatus());
amp.registerCommand(
'skill-invoker.select',
{
title: 'Select skill…',
category: 'Skill',
description: 'Choose a skill to invoke with the next message.',
},
async (ctx) => {
refreshSkills();
const threadID = activeThreadID(ctx);
if (skills.length === 0) {
await ctx.ui.notify(
'No skills found in ~/.config/agents/skills, ~/.codex/skills, or ~/.claude/skills.',
);
return;
}
const skillName = await chooseSkill(ctx, skills);
if (!skillName) {
return;
}
const pending = addPendingSkill(threadID, skillName);
await ctx.ui.notify(
`${statusText(pending)} selected for next message. Run “Skill: Cancel pending skills” to cancel.`,
);
},
);
cancelCommand = amp.registerCommand(
'skill-invoker.cancel',
{
title: 'Cancel pending skills',
category: 'Skill',
description: 'Clear skills selected from the command palette before sending.',
availability: { type: 'disabled', reason: 'No pending skills' },
},
async (ctx) => {
const threadID = activeThreadID(ctx);
if (threadID) {
pendingSkillsByThread.delete(threadID);
}
pendingSkillsForNextThread = [];
updatePendingCommandAvailability();
updatePendingStatus();
await ctx.ui.notify('Pending skills cleared.');
},
);
showPendingCommand = amp.registerCommand(
'skill-invoker.show-pending',
{
title: 'Show pending skills',
category: 'Skill',
description: 'Show skills selected for the next message.',
availability: { type: 'disabled', reason: 'No pending skills' },
},
async (ctx) => {
await ctx.ui.notify(`${statusText(pendingSkills(ctx))} selected for next message.`);
},
);
amp.registerCommand(
'skill-invoker.reload',
{
title: 'Reload skill list',
category: 'Skill',
description: 'Rescan local skill directories.',
},
async (ctx) => {
refreshSkills();
await ctx.ui.notify(`Loaded ${skills.length} skills.`);
},
);
registerSkillCommands();
amp.on('agent.start', async (event: AgentStartEvent, _ctx: PluginEventContext<'agent.start'>) => {
const pending = unique([
...pendingSkillsForNextThread,
...(pendingSkillsByThread.get(event.thread.id) ?? []),
]);
const names = selectedSkillNames(skillsByName, event.message, pending);
pendingSkillsForNextThread = [];
pendingSkillsByThread.delete(event.thread.id);
updatePendingCommandAvailability();
updatePendingStatus();
if (names.length === 0) {
return {};
}
const invokedSkills = names
.map((name) => skillsByName.get(name))
.filter((skill): skill is Skill => Boolean(skill));
return {
message: {
content: buildSkillInvocationRequest(invokedSkills),
display: false,
},
};
});
amp.logger.log(`skill-invoker loaded ${skills.length} skills.`);
}
name amp-review
description Review code using Amp's review command. Use when explicitly asked to do an "amp-review" or amp review.

Amp Code Review

Start an Amp review by running the amp review CLI with the right target. This works from Amp or any other agent harness with shell access.

Scope terms

  • Uncommitted changes = staged + unstaged + untracked local file changes — everything git status would list as not yet committed. Never silently drop untracked files from a default local review; they are part of the work surface.
  • Mainline = the branch a feature branch diverges from; resolved in the order below.

Target Selection

First determine the target from the user's request and git state.

Priority order:

  1. Specific commit/range only when explicitly requested

    • If the user gives a commit SHA, tag, ref, or range, review exactly that commit/range.
    • Do not reinterpret a commit request as worktree or PR review.
  2. Specific worktree scope only when explicitly requested

    • If the user asks for uncommitted, staged, unstaged, worktree, or local-only changes, review only that requested scope.
    • Do not add committed branch changes just because the current branch is not main or master.
  3. Feature/special branch default: review uncommitted changes plus branch changes vs merge base

    • If current branch is not main or master, treat it as a PR/branch review.
    • Include both:
      • committed branch changes since merge base with mainline
      • uncommitted local changes (per Scope terms — includes untracked)
    • Separate findings by committed branch changes vs uncommitted changes when useful.
  4. Mainline branch default: review uncommitted changes only

    • If current branch is main or master, review uncommitted local changes (per Scope terms — includes untracked).
  5. Ambiguous or no clear target: ask

    • If there is no relevant diff, detached HEAD without a specified commit/range, no clear mainline, or multiple plausible targets, ask the user which target to review.

Discovery Commands

Run these first unless the user gave an explicit commit/range:

git status --short --branch
git branch --show-current
git symbolic-ref --quiet refs/remotes/origin/HEAD || true
git remote show origin || true

Find mainline in this order:

  1. user-specified base
  2. origin/HEAD
  3. origin/main, origin/master
  4. local main, local master

Do not switch branches. Fetch only if needed for a reliable merge base; ask before fetch when network side effects are undesirable.

Run Amp Review

Use amp review [diff_description...] as the review engine. The diff_description can be a git command, commit range, single commit/ref, natural language description, or empty for uncommitted changes.

Useful options:

  • --instructions <text> / -i <text>: add review focus.
  • --files <files...> / -f <files...>: focus specific files or directories.
  • --thinking high: use for larger or riskier reviews.
  • --json: structured output when another agent needs to parse results.

Command selection:

  • Explicit commit/range: amp review "<commit-or-range>" --thinking high
  • Explicit uncommitted/worktree scope: amp review "uncommitted changes" --thinking high
  • Feature/special branch default: after detecting mainline, run amp review "<mainline>...HEAD plus uncommitted changes" --thinking high
  • Mainline branch default: amp review --thinking high

Add instructions unless the user supplied more specific review focus:

amp review "<target>" --thinking high --instructions "Focus on correctness bugs, regressions, security, data loss, API/contract drift, performance, and missing tests that could hide bugs. Return only actionable findings with file/line evidence and concrete failure modes. If no blocking issues, say so and list residual risk."

If amp review is unavailable or fails before starting, report the exact error and stop. Do not perform a manual fallback review unless the user explicitly asks for one.

Output

Lead with findings, not summary.

Use this shape:

Ref: <branch/range/worktree scope>
Base: <mainline/merge-base or N/A>
Mode: <commit | branch+uncommitted | uncommitted>

Findings:
- <severity> <file:line> — <bug/failure mode>. <why it happens>. <smallest fix>.

No blocking issues found. Checked: <strongest proof>. Residual risk: <test gaps/large unreviewed surface>.

Severity levels: blocker (must fix before merge), major (real bug, fix soon), minor (worth fixing), nit (style/clarity). Use blocker only for changes that could break behavior, lose data, or introduce security holes — reserve it so it stays meaningful.

Rules:

  • No edits during review unless the user explicitly asks for fixes.
  • Do not approve, merge, push, or comment on hosted PRs unless asked.
  • Do not invent line numbers; read files or diffs for evidence.
  • If unsure, say what evidence is missing instead of weakening into vague suggestions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment