Skip to content

Commit 334eb9e

Browse files
sanchitrkclaude
andcommitted
feat(harness): discover agent skills in the sandbox
Add the codework.prompt.skill builtin. Skills are SKILL.md directories (and standalone .md files in .codework/skills) found through the session's sandbox filesystem, never the host or <home>: .codework/skills at the cwd, then .agents/skills from the cwd up to the git root, or the sandbox root outside a repository. Only name, description and location go into the <skills> prompt section; the model reads the file when a task matches. Invalid frontmatter, unreadable files and name collisions warn instead of failing. Share the ancestor walk, the batched existence probe and BOM stripping between the instruction and skill plugins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e7c141b commit 334eb9e

13 files changed

Lines changed: 1384 additions & 34 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ This file is the canonical source for unreleased changes and published release n
66

77
- Add in-progress work under `## [Unreleased]
88

9+
## [Unreleased]
10+
11+
### Added
12+
13+
- Added Agent Skills (`codework.prompt.skill`). A skill is a directory with a `SKILL.md` whose frontmatter has a `name` and a `description`; `.codework/skills` may also hold standalone `.md` skills. Skills are found in the session's sandbox, local or remote, and never on the host or in `<home>`: `.codework/skills` in the working directory, then `.agents/skills` in the working directory and each parent up to the git root (or up to the sandbox root outside a repository). `.gitignore`, `.ignore` and `.fdignore` are honoured. The system prompt lists only each skill's name, description and location under `<skills>`; the model reads the full file when a task matches. A skill with `disable-model-invocation: true` is not listed. Invalid fields, unreadable files and name collisions log a warning instead of failing; the first skill found keeps the name. The hint names the `read` tool, or `bash` when `read` is off, and names no tool when the reader is hidden from the prompt. Turn it off with `{ "plugin": "codework.prompt.skill", "enabled": false }` in `plugins`.
14+
915
## [@codeworksh/cli@0.3.0] - 2026-10-13
1016

1117
### Added

‎packages/harness/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
"test": "vp test",
7575
"test:sandbox:package": "node test/scripts/codework-sandbox-vercel-smoke.mjs",
7676
"test:sandbox:package:e2e": "node test/scripts/codework-sandbox-vercel-smoke.mjs --e2e",
77-
"test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts test/tools.registry.remote.e2e.test.ts test/session.relink.e2e.test.ts test/plugin.instruction.e2e.test.ts"
77+
"test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts test/tools.registry.remote.e2e.test.ts test/session.relink.e2e.test.ts test/plugin.instruction.e2e.test.ts test/plugin.skill.e2e.test.ts"
7878
},
7979
"dependencies": {
8080
"@codeworksh/aikit": "workspace:^",
@@ -86,11 +86,13 @@
8686
"@platformatic/vfs": "^0.4.0",
8787
"@silvia-odwyer/photon-node": "0.3.4",
8888
"diff": "9.0.0",
89+
"ignore": "7.0.8",
8990
"jsonc-parser": "3.3.1",
9091
"just-bash": "^3.4.2",
9192
"npm-package-arg": "^14.0.0",
9293
"pacote": "^22.0.0",
93-
"uuidv7": "^1.2.1"
94+
"uuidv7": "^1.2.1",
95+
"yaml": "2.9.1"
9496
},
9597
"devDependencies": {
9698
"@daytona/sdk": "^0.211.2",

‎packages/harness/src/plugin/builtin.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defaultPromptPlugin } from "./builtin/prompt/default.ts";
22
import { instructionPlugin } from "./builtin/prompt/instruction.ts";
3+
import { skillPlugin } from "./builtin/prompt/skill.ts";
34
import { bashPlugin } from "./builtin/tool/bash.ts";
45
import { editPlugin } from "./builtin/tool/edit.ts";
56
import { findPlugin } from "./builtin/tool/find.ts";
@@ -24,4 +25,5 @@ export const builtins = Object.freeze([
2425
lsPlugin,
2526
defaultPromptPlugin,
2627
instructionPlugin,
28+
skillPlugin,
2729
]);

‎packages/harness/src/plugin/builtin/prompt/instruction.ts‎

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77

88
import * as Section from "@codeworksh/plugin/plugin/section";
99
import { Effect, Option } from "effect";
10-
import { SandboxFileSystem } from "../../../sandbox/fs/filesystem.ts";
10+
import type { SandboxFileSystem } from "../../../sandbox/fs/filesystem.ts";
1111
import { SandboxIO } from "../../../sandbox/io.ts";
1212
import { posix } from "../../../util/posix.ts";
1313
import type { SharedPluginContext } from "../../context.ts";
1414
import { define } from "../../plugin.ts";
15+
import { ancestors, present, stripBom } from "./walk.ts";
1516

1617
/** First readable match per directory wins. */
1718
const names: ReadonlyArray<string> = [
@@ -31,40 +32,10 @@ export interface Found {
3132
}
3233

3334
const content = (raw: string): string | undefined => {
34-
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
35+
const text = stripBom(raw);
3536
return text.trim().length === 0 ? undefined : text;
3637
};
3738

38-
/** `directory` and every ancestor up to the sandbox root, closest first. */
39-
const ancestors = (directory: string): string[] => {
40-
const chain: string[] = [];
41-
for (let current = directory; ; current = posix.dirname(current)) {
42-
chain.push(current);
43-
if (posix.dirname(current) === current) return chain;
44-
}
45-
};
46-
47-
/**
48-
* Which of `paths` are files. Absence is the expected answer and stays silent; any other failure
49-
* (permissions, a remote blip) is logged so a dropped file is not a mystery, then counts as absent.
50-
*/
51-
const present = (fs: SandboxFileSystem.Interface, paths: ReadonlyArray<string>) =>
52-
Effect.forEach(
53-
paths,
54-
(path) =>
55-
fs.exists(path).pipe(
56-
Effect.flatMap((exists) =>
57-
exists ? fs.stat(path).pipe(Effect.map((stat) => stat.isFile)) : Effect.succeed(false),
58-
),
59-
Effect.catch((error) =>
60-
SandboxFileSystem.isNotFoundError(error.cause)
61-
? Effect.succeed(false)
62-
: Effect.logWarning(`could not stat instruction file ${path}`, error.cause).pipe(Effect.as(false)),
63-
),
64-
),
65-
{ concurrency: "unbounded" },
66-
).pipe(Effect.map((hits) => new Set(paths.filter((_, index) => hits[index]))));
67-
6839
/** A file that stats but does not read falls through to the next candidate. */
6940
const first = Effect.fnUntraced(function* (
7041
fs: SandboxFileSystem.Interface,
@@ -110,6 +81,8 @@ export const discover = Effect.fnUntraced(function* (
11081
const files = yield* present(
11182
fs,
11283
chain.flatMap((directory) => names.map((name) => posix.join(directory, name))),
84+
"file",
85+
"instruction file",
11386
);
11487
const found = yield* Effect.forEach(chain, (directory) => first(fs, directory, files), {
11588
concurrency: "unbounded",

0 commit comments

Comments
 (0)