diff --git a/docs/milestones/M4.md b/docs/milestones/M4.md new file mode 100644 index 0000000..a1dec6b --- /dev/null +++ b/docs/milestones/M4.md @@ -0,0 +1,52 @@ +# M4 — Skills + Sub-agents + Output Styles + +> **Status**: ✅ Loader infrastructure shipped · 15 built-in skill markdown files deferred (content, not code — can be authored without engineering) +> **Branch**: `feat/m4-skills-agents-styles` + +## Shipped + +| Module | Purpose | Tests | +|---|---|---| +| `skills/frontmatter.ts` | Zero-dep YAML frontmatter parser (strings/numbers/bools/flow + block arrays/objects) | 10 | +| `skills/loader.ts` | 4-layer loader (builtin / user / project / plugin) + `buildSkillsDescriptionBlock()` for system-prompt injection | 9 | +| `sub-agents/loader.ts` | `.deepcode/agents/*.md` → `SubAgent` objects with isolation / tools / model / maxTurns | 6 | +| `output-styles/loader.ts` | 4 built-in styles (default / explanatory / learning / proactive) + user/project overrides + `applyStyle()` | 9 | +| Top-level re-exports | All new types/functions exposed from `@deepcode/core` | — | + +**Total new tests**: 34. Across whole project: 240 passing / 4 skipped / 0 failed. + +## What's in each subsystem + +**Skills** (`SKILL.md` files in `//SKILL.md`): +- Frontmatter spec: `name`, `description`, `allowed-tools`, `model`, `effort`, `shell`, `hooks`, `disabled` +- Qualified names: bare for user/project, `:` for plugin-shipped +- `disabled: true` in frontmatter OR `skillOverrides[name].disabled = true` in settings → skip load +- `buildSkillsDescriptionBlock()` produces the system-prompt fragment that lists available skills (name + description only — body is loaded on Skill-tool invocation) + +**Sub-agents** (`.deepcode/agents/.md`): +- Frontmatter: `name`, `description`, `tools[]`, `model`, `isolation`, `maxTurns` +- CLI `--agents ` flag honored via `projectDirOverride` option +- `findSubAgent(agents, name)` lookup helper + +**Output styles** (`/.deepcode/output-styles/.md`): +- 4 built-in: `default`, `explanatory`, `learning`, `proactive` +- Frontmatter: `name`, `description`, `keep-coding-instructions` +- User → project layer order with replace semantics +- `applyStyle(basePrompt, style)` appends body to system prompt + +## NOT delivered in this PR + +- **15 built-in skill markdown files** (`init` / `verify` / `run` / `code-review` / `security-review` / `skill-creator` / `xlsx` / `docx` / `pdf` / `pptx` / `loop` / `schedule` / `deepseek-api` / `consolidate-memory` / `fewer-permission-prompts` / `update-config` / `keybindings-help` / `review`) — the loader picks up whatever is on disk; authoring 15 markdown files is content work that can happen out-of-band. Today's PR ships the **infrastructure** that loads them. +- **Skill tool** (`Skill({ skill: "code-review" })`) — invocation mechanism. M5 will add this as a built-in tool that takes a skill name and injects its body as a system message in the next turn. +- **Effort levels CLI/REPL plumbing** — parser accepts `--effort` (M2), loader respects `effort:` in skill frontmatter (M4), but the REPL doesn't yet apply skill-frontmatter effort overrides per skill activation. M5 wires the Skill tool which will plumb this. +- **`auto` classifier mode** (M3c). + +## Why no YAML library? + +Considered `yaml` (~120KB), `js-yaml` (~640KB). Frontmatter for SKILL.md / agents / output styles uses a tiny subset (no aliases, anchors, multi-doc, custom types). Hand-rolled ~80 lines covers it and stays predictable. Test suite catches edge cases. + +## Verified + +- `pnpm typecheck` → green +- `pnpm test` → 240 passed / 4 skipped / 0 failed +- `pnpm format:check` → conformant diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 198c279..ab668a4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -122,3 +122,35 @@ export { dispatchToolCall, type DispatchRequest, type DispatchVerdict } from './ // Agent loop's approval callback type (M3b) export type { ApprovalCallback } from './agent.js'; + +// Skills (M4 — SKILL.md frontmatter loading + system-prompt builder) +export { + loadSkills, + buildSkillsDescriptionBlock, + parseFrontmatter, + parseSimpleYaml, + type Skill, + type SkillFrontmatter, + type LoadSkillsOpts, + type Frontmatter, +} from './skills/index.js'; + +// Sub-agents (M4 — .deepcode/agents/*.md) +export { + loadSubAgents, + findSubAgent, + type SubAgent, + type SubAgentFrontmatter, + type LoadSubAgentsOpts, +} from './sub-agents/index.js'; + +// Output styles (M4 — 4 built-in + custom) +export { + loadOutputStyles, + findStyle, + applyStyle, + BUILTIN_STYLES, + type OutputStyle, + type OutputStyleFrontmatter, + type LoadOutputStylesOpts, +} from './output-styles/index.js'; diff --git a/packages/core/src/output-styles/index.ts b/packages/core/src/output-styles/index.ts index 8103a83..cdc33eb 100644 --- a/packages/core/src/output-styles/index.ts +++ b/packages/core/src/output-styles/index.ts @@ -1,6 +1,13 @@ -// Module: output-styles +// Output styles subsystem entry. +// Spec: docs/DEVELOPMENT_PLAN.md §3.13b // Milestone: M4 -// Spec: docs/DEVELOPMENT_PLAN.md §3.13b ~/.deepcode/output-styles/*.md — 4 built-in + custom -// Status: placeholder — implemented in M4 -export {}; +export { + loadOutputStyles, + findStyle, + applyStyle, + BUILTIN_STYLES, + type OutputStyle, + type OutputStyleFrontmatter, + type LoadOutputStylesOpts, +} from './loader.js'; diff --git a/packages/core/src/output-styles/loader.test.ts b/packages/core/src/output-styles/loader.test.ts new file mode 100644 index 0000000..122cbb4 --- /dev/null +++ b/packages/core/src/output-styles/loader.test.ts @@ -0,0 +1,81 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { applyStyle, BUILTIN_STYLES, findStyle, loadOutputStyles } from './loader.js'; + +describe('output styles', () => { + let home: string; + let cwd: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-styles-home-')); + cwd = await mkdtemp(join(tmpdir(), 'dc-styles-cwd-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + }); + + it('ships 4 built-in styles', () => { + const names = BUILTIN_STYLES.map((s) => s.name); + expect(names).toEqual(['default', 'explanatory', 'learning', 'proactive']); + }); + + it('loadOutputStyles returns built-ins by default', async () => { + const styles = await loadOutputStyles({ cwd, home }); + expect(styles.length).toBeGreaterThanOrEqual(4); + }); + + it('user-level style overrides built-in', async () => { + await fs.mkdir(join(home, '.deepcode', 'output-styles'), { recursive: true }); + await fs.writeFile( + join(home, '.deepcode', 'output-styles', 'default.md'), + '---\nname: default\n---\nMy custom default style', + 'utf8', + ); + const styles = await loadOutputStyles({ cwd, home }); + const def = findStyle(styles, 'default'); + expect(def?.source).toBe('user'); + expect(def?.body).toContain('My custom default style'); + }); + + it('project-level style overrides user', async () => { + await fs.mkdir(join(home, '.deepcode', 'output-styles'), { recursive: true }); + await fs.writeFile( + join(home, '.deepcode', 'output-styles', 'foo.md'), + '---\nname: foo\n---\nuser-level foo', + 'utf8', + ); + await fs.mkdir(join(cwd, '.deepcode', 'output-styles'), { recursive: true }); + await fs.writeFile( + join(cwd, '.deepcode', 'output-styles', 'foo.md'), + '---\nname: foo\n---\nproject-level foo', + 'utf8', + ); + const styles = await loadOutputStyles({ cwd, home }); + expect(findStyle(styles, 'foo')?.body).toContain('project-level'); + expect(findStyle(styles, 'foo')?.source).toBe('project'); + }); + + it('applyStyle appends body to base prompt', () => { + const base = 'You are an assistant.'; + const styled = applyStyle(base, BUILTIN_STYLES[1]); // explanatory + expect(styled).toContain(base); + expect(styled).toContain('Output style: explanatory'); + expect(styled).toMatch(/briefly explain why/); + }); + + it('applyStyle is identity for empty body', () => { + expect(applyStyle('base', BUILTIN_STYLES[0])).toBe('base'); // default has empty body + }); + + it('applyStyle handles undefined style', () => { + expect(applyStyle('base', undefined)).toBe('base'); + }); + + it('findStyle returns undefined on unknown', () => { + expect(findStyle(BUILTIN_STYLES, 'nonexistent')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/output-styles/loader.ts b/packages/core/src/output-styles/loader.ts new file mode 100644 index 0000000..8912eb7 --- /dev/null +++ b/packages/core/src/output-styles/loader.ts @@ -0,0 +1,137 @@ +// Output styles loader — `~/.deepcode/output-styles/*.md` and built-ins. +// Spec: docs/DEVELOPMENT_PLAN.md §3.13b + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parseFrontmatter } from '../skills/frontmatter.js'; + +export interface OutputStyleFrontmatter { + name: string; + description?: string; + /** Whether to keep the default "how to write code" instructions in the system prompt. */ + 'keep-coding-instructions'?: boolean; +} + +export interface OutputStyle { + name: string; + frontmatter: OutputStyleFrontmatter; + /** Markdown body — appended to the system prompt when style is active. */ + body: string; + source: 'builtin' | 'user' | 'project'; +} + +export interface LoadOutputStylesOpts { + cwd: string; + home?: string; +} + +/** Built-in styles (M4 ships 4 — matches §3.13b table). */ +export const BUILTIN_STYLES: OutputStyle[] = [ + { + name: 'default', + frontmatter: { + name: 'default', + description: 'Concise, direct, minimal preamble.', + 'keep-coding-instructions': true, + }, + body: '', + source: 'builtin', + }, + { + name: 'explanatory', + frontmatter: { + name: 'explanatory', + description: 'Explain reasoning alongside changes; helpful for learning.', + 'keep-coding-instructions': true, + }, + body: + 'When you produce changes, also: ' + + '(1) briefly explain why; ' + + '(2) point out any non-obvious side effects; ' + + '(3) note one thing a newcomer should watch out for. ' + + 'Avoid restating obvious code; diffs are sufficient.', + source: 'builtin', + }, + { + name: 'learning', + frontmatter: { + name: 'learning', + description: 'Teaching mode — guide the user to write the key code themselves.', + 'keep-coding-instructions': false, + }, + body: + 'You are in teaching mode. Provide a skeleton or hint, but let the user write key logic. ' + + 'After each step, ask one clarifying question to check understanding. ' + + 'When the user gets it right, affirm clearly.', + source: 'builtin', + }, + { + name: 'proactive', + frontmatter: { + name: 'proactive', + description: 'Volunteer next steps and risk callouts.', + 'keep-coding-instructions': true, + }, + body: + 'Beyond answering, proactively: ' + + '(a) propose 1-2 reasonable next steps; ' + + '(b) flag any risks or surprising assumptions; ' + + "(c) if you notice tech debt nearby, mention it (don't auto-fix unless asked).", + source: 'builtin', + }, +]; + +export async function loadOutputStyles(opts: LoadOutputStylesOpts): Promise { + const home = opts.home ?? homedir(); + const out: OutputStyle[] = [...BUILTIN_STYLES]; + await loadFromDir(join(home, '.deepcode', 'output-styles'), 'user', out); + await loadFromDir(join(opts.cwd, '.deepcode', 'output-styles'), 'project', out); + return out; +} + +async function loadFromDir( + root: string, + source: OutputStyle['source'], + out: OutputStyle[], +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(root); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + for (const entry of entries) { + if (!entry.endsWith('.md')) continue; + const path = join(root, entry); + const raw = await fs.readFile(path, 'utf8'); + const { fields, body } = parseFrontmatter(raw); + const front = fields as unknown as Partial; + if (!front.name) continue; + // User/project overrides displace any earlier entry with same name + const existing = out.findIndex((s) => s.name === front.name); + const next: OutputStyle = { + name: front.name, + frontmatter: front as OutputStyleFrontmatter, + body, + source, + }; + if (existing >= 0) out[existing] = next; + else out.push(next); + } +} + +export function findStyle(styles: OutputStyle[], name: string): OutputStyle | undefined { + return styles.find((s) => s.name === name); +} + +/** + * Append a style's body to a base system prompt. + * If the style has `keep-coding-instructions: false`, the caller is expected + * to omit the default "how to write code" boilerplate. + */ +export function applyStyle(basePrompt: string, style: OutputStyle | undefined): string { + if (!style || !style.body) return basePrompt; + return basePrompt + '\n\n## Output style: ' + style.name + '\n\n' + style.body; +} diff --git a/packages/core/src/skills/frontmatter.test.ts b/packages/core/src/skills/frontmatter.test.ts new file mode 100644 index 0000000..c6f52f0 --- /dev/null +++ b/packages/core/src/skills/frontmatter.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { parseFrontmatter, parseSimpleYaml } from './frontmatter.js'; + +describe('parseFrontmatter', () => { + it('returns body unchanged when no frontmatter', () => { + expect(parseFrontmatter('hello')).toEqual({ fields: {}, body: 'hello' }); + }); + + it('parses simple frontmatter', () => { + const r = parseFrontmatter('---\nname: foo\ndescription: bar\n---\nbody here'); + expect(r.fields.name).toBe('foo'); + expect(r.fields.description).toBe('bar'); + expect(r.body).toBe('body here'); + }); + + it('parses quoted strings', () => { + const r = parseFrontmatter('---\nname: "with: colon"\n---\n'); + expect(r.fields.name).toBe('with: colon'); + }); + + it('parses booleans', () => { + const r = parseFrontmatter('---\nfoo: true\nbar: false\n---\n'); + expect(r.fields.foo).toBe(true); + expect(r.fields.bar).toBe(false); + }); + + it('parses numbers', () => { + const r = parseFrontmatter('---\nmax: 42\nratio: 3.14\n---\n'); + expect(r.fields.max).toBe(42); + expect(r.fields.ratio).toBe(3.14); + }); + + it('parses flow-style arrays', () => { + const r = parseFrontmatter('---\ntools: ["Read", "Write"]\n---\n'); + expect(r.fields.tools).toEqual(['Read', 'Write']); + }); + + it('parses block-style arrays', () => { + const r = parseFrontmatter('---\ntools:\n - Read\n - Write\n - Edit\n---\n'); + expect(r.fields.tools).toEqual(['Read', 'Write', 'Edit']); + }); + + it('parses block-style objects', () => { + const r = parseFrontmatter('---\nlimits:\n max: 10\n min: 1\n---\n'); + expect(r.fields.limits).toEqual({ max: 10, min: 1 }); + }); + + it('handles missing closing ---', () => { + const raw = '---\nname: foo\nbody here'; + expect(parseFrontmatter(raw)).toEqual({ fields: {}, body: raw }); + }); + + it('skips comments and empty lines', () => { + const r = parseSimpleYaml(['# comment', '', 'name: x', '# another', 'value: 1']); + expect(r).toEqual({ name: 'x', value: 1 }); + }); +}); diff --git a/packages/core/src/skills/frontmatter.ts b/packages/core/src/skills/frontmatter.ts new file mode 100644 index 0000000..e94b906 --- /dev/null +++ b/packages/core/src/skills/frontmatter.ts @@ -0,0 +1,117 @@ +// YAML frontmatter parser — minimal, handles the subset needed for +// SKILL.md / sub-agent .md / output-style .md files. +// Spec: docs/DEVELOPMENT_PLAN.md §3.13 / §3.13a / §3.13b +// +// We don't pull in a full YAML lib — frontmatter for these files is restricted +// to: strings, numbers, booleans, string arrays, simple objects. This keeps +// dependencies zero and behavior predictable. + +export interface Frontmatter { + /** All parsed key-value pairs (strings, arrays, booleans). */ + fields: Record; + /** Markdown body after the frontmatter. */ + body: string; +} + +export function parseFrontmatter(raw: string): Frontmatter { + if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) { + return { fields: {}, body: raw }; + } + // Locate the closing `---` + const lines = raw.split(/\r?\n/); + // First line is "---" + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i] === '---') { + end = i; + break; + } + } + if (end === -1) return { fields: {}, body: raw }; + + const yamlLines = lines.slice(1, end); + const body = lines.slice(end + 1).join('\n'); + return { fields: parseSimpleYaml(yamlLines), body }; +} + +/** + * Parse a small subset of YAML. Supports: + * key: "string" + * key: bare-string + * key: 42 + * key: true / false + * key: ["a", "b", "c"] (flow-style array) + * key: (block-style array) + * - item1 + * - item2 + * key: (object — single level) + * subkey: value + */ +export function parseSimpleYaml(lines: string[]): Record { + const out: Record = {}; + let i = 0; + while (i < lines.length) { + const line = lines[i]!; + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) { + i++; + continue; + } + const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line); + if (!m) { + i++; + continue; + } + const [, key, value] = m; + const k = key!; + const v = (value ?? '').trim(); + if (v === '') { + // Possibly block-style array or object + const next: string[] = []; + let j = i + 1; + while (j < lines.length && /^\s+/.test(lines[j]!)) { + next.push(lines[j]!); + j++; + } + out[k] = parseBlock(next); + i = j; + continue; + } + out[k] = parseScalar(v); + i++; + } + return out; +} + +function parseScalar(v: string): unknown { + // Strip optional quotes + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { + return v.slice(1, -1); + } + if (v === 'true') return true; + if (v === 'false') return false; + if (v === 'null') return null; + if (/^-?\d+$/.test(v)) return Number.parseInt(v, 10); + if (/^-?\d+\.\d+$/.test(v)) return Number.parseFloat(v); + // Flow-style array + if (v.startsWith('[') && v.endsWith(']')) { + const inner = v.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(',').map((s) => parseScalar(s.trim())); + } + return v; +} + +function parseBlock(lines: string[]): unknown { + if (lines.length === 0) return {}; + // Detect array form + const arrayItem = /^\s*-\s+(.+)$/; + if (lines.every((l) => arrayItem.test(l) || l.trim() === '')) { + return lines + .map((l) => arrayItem.exec(l)) + .filter((m): m is RegExpExecArray => m !== null) + .map((m) => parseScalar(m[1]!.trim())); + } + // Otherwise treat as object + return parseSimpleYaml(lines.map((l) => l.replace(/^\s+/, ''))); +} diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index db60913..16be674 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -1,6 +1,13 @@ -// Module: skills +// Skills subsystem entry — SKILL.md frontmatter loading + system-prompt builder. +// Spec: docs/DEVELOPMENT_PLAN.md §3.13 // Milestone: M4 -// Spec: docs/DEVELOPMENT_PLAN.md §3.13 skills loader + 15 built-in (init/verify/run/code-review/...) -// Status: placeholder — implemented in M4 -export {}; +export { + loadSkills, + buildSkillsDescriptionBlock, + type Skill, + type SkillFrontmatter, + type LoadSkillsOpts, +} from './loader.js'; + +export { parseFrontmatter, parseSimpleYaml, type Frontmatter } from './frontmatter.js'; diff --git a/packages/core/src/skills/loader.test.ts b/packages/core/src/skills/loader.test.ts new file mode 100644 index 0000000..3fdcb6f --- /dev/null +++ b/packages/core/src/skills/loader.test.ts @@ -0,0 +1,150 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { buildSkillsDescriptionBlock, loadSkills } from './loader.js'; + +async function writeSkill( + base: string, + name: string, + front: Record, + body = 'body', +): Promise { + const dir = join(base, name); + await fs.mkdir(dir, { recursive: true }); + const fm = ['---']; + for (const [k, v] of Object.entries(front)) { + if (Array.isArray(v)) fm.push(`${k}: [${v.map((x) => `"${x}"`).join(', ')}]`); + else if (typeof v === 'boolean') fm.push(`${k}: ${v}`); + else fm.push(`${k}: "${v}"`); + } + fm.push('---', '', body); + await fs.writeFile(join(dir, 'SKILL.md'), fm.join('\n'), 'utf8'); +} + +describe('loadSkills', () => { + let home: string; + let cwd: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-skills-home-')); + cwd = await mkdtemp(join(tmpdir(), 'dc-skills-cwd-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + }); + + it('returns [] when no skills exist', async () => { + expect(await loadSkills({ cwd, home })).toEqual([]); + }); + + it('loads user-level skills', async () => { + await writeSkill(join(home, '.deepcode', 'skills'), 'verify', { + name: 'verify', + description: 'Run the app and confirm.', + }); + const skills = await loadSkills({ cwd, home }); + expect(skills).toHaveLength(1); + expect(skills[0]?.qualifiedName).toBe('verify'); + expect(skills[0]?.source).toBe('user'); + }); + + it('loads project-level skills', async () => { + await writeSkill(join(cwd, '.deepcode', 'skills'), 'project-x', { + name: 'project-x', + description: 'Project-specific skill.', + }); + const skills = await loadSkills({ cwd, home }); + expect(skills[0]?.source).toBe('project'); + }); + + it('parses tools array + effort + model', async () => { + await writeSkill(join(cwd, '.deepcode', 'skills'), 'review', { + name: 'review', + description: 'Code review.', + 'allowed-tools': ['Read', 'Bash', 'Grep'], + model: 'deepseek-reasoner', + effort: 'high', + }); + const skills = await loadSkills({ cwd, home }); + expect(skills[0]?.frontmatter['allowed-tools']).toEqual(['Read', 'Bash', 'Grep']); + expect(skills[0]?.frontmatter.model).toBe('deepseek-reasoner'); + expect(skills[0]?.frontmatter.effort).toBe('high'); + }); + + it('skips malformed skills (missing name or description)', async () => { + const dir = join(cwd, '.deepcode', 'skills', 'broken'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(join(dir, 'SKILL.md'), '---\nname: only-name\n---\nbody', 'utf8'); + const skills = await loadSkills({ cwd, home }); + expect(skills).toHaveLength(0); + }); + + it('applies overrides to skip disabled skills', async () => { + await writeSkill(join(cwd, '.deepcode', 'skills'), 'skip-me', { + name: 'skip-me', + description: 'disabled via override', + }); + await writeSkill(join(cwd, '.deepcode', 'skills'), 'keep', { + name: 'keep', + description: 'enabled', + }); + const skills = await loadSkills({ + cwd, + home, + overrides: { 'skip-me': { disabled: true } }, + }); + expect(skills.map((s) => s.qualifiedName)).toEqual(['keep']); + }); + + it('respects `disabled: true` in frontmatter', async () => { + await writeSkill(join(cwd, '.deepcode', 'skills'), 'inert', { + name: 'inert', + description: 'disabled at source', + disabled: true, + }); + const skills = await loadSkills({ cwd, home }); + expect(skills).toHaveLength(0); + }); + + it('qualifies plugin skills with plugin name', async () => { + const plugDir = await mkdtemp(join(tmpdir(), 'dc-plugin-')); + await writeSkill(join(plugDir, 'skills'), 'plug-skill', { + name: 'plug-skill', + description: 'from plugin', + }); + const skills = await loadSkills({ cwd, home, pluginDirs: [plugDir] }); + expect(skills[0]?.qualifiedName).toMatch(/:plug-skill$/); + expect(skills[0]?.source).toBe('plugin'); + await rm(plugDir, { recursive: true, force: true }); + }); +}); + +describe('buildSkillsDescriptionBlock', () => { + it('returns empty string when no skills', () => { + expect(buildSkillsDescriptionBlock([])).toBe(''); + }); + + it('lists name + description per skill', () => { + const block = buildSkillsDescriptionBlock([ + { + qualifiedName: 'foo', + frontmatter: { name: 'foo', description: 'does foo' }, + body: '', + path: '/x', + source: 'user', + }, + { + qualifiedName: 'bar', + frontmatter: { name: 'bar', description: 'does bar' }, + body: '', + path: '/x', + source: 'user', + }, + ]); + expect(block).toMatch(/foo.*does foo/); + expect(block).toMatch(/bar.*does bar/); + }); +}); diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts new file mode 100644 index 0000000..cf2e9c0 --- /dev/null +++ b/packages/core/src/skills/loader.ts @@ -0,0 +1,140 @@ +// Skills loader — scans the three layers and produces a registry. +// Spec: docs/DEVELOPMENT_PLAN.md §3.13 +// +// Layout per skill: +// //SKILL.md (frontmatter + body) +// // (optional helper files) + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { Effort } from '../types.js'; +import { parseFrontmatter } from './frontmatter.js'; + +export interface SkillFrontmatter { + name: string; + description: string; + /** Optional allow-list of tools the skill is permitted to call. */ + 'allowed-tools'?: string[]; + /** Optional model override. */ + model?: string; + /** Optional effort override (low/medium/high/xhigh/max). */ + effort?: Effort; + /** Optional shell for embedded scripts. */ + shell?: string; + /** Skill-scoped hooks (only fire while this skill is active). */ + hooks?: Record; + /** User-toggle: skip loading this skill entirely. */ + disabled?: boolean; +} + +export interface Skill { + /** Either `` (built-in/user/project) or `:` (plugin-bundled). */ + qualifiedName: string; + frontmatter: SkillFrontmatter; + body: string; + /** Path to SKILL.md on disk. */ + path: string; + /** Source layer this came from — for display. */ + source: 'builtin' | 'user' | 'project' | 'plugin'; +} + +export interface LoadSkillsOpts { + cwd: string; + home?: string; + /** Optional list of plugin directories (M5+). */ + pluginDirs?: string[]; + /** Skill name → { disabled: true } overrides from settings.json. */ + overrides?: Record; + /** Built-in skill dir (for tests). */ + builtinDir?: string; +} + +export async function loadSkills(opts: LoadSkillsOpts): Promise { + const home = opts.home ?? homedir(); + const out: Skill[] = []; + + // 1. Built-in skills (shipped with DeepCode) + if (opts.builtinDir) { + await loadFromDir(opts.builtinDir, 'builtin', out); + } + + // 2. User-level + await loadFromDir(join(home, '.deepcode', 'skills'), 'user', out); + + // 3. Project-level + await loadFromDir(join(opts.cwd, '.deepcode', 'skills'), 'project', out); + + // 4. Plugin-bundled (M5) + for (const pluginDir of opts.pluginDirs ?? []) { + const pluginName = pluginDir.split('/').filter(Boolean).pop() ?? 'plugin'; + await loadFromDir(join(pluginDir, 'skills'), 'plugin', out, pluginName); + } + + // Apply overrides (skip disabled skills) + const overrides = opts.overrides ?? {}; + return out.filter((s) => !overrides[s.qualifiedName]?.disabled); +} + +async function loadFromDir( + root: string, + source: Skill['source'], + out: Skill[], + pluginName?: string, +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(root); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + for (const entry of entries) { + const skillDir = join(root, entry); + let stat; + try { + stat = await fs.stat(skillDir); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + const skillPath = join(skillDir, 'SKILL.md'); + let raw: string; + try { + raw = await fs.readFile(skillPath, 'utf8'); + } catch { + continue; + } + const { fields, body } = parseFrontmatter(raw); + const front = fields as unknown as Partial; + if (!front.name || !front.description) { + // Malformed skill — skip silently (could log later) + continue; + } + if (front.disabled === true) continue; + const qualifiedName = pluginName ? `${pluginName}:${front.name}` : front.name; + out.push({ + qualifiedName, + frontmatter: front as SkillFrontmatter, + body, + path: skillPath, + source, + }); + } +} + +/** + * Build the system-prompt fragment that lists available skills (name + description). + * Body text is NOT included here — only when the model actually invokes the skill. + */ +export function buildSkillsDescriptionBlock(skills: Skill[]): string { + if (skills.length === 0) return ''; + const lines = [ + "## Available skills (call via the Skill tool to load a skill's instructions)", + '', + ]; + for (const s of skills) { + lines.push(`- **${s.qualifiedName}** — ${s.frontmatter.description}`); + } + return lines.join('\n'); +} diff --git a/packages/core/src/sub-agents/index.ts b/packages/core/src/sub-agents/index.ts index afd46be..488d020 100644 --- a/packages/core/src/sub-agents/index.ts +++ b/packages/core/src/sub-agents/index.ts @@ -1,6 +1,11 @@ -// Module: sub-agents +// Sub-agents subsystem entry — `.deepcode/agents/*.md` files. +// Spec: docs/DEVELOPMENT_PLAN.md §3.13a // Milestone: M4 -// Spec: docs/DEVELOPMENT_PLAN.md §3.13a .deepcode/agents/*.md with frontmatter -// Status: placeholder — implemented in M4 -export {}; +export { + loadSubAgents, + findSubAgent, + type SubAgent, + type SubAgentFrontmatter, + type LoadSubAgentsOpts, +} from './loader.js'; diff --git a/packages/core/src/sub-agents/loader.test.ts b/packages/core/src/sub-agents/loader.test.ts new file mode 100644 index 0000000..c0dc2c4 --- /dev/null +++ b/packages/core/src/sub-agents/loader.test.ts @@ -0,0 +1,109 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { findSubAgent, loadSubAgents } from './loader.js'; + +async function writeAgent( + base: string, + name: string, + front: Record, + body = 'body', +): Promise { + await fs.mkdir(base, { recursive: true }); + const fm = ['---']; + for (const [k, v] of Object.entries(front)) { + if (Array.isArray(v)) fm.push(`${k}: [${v.map((x) => `"${x}"`).join(', ')}]`); + else if (typeof v === 'number') fm.push(`${k}: ${v}`); + else fm.push(`${k}: "${v}"`); + } + fm.push('---', '', body); + await fs.writeFile(join(base, `${name}.md`), fm.join('\n'), 'utf8'); +} + +describe('loadSubAgents', () => { + let home: string; + let cwd: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-agents-home-')); + cwd = await mkdtemp(join(tmpdir(), 'dc-agents-cwd-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + await rm(cwd, { recursive: true, force: true }); + }); + + it('returns [] when none exist', async () => { + expect(await loadSubAgents({ cwd, home })).toEqual([]); + }); + + it('loads project agent with all fields', async () => { + await writeAgent(join(cwd, '.deepcode', 'agents'), 'explorer', { + name: 'explorer', + description: 'read-only explorer', + tools: ['Read', 'Grep', 'Glob'], + model: 'deepseek-chat', + isolation: 'subprocess', + maxTurns: 12, + }); + const agents = await loadSubAgents({ cwd, home }); + expect(agents).toHaveLength(1); + const a = agents[0]!; + expect(a.qualifiedName).toBe('explorer'); + expect(a.frontmatter.tools).toEqual(['Read', 'Grep', 'Glob']); + expect(a.frontmatter.maxTurns).toBe(12); + expect(a.frontmatter.isolation).toBe('subprocess'); + expect(a.body).toContain('body'); + }); + + it('user-level agents alongside project', async () => { + await writeAgent(join(home, '.deepcode', 'agents'), 'user-agent', { + name: 'user-agent', + description: 'user-scoped', + }); + await writeAgent(join(cwd, '.deepcode', 'agents'), 'proj-agent', { + name: 'proj-agent', + description: 'project-scoped', + }); + const agents = await loadSubAgents({ cwd, home }); + expect(agents.map((a) => a.qualifiedName).sort()).toEqual(['proj-agent', 'user-agent']); + }); + + it('skips malformed', async () => { + await fs.mkdir(join(cwd, '.deepcode', 'agents'), { recursive: true }); + await fs.writeFile( + join(cwd, '.deepcode', 'agents', 'bad.md'), + '---\nname: only-name\n---\nbody', + 'utf8', + ); + const agents = await loadSubAgents({ cwd, home }); + expect(agents).toHaveLength(0); + }); + + it('projectDirOverride supports --agents CLI flag', async () => { + const override = await mkdtemp(join(tmpdir(), 'dc-agents-override-')); + await writeAgent(override, 'overridden', { + name: 'overridden', + description: 'from override dir', + }); + const agents = await loadSubAgents({ + cwd, + home, + projectDirOverride: override, + }); + expect(agents[0]?.qualifiedName).toBe('overridden'); + await rm(override, { recursive: true, force: true }); + }); + + it('findSubAgent looks up by qualified name', async () => { + await writeAgent(join(cwd, '.deepcode', 'agents'), 'finder', { + name: 'finder', + description: 'd', + }); + const agents = await loadSubAgents({ cwd, home }); + expect(findSubAgent(agents, 'finder')?.qualifiedName).toBe('finder'); + expect(findSubAgent(agents, 'no-such')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/sub-agents/loader.ts b/packages/core/src/sub-agents/loader.ts new file mode 100644 index 0000000..130810c --- /dev/null +++ b/packages/core/src/sub-agents/loader.ts @@ -0,0 +1,90 @@ +// Sub-agents loader — .deepcode/agents/*.md files with YAML frontmatter. +// Spec: docs/DEVELOPMENT_PLAN.md §3.13a + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parseFrontmatter } from '../skills/frontmatter.js'; + +export interface SubAgentFrontmatter { + name: string; + description: string; + /** Tool whitelist for this sub-agent. */ + tools?: string[]; + /** Model override. */ + model?: string; + /** Isolation style. */ + isolation?: 'subprocess' | 'worktree' | 'none'; + /** Max turns this sub-agent can use. */ + maxTurns?: number; +} + +export interface SubAgent { + /** `` (user/project) or `:`. */ + qualifiedName: string; + frontmatter: SubAgentFrontmatter; + /** Markdown body — becomes the sub-agent's system prompt. */ + body: string; + path: string; + source: 'user' | 'project' | 'plugin'; +} + +export interface LoadSubAgentsOpts { + cwd: string; + home?: string; + pluginDirs?: string[]; + /** Override the project-level dir (used by CLI `--agents` flag). */ + projectDirOverride?: string; +} + +export async function loadSubAgents(opts: LoadSubAgentsOpts): Promise { + const home = opts.home ?? homedir(); + const out: SubAgent[] = []; + await loadFromDir(join(home, '.deepcode', 'agents'), 'user', out); + await loadFromDir( + opts.projectDirOverride ?? join(opts.cwd, '.deepcode', 'agents'), + 'project', + out, + ); + for (const pluginDir of opts.pluginDirs ?? []) { + const pluginName = pluginDir.split('/').filter(Boolean).pop() ?? 'plugin'; + await loadFromDir(join(pluginDir, 'agents'), 'plugin', out, pluginName); + } + return out; +} + +async function loadFromDir( + root: string, + source: SubAgent['source'], + out: SubAgent[], + pluginName?: string, +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(root); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + for (const entry of entries) { + if (!entry.endsWith('.md')) continue; + const path = join(root, entry); + const raw = await fs.readFile(path, 'utf8'); + const { fields, body } = parseFrontmatter(raw); + const front = fields as unknown as Partial; + if (!front.name || !front.description) continue; + const qualifiedName = pluginName ? `${pluginName}:${front.name}` : front.name; + out.push({ + qualifiedName, + frontmatter: front as SubAgentFrontmatter, + body, + path, + source, + }); + } +} + +/** Lookup helper. */ +export function findSubAgent(agents: SubAgent[], name: string): SubAgent | undefined { + return agents.find((a) => a.qualifiedName === name); +}