Skip to content

Commit 86f0549

Browse files
olaservoclaude
andcommitted
feat(skill): add inbox-triage skill for notifications toolset
Ship a second bundled skill that walks the agent through systematic GitHub notifications triage: enumerate with list_notifications, partition by reason (review_requested / mention / assign / security_alert — high; author / comment / state_change — medium; ci_activity / subscribed — low), act on high-priority items, then dismiss with state "done" or "read" per the skill's rule. Demonstrates: - Multiple bundled skills in one server (registry now has two entries). - Per-skill toolset gating — pull-requests gates on pull_requests, inbox-triage gates on the non-default notifications toolset, so enabling one does not force the other. - Cross-skill reference (the inbox-triage workflow points at the pull-requests skill when handling review_requested items). - Skills teaching workflow judgment (priority buckets) that tool descriptions alone cannot encode. Tests cover the symmetric structural checks, per-toolset registration paths, the multi-skill index.json shape, and the capability declaration firing on either toolset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0c1d89c commit 86f0549

File tree

4 files changed

+191
-11
lines changed

4 files changed

+191
-11
lines changed

pkg/github/bundled_skills.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,21 @@ import (
1414
// Adding a new server-bundled skill is one entry here plus a //go:embed
1515
// line in package skills.
1616
func bundledSkills(inv *inventory.Inventory) *skills.Registry {
17-
return skills.New().Add(skills.Bundled{
18-
Name: "pull-requests",
19-
Description: "Submit a multi-comment GitHub pull request review using the pending-review workflow. Use when leaving line-specific feedback on a pull request, when asked to review a PR, or whenever creating any review with more than one comment.",
20-
Content: skills.PullRequestsSKILL,
21-
Icons: octicons.Icons("light-bulb"),
22-
Enabled: func() bool { return inv.IsToolsetEnabled(ToolsetMetadataPullRequests.ID) },
23-
})
17+
return skills.New().
18+
Add(skills.Bundled{
19+
Name: "pull-requests",
20+
Description: "Submit a multi-comment GitHub pull request review using the pending-review workflow. Use when leaving line-specific feedback on a pull request, when asked to review a PR, or whenever creating any review with more than one comment.",
21+
Content: skills.PullRequestsSKILL,
22+
Icons: octicons.Icons("light-bulb"),
23+
Enabled: func() bool { return inv.IsToolsetEnabled(ToolsetMetadataPullRequests.ID) },
24+
}).
25+
Add(skills.Bundled{
26+
Name: "inbox-triage",
27+
Description: "Systematically triage the current user's GitHub notifications inbox — enumerate unread items, prioritize by notification reason (review requests, mentions, assignments, security alerts), act on the high-priority ones, then dismiss the rest. Use when the user asks \"what should I work on?\", \"catch me up on GitHub\", \"triage my inbox\", \"what needs my attention?\", or otherwise wants to clear their notifications backlog.",
28+
Content: skills.InboxTriageSKILL,
29+
Icons: octicons.Icons("bell"),
30+
Enabled: func() bool { return inv.IsToolsetEnabled(ToolsetMetadataNotifications.ID) },
31+
})
2432
}
2533

2634
// DeclareSkillsExtensionIfEnabled adds the skills-over-MCP extension

pkg/github/bundled_skills_test.go

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ import (
1313
"github.com/stretchr/testify/require"
1414
)
1515

16-
// pullRequestsSkillURI is the canonical URI of the bundled pull-requests
17-
// skill, derived from the skills.Bundled entry so tests never drift from
18-
// the single source of truth.
19-
var pullRequestsSkillURI = skills.Bundled{Name: "pull-requests"}.URI()
16+
// pullRequestsSkillURI / inboxTriageSkillURI are the canonical URIs of the
17+
// bundled skills, derived from skills.Bundled so tests never drift from the
18+
// single source of truth.
19+
var (
20+
pullRequestsSkillURI = skills.Bundled{Name: "pull-requests"}.URI()
21+
inboxTriageSkillURI = skills.Bundled{Name: "inbox-triage"}.URI()
22+
)
2023

2124
// Test_PullRequestsSkill_EmbeddedContent verifies the SEP structural requirement
2225
// that the frontmatter `name` field matches the final segment of the skill-path
@@ -50,6 +53,35 @@ func Test_PullRequestsSkill_EmbeddedContent(t *testing.T) {
5053
assert.Contains(t, body, "submit_pending", "the distinctive tool method must be present")
5154
}
5255

56+
// Test_InboxTriageSkill_EmbeddedContent verifies the SEP structural
57+
// requirements for the inbox-triage skill and that its substantive tool
58+
// references are preserved.
59+
func Test_InboxTriageSkill_EmbeddedContent(t *testing.T) {
60+
require.NotEmpty(t, skills.InboxTriageSKILL, "SKILL.md must be embedded")
61+
62+
md := strings.ReplaceAll(skills.InboxTriageSKILL, "\r\n", "\n")
63+
require.True(t, strings.HasPrefix(md, "---\n"), "SKILL.md must begin with YAML frontmatter")
64+
65+
end := strings.Index(md[4:], "\n---\n")
66+
require.GreaterOrEqual(t, end, 0, "SKILL.md must have closing frontmatter fence")
67+
frontmatter := md[4 : 4+end]
68+
69+
var frontmatterName string
70+
for _, line := range strings.Split(frontmatter, "\n") {
71+
if strings.HasPrefix(line, "name:") {
72+
frontmatterName = strings.TrimSpace(strings.TrimPrefix(line, "name:"))
73+
break
74+
}
75+
}
76+
require.NotEmpty(t, frontmatterName, "SKILL.md frontmatter must declare `name`")
77+
assert.Equal(t, "inbox-triage", frontmatterName, "frontmatter name must match final skill-path segment in %s", inboxTriageSkillURI)
78+
79+
body := md[4+end+5:]
80+
assert.Contains(t, body, "## Workflow")
81+
assert.Contains(t, body, "list_notifications", "triage workflow must reference list_notifications")
82+
assert.Contains(t, body, "dismiss_notification", "triage workflow must reference dismiss_notification")
83+
}
84+
5385
// Test_BundledSkills_Registration verifies that skill resources are
5486
// registered when the backing toolset is enabled, and omitted when it is not.
5587
func Test_BundledSkills_Registration(t *testing.T) {
@@ -87,9 +119,53 @@ func Test_BundledSkills_Registration(t *testing.T) {
87119

88120
for _, r := range listResources(t, ctx, srv) {
89121
assert.NotEqual(t, pullRequestsSkillURI, r.URI)
122+
assert.NotEqual(t, inboxTriageSkillURI, r.URI)
90123
assert.NotEqual(t, skills.IndexURI, r.URI)
91124
}
92125
})
126+
127+
t.Run("registers inbox-triage when notifications toolset enabled", func(t *testing.T) {
128+
inv, err := NewInventory(translations.NullTranslationHelper).
129+
WithToolsets([]string{string(ToolsetMetadataNotifications.ID)}).
130+
Build()
131+
require.NoError(t, err)
132+
133+
srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, &mcp.ServerOptions{
134+
Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}},
135+
})
136+
RegisterBundledSkills(srv, inv)
137+
138+
uris := map[string]string{}
139+
for _, r := range listResources(t, ctx, srv) {
140+
uris[r.URI] = r.MIMEType
141+
}
142+
assert.Equal(t, "text/markdown", uris[inboxTriageSkillURI])
143+
assert.NotContains(t, uris, pullRequestsSkillURI, "only notifications enabled — pull-requests should not be registered")
144+
assert.Equal(t, "application/json", uris[skills.IndexURI])
145+
})
146+
147+
t.Run("registers both when both toolsets enabled", func(t *testing.T) {
148+
inv, err := NewInventory(translations.NullTranslationHelper).
149+
WithToolsets([]string{
150+
string(ToolsetMetadataPullRequests.ID),
151+
string(ToolsetMetadataNotifications.ID),
152+
}).
153+
Build()
154+
require.NoError(t, err)
155+
156+
srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, &mcp.ServerOptions{
157+
Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}},
158+
})
159+
RegisterBundledSkills(srv, inv)
160+
161+
uris := map[string]struct{}{}
162+
for _, r := range listResources(t, ctx, srv) {
163+
uris[r.URI] = struct{}{}
164+
}
165+
assert.Contains(t, uris, pullRequestsSkillURI)
166+
assert.Contains(t, uris, inboxTriageSkillURI)
167+
assert.Contains(t, uris, skills.IndexURI)
168+
})
93169
}
94170

95171
// Test_BundledSkills_ReadContent verifies that reading the skill resource
@@ -134,6 +210,37 @@ func Test_BundledSkills_ReadContent(t *testing.T) {
134210
})
135211
}
136212

213+
// Test_BundledSkills_Index_MultipleSkills verifies that all enabled skills
214+
// appear in the discovery index, not just the first one.
215+
func Test_BundledSkills_Index_MultipleSkills(t *testing.T) {
216+
ctx := context.Background()
217+
inv, err := NewInventory(translations.NullTranslationHelper).
218+
WithToolsets([]string{
219+
string(ToolsetMetadataPullRequests.ID),
220+
string(ToolsetMetadataNotifications.ID),
221+
}).
222+
Build()
223+
require.NoError(t, err)
224+
225+
srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, &mcp.ServerOptions{
226+
Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}},
227+
})
228+
RegisterBundledSkills(srv, inv)
229+
230+
session := connectClient(t, ctx, srv)
231+
res, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: skills.IndexURI})
232+
require.NoError(t, err)
233+
234+
var idx skills.IndexDoc
235+
require.NoError(t, json.Unmarshal([]byte(res.Contents[0].Text), &idx))
236+
names := map[string]string{}
237+
for _, s := range idx.Skills {
238+
names[s.Name] = s.URL
239+
}
240+
assert.Equal(t, pullRequestsSkillURI, names["pull-requests"])
241+
assert.Equal(t, inboxTriageSkillURI, names["inbox-triage"])
242+
}
243+
137244
// Test_DeclareSkillsExtensionIfEnabled verifies that the skills-over-MCP
138245
// extension (SEP-2133) is declared in ServerOptions.Capabilities when the
139246
// pull_requests toolset is enabled, and is absent when it is not.
@@ -167,6 +274,20 @@ func Test_DeclareSkillsExtensionIfEnabled(t *testing.T) {
167274
}
168275
})
169276

277+
t.Run("declares when notifications enabled (any skill triggers declaration)", func(t *testing.T) {
278+
inv, err := NewInventory(translations.NullTranslationHelper).
279+
WithToolsets([]string{string(ToolsetMetadataNotifications.ID)}).
280+
Build()
281+
require.NoError(t, err)
282+
283+
opts := &mcp.ServerOptions{}
284+
DeclareSkillsExtensionIfEnabled(opts, inv)
285+
286+
require.NotNil(t, opts.Capabilities)
287+
_, ok := opts.Capabilities.Extensions[skills.ExtensionKey]
288+
assert.True(t, ok, "skills extension must be declared when any bundled skill is enabled")
289+
})
290+
170291
t.Run("preserves other extensions already declared", func(t *testing.T) {
171292
inv, err := NewInventory(translations.NullTranslationHelper).
172293
WithToolsets([]string{string(ToolsetMetadataPullRequests.ID)}).

skills/bundled.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ import _ "embed"
1414

1515
//go:embed pull-requests/SKILL.md
1616
var PullRequestsSKILL string
17+
18+
//go:embed inbox-triage/SKILL.md
19+
var InboxTriageSKILL string

skills/inbox-triage/SKILL.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
name: inbox-triage
3+
description: Systematically triage the current user's GitHub notifications inbox — enumerate unread items, prioritize by notification reason (review requests, mentions, assignments, security alerts), act on the high-priority ones, then dismiss the rest. Use when the user asks "what should I work on?", "catch me up on GitHub", "triage my inbox", "what needs my attention?", or otherwise wants to clear their notifications backlog.
4+
---
5+
6+
## When to use
7+
8+
Use this skill when the user asks about their GitHub inbox, pending work, or outstanding notifications — any of:
9+
10+
- "What should I work on next?"
11+
- "Catch me up on GitHub."
12+
- "Triage my inbox."
13+
- "What needs my attention?"
14+
- "Clear my notifications."
15+
16+
## Workflow
17+
18+
1. **Enumerate.** Call `list_notifications` with `filter: "default"` (unread only — the common case). Switch to `filter: "include_read"` only if the user explicitly asks for a full sweep. Pass `since` as an RFC3339 timestamp to scope to recent activity (e.g. the last day or since the last triage).
19+
20+
2. **Partition by `reason`.** Each notification carries a `reason` field. Group into priority buckets:
21+
22+
- **High — act or respond promptly:**
23+
- `review_requested` — someone is waiting on your review.
24+
- `mention` / `team_mention` — you were @-referenced.
25+
- `assign` — you were assigned an issue or PR.
26+
- `security_alert` — security advisory or Dependabot alert.
27+
- **Medium — read and decide:**
28+
- `author` — updates on threads you opened.
29+
- `comment` — replies on threads you participated in.
30+
- `state_change` — issue/PR closed or reopened.
31+
- **Low — usually safe to mark read without reading:**
32+
- `ci_activity` — workflow runs. Look only if you own CI for this repo.
33+
- `subscribed` — repo-watch updates on threads you haven't participated in.
34+
35+
3. **Drill in on high-priority.** For each high-priority notification, call `get_notification_details` to inspect the item, then take the appropriate action — leave a review (see the `pull-requests` skill), comment, close, etc.
36+
37+
4. **Dismiss as you go.** After acting on (or deciding to skip) each high-priority item, call `dismiss_notification` with the `threadID` and a `state`:
38+
- `state: "done"` archives the notification so it no longer appears in default queries. Use for items you've fully resolved.
39+
- `state: "read"` keeps the notification visible but marks it acknowledged. Use for "I've seen this, coming back later."
40+
41+
5. **Bulk-close the noise.** After the high-priority pass, if a large medium/low bucket remains and the user is comfortable, call `mark_all_notifications_read`. Only do this with explicit user approval — a blanket mark-read can bury something the partitioning rules missed.
42+
43+
## Caveats
44+
45+
- **`read` vs `done` matters.** `read` leaves the notification in the default inbox; `done` removes it. Pick intentionally based on whether there's follow-up.
46+
- **Silence chatty threads.** If one issue/PR is generating a flood, call `manage_notification_subscription` with action `ignore` to silence that specific thread. For an entire noisy repository, use `manage_repository_notification_subscription`.
47+
- **Surface decisions, don't hide them.** After each bucket, summarize to the user what you acted on, what you dismissed, and what's left open for them. Do not silently mark-read a pile of notifications.
48+
- **Respect scope.** If the user narrows to a specific repo ("triage my inbox for `owner/repo`"), pass `owner` and `repo` to `list_notifications` rather than filtering client-side after fetching everything.

0 commit comments

Comments
 (0)