Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/milestones/M4.md
Original file line number Diff line number Diff line change
@@ -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 `<root>/<name>/SKILL.md`):
- Frontmatter spec: `name`, `description`, `allowed-tools`, `model`, `effort`, `shell`, `hooks`, `disabled`
- Qualified names: bare for user/project, `<plugin>:<name>` 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/<name>.md`):
- Frontmatter: `name`, `description`, `tools[]`, `model`, `isolation`, `maxTurns`
- CLI `--agents <dir>` flag honored via `projectDirOverride` option
- `findSubAgent(agents, name)` lookup helper

**Output styles** (`<root>/.deepcode/output-styles/<name>.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
32 changes: 32 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
15 changes: 11 additions & 4 deletions packages/core/src/output-styles/index.ts
Original file line number Diff line number Diff line change
@@ -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';
81 changes: 81 additions & 0 deletions packages/core/src/output-styles/loader.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
137 changes: 137 additions & 0 deletions packages/core/src/output-styles/loader.ts
Original file line number Diff line number Diff line change
@@ -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<OutputStyle[]> {
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<void> {
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<OutputStyleFrontmatter>;
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;
}
57 changes: 57 additions & 0 deletions packages/core/src/skills/frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading