Skip to content

Commit 84074e7

Browse files
committed
Add a background git-worktrees skill
Hide the recipe from slash and use_skill listing while keeping resolve by name for implement and linear-issue-workflow. Closes CL-7013
1 parent 959e0cd commit 84074e7

8 files changed

Lines changed: 189 additions & 46 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -416,19 +416,20 @@ Primary is Skywalker. Bundled skill bodies that are operator slashes are **actio
416416
| `.claude/skills/` | Claude Code workspace skills |
417417
| `.codex/skills/` | Codex workspace skills |
418418

419-
Each `<base>/<skill-name>/SKILL.md` is one skill. Discovery dedupes by directory name: the first base dir that provides a given name wins, so an enabled plugin skill shadows a project-local skill of the same name. Plugin dirs are passed in discovery order (repo first), so a first-party catalog name wins over a later marketplace or project skill of the same name. `resolveSkillBody(cwd, ref, pluginDirs)` resolves a skill's body using the same ordered list (it accepts a bare name or a `plugin:name` ref, keying on the name).
419+
Each `<base>/<skill-name>/SKILL.md` is one skill. Discovery dedupes by directory name: the first base dir that provides a given name wins, so an enabled plugin skill shadows a project-local skill of the same name. Plugin dirs are passed in discovery order (repo first), so a first-party catalog name wins over a later marketplace or project skill of the same name. Skills with `disable-model-invocation: true` are omitted from the returned listing but still claim the name (first-wins), so a lower-priority same-name skill cannot leak into the listing. `resolveSkillBody(cwd, ref, pluginDirs)` resolves a skill's body using the same ordered list (it accepts a bare name or a `plugin:name` ref, keying on the name) and **does not** hard-fail on `disable-model-invocation` — explicit `use_skill("name")` still loads background libraries.
420420

421421
#### SKILL.md format
422422

423-
A skill file begins with a YAML frontmatter block, followed by the body that holds the instructions. Discovery parses `description`; `loadSkillCommands` also reads `user-invocable`. The skill's identifier (what `use_skill` and `/<skill-name>` take) is its directory name. A skill with no `SKILL.md` or an empty body is skipped.
423+
A skill file begins with a YAML frontmatter block, followed by the body that holds the instructions. Discovery parses `description` and `disable-model-invocation`; `loadSkillCommands` also reads `user-invocable`. The skill's identifier (what `use_skill` and `/<skill-name>` take) is its directory name. A skill with no `SKILL.md` or an empty body is skipped.
424424

425-
| Field | Required | Description |
426-
| ---------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
427-
| `description` | yes | One-line summary shown in the prompt's lazy skills listing and the slash picker |
428-
| `name` | conventional | Conventionally matches the directory name; the directory name is what is actually used as the identifier |
429-
| `user-invocable` | no | When `false`, `loadSkillCommands` skips slash synthesis; the skill remains `use_skill` only. Untagged skills still become slashes (marketplace BC) |
425+
| Field | Required | Description |
426+
| --------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
427+
| `description` | yes | One-line summary shown in the prompt's lazy skills listing and the slash picker |
428+
| `name` | conventional | Conventionally matches the directory name; the directory name is what is actually used as the identifier |
429+
| `user-invocable` | no | When `false`, `loadSkillCommands` skips slash synthesis; the skill remains `use_skill` only. Untagged skills still become slashes (marketplace BC) |
430+
| `disable-model-invocation` | no | When `true`, `discoverSkills` omits the skill from the lazy listing (but still claims the name for first-wins). Explicit `resolveSkillBody` / `use_skill("name")` still loads the body. Does not affect slash emission. |
430431

431-
There are no `type` or `disable-model-invocation` fields required for model invocation — a skill body is plain instruction text. `argument-hint` on frontmatter is preserved for the slash picker (greyed arg guidance). Multi-step orchestration is a separate mechanism (see Workflows above), not a skill `type`.
432+
There is no skill `type` field required for model invocation — a skill body is plain instruction text. Background libraries (e.g. `git-worktrees`) set both `user-invocable: false` and `disable-model-invocation: true` so they are absent from slash and listing, yet recipes can still `use_skill("git-worktrees")`. `argument-hint` on frontmatter is preserved for the slash picker (greyed arg guidance). Multi-step orchestration is a separate mechanism (see Workflows above), not a skill `type`.
432433

433434
#### Loading (model and operator)
434435

docs/PLUGINS.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,14 @@ shape.
309309
become slashes (marketplace backward compatibility). Frontmatter
310310
`argument-hint` is preserved so the TUI can show greyed arg guidance (e.g.
311311
`/create-issue``[description] [--from-doc]`). This is an additional
312-
surface: `discoverSkills` is unchanged, so the model can still auto-invoke any
313-
skill via `use_skill` — including first-party recipes that are not operator
314-
slashes (`dispatch`, `git-rebase`, `linear-issue-workflow`, `style`,
315-
`philosophy`, `typescript`, `opsh`). The slash command is a direct user entry
312+
surface: `discoverSkills` skips skills with `disable-model-invocation: true`
313+
from the lazy listing (those stay loadable via explicit `use_skill` /
314+
`resolveSkillBody`), so the model does not auto-suggest background libraries.
315+
First-party recipes that are not operator slashes remain listed for
316+
`use_skill` when they only set `user-invocable: false` (`dispatch`,
317+
`git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `typescript`,
318+
`opsh`). Background libs such as `git-worktrees` set both flags. The slash
319+
command is a direct user entry
316320
point on top.
317321
- **First-party catalog.** `plugins/corbits-skills/` (id `corbits-skills`,
318322
kind `command`, `defaultEnabled: true`) is the bundled skill catalog. Origin
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
name: git-worktrees
3+
user-invocable: false
4+
disable-model-invocation: true
5+
description: Create a git worktree from origin/<default-branch> and tear it down. Background library — load via use_skill("git-worktrees"); absent from slash and use_skill listing.
6+
---
7+
8+
# git-worktrees
9+
10+
Background recipe. Skywalker loads via `use_skill("git-worktrees")` and copies commands into an intern brief. Intern executes via `run_shell`. Skywalker does not run the git.
11+
12+
## Create from origin/<default-branch>
13+
14+
```bash
15+
git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'
16+
git fetch origin
17+
git worktree add ../worktree/<branch-name> -b <branch-name> origin/<default-branch>
18+
```
19+
20+
Always base new branches on `origin/<default-branch>` (whatever the repository uses). After creating the worktree, intern `cd`s into it and installs local dependencies (`bun install` when the project uses Bun; otherwise follow developer docs). Worktrees do not share `node_modules`.
21+
22+
## Teardown
23+
24+
```bash
25+
cd <path-to-main-repo>
26+
git fetch origin
27+
git worktree remove ../worktree/<branch-name>
28+
git branch -d <branch-name>
29+
```
30+
31+
If the worktree directory was already deleted: `git worktree prune`.

plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,15 @@ If the scope is unclear, `ask_operator` before proceeding. Do not guess.
2121

2222
Read `branchName` from the issue (call `mcp__linear__get_issue` again if needed).
2323

24-
Spawn `task(agent="intern")` with this sequenced `run_shell` list copied into the brief. Intern executes; Skywalker does not run the git.
24+
Load `use_skill("git-worktrees")`. Copy the create-from-origin/<default-branch> recipe into an intern brief (substitute `<branch-name>`). Spawn `task(agent="intern")`. Intern executes; Skywalker does not run the git.
2525

26-
```bash
27-
git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'
28-
git fetch origin
29-
git worktree add ../worktree/<branch-name> -b <branch-name> origin/<default-branch>
30-
```
31-
32-
Always base new branches on `origin/<default-branch>` (whatever the repository uses). After creating the worktree, intern `cd`s into it and installs local dependencies from developer documentation. Worktrees do not share `node_modules`.
33-
34-
If intern fails, stop and `ask_operator`. If the operator rejects the issue before implementation, intern tears down the worktree (Phase 7 commands) rather than leaving it stranded.
26+
If intern fails, stop and `ask_operator`. If the operator rejects the issue before implementation, intern tears down the worktree via the git-worktrees teardown recipe rather than leaving it stranded.
3527

3628
## Phase 3: Plan, attach, mark In Progress
3729

3830
1. Spawn `task(agent="explore")` if the codebase map is not already known. Brief it with the absolute worktree path (it must work there) and the issue: where changes go, existing patterns, related code.
3931
2. Follow the `/implement` loop's greybeard step (Phase 4) for the approach. Present the plan to the operator and `ask_operator` whether to proceed. Do not start implementation until approved.
40-
3. If the operator rejects the plan and the issue cannot be salvaged, intern tears down the worktree (Phase 7) rather than leaving it stranded.
32+
3. If the operator rejects the plan and the issue cannot be salvaged, intern tears down the worktree via the git-worktrees teardown recipe rather than leaving it stranded.
4133
4. Attach the plan to the Linear issue. **Do not post the plan as a comment** — comments are for discussion, not archives.
4234

4335
Spawn `task(agent="build")` with a mechanical brief to write the approved plan to the worktree's `tmp/plan-<ISSUE-ID>.md` (do not commit it). Intern captures byte size with `wc -c`. Primary then:
@@ -130,16 +122,7 @@ Phase 6 ends when the PR is open. Phase 7 runs **after the PR is merged** and **
130122
2. Re-read the issue with `mcp__linear__get_issue`. Flip checkboxes the merged PR actually completed on `main` via `mcp__linear__save_issue`. Never check a box on intent.
131123
3. `mcp__linear__save_comment` with PR URL, merge SHA, and CI-green confirmation. Short. Present-tense facts.
132124
4. If every outcome checkbox is checked, set state to `Done` with `mcp__linear__save_issue`. Otherwise leave In Progress.
133-
5. Only then intern cleans up:
134-
135-
```bash
136-
cd <path-to-main-repo>
137-
git fetch origin
138-
git worktree remove ../worktree/<branch-name>
139-
git branch -d <branch-name>
140-
```
141-
142-
If the worktree directory was already deleted: `git worktree prune`.
125+
5. Only then intern cleans up: load `use_skill("git-worktrees")` and copy the teardown recipe into an intern brief (substitute `<branch-name>` and `<path-to-main-repo>`).
143126

144127
## Linear MCP tool reference
145128

src/extensions/skills.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,25 @@ function stripFrontmatter(raw: string): string {
5050
return raw.slice(end + 3).trim();
5151
}
5252

53-
function parseSkillFrontmatter(raw: string): { name?: string; description?: string } {
53+
function parseSkillFrontmatter(raw: string): {
54+
name?: string;
55+
description?: string;
56+
disableModelInvocation?: boolean;
57+
} {
5458
const block = frontmatterBlock(raw);
5559
if (block === undefined) return {};
56-
const out: { name?: string; description?: string } = {};
60+
const out: {
61+
name?: string;
62+
description?: string;
63+
disableModelInvocation?: boolean;
64+
} = {};
5765
for (const line of block.split("\n")) {
58-
const match = /^(name|description):\s*(.+)$/.exec(line.trim());
66+
const trimmed = line.trim();
67+
const match = /^(name|description):\s*(.+)$/.exec(trimmed);
5968
if (match) out[match[1] as "name" | "description"] = match[2]!.trim();
69+
if (/^disable-model-invocation:\s*true\s*$/.test(trimmed)) {
70+
out.disableModelInvocation = true;
71+
}
6072
}
6173
return out;
6274
}
@@ -136,11 +148,15 @@ export async function resolveSkillBody(
136148
// Discover every available skill (name + one-line description) for the lazy
137149
// listing in the system prompt. Deduped by name: the first base dir that
138150
// provides a skill wins, so a higher-precedence dir shadows a lower one.
151+
// Skills with `disable-model-invocation: true` are omitted from the listing
152+
// but still occupy the name in `seen` so a lower-priority same-name skill
153+
// cannot leak in. Explicit `use_skill` / `resolveSkillBody` loads still work.
139154
export async function discoverSkills(
140155
cwd: string,
141156
pluginDirs: string[] = [],
142157
): Promise<SkillSummary[]> {
143-
const seen = new Map<string, SkillSummary>();
158+
const seen = new Set<string>();
159+
const skills: SkillSummary[] = [];
144160
for (const base of skillBaseDirs(cwd, pluginDirs)) {
145161
const entries = await readdir(base, { withFileTypes: true }).catch(() => undefined);
146162
if (entries === undefined) continue;
@@ -149,8 +165,11 @@ export async function discoverSkills(
149165
const raw = await readRaw(join(base, entry.name, "SKILL.md"));
150166
if (raw === undefined) continue;
151167
const fm = parseSkillFrontmatter(raw);
152-
seen.set(entry.name, { name: entry.name, description: fm.description ?? "" });
168+
// First-wins: claim the name even when skipping the listing.
169+
seen.add(entry.name);
170+
if (fm.disableModelInvocation) continue;
171+
skills.push({ name: entry.name, description: fm.description ?? "" });
153172
}
154173
}
155-
return [...seen.values()];
174+
return skills;
156175
}

src/plugins/skill-commands.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import { splitFrontmatter } from "./frontmatter.js";
1111
// body (plus args) to the agent. Convention/internal skills opt out with
1212
// `user-invocable: false` in frontmatter and are not emitted as slash commands.
1313
// Untagged skills still become slash commands (marketplace BC).
14-
// `disable-model-invocation` does not affect slash emission. A skill authored
15-
// as `skills/<name>/SKILL.md` is still model-invoked via the `use_skill` tool;
16-
// `discoverSkills` is unchanged, so the model can still auto-invoke any skill.
14+
// `disable-model-invocation` does not affect slash emission — that flag only
15+
// skips the skill from `discoverSkills` lazy listing. Explicit `use_skill` /
16+
// `resolveSkillBody` still loads the body by name.
1717

1818
const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1919

@@ -60,7 +60,8 @@ export async function loadSkillCommands(
6060
continue;
6161
}
6262
// Opt-out of the slash surface. Untagged skills still emit a command
63-
// (marketplace BC); `disable-model-invocation` does not affect this.
63+
// (marketplace BC); `disable-model-invocation` does not affect this —
64+
// it only skips discoverSkills listing (see src/extensions/skills.ts).
6465
if (frontmatter["user-invocable"] === false) continue;
6566

6667
const name =

tests/unit/corbits-skills-catalog.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const SKILL_DIRS = [
1717
"typescript",
1818
"interview",
1919
"git-rebase",
20+
"git-worktrees",
2021
"refactor",
2122
"pull-request-review",
2223
"create-issue",
@@ -27,6 +28,7 @@ const SKILL_DIRS = [
2728

2829
const SPAWN_RECIPE_SKILLS = ["implement", "scribe", "review", "dispatch", "plan"] as const;
2930

31+
/** use_skill listing + resolve; not slash. No disable-model-invocation. */
3032
const USE_SKILL_ONLY = [
3133
"dispatch",
3234
"git-rebase",
@@ -37,6 +39,9 @@ const USE_SKILL_ONLY = [
3739
"opsh",
3840
] as const;
3941

42+
/** Background libs: absent from slash and use_skill listing; explicit resolve only. */
43+
const BACKGROUND_ONLY = ["git-worktrees"] as const;
44+
4045
const SLASH_SKILLS = [
4146
"implement",
4247
"refactor",
@@ -52,6 +57,7 @@ const SLASH_SKILLS = [
5257
const BANNED_TOKENS = ["TaskCreate", "@greybeard", 'intent="general"'] as const;
5358

5459
const USER_INVOCABLE_FALSE = "user-invocable: false";
60+
const DISABLE_MODEL_INVOCATION = "disable-model-invocation: true";
5561

5662
async function listFilesRecursive(dir: string): Promise<string[]> {
5763
const out: string[] = [];
@@ -82,8 +88,8 @@ test("corbits-skills plugin has no agents directory", () => {
8288
expect(existsSync(join(pluginRoot, "agents"))).toBe(false);
8389
});
8490

85-
test("corbits-skills catalog lists 16 skills with name and description", async () => {
86-
expect(SKILL_DIRS).toHaveLength(16);
91+
test("corbits-skills catalog lists 17 skills with name and description", async () => {
92+
expect(SKILL_DIRS).toHaveLength(17);
8793
const entries = await readdir(join(pluginRoot, "skills"), { withFileTypes: true });
8894
const dirs = entries
8995
.filter((entry) => entry.isDirectory())
@@ -114,13 +120,41 @@ test("create-issue selects Linear MCP, GitHub gh, and MEMORY.md preference", asy
114120
expect(skill).toContain("Preferred issue tracker:");
115121
});
116122

117-
test("use_skill-only skills set user-invocable: false", async () => {
123+
test("use_skill-only skills set user-invocable: false without disable-model-invocation", async () => {
118124
for (const name of USE_SKILL_ONLY) {
119125
const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text();
120126
expect(skill).toContain(USER_INVOCABLE_FALSE);
127+
expect(skill).not.toContain(DISABLE_MODEL_INVOCATION);
128+
}
129+
});
130+
131+
test("background-only skills set both exclusion flags", async () => {
132+
for (const name of BACKGROUND_ONLY) {
133+
const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text();
134+
expect(skill).toContain(USER_INVOCABLE_FALSE);
135+
expect(skill).toContain(DISABLE_MODEL_INVOCATION);
136+
}
137+
});
138+
139+
test("only background libs carry disable-model-invocation", async () => {
140+
for (const name of SKILL_DIRS) {
141+
const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text();
142+
if ((BACKGROUND_ONLY as readonly string[]).includes(name)) {
143+
expect(skill).toContain(DISABLE_MODEL_INVOCATION);
144+
} else {
145+
expect(skill).not.toContain(DISABLE_MODEL_INVOCATION);
146+
}
121147
}
122148
});
123149

150+
test("linear-issue-workflow references use_skill(git-worktrees)", async () => {
151+
const skill = await Bun.file(
152+
join(pluginRoot, "skills/linear-issue-workflow/SKILL.md"),
153+
).text();
154+
expect(skill).toContain('use_skill("git-worktrees")');
155+
expect(skill).not.toContain("git worktree add");
156+
});
157+
124158
test("slash skills do not set user-invocable: false", async () => {
125159
for (const name of SLASH_SKILLS) {
126160
const skill = await Bun.file(join(pluginRoot, "skills", name, "SKILL.md")).text();

0 commit comments

Comments
 (0)