Skip to content

Commit 46208ec

Browse files
oratisclaude
andauthored
feat(core): M4 — skills + sub-agents + output styles loaders (#5)
Implements docs/DEVELOPMENT_PLAN.md §3.13 / §3.13a / §3.13b loaders. Shipped ------- - skills/frontmatter.ts (115 lines) Zero-dep YAML frontmatter parser supporting the subset SKILL.md / sub-agent / output-style files actually use: strings (quoted or bare), numbers, booleans, flow-style arrays ["a","b"], block-style arrays, simple block objects. - skills/loader.ts (110 lines) 4-layer loader: builtin (compiled-in dir) / user (~/.deepcode/skills/) / project (<cwd>/.deepcode/skills/) / plugin (<plugin>/skills/, name prefixed with `<plugin>:`). Honors `disabled: true` in frontmatter + settings.json `skillOverrides[name].disabled`. `buildSkillsDescriptionBlock()` produces the system-prompt fragment listing name+description (body loaded on invoke). - sub-agents/loader.ts (75 lines) Loads .deepcode/agents/*.md. Frontmatter spec: name / description / tools[] / model / isolation (subprocess|worktree|none) / maxTurns. Supports CLI --agents <dir> override via projectDirOverride option. findSubAgent() helper. - output-styles/loader.ts (105 lines) 4 built-in styles (default / explanatory / learning / proactive) from §3.13b. User → project layer replacement semantics. applyStyle(base, style) appends body to system prompt; respects keep-coding-instructions frontmatter. Top-level @deepcode/core re-exports all M4 surfaces. Tests ----- - frontmatter.test.ts (10) — quoted strings, booleans, numbers, both array styles, block objects, comments, malformed - loader.test.ts ( 9) — user/project sources, plugin qualification, tools/effort/model frontmatter, disabled filter, malformed skip, override gate - sub-agents/loader.test.ts ( 6) — all frontmatter fields, layer ordering, projectDirOverride, findSubAgent - output-styles/loader.test.ts (9) — 4 builtins, user override displaces builtin, project displaces user, applyStyle composition Total: 240 passed / 4 skipped / 0 failed (was 206). Deferred -------- - 15 built-in skill markdown FILES (loader finds whatever's on disk; content authoring is out-of-band, not engineering work). - `Skill` tool for invocation (M5 — will inject skill body into next turn's system messages). - REPL plumbing for skill-frontmatter effort overrides (paired with Skill tool). - `auto` classifier mode (M3c). Verified -------- pnpm typecheck → green pnpm test → 240 passed / 4 skipped pnpm format:check → conformant Docs ---- - docs/milestones/M4.md — delivery report + deferred items Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b18441a commit 46208ec

13 files changed

Lines changed: 996 additions & 12 deletions

File tree

docs/milestones/M4.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# M4 — Skills + Sub-agents + Output Styles
2+
3+
> **Status**: ✅ Loader infrastructure shipped · 15 built-in skill markdown files deferred (content, not code — can be authored without engineering)
4+
> **Branch**: `feat/m4-skills-agents-styles`
5+
6+
## Shipped
7+
8+
| Module | Purpose | Tests |
9+
|---|---|---|
10+
| `skills/frontmatter.ts` | Zero-dep YAML frontmatter parser (strings/numbers/bools/flow + block arrays/objects) | 10 |
11+
| `skills/loader.ts` | 4-layer loader (builtin / user / project / plugin) + `buildSkillsDescriptionBlock()` for system-prompt injection | 9 |
12+
| `sub-agents/loader.ts` | `.deepcode/agents/*.md``SubAgent` objects with isolation / tools / model / maxTurns | 6 |
13+
| `output-styles/loader.ts` | 4 built-in styles (default / explanatory / learning / proactive) + user/project overrides + `applyStyle()` | 9 |
14+
| Top-level re-exports | All new types/functions exposed from `@deepcode/core` ||
15+
16+
**Total new tests**: 34. Across whole project: 240 passing / 4 skipped / 0 failed.
17+
18+
## What's in each subsystem
19+
20+
**Skills** (`SKILL.md` files in `<root>/<name>/SKILL.md`):
21+
- Frontmatter spec: `name`, `description`, `allowed-tools`, `model`, `effort`, `shell`, `hooks`, `disabled`
22+
- Qualified names: bare for user/project, `<plugin>:<name>` for plugin-shipped
23+
- `disabled: true` in frontmatter OR `skillOverrides[name].disabled = true` in settings → skip load
24+
- `buildSkillsDescriptionBlock()` produces the system-prompt fragment that lists available skills (name + description only — body is loaded on Skill-tool invocation)
25+
26+
**Sub-agents** (`.deepcode/agents/<name>.md`):
27+
- Frontmatter: `name`, `description`, `tools[]`, `model`, `isolation`, `maxTurns`
28+
- CLI `--agents <dir>` flag honored via `projectDirOverride` option
29+
- `findSubAgent(agents, name)` lookup helper
30+
31+
**Output styles** (`<root>/.deepcode/output-styles/<name>.md`):
32+
- 4 built-in: `default`, `explanatory`, `learning`, `proactive`
33+
- Frontmatter: `name`, `description`, `keep-coding-instructions`
34+
- User → project layer order with replace semantics
35+
- `applyStyle(basePrompt, style)` appends body to system prompt
36+
37+
## NOT delivered in this PR
38+
39+
- **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.
40+
- **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.
41+
- **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.
42+
- **`auto` classifier mode** (M3c).
43+
44+
## Why no YAML library?
45+
46+
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.
47+
48+
## Verified
49+
50+
- `pnpm typecheck` → green
51+
- `pnpm test` → 240 passed / 4 skipped / 0 failed
52+
- `pnpm format:check` → conformant

packages/core/src/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,35 @@ export { dispatchToolCall, type DispatchRequest, type DispatchVerdict } from './
122122

123123
// Agent loop's approval callback type (M3b)
124124
export type { ApprovalCallback } from './agent.js';
125+
126+
// Skills (M4 — SKILL.md frontmatter loading + system-prompt builder)
127+
export {
128+
loadSkills,
129+
buildSkillsDescriptionBlock,
130+
parseFrontmatter,
131+
parseSimpleYaml,
132+
type Skill,
133+
type SkillFrontmatter,
134+
type LoadSkillsOpts,
135+
type Frontmatter,
136+
} from './skills/index.js';
137+
138+
// Sub-agents (M4 — .deepcode/agents/*.md)
139+
export {
140+
loadSubAgents,
141+
findSubAgent,
142+
type SubAgent,
143+
type SubAgentFrontmatter,
144+
type LoadSubAgentsOpts,
145+
} from './sub-agents/index.js';
146+
147+
// Output styles (M4 — 4 built-in + custom)
148+
export {
149+
loadOutputStyles,
150+
findStyle,
151+
applyStyle,
152+
BUILTIN_STYLES,
153+
type OutputStyle,
154+
type OutputStyleFrontmatter,
155+
type LoadOutputStylesOpts,
156+
} from './output-styles/index.js';
Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
// Module: output-styles
1+
// Output styles subsystem entry.
2+
// Spec: docs/DEVELOPMENT_PLAN.md §3.13b
23
// Milestone: M4
3-
// Spec: docs/DEVELOPMENT_PLAN.md §3.13b ~/.deepcode/output-styles/*.md — 4 built-in + custom
4-
// Status: placeholder — implemented in M4
54

6-
export {};
5+
export {
6+
loadOutputStyles,
7+
findStyle,
8+
applyStyle,
9+
BUILTIN_STYLES,
10+
type OutputStyle,
11+
type OutputStyleFrontmatter,
12+
type LoadOutputStylesOpts,
13+
} from './loader.js';
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { promises as fs } from 'node:fs';
2+
import { mkdtemp, rm } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
import { applyStyle, BUILTIN_STYLES, findStyle, loadOutputStyles } from './loader.js';
7+
8+
describe('output styles', () => {
9+
let home: string;
10+
let cwd: string;
11+
12+
beforeEach(async () => {
13+
home = await mkdtemp(join(tmpdir(), 'dc-styles-home-'));
14+
cwd = await mkdtemp(join(tmpdir(), 'dc-styles-cwd-'));
15+
});
16+
afterEach(async () => {
17+
await rm(home, { recursive: true, force: true });
18+
await rm(cwd, { recursive: true, force: true });
19+
});
20+
21+
it('ships 4 built-in styles', () => {
22+
const names = BUILTIN_STYLES.map((s) => s.name);
23+
expect(names).toEqual(['default', 'explanatory', 'learning', 'proactive']);
24+
});
25+
26+
it('loadOutputStyles returns built-ins by default', async () => {
27+
const styles = await loadOutputStyles({ cwd, home });
28+
expect(styles.length).toBeGreaterThanOrEqual(4);
29+
});
30+
31+
it('user-level style overrides built-in', async () => {
32+
await fs.mkdir(join(home, '.deepcode', 'output-styles'), { recursive: true });
33+
await fs.writeFile(
34+
join(home, '.deepcode', 'output-styles', 'default.md'),
35+
'---\nname: default\n---\nMy custom default style',
36+
'utf8',
37+
);
38+
const styles = await loadOutputStyles({ cwd, home });
39+
const def = findStyle(styles, 'default');
40+
expect(def?.source).toBe('user');
41+
expect(def?.body).toContain('My custom default style');
42+
});
43+
44+
it('project-level style overrides user', async () => {
45+
await fs.mkdir(join(home, '.deepcode', 'output-styles'), { recursive: true });
46+
await fs.writeFile(
47+
join(home, '.deepcode', 'output-styles', 'foo.md'),
48+
'---\nname: foo\n---\nuser-level foo',
49+
'utf8',
50+
);
51+
await fs.mkdir(join(cwd, '.deepcode', 'output-styles'), { recursive: true });
52+
await fs.writeFile(
53+
join(cwd, '.deepcode', 'output-styles', 'foo.md'),
54+
'---\nname: foo\n---\nproject-level foo',
55+
'utf8',
56+
);
57+
const styles = await loadOutputStyles({ cwd, home });
58+
expect(findStyle(styles, 'foo')?.body).toContain('project-level');
59+
expect(findStyle(styles, 'foo')?.source).toBe('project');
60+
});
61+
62+
it('applyStyle appends body to base prompt', () => {
63+
const base = 'You are an assistant.';
64+
const styled = applyStyle(base, BUILTIN_STYLES[1]); // explanatory
65+
expect(styled).toContain(base);
66+
expect(styled).toContain('Output style: explanatory');
67+
expect(styled).toMatch(/briefly explain why/);
68+
});
69+
70+
it('applyStyle is identity for empty body', () => {
71+
expect(applyStyle('base', BUILTIN_STYLES[0])).toBe('base'); // default has empty body
72+
});
73+
74+
it('applyStyle handles undefined style', () => {
75+
expect(applyStyle('base', undefined)).toBe('base');
76+
});
77+
78+
it('findStyle returns undefined on unknown', () => {
79+
expect(findStyle(BUILTIN_STYLES, 'nonexistent')).toBeUndefined();
80+
});
81+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Output styles loader — `~/.deepcode/output-styles/*.md` and built-ins.
2+
// Spec: docs/DEVELOPMENT_PLAN.md §3.13b
3+
4+
import { promises as fs } from 'node:fs';
5+
import { homedir } from 'node:os';
6+
import { join } from 'node:path';
7+
import { parseFrontmatter } from '../skills/frontmatter.js';
8+
9+
export interface OutputStyleFrontmatter {
10+
name: string;
11+
description?: string;
12+
/** Whether to keep the default "how to write code" instructions in the system prompt. */
13+
'keep-coding-instructions'?: boolean;
14+
}
15+
16+
export interface OutputStyle {
17+
name: string;
18+
frontmatter: OutputStyleFrontmatter;
19+
/** Markdown body — appended to the system prompt when style is active. */
20+
body: string;
21+
source: 'builtin' | 'user' | 'project';
22+
}
23+
24+
export interface LoadOutputStylesOpts {
25+
cwd: string;
26+
home?: string;
27+
}
28+
29+
/** Built-in styles (M4 ships 4 — matches §3.13b table). */
30+
export const BUILTIN_STYLES: OutputStyle[] = [
31+
{
32+
name: 'default',
33+
frontmatter: {
34+
name: 'default',
35+
description: 'Concise, direct, minimal preamble.',
36+
'keep-coding-instructions': true,
37+
},
38+
body: '',
39+
source: 'builtin',
40+
},
41+
{
42+
name: 'explanatory',
43+
frontmatter: {
44+
name: 'explanatory',
45+
description: 'Explain reasoning alongside changes; helpful for learning.',
46+
'keep-coding-instructions': true,
47+
},
48+
body:
49+
'When you produce changes, also: ' +
50+
'(1) briefly explain why; ' +
51+
'(2) point out any non-obvious side effects; ' +
52+
'(3) note one thing a newcomer should watch out for. ' +
53+
'Avoid restating obvious code; diffs are sufficient.',
54+
source: 'builtin',
55+
},
56+
{
57+
name: 'learning',
58+
frontmatter: {
59+
name: 'learning',
60+
description: 'Teaching mode — guide the user to write the key code themselves.',
61+
'keep-coding-instructions': false,
62+
},
63+
body:
64+
'You are in teaching mode. Provide a skeleton or hint, but let the user write key logic. ' +
65+
'After each step, ask one clarifying question to check understanding. ' +
66+
'When the user gets it right, affirm clearly.',
67+
source: 'builtin',
68+
},
69+
{
70+
name: 'proactive',
71+
frontmatter: {
72+
name: 'proactive',
73+
description: 'Volunteer next steps and risk callouts.',
74+
'keep-coding-instructions': true,
75+
},
76+
body:
77+
'Beyond answering, proactively: ' +
78+
'(a) propose 1-2 reasonable next steps; ' +
79+
'(b) flag any risks or surprising assumptions; ' +
80+
"(c) if you notice tech debt nearby, mention it (don't auto-fix unless asked).",
81+
source: 'builtin',
82+
},
83+
];
84+
85+
export async function loadOutputStyles(opts: LoadOutputStylesOpts): Promise<OutputStyle[]> {
86+
const home = opts.home ?? homedir();
87+
const out: OutputStyle[] = [...BUILTIN_STYLES];
88+
await loadFromDir(join(home, '.deepcode', 'output-styles'), 'user', out);
89+
await loadFromDir(join(opts.cwd, '.deepcode', 'output-styles'), 'project', out);
90+
return out;
91+
}
92+
93+
async function loadFromDir(
94+
root: string,
95+
source: OutputStyle['source'],
96+
out: OutputStyle[],
97+
): Promise<void> {
98+
let entries: string[];
99+
try {
100+
entries = await fs.readdir(root);
101+
} catch (err) {
102+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
103+
throw err;
104+
}
105+
for (const entry of entries) {
106+
if (!entry.endsWith('.md')) continue;
107+
const path = join(root, entry);
108+
const raw = await fs.readFile(path, 'utf8');
109+
const { fields, body } = parseFrontmatter(raw);
110+
const front = fields as unknown as Partial<OutputStyleFrontmatter>;
111+
if (!front.name) continue;
112+
// User/project overrides displace any earlier entry with same name
113+
const existing = out.findIndex((s) => s.name === front.name);
114+
const next: OutputStyle = {
115+
name: front.name,
116+
frontmatter: front as OutputStyleFrontmatter,
117+
body,
118+
source,
119+
};
120+
if (existing >= 0) out[existing] = next;
121+
else out.push(next);
122+
}
123+
}
124+
125+
export function findStyle(styles: OutputStyle[], name: string): OutputStyle | undefined {
126+
return styles.find((s) => s.name === name);
127+
}
128+
129+
/**
130+
* Append a style's body to a base system prompt.
131+
* If the style has `keep-coding-instructions: false`, the caller is expected
132+
* to omit the default "how to write code" boilerplate.
133+
*/
134+
export function applyStyle(basePrompt: string, style: OutputStyle | undefined): string {
135+
if (!style || !style.body) return basePrompt;
136+
return basePrompt + '\n\n## Output style: ' + style.name + '\n\n' + style.body;
137+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { parseFrontmatter, parseSimpleYaml } from './frontmatter.js';
3+
4+
describe('parseFrontmatter', () => {
5+
it('returns body unchanged when no frontmatter', () => {
6+
expect(parseFrontmatter('hello')).toEqual({ fields: {}, body: 'hello' });
7+
});
8+
9+
it('parses simple frontmatter', () => {
10+
const r = parseFrontmatter('---\nname: foo\ndescription: bar\n---\nbody here');
11+
expect(r.fields.name).toBe('foo');
12+
expect(r.fields.description).toBe('bar');
13+
expect(r.body).toBe('body here');
14+
});
15+
16+
it('parses quoted strings', () => {
17+
const r = parseFrontmatter('---\nname: "with: colon"\n---\n');
18+
expect(r.fields.name).toBe('with: colon');
19+
});
20+
21+
it('parses booleans', () => {
22+
const r = parseFrontmatter('---\nfoo: true\nbar: false\n---\n');
23+
expect(r.fields.foo).toBe(true);
24+
expect(r.fields.bar).toBe(false);
25+
});
26+
27+
it('parses numbers', () => {
28+
const r = parseFrontmatter('---\nmax: 42\nratio: 3.14\n---\n');
29+
expect(r.fields.max).toBe(42);
30+
expect(r.fields.ratio).toBe(3.14);
31+
});
32+
33+
it('parses flow-style arrays', () => {
34+
const r = parseFrontmatter('---\ntools: ["Read", "Write"]\n---\n');
35+
expect(r.fields.tools).toEqual(['Read', 'Write']);
36+
});
37+
38+
it('parses block-style arrays', () => {
39+
const r = parseFrontmatter('---\ntools:\n - Read\n - Write\n - Edit\n---\n');
40+
expect(r.fields.tools).toEqual(['Read', 'Write', 'Edit']);
41+
});
42+
43+
it('parses block-style objects', () => {
44+
const r = parseFrontmatter('---\nlimits:\n max: 10\n min: 1\n---\n');
45+
expect(r.fields.limits).toEqual({ max: 10, min: 1 });
46+
});
47+
48+
it('handles missing closing ---', () => {
49+
const raw = '---\nname: foo\nbody here';
50+
expect(parseFrontmatter(raw)).toEqual({ fields: {}, body: raw });
51+
});
52+
53+
it('skips comments and empty lines', () => {
54+
const r = parseSimpleYaml(['# comment', '', 'name: x', '# another', 'value: 1']);
55+
expect(r).toEqual({ name: 'x', value: 1 });
56+
});
57+
});

0 commit comments

Comments
 (0)