Skip to content

Commit 06ad63a

Browse files
committed
feat(skills): nested sibling files + spawn_agent skills handoff
use_skill returns skill dir so workers can read brand-guidelines.md next to SKILL.md; user-global skill dirs become read-only trusted roots; spawn_agent accepts skills[] unioned with package allowlist.
1 parent a9b999a commit 06ad63a

7 files changed

Lines changed: 226 additions & 22 deletions

File tree

‎src/agent/use-skill.test.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ describe("createUseSkillTool already-in-context", () => {
5757
import.meta.resolve("../extensions/skills.js"),
5858
(real: typeof import("../extensions/skills.js")) => ({
5959
...real,
60-
resolveSkillBody: async (
61-
...args: Parameters<typeof real.resolveSkillBody>
60+
resolveSkillWithDir: async (
61+
...args: Parameters<typeof real.resolveSkillWithDir>
6262
) => {
6363
resolveCalls += 1;
64-
return real.resolveSkillBody(...args);
64+
return real.resolveSkillWithDir(...args);
6565
},
6666
}),
6767
async () => {
@@ -102,12 +102,12 @@ describe("createUseSkillTool already-in-context", () => {
102102
import.meta.resolve("../extensions/skills.js"),
103103
(real: typeof import("../extensions/skills.js")) => ({
104104
...real,
105-
resolveSkillBody: async (
106-
...args: Parameters<typeof real.resolveSkillBody>
105+
resolveSkillWithDir: async (
106+
...args: Parameters<typeof real.resolveSkillWithDir>
107107
) => {
108108
resolveCalls += 1;
109109
await Promise.resolve();
110-
return real.resolveSkillBody(...args);
110+
return real.resolveSkillWithDir(...args);
111111
},
112112
}),
113113
async () => {
@@ -144,3 +144,25 @@ describe("createUseSkillTool already-in-context", () => {
144144
expect(out).toContain('Skill "git-worktrees"');
145145
});
146146
});
147+
148+
describe("createUseSkillTool skill directory footer", () => {
149+
test("loaded body appends the skill dir plus a sibling-file hint", async () => {
150+
const cwd = await fixtureWithHiddenSkill();
151+
const tool = createUseSkillTool(cwd);
152+
const out = await call(tool, { name: "git-worktrees" });
153+
expect(out).toContain("Create worktree recipe.");
154+
expect(out).toContain(
155+
`Skill directory: ${join(cwd, ".agents", "skills", "git-worktrees")}.`,
156+
);
157+
expect(out).toContain("brand-guidelines.md");
158+
expect(out).toContain("read_file");
159+
});
160+
161+
test("unknown skill has no footer", async () => {
162+
const cwd = await fixtureWithHiddenSkill();
163+
const tool = createUseSkillTool(cwd);
164+
const out = await call(tool, { name: "nope-missing" });
165+
expect(out).toBe('No skill named "nope-missing" is available.');
166+
expect(out).not.toContain("Skill directory:");
167+
});
168+
});

‎src/agent/use-skill.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { AgentTool } from "@intx/agent";
33
import type { ToolDefinition } from "@intx/types/runtime";
44
import { type } from "arktype";
55

6-
import { resolveSkillBody } from "../extensions/skills.js";
6+
import { resolveSkillWithDir } from "../extensions/skills.js";
77
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
88
import { captureSkillUsed } from "../telemetry/product-events.js";
99

@@ -72,8 +72,11 @@ export function createUseSkillTool(
7272
if (loaded.has(name)) return alreadyInContextMessage(name);
7373
loaded.add(name);
7474
let body: string | undefined;
75+
let dir: string | undefined;
7576
try {
76-
body = await resolveSkillBody(cwd, name, skillDirs);
77+
const resolved = await resolveSkillWithDir(cwd, name, skillDirs);
78+
body = resolved?.body;
79+
dir = resolved?.dir;
7780
} catch (err) {
7881
loaded.delete(name);
7982
throw err;
@@ -86,7 +89,11 @@ export function createUseSkillTool(
8689
// name never leaves the process: first-party `corbits-skills` names
8790
// are reported by name, everything else as `custom`.
8891
captureSkillUsed(telemetry, name);
89-
return `Skill "${name}" — follow these instructions for this task:\n\n${body}`;
92+
const footer =
93+
dir !== undefined
94+
? `\n\nSkill directory: ${dir}. Sibling files (e.g. brand-guidelines.md) can be read with read_file relative to that dir.`
95+
: "";
96+
return `Skill "${name}" — follow these instructions for this task:\n\n${body}${footer}`;
9097
},
9198
});
9299
}

‎src/extensions/skills.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { join } from "node:path";
33
import { tmpdir } from "node:os";
44
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
55

6-
import { discoverSkills, resolveSkillBody } from "./skills.js";
6+
import {
7+
discoverSkills,
8+
resolveSkillBody,
9+
resolveSkillWithDir,
10+
} from "./skills.js";
711
import { defined } from "../../testkit/defined.js";
812
import { withMockedHomedir } from "../../testkit/mock-module.js";
913

@@ -189,6 +193,25 @@ describe("skill resolution", () => {
189193
).toBeUndefined();
190194
});
191195

196+
test("resolveSkillWithDir returns the body plus the winning skill directory", async () => {
197+
const resolved = await resolveSkillWithDir(
198+
fixtureCwd,
199+
"scribe",
200+
pluginDirs,
201+
);
202+
expect(resolved).toBeDefined();
203+
expect(defined(resolved, "resolved skill").body).toContain("Scribe");
204+
expect(defined(resolved, "resolved skill").dir).toBe(
205+
join(exampleAgentPlugin, "skills", "scribe"),
206+
);
207+
});
208+
209+
test("resolveSkillWithDir returns undefined for an unknown skill", async () => {
210+
expect(
211+
await resolveSkillWithDir(fixtureCwd, "does-not-exist-xyz", pluginDirs),
212+
).toBeUndefined();
213+
});
214+
192215
test("resolveSkillBody still loads disable-model-invocation skills by name", async () => {
193216
const plugin = await mkdtemp(join(tmpdir(), "skill-dmi-resolve-"));
194217
try {

‎src/extensions/skills.ts‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { homedir } from "node:os";
22
import { realpath } from "node:fs/promises";
33
import { readdir, readFile } from "node:fs/promises";
4-
import { basename, isAbsolute, join, resolve } from "node:path";
4+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
55
import { pathIsInsideOrEqual } from "../util/path-contain.js";
66

77
const SKILL_RELATIVE_DIRS = [
@@ -52,6 +52,14 @@ function skillBaseDirs(
5252
return [...pluginBases, ...projectBases, ...userBases];
5353
}
5454

55+
// User-global skill base dirs (`~/.corbits/skills`, …). Project-local skill
56+
// dirs resolve under the session cwd (already inside the workspace); these
57+
// home-based dirs live outside it, so read-only file tools need them as
58+
// trusted roots to open sibling files (see assembleSessionGate).
59+
export function userSkillBaseDirs(home: string = homedir()): string[] {
60+
return SKILL_RELATIVE_DIRS.map((rel) => join(home, rel));
61+
}
62+
5563
function parseSkillRef(ref: string): string {
5664
const idx = ref.indexOf(":");
5765
return idx === -1 ? ref : ref.slice(idx + 1);
@@ -119,16 +127,26 @@ async function bodyFromSkillPath(path: string): Promise<string | undefined> {
119127
return body.length > 0 ? body : undefined;
120128
}
121129

130+
/**
131+
* A resolved skill: the SKILL.md body (frontmatter stripped) plus the skill
132+
* directory that won, so callers can point the model at sibling files
133+
* (e.g. brand-guidelines.md) living next to SKILL.md.
134+
*/
135+
export interface ResolvedSkill {
136+
body: string;
137+
dir: string;
138+
}
139+
122140
/**
123141
* Resolve a path-like skill ref against `pluginRoot`. Absolute refs and
124142
* escapes outside the root are rejected. Accepts a SKILL.md file path or a
125143
* directory that contains SKILL.md. When the candidate exists, both sides are
126144
* realpath'd so a symlink under the root cannot escape to outside content.
127145
*/
128-
async function resolvePathLikeSkillBody(
146+
async function resolvePathLikeSkill(
129147
pluginRoot: string,
130148
ref: string,
131-
): Promise<string | undefined> {
149+
): Promise<ResolvedSkill | undefined> {
132150
if (isAbsolute(ref)) return undefined;
133151
const root = resolve(pluginRoot);
134152
const resolved = resolve(root, ref);
@@ -146,7 +164,9 @@ async function resolvePathLikeSkillBody(
146164
realpath(root),
147165
]);
148166
if (!pathIsInsideOrEqual(realSkill, realRoot)) return undefined;
149-
return bodyFromSkillPath(realSkill);
167+
const body = await bodyFromSkillPath(realSkill);
168+
if (body === undefined) return undefined;
169+
return { body, dir: dirname(realSkill) };
150170
} catch {
151171
// Missing path (or unreadable root) → not a resolvable skill.
152172
return undefined;
@@ -160,31 +180,42 @@ async function resolvePathLikeSkillBody(
160180
// project-local then user-global fallbacks). Path-like refs (`./skills/style`,
161181
// `skills/foo`) resolve only under `options.pluginRoot` with containment
162182
// checks; absolute and escape paths fail.
163-
export async function resolveSkillBody(
183+
export async function resolveSkillWithDir(
164184
cwd: string,
165185
ref: string,
166186
pluginDirs: string[] = [],
167187
options?: ResolveSkillBodyOptions,
168-
): Promise<string | undefined> {
188+
): Promise<ResolvedSkill | undefined> {
169189
const name = parseSkillRef(ref);
170190
// Bare `.` / `..` are not skill names and must not fall through to directory search.
171191
if (name === "." || name === "..") return undefined;
172192
if (isPathLikeSkillRef(name)) {
173193
const pluginRoot = options?.pluginRoot;
174194
if (pluginRoot === undefined) return undefined;
175-
return resolvePathLikeSkillBody(pluginRoot, name);
195+
return resolvePathLikeSkill(pluginRoot, name);
176196
}
177197
for (const base of skillBaseDirs(
178198
cwd,
179199
pluginDirs,
180200
options?.pluginDirsOnly !== true,
181201
)) {
182-
const body = await bodyFromSkillPath(join(base, name, "SKILL.md"));
183-
if (body !== undefined) return body;
202+
const dir = join(base, name);
203+
const body = await bodyFromSkillPath(join(dir, "SKILL.md"));
204+
if (body !== undefined) return { body, dir };
184205
}
185206
return undefined;
186207
}
187208

209+
export async function resolveSkillBody(
210+
cwd: string,
211+
ref: string,
212+
pluginDirs: string[] = [],
213+
options?: ResolveSkillBodyOptions,
214+
): Promise<string | undefined> {
215+
const resolved = await resolveSkillWithDir(cwd, ref, pluginDirs, options);
216+
return resolved?.body;
217+
}
218+
188219
// Discover every available skill (name + one-line description). Deduped by name:
189220
// the first base dir that provides a skill wins, so a higher-precedence dir
190221
// shadows a lower one. Descriptions feed skill_search and the slash picker; the

‎src/session/assemble-runtime.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
} from "../permission/gate.js";
108108
import type { Approval, RequestApproval } from "../permission/types.js";
109109
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
110+
import { userSkillBaseDirs } from "../extensions/skills.js";
110111
import { userPluginsRoot } from "../plugins/uninstall.js";
111112
import { createApprovalLog } from "../permission/approval-log.js";
112113
import { sessionDir } from "./index.js";
@@ -312,7 +313,11 @@ export async function assembleSessionGate(
312313
telemetry: args.telemetry,
313314
cwd: args.cwd,
314315
rootsProvider: createWorktreeRootsProvider(args.cwd),
315-
trustedPluginRoots: () => [userPluginsRoot()],
316+
// User-global skill dirs live outside the workspace, so sibling reads
317+
// (read_file/grep/search_files/list_dir of files next to a loaded
318+
// SKILL.md) need them as trusted roots. Trusted roots are read-only by
319+
// construction — writes/deletes under them stay hard escape denies.
320+
trustedPluginRoots: () => [userPluginsRoot(), ...userSkillBaseDirs()],
316321
providerName: args.providerName,
317322
model: args.model,
318323
requestApproval: args.requestApproval,

‎src/subagent/agent-fleet.test.ts‎

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { mkdtemp, readFile } from "node:fs/promises";
2+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

@@ -453,6 +453,103 @@ describe("spawn_agent", () => {
453453
expect(captured?.systemPromptRole).toBe("You are the plugin designer.");
454454
expect(captured?.allowedSkillNames).toEqual(["better-ui"]);
455455
});
456+
457+
test("spawn skills[] unions with the package allowlist; omitted stays fail-closed", async () => {
458+
const fixture = await mkdtemp(join(tmpdir(), "corbits-spawn-skills-"));
459+
const skillDir = join(fixture, ".agents", "skills", "custom");
460+
await mkdir(skillDir, { recursive: true });
461+
await writeFile(
462+
join(skillDir, "SKILL.md"),
463+
"---\nname: custom\ndescription: Custom lane.\n---\nCustom body.\n",
464+
);
465+
466+
const captured: RunSubAgentParams[] = [];
467+
const deps = createFleetDeps(
468+
async (params) => {
469+
captured.push(params);
470+
return { report: "done" };
471+
},
472+
{
473+
profiles: [
474+
{
475+
id: "bare-worker",
476+
systemPromptRole: "You are a bare worker with no package skills.",
477+
},
478+
],
479+
},
480+
);
481+
const spawn = createSpawnAgentTool(deps);
482+
483+
// Package has no skill fields: spawn skills alone open the allowlist,
484+
// and blank entries are ignored, never an error.
485+
const withSkills = await callFleetTool(spawn, {
486+
description: "skilled job",
487+
prompt: "do it",
488+
agent: "bare-worker",
489+
skills: ["custom", " ", ""],
490+
});
491+
expect(withSkills.status).toBe("running");
492+
expect(captured[0]?.allowedSkillNames).toEqual(["custom"]);
493+
494+
// The dispatched allowlist is what gates use_skill: the spawned worker
495+
// loads the custom skill through it.
496+
const { createUseSkillTool } = await import("../agent/use-skill.js");
497+
const callUseSkill = (
498+
tool: ReturnType<typeof createUseSkillTool>,
499+
args: Record<string, unknown>,
500+
): Promise<string> => {
501+
if (tool.kind !== "string") throw new Error("expected string tool");
502+
return tool.handler(args, new AbortController().signal);
503+
};
504+
const skilled = createUseSkillTool(
505+
fixture,
506+
[],
507+
undefined,
508+
captured[0]?.allowedSkillNames,
509+
);
510+
expect(await callUseSkill(skilled, { name: "custom" })).toContain(
511+
"Custom body.",
512+
);
513+
514+
// Without the spawn handoff the same package stays fail-closed.
515+
const withoutSkills = await callFleetTool(spawn, {
516+
description: "bare job",
517+
prompt: "do it",
518+
agent: "bare-worker",
519+
});
520+
expect(withoutSkills.status).toBe("running");
521+
expect(captured[1]?.allowedSkillNames).toEqual([]);
522+
const bare = createUseSkillTool(
523+
fixture,
524+
[],
525+
undefined,
526+
captured[1]?.allowedSkillNames,
527+
);
528+
expect(await callUseSkill(bare, { name: "custom" })).toBe(
529+
'No skill named "custom" is available.',
530+
);
531+
});
532+
533+
test("spawn skills[] unions with a closed package allowlist", async () => {
534+
const captured: RunSubAgentParams[] = [];
535+
const deps = createFleetDeps(async (params) => {
536+
captured.push(params);
537+
return { report: "done" };
538+
});
539+
const spawn = createSpawnAgentTool(deps);
540+
541+
// coder declares optionalSkills ["typescript"]: the spawn list unions,
542+
// it does not replace.
543+
const result = await callFleetTool(spawn, {
544+
description: "coded job",
545+
prompt: "implement it",
546+
agent: "coder",
547+
success_criteria: ["it ships"],
548+
skills: ["custom"],
549+
});
550+
expect(result.status).toBe("running");
551+
expect(captured[0]?.allowedSkillNames).toEqual(["typescript", "custom"]);
552+
});
456553
});
457554

458555
describe("spawn_agent + wait_agents", () => {

0 commit comments

Comments
 (0)