Give a coder session this repository's skills

fde22295af0b · AtlantisPleb · · parent be7971fd0443

Give a coder session this repository's skills

A skill is a directory holding a `SKILL.md`: front matter naming it and saying
when it applies, then a body of instructions. The format is already shared with
the other agents that read this repository, so the same file serves all of them
and none of them owns it. `openagents coder` was the one that could not read
them.

Not by loading them. The skills reachable from this machine run to some 46 KB,
more than a local model's context should spend on instructions it will not use,
and the total grows with every skill anyone adds. So the session offers a
catalog -- one line each, name and description -- and hands over a body only
when the model asks for one. That is what the front matter's `description` is
for: it is the sentence the model chooses on.

The catalog rides in the `skill` tool's own description rather than in a system
prompt, because the two lanes do not share a prompt: the local lane writes its
own and the thread lane's is the server's. A tool description reaches both, and
both were verified against the real skills in this repository.

Skills are read from `.agents/skills` in the repository and then under the home
directory, nearest winning a name. `.claude/skills` and `.devin/skills` are not
read: they are one vendor's directory each, and `.agents` is the one written to
be shared. The home directory is a parameter rather than a lookup so a test can
point at a directory it made, which is the difference between testing discovery
and testing this machine.

Front matter is read for two bounded fields rather than parsed as YAML, but it
does have to read them correctly. A description longer than a line is written as
a folded block, and taking the marker as the value is how `stripe-directory`
came to describe itself to the model as ">-". Folded and literal blocks both
resolve now, and a skill missing a name or a description is skipped: one cannot
be asked for, the other gives nothing to choose on.

Skills do not depend on delegation. A session with no credential cannot hand
work to a child but still reads the conventions of the repository it is sitting
in.

292 tests pass, 12 on skills.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-skills.ts
  • modified packages/openagents-cli/src/coder-tools.ts
  • added packages/openagents-cli/test/coder-skills.test.ts

Diff

6 files changed, +367 -6

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2430,
7
    "filesScanned": 2431,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:30573ab546f371af545bfe2cfedba47c5ec547e2acc61c8d9ce115761d3f7d4a",
4
  "sourceDigest": "sha256:78a1326f50e5901e25bd56e2bed33f1635fcb75d3dbb89b8bfd8ef4558d00fd3",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (30 tracked test files)"
1879
          "ref": "packages/openagents-cli (31 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +12 -3

@@ -30,7 +30,8 @@ import { runCoderUi } from "./coder-ui.js";

30 30
import { backendIds } from "./coder-backends.js";
31 31
import { OllamaReplySource, isOllamaModelFlag, parseOllamaModelFlag } from "./coder-ollama.js";
32 32
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
33
import { delegateTool } from "./coder-tools.js";
33
import { delegateTool, skillTool } from "./coder-tools.js";
34
import { discoverSkills } from "./coder-skills.js";
34 35
import { describeWorkspace } from "./coder-workspace.js";
35 36
import { ComputerClient } from "./computer-client.js";
36 37
import { ComputerUp } from "./computer-up.js";

@@ -1740,8 +1741,16 @@ const coderCommand = Command.make(

1740 1741
      // remember a slash command. A turn that needs three agents asks for them
1741 1742
      // mid-sentence, and `/delegate` stays as the way to launch a fan-out
1742 1743
      // without spending a turn to ask for one.
1743
      if (setup !== undefined) {
1744
        source.useTools?.([delegateTool(setup.delegation)]);
1744
      // Skills do not depend on delegation: a session with no credential still
1745
      // reads this repository's conventions, it just cannot hand work to a
1746
      // child. A session with neither declares no tools at all.
1747
      const skills = discoverSkills();
1748
      const tools = [
1749
        ...(skills.length === 0 ? [] : [skillTool(skills)]),
1750
        ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
1751
      ];
1752
      if (tools.length > 0) {
1753
        source.useTools?.(tools);
1745 1754
      }
1746 1755
1747 1756
      // Delegation is off rather than quietly running children on the
packages/openagents-cli/src/coder-skills.ts added +155

@@ -0,0 +1,155 @@

1
/**
2
 * The skills a session can read.
3
 *
4
 * A skill is a directory holding a `SKILL.md`: YAML front matter naming it and
5
 * saying when it applies, then a body of instructions. The format is shared
6
 * with the other agents that read this repository, so the same file serves all
7
 * of them and none of them owns it.
8
 *
9
 * Skills are offered by name and description, not by body. Eight skills across
10
 * this machine and this repository are some 46 KB, more than a local model's
11
 * context should spend on instructions it will not use, and the total grows
12
 * with every skill anyone adds. So the catalog is small and constant, and a
13
 * body is read only when the model asks for one -- which is what the front
14
 * matter's `description` is for: it is the sentence the model chooses on.
15
 *
16
 * The catalog rides in the `skill` tool's description rather than in a system
17
 * prompt. The local lane composes its own prompt; the thread lane's is the
18
 * server's. A tool description reaches both.
19
 */
20
21
import { readdirSync, readFileSync, statSync } from "node:fs";
22
import { homedir } from "node:os";
23
import { join } from "node:path";
24
25
/** Where skills live, nearest first. A repository skill wins its name. */
26
const SKILL_DIRECTORIES = (cwd: string, home: string): ReadonlyArray<string> => [
27
  join(cwd, ".agents", "skills"),
28
  join(home, ".agents", "skills"),
29
];
30
31
/** How much of one skill body is handed back. */
32
const BODY_LIMIT = 32_000;
33
34
export interface CoderSkill {
35
  /** The name the model asks for, from the front matter. */
36
  readonly name: string;
37
  /** When to use it, from the front matter. One sentence, shown in the catalog. */
38
  readonly description: string;
39
  /** The instructions, front matter removed. */
40
  readonly body: string;
41
  /** Where it was read from, so a reader can open it. */
42
  readonly path: string;
43
}
44
45
/**
46
 * Read `name` and `description` out of YAML front matter.
47
 *
48
 * Deliberately not a YAML parser. These are two bounded scalar fields at the
49
 * top of a known file, and a dependency that can parse anchors and merge keys
50
 * is a dependency that can also do something surprising with a file anyone may
51
 * drop in a skills directory.
52
 */
53
const frontMatter = (source: string): { name?: string; description?: string } => {
54
  if (!source.startsWith("---")) return {};
55
  const end = source.indexOf("\n---", 3);
56
  if (end < 0) return {};
57
58
  const fields: { name?: string; description?: string } = {};
59
  const lines = source.slice(3, end).split("\n");
60
61
  for (const [at, line] of lines.entries()) {
62
    const match = /^(name|description):\s*(.*)$/.exec(line);
63
    if (match === null) continue;
64
    const key = match[1] as "name" | "description";
65
    const inline = match[2]!.trim();
66
67
    // `>` and `|` say the value is the indented block beneath, which is how a
68
    // description longer than a line is written. Taking the marker as the value
69
    // is how `stripe-directory` came to describe itself as ">-".
70
    if (inline === "" || /^[>|][-+]?$/.test(inline)) {
71
      const block: string[] = [];
72
      for (const next of lines.slice(at + 1)) {
73
        if (!/^\s/.test(next) || next.trim() === "") break;
74
        block.push(next.trim());
75
      }
76
      // A folded block is one paragraph; a literal one keeps its line breaks.
77
      if (block.length > 0) fields[key] = block.join(inline.startsWith("|") ? "\n" : " ");
78
      continue;
79
    }
80
81
    // A quoted scalar is the same string without its quotes.
82
    fields[key] = inline.replace(/^["'](.*)["']$/, "$1");
83
  }
84
  return fields;
85
};
86
87
/** The body after the front matter, or the whole file when there is none. */
88
const withoutFrontMatter = (source: string): string => {
89
  if (!source.startsWith("---")) return source.trim();
90
  const end = source.indexOf("\n---", 3);
91
  if (end < 0) return source.trim();
92
  return source.slice(source.indexOf("\n", end + 1) + 1).trim();
93
};
94
95
/**
96
 * Every skill readable from this directory, nearest source winning its name.
97
 *
98
 * A directory that is missing, unreadable, or holds no `SKILL.md` contributes
99
 * nothing. A skills directory is optional, and a session whose repository has
100
 * none is a session with no skills, not a session that failed to start.
101
 */
102
export function discoverSkills(
103
  cwd: string = process.cwd(),
104
  // Taken rather than read so a test can point at a directory it made. A
105
  // function that always reads the real home directory can only be tested on a
106
  // machine that happens to have the right skills in it.
107
  home: string = homedir(),
108
): ReadonlyArray<CoderSkill> {
109
  const found = new Map<string, CoderSkill>();
110
111
  for (const directory of SKILL_DIRECTORIES(cwd, home)) {
112
    let entries: ReadonlyArray<string>;
113
    try {
114
      entries = readdirSync(directory);
115
    } catch {
116
      continue;
117
    }
118
119
    for (const entry of entries) {
120
      const path = join(directory, entry, "SKILL.md");
121
      let source: string;
122
      try {
123
        if (!statSync(path).isFile()) continue;
124
        source = readFileSync(path, "utf8");
125
      } catch {
126
        continue;
127
      }
128
129
      const { name, description } = frontMatter(source);
130
      // A skill with no name cannot be asked for, and one with no description
131
      // gives the model nothing to choose on. Both are required.
132
      if (name === undefined || description === undefined) continue;
133
      if (found.has(name)) continue;
134
135
      found.set(name, { name, description, body: withoutFrontMatter(source), path });
136
    }
137
  }
138
139
  // Sorting a fresh array built from the map, so nothing shared is mutated.
140
  // eslint-disable-next-line unicorn/no-array-sort -- the spread is the copy
141
  return [...found.values()].sort((left, right) => left.name.localeCompare(right.name));
142
}
143
144
/** The catalog line for one skill, as the model reads it. */
145
export const catalogEntry = (skill: CoderSkill): string =>
146
  `- \`${skill.name}\`: ${skill.description}`;
147
148
/** What a skill hands back when it is read. */
149
export const renderSkill = (skill: CoderSkill): string => {
150
  const body =
151
    skill.body.length > BODY_LIMIT
152
      ? `${skill.body.slice(0, BODY_LIMIT)}\n\n[truncated; the rest is in ${skill.path}]`
153
      : skill.body;
154
  return `Skill \`${skill.name}\` (${skill.path}):\n\n${body}`;
155
};
packages/openagents-cli/src/coder-tools.ts modified +50

@@ -15,6 +15,7 @@

15 15
 */
16 16
17 17
import type { CoderDelegation } from "./coder-session.js";
18
import { catalogEntry, renderSkill, type CoderSkill } from "./coder-skills.js";
18 19
import { describePrompt, MAX_DELEGATE_COUNT } from "./coder-delegate.js";
19 20
import type { DelegationOutcome } from "./coder-delegate.js";
20 21

@@ -168,3 +169,52 @@ function report(outcomes: ReadonlyArray<DelegationOutcome>, delegation: CoderDel

168 169
function clip(text: string, limit: number): string {
169 170
  return text.length <= limit ? text : `${text.slice(0, limit)}\n…[truncated]`;
170 171
}
172
173
/**
174
 * The skill tool: read one of this session's skills.
175
 *
176
 * The catalog is the description rather than a system prompt, so the same tool
177
 * carries it on the local lane, where the client writes the prompt, and on the
178
 * thread lane, where the server does. Bodies are not sent until asked for: the
179
 * catalog is a line each, and the skills on one machine already run to tens of
180
 * kilobytes.
181
 *
182
 * Returned as text rather than thrown for a name that does not match, and the
183
 * text lists what does: a model that misremembers a name can correct itself on
184
 * the next call, and cannot correct a turn that died.
185
 */
186
export function skillTool(skills: ReadonlyArray<CoderSkill>): CoderTool {
187
  const catalog = skills.map((skill) => catalogEntry(skill)).join("\n");
188
  return {
189
    name: "skill",
190
    description:
191
      "Read one of this repository's skills: a written procedure for a kind of work, with the " +
192
      "conventions, commands, and rules it needs. Call it before doing work a skill covers, and " +
193
      "follow what it says over your own habits. Skills available:\n" +
194
      catalog,
195
    parameters: {
196
      type: "object",
197
      properties: {
198
        name: {
199
          type: "string",
200
          enum: skills.map((skill) => skill.name),
201
          description: "The skill to read.",
202
        },
203
      },
204
      required: ["name"],
205
      additionalProperties: false,
206
    },
207
    run: (args) => {
208
      const name = typeof args["name"] === "string" ? args["name"].trim() : "";
209
      const skill = skills.find((candidate) => candidate.name === name);
210
      if (skill === undefined) {
211
        return Promise.resolve(
212
          `There is no \`${name}\` skill. This session has: ${skills
213
            .map((candidate) => `\`${candidate.name}\``)
214
            .join(", ")}.`,
215
        );
216
      }
217
      return Promise.resolve(renderSkill(skill));
218
    },
219
  };
220
}
packages/openagents-cli/test/coder-skills.test.ts added +147

@@ -0,0 +1,147 @@

1
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import { discoverSkills } from "../src/coder-skills.js";
7
import { skillTool } from "../src/coder-tools.js";
8
9
/** A repository with the given skills under `.agents/skills`. */
10
const workspace = (skills: Record<string, string>): string => {
11
  const root = mkdtempSync(join(tmpdir(), "coder-skills-"));
12
  for (const [name, source] of Object.entries(skills)) {
13
    const directory = join(root, ".agents", "skills", name);
14
    mkdirSync(directory, { recursive: true });
15
    writeFileSync(join(directory, "SKILL.md"), source);
16
  }
17
  return root;
18
};
19
20
/** A home directory with no skills in it, so a test reads only its workspace. */
21
const EMPTY_HOME = mkdtempSync(join(tmpdir(), "coder-skills-home-"));
22
23
const SKILL = `---
24
name: house-style
25
description: How this repository writes prose.
26
---
27
28
# House style
29
30
Use sentence case.
31
`;
32
33
describe("discovering skills", () => {
34
  it("reads the name, the description, and the body without its front matter", () => {
35
    const [skill] = discoverSkills(workspace({ "house-style": SKILL }), EMPTY_HOME);
36
37
    expect(skill).toMatchObject({
38
      name: "house-style",
39
      description: "How this repository writes prose.",
40
    });
41
    expect(skill?.body).toBe("# House style\n\nUse sentence case.");
42
    // The body is what the front matter is not, so neither leaks into the other.
43
    expect(skill?.body).not.toContain("description:");
44
  });
45
46
  it("takes the name from the front matter, not from the directory", () => {
47
    const [skill] = discoverSkills(workspace({ "some-folder": SKILL }), EMPTY_HOME);
48
49
    expect(skill?.name).toBe("house-style");
50
  });
51
52
  it("skips a directory with no SKILL.md rather than failing", () => {
53
    const root = workspace({ "house-style": SKILL });
54
    mkdirSync(join(root, ".agents", "skills", "empty"), { recursive: true });
55
56
    expect(discoverSkills(root, EMPTY_HOME).map((skill) => skill.name)).toEqual(["house-style"]);
57
  });
58
59
  it("skips a skill missing a name or a description", () => {
60
    const root = workspace({
61
      nameless: "---\ndescription: No name.\n---\n\nBody.",
62
      quiet: "---\nname: quiet\n---\n\nBody.",
63
      "house-style": SKILL,
64
    });
65
66
    // One cannot be asked for and the other gives nothing to choose on.
67
    expect(discoverSkills(root, EMPTY_HOME).map((skill) => skill.name)).toEqual(["house-style"]);
68
  });
69
70
71
  it("reads a description written as a folded block, not the block marker", () => {
72
    const root = workspace({
73
      folded: [
74
        "---",
75
        "name: folded",
76
        "description: >-",
77
        "  Use when the reader wants one sentence",
78
        "  spread over two lines.",
79
        "---",
80
        "",
81
        "Body.",
82
      ].join("\n"),
83
    });
84
85
    // Taking `>-` as the value is how a skill came to describe itself as ">-".
86
    expect(discoverSkills(root, EMPTY_HOME)[0]?.description).toBe(
87
      "Use when the reader wants one sentence spread over two lines.",
88
    );
89
  });
90
91
  it("keeps the line breaks of a literal block", () => {
92
    const root = workspace({
93
      literal: ["---", "name: literal", "description: |", "  One.", "  Two.", "---", "", "Body."].join(
94
        "\n",
95
      ),
96
    });
97
98
    expect(discoverSkills(root, EMPTY_HOME)[0]?.description).toBe("One.\nTwo.");
99
  });
100
101
  it("is empty for a repository with no skills directory", () => {
102
    expect(discoverSkills(mkdtempSync(join(tmpdir(), "coder-skills-")), EMPTY_HOME)).toEqual([]);
103
  });
104
105
  it("strips the quotes from a quoted description", () => {
106
    const root = workspace({
107
      quoted: '---\nname: quoted\ndescription: "Quoted, with a comma."\n---\n\nBody.',
108
    });
109
110
    expect(discoverSkills(root, EMPTY_HOME)[0]?.description).toBe("Quoted, with a comma.");
111
  });
112
});
113
114
describe("the skill tool", () => {
115
  const skills = discoverSkills(workspace({ "house-style": SKILL }), EMPTY_HOME);
116
  const tool = skillTool(skills);
117
118
  it("carries the catalog in its description, so both lanes read it", () => {
119
    // The local lane writes its own prompt and the thread lane's is the
120
    // server's. A tool description is the one place that reaches both.
121
    expect(tool.description).toContain("`house-style`: How this repository writes prose.");
122
    // The body is not in the catalog. That is the point of the catalog.
123
    expect(tool.description).not.toContain("Use sentence case.");
124
  });
125
126
  it("offers only the names it found, so a model cannot ask for another", () => {
127
    expect((tool.parameters["properties"] as Record<string, { enum: string[] }>)["name"]?.enum).toEqual([
128
      "house-style",
129
    ]);
130
  });
131
132
  it("hands back the body when asked", async () => {
133
    const output = await tool.run({ name: "house-style" }, new AbortController().signal);
134
135
    expect(output).toContain("Use sentence case.");
136
    expect(output).toContain("house-style");
137
  });
138
139
  it("answers an unknown name with what it does have, rather than throwing", async () => {
140
    const output = await tool.run({ name: "nope" }, new AbortController().signal);
141
142
    // A model that misremembers can correct itself; it cannot correct a turn
143
    // that died.
144
    expect(output).toContain("There is no `nope` skill");
145
    expect(output).toContain("`house-style`");
146
  });
147
});

This page updates live while a promote is in flight · changelog