Skip to content
Merged
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
10 changes: 6 additions & 4 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
contents: read
pull-requests: write
issues: write
steps:
Expand All @@ -18,11 +18,13 @@ jobs:
persist-credentials: false
- uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/claude-opus-4-5
model: openrouter/mistralai/devstral-2512:free
prompt: |
Review this pull request:
- Check for code quality issues
- Look for potential bugs
- Suggest improvements
- Suggest improvements

Do NOT make any commits or modify files. Only post review comments.
4 changes: 2 additions & 2 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ jobs:
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/claude-opus-4-5
model: openrouter/mistralai/devstral-2512:free
10 changes: 9 additions & 1 deletion skill/code-simplify/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
---
name: code-simplify
description: Simplify code you just wrote or modified. Load after completing a feature, fix, or refactor to improve clarity while preserving behavior.
description: >
Simplify code you just wrote or modified. Load after completing a feature, fix, or refactor
to improve clarity while preserving behavior.
use_when: >
CRITICAL REMINDER - DO NOT SKIP: You MUST call skill({ name: "code-simplify" }) IMMEDIATELY
after ANY code modification (new files, edits, refactors), BEFORE responding to the user or
marking todos complete. This is NOT optional. If you just wrote or modified code and are about
to say "done" or ask "shall I commit?", STOP and call this skill FIRST. Failure to do so is
a protocol violation.
---

Run the `/simplify-changes` command.
128 changes: 128 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
mergeHooks,
type HookConfig,
type HookEvent,
type LoadedSkill,
} from "./loaders"
import { executeBashAction, type BashContext } from "./bash-executor"
import { buildSkillActivationBlock } from "./skill-activation"

describe("parseFrontmatter", () => {
it("should parse valid frontmatter", () => {
Expand Down Expand Up @@ -278,6 +280,45 @@ Content`
expect(result).toHaveLength(1)
expect(result[0].name).toBe("valid")
})

it("should load use_when field from frontmatter", () => {
const skillDir = join(testDir, "auto-skill")
mkdirSync(skillDir)

const skillContent = `---
name: auto-skill
description: An auto-triggered skill
use_when: After completing a task, call this skill
---

Skill instructions here.`

writeFileSync(join(skillDir, "SKILL.md"), skillContent)

const result = loadSkills(testDir)

expect(result).toHaveLength(1)
expect(result[0].name).toBe("auto-skill")
expect(result[0].useWhen).toBe("After completing a task, call this skill")
})

it("should have undefined useWhen when not provided", () => {
const skillDir = join(testDir, "manual-skill")
mkdirSync(skillDir)

const skillContent = `---
name: manual-skill
description: A manual skill
---

Content`

writeFileSync(join(skillDir, "SKILL.md"), skillContent)

const result = loadSkills(testDir)

expect(result[0].useWhen).toBeUndefined()
})
})

describe("loadCommands", () => {
Expand Down Expand Up @@ -984,6 +1025,93 @@ describe("executeBashAction", () => {
})
})

describe("buildSkillActivationBlock", () => {
it("should generate XML block with skill rules", () => {
const skills: LoadedSkill[] = [
{
name: "code-review",
description: "Review code",
useWhen: "After writing code",
path: "/path/to/skill",
body: "",
},
]

const result = buildSkillActivationBlock(skills)

expect(result).toContain("<skill-activation-rules>")
expect(result).toContain("</skill-activation-rules>")
expect(result).toContain('skill="code-review"')
expect(result).toContain('trigger="After writing code"')
expect(result).toContain("MANDATORY")
})

it("should escape quotes in trigger text", () => {
const skills: LoadedSkill[] = [
{
name: "test-skill",
description: "Test",
useWhen: 'Call when user says "help"',
path: "/path",
body: "",
},
]

const result = buildSkillActivationBlock(skills)

expect(result).toContain("&quot;help&quot;")
expect(result).not.toContain('"help"')
})

it("should replace newlines with spaces in trigger text", () => {
const skills: LoadedSkill[] = [
{
name: "multi-line",
description: "Test",
useWhen: "First line\nSecond line\nThird line",
path: "/path",
body: "",
},
]

const result = buildSkillActivationBlock(skills)

expect(result).toContain("First line Second line Third line")
// The trigger attribute should not contain newlines (though the XML block itself does)
expect(result).toMatch(/trigger="[^"]*First line Second line Third line[^"]*"/)
})

it("should trim whitespace from trigger text", () => {
const skills: LoadedSkill[] = [
{
name: "whitespace",
description: "Test",
useWhen: " padded trigger ",
path: "/path",
body: "",
},
]

const result = buildSkillActivationBlock(skills)

expect(result).toContain('trigger="padded trigger"')
})

it("should generate multiple rules for multiple skills", () => {
const skills: LoadedSkill[] = [
{ name: "skill-a", description: "A", useWhen: "Trigger A", path: "/a", body: "" },
{ name: "skill-b", description: "B", useWhen: "Trigger B", path: "/b", body: "" },
]

const result = buildSkillActivationBlock(skills)

expect(result).toContain('skill="skill-a"')
expect(result).toContain('skill="skill-b"')
expect(result).toContain('trigger="Trigger A"')
expect(result).toContain('trigger="Trigger B"')
})
})

describe("mergeHooks", () => {
it("should return empty map when merging empty maps", () => {
const result = mergeHooks(
Expand Down
33 changes: 32 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
loadAgents,
loadCommands,
loadHooks,
loadSkills,
mergeHooks,
type HookConfig,
type HookEvent,
type LoadedSkill,
} from "./loaders"
import { getGlobalHookDir, getProjectHookDir } from "./config-paths"
import { hasCodeExtension } from "./code-files"
Expand All @@ -22,14 +24,18 @@ import {
createPromptSessionTool,
createListChildSessionsTool,
createAgentPromoteTool,
createSkillTool,
getPromotedAgents,
ethTransactionTool,
ethAddressTxsTool,
ethAddressBalanceTool,
ethTokenTransfersTool,
} from "./tools"

export { parseFrontmatter, loadAgents, loadCommands } from "./loaders"
export { parseFrontmatter, loadAgents, loadCommands, type LoadedSkill } from "./loaders"
export { buildSkillActivationBlock } from "./skill-activation"

import { buildSkillActivationBlock } from "./skill-activation"

// ============================================================================
// TYPES
Expand Down Expand Up @@ -72,6 +78,7 @@ const __dirname = dirname(__filename)
const PLUGIN_ROOT = join(__dirname, "..")
const AGENT_DIR = join(PLUGIN_ROOT, "agent")
const COMMAND_DIR = join(PLUGIN_ROOT, "command")
const SKILL_DIR = join(PLUGIN_ROOT, "skill")

// ============================================================================
// PLUGIN
Expand All @@ -80,6 +87,7 @@ const COMMAND_DIR = join(PLUGIN_ROOT, "command")
const SmartfrogPlugin: Plugin = async (ctx) => {
const agents = loadAgents(AGENT_DIR)
const commands = loadCommands(COMMAND_DIR)
const skills = loadSkills(SKILL_DIR)

const globalHooks = loadHooks(getGlobalHookDir())
const projectHooks = loadHooks(getProjectHookDir(ctx.directory))
Expand All @@ -88,12 +96,25 @@ const SmartfrogPlugin: Plugin = async (ctx) => {
const modifiedCodeFiles = new Map<string, Set<string>>()
const pendingToolArgs = new Map<string, Record<string, unknown>>()

const skillsWithTriggers = skills.filter(s => s.useWhen)
const skillActivationBlock = skillsWithTriggers.length > 0
? buildSkillActivationBlock(skillsWithTriggers)
: null

const skillTool = createSkillTool({
pluginSkills: skills,
pluginDir: PLUGIN_ROOT,
})

log("[init] Plugin loaded", {
agents: Object.keys(agents),
commands: Object.keys(commands),
skills: skills.map(s => s.name),
skillsWithTriggers: skillsWithTriggers.map(s => s.name),
hooks: Array.from(hooks.keys()),
tools: [
"gitingest",
"skill",
"agent-promote",
"eth-transaction",
"eth-address-txs",
Expand Down Expand Up @@ -283,6 +304,7 @@ const SmartfrogPlugin: Plugin = async (ctx) => {

tool: {
gitingest: gitingestTool,
skill: skillTool,
"prompt-session": createPromptSessionTool(ctx.client),
"list-child-sessions": createListChildSessionsTool(ctx.client),
"agent-promote": createAgentPromoteTool(ctx.client, Object.keys(agents)),
Expand Down Expand Up @@ -379,6 +401,15 @@ const SmartfrogPlugin: Plugin = async (ctx) => {
await triggerHooks("session.idle", sessionID, { files: files ? Array.from(files) : [] })
}
},

"experimental.chat.system.transform": async (
_input: Record<string, unknown>,
output: { system: string[] }
): Promise<void> => {
if (!skillActivationBlock) return
output.system.push(skillActivationBlock)
},

}
}

Expand Down
3 changes: 3 additions & 0 deletions src/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AgentFrontmatter {
export interface SkillFrontmatter {
name: string
description: string
use_when?: string
license?: string
compatibility?: string
metadata?: Record<string, string>
Expand All @@ -44,6 +45,7 @@ export interface CommandConfig {
export interface LoadedSkill {
name: string
description: string
useWhen?: string
path: string
body: string
}
Expand Down Expand Up @@ -176,6 +178,7 @@ export function loadSkills(skillDir: string): LoadedSkill[] {
skills.push({
name: data.name || entry.name,
description: data.description || "",
useWhen: data.use_when,
path: skillPath,
body: body.trim(),
})
Expand Down
17 changes: 17 additions & 0 deletions src/skill-activation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type LoadedSkill } from "./loaders"

function escapeTrigger(text: string): string {
return text.replace(/"/g, "&quot;").replace(/\n/g, " ").trim()
}

export function buildSkillActivationBlock(skills: LoadedSkill[]): string {
const rules = skills
.map(s => ` <rule skill="${s.name}" trigger="${escapeTrigger(s.useWhen!)}"/>`)
.join("\n")

return `<skill-activation-rules>
MANDATORY: Call skill({ name }) BEFORE responding when trigger matches.

${rules}
</skill-activation-rules>`
}
1 change: 1 addition & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { gitingestTool, fetchGitingest, type GitingestArgs } from "./gitingest"
export { createPromptSessionTool, type PromptSessionArgs } from "./prompt-session"
export { createListChildSessionsTool } from "./list-child-sessions"
export { createAgentPromoteTool, getPromotedAgents, type AgentPromoteArgs } from "./agent-promote"
export { createSkillTool, type CreateSkillToolOptions, type SkillInfo } from "./skill"

export {
ethTransactionTool,
Expand Down
Loading