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
22 changes: 19 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,21 @@ Small and stable — the schema is part of the cached prefix:

## Skills

- Discovery: `./.agents/skills/`, `~/.agents/skills/` (cross-client
convention), plus `./.dsc/skills/`, `~/.dsc/skills/`.
- Discovery (SHIPPED, devlog 2026-08-13-m8-skills.org):
`<project>/.dsc/skills/`, `<project>/.agents/skills/`,
`~/.dsc/skills/`, `~/.agents/skills/` — precedence in that order on a
name collision (project beats home, harness-native beats the shared
convention). That mirrors dsh's root ranks (its `.dsh/skills` at the
positions our `.dsc/skills` holds), so both harnesses resolve the
same collision the same way. Project root = nearest ancestor with
`.git`, else cwd — also the dsh rule.
- SKILL.md with frontmatter; progressive disclosure (index line in
prompt, body loaded on invoke) — skill bodies never sit in the stable
prefix, only the one-line index does (prefix stays small AND stable).
Shipped as the `skill` tool: static schema (no name enum — names live
in the index, so adding a skill never rewrites the tool bytes),
registered only when discovery found something; zero skills = the
gated default prompt, byte-identical.
- v2: skill versioning + per-skill eval records (the "skills with
evidence" idea from the research docs).

Expand Down Expand Up @@ -300,7 +310,13 @@ done right).
appends a request event, only the daemon fires. The read preset
carries a ported classifyBash (docs/research/deepseek-pi-sibling.md)
so "read-only scheduled run" is enforced, not advisory prose.
7. Skills + MCP client.
7. Skills + MCP client. Skills SHIPPED (as M8 in the devlog numbering —
/seek took the M6 slot and the scheduler M7, so this item kept its
build-order number but not its milestone number; see
docs/devlog/2026-08-13-m8-skills.org). Discovery reads the same
roots dsh reads: one skills directory, two harnesses. MCP remains
OUT of core per the Round 3 amendment below; nothing shipped here
changes that.

Eval harness (EVAL.md, separate task) develops in parallel from milestone
1 — it gates every milestone exit.
Expand Down
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ instead of a few hundred thousand.

**Status: work in progress.** The agent loop, tools, context engine,
sessions/compaction, sub-agents, and interactive mode work and are
eval-gated; the job scheduler works and is test-gated (the default
prompt is byte-unchanged by it). Interfaces will move.
eval-gated; the job scheduler and SKILL.md skills work and are
test-gated (the default prompt is byte-unchanged by both). Interfaces
will move.

## dsc and dsh

Expand Down Expand Up @@ -43,9 +44,9 @@ dsc's lane is narrower and stays that way:
behavior in an afternoon and measure the difference.

Interop note: dsh discovers SKILL.md skills in `~/.agents/skills` and
`<project>/.agents/skills`; dsc's planned skills support (milestone 7
in the build order) targets the same layout, so one skills directory
should serve both. Planned, not shipped.
`<project>/.agents/skills`; dsc reads the same layout with the same
precedence rules (shipped — see Skills below), so one skills directory
serves both harnesses.

Short version: use dsh for the official, full-featured harness. Use
dsc when you want a minimal auditable agent, wire-level experiments,
Expand Down Expand Up @@ -138,6 +139,18 @@ against `max_tokens`, which dsc's budgets already account for.
preset is read-only, with bash held to an inspection allowlist; the
documented safety model for write jobs is the decision-file pattern.
See `docs/scheduler.md`.
- **Skills** (`src/skills/`, `src/tools/skill.ts`) — SKILL.md discovery
from the same directories dsh reads: `<project>/.dsc/skills`,
`<project>/.agents/skills`, `~/.dsc/skills`, `~/.agents/skills`, in
that precedence on a name collision (project beats home,
harness-native beats shared — dsh's rank order, so both harnesses
resolve a collision identically). One skills directory serves both
harnesses. Progressive disclosure: the system prompt carries one
index line per skill; the body enters context only when the model
invokes the `skill` tool. With nothing discovered, no tool registers
and the default prompt is byte-unchanged. Broken frontmatter skips
that skill with a warning, never a crash. `dsc skills` lists what
discovery found and from where.
- **Eval harness** (`eval/`) — the part that decides what stays.

## Everything here is eval-gated
Expand Down
111 changes: 111 additions & 0 deletions docs/devlog/2026-08-13-m8-skills.org
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#+TITLE: M8 skills — SKILL.md discovery shared with dsh: one directory, two harnesses
#+DATE: 2026-08-13

* Tension

Skills were already the next milestone (build-order #7; the devlog
numbering drifted to M8 because /seek took M6 and the scheduler M7).
Then DeepSeek shipped dsh on launch day, and dsh reads SKILL.md skills
from =~/.agents/skills= and =<project>/.agents/skills=. That upgraded a
planned feature into an interop story: if dsc reads the same roots with
the same precedence, one skills directory serves both harnesses, on the
day the official harness launched.

The constraint that shaped everything: the default system prompt is
byte-frozen (prompt-golden) and every one of its bytes went through the
eval gate. Skills had to be able to appear WITHOUT costing anything
when absent, and without ever putting a skill body into the stable
prefix.

* What shipped

- src/skills/discover.ts — discovery across four roots, in precedence
order: =<project>/.dsc/skills=, =<project>/.agents/skills=,
=~/.dsc/skills=, =~/.agents/skills=. First name wins a collision
(project beats home, harness-native beats shared); the loser gets a
"shadowed" warning, not silence. This mirrors dsh's root ranks (its
=.dsh/skills= sits where our =.dsc/skills= does — verified in dsh's
skill-filesystem package), so the two harnesses resolve the same
collision the same way. Project root = nearest ancestor with =.git=,
else cwd — also the dsh rule, so a repo subdirectory sees the same
skills in both harnesses. Skill identity is the frontmatter =name=
(dsh's rule, including its =^[a-z0-9]+(-[a-z0-9]+)*$= name check),
not the directory name.
- Frontmatter parsing is a deliberate YAML subset, zero deps: top-level
=key: value= scalars, quoted values, =|=/=>= blocks (folded to one
line — the index renders one line per skill), nested structures
tolerated and ignored. Anything unparseable skips THAT skill with a
warning; a broken SKILL.md must never take down the session. Run
against the 126 real skills in this machine's =~/.agents/skills=:
all 126 parsed, zero warnings.
- src/tools/skill.ts — progressive disclosure. The system prompt
carries one index line per skill (name + description); the body
enters context only when the model invokes =skill= with a name. The
tool schema is static (no name enum): names live in the index, so
adding a skill never rewrites the tool's prefix bytes. Zero skills =
no tool registered = the gated default prompt, byte-identical
(same rule as the task tool, and the golden test now proves it for
an empty index explicitly).
- Discovery runs ONCE per process, so the prompt is byte-stable across
every run of a session — the epoch rule (never re-render
mid-session) satisfied by construction rather than by a
re-render-and-diff mechanism we did not build.
- =dsc skills= — what discovery found, its source root, and every
warning. The listing is exactly what the session index will carry.
/status gained a =skills= line.

* Measured: what the index costs (real API, flash)

Same one-shot prompt, clean home, project-only skills, usage totals
from the provider (fresh + cache_read; totals were byte-stable across
repeat runs):

| skills | input tokens/turn | delta |
|--------+-------------------+-------|
| 0 | 1,134 | — |
| 1 | 1,308 | +174 |
| 3 | 1,359 | +225 |

So the surface costs ~149 tokens fixed (skill tool schema + index line
+ one guideline + section header) and ~25 tokens per skill at
realistic description lengths (~15–20 words). A 40-skill index would
add ~1,100 tokens to every turn's prefix — cheap while cached, but not
free; description length is the lever users control.

* Verified live (real API)

With three skills discovered, flash was asked to use one: it invoked
=skill {"name": "release-notes"}=, got the body, and answered from it
correctly. One honest wrinkle: the tool's output names the SKILL.md
path (so skills can reference sibling files), and the model then
re-read the same file with =read= before answering — a paid extra turn
to confirm what it already had. Not changed for now: the path is what
makes bundled-resource skills possible, and one wasted turn on a probe
is observation, not measurement.

* Deviations from DESIGN.md and the milestone spec

- Build order #7 said "Skills + MCP client". MCP shipped nothing and
will ship nothing here: the Round 3 amendment already moved it out
of core, and dsh's launch adds a second reason to hold (their plugin
manifest format was deleted upstream 08-09 with no migration).
Recorded in DESIGN.md against the build-order entry.
- dsh also accepts flat =<root>/skills/<name>.md= files (no directory).
dsc reads only the directory-bundle layout DESIGN specified —
=<name>/SKILL.md= — because that is the shared convention worth
interop. If flat-file skills show up in the wild, three lines add
them.
- Scheduler jobs and sub-agent children do not get skills. Children
share one stable prefix per toolset by design, and job prompts are
their own gated surface; wiring skills into either is a decision for
a user who needs it, not a default.

* Open

- Skill bodies are capped by truncateHead (50 KB) rather than spilled;
a skill that long is probably wrong, but the spill path exists if
one ever earns it.
- Mid-session skill changes are invisible until the next process
start (deliberate: epoch rule). If it ever matters, the epoch
boundary is where the refresh belongs — never prompt mutation.
- Per-skill eval records ("skills with evidence") stay the v2 idea.
5 changes: 5 additions & 0 deletions docs/devlog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ Entries present here:
add and fire, the two-file ledger, the read-only preset built on a
classifier ported from the Go sibling (with the holes found porting
it), and why serve ships without HTTP.
- `2026-08-13-m8-skills.org` — SKILL.md skills, shipped the day dsh
launched and reading the same directories it reads: one skills dir,
two harnesses. The measured cost of the index (~149 tokens fixed,
~25 per skill), the byte-frozen-prompt constraint that shaped the
design, and why MCP still shipped nothing.

Entries for M1-M3 are held back for now: they are written around a
private comparison harness (a proprietary agent CLI retargeted to
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dsc",
"version": "0.6.0",
"version": "0.7.0",
"private": false,
"type": "module",
"description": "DeepSeek-native coding agent for the v4 flash/pro series. TypeScript/Bun, zero runtime dependencies.",
Expand Down
46 changes: 42 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ import type { RunResult } from "./engine/loop";
import { SubagentManager } from "./engine/subagent";
import { Session } from "./session/session";
import { SessionStore } from "./session/store";
import { discoverSkills } from "./skills/discover";
import { readTool } from "./tools/read";
import { bashTool } from "./tools/bash";
import { editTool } from "./tools/edit";
import { writeTool } from "./tools/write";
import { makeTaskTool } from "./tools/task";
import { makeSkillTool } from "./tools/skill";
import { runRepl } from "./ui/repl";

function argValue(name: string): string | undefined {
Expand All @@ -53,6 +55,7 @@ const USAGE = `usage:
dsc interactive session in the current directory
dsc -p "prompt" one-shot, print the result and exit
dsc job|ps|serve scheduled jobs (dsc job for details)
dsc skills discovered SKILL.md skills and their sources

--model NAME ${Object.keys(MODELS).join(" | ")}
--cwd DIR working directory for the run
Expand All @@ -78,6 +81,26 @@ if (["job", "ps", "serve"].includes(process.argv[2] ?? "")) {
process.exit(await schedulerCli(process.argv.slice(2)));
}

// `dsc skills` — what discovery found and from where, no API key needed.
// The listing shows exactly what the session index will carry; a skipped
// or shadowed SKILL.md is reported here rather than silently absent.
if (process.argv[2] === "skills") {
const dir = resolve(argValue("cwd") ?? process.cwd());
const found = discoverSkills(dir);
for (const w of found.warnings) console.error(`warning: ${w}`);
if (found.skills.length === 0) {
console.log(
"no skills (searched <project>/.dsc/skills, <project>/.agents/skills, ~/.dsc/skills, ~/.agents/skills)",
);
}
for (const s of found.skills) {
console.log(`${s.name} [${s.source}]`);
console.log(` ${s.description}`);
console.log(` ${s.path}`);
}
process.exit(0);
}

const promptIdx = process.argv.indexOf("-p");
const prompt = promptIdx >= 0 ? process.argv[promptIdx + 1] : undefined;
const interactive = prompt === undefined;
Expand Down Expand Up @@ -116,6 +139,13 @@ if (!apiKey) {
process.exit(2);
}

// Skills are discovered ONCE per process, so the system prompt is
// byte-stable across every run of a session (the epoch rule: the index
// never re-renders mid-session). With nothing discovered, no skill tool
// is registered and the prompt stays byte-identical to the gated golden.
const skillset = discoverSkills(cwd);
for (const w of skillset.warnings) console.error(`dsc: skills: ${w}`);

const t0 = Date.now();
const baseUrl = process.env.DSC_BASE_URL ?? DEFAULT_BASE_URL;

Expand Down Expand Up @@ -184,17 +214,24 @@ const makeManager = (): SubagentManager => {

// The task TOOL stays opt-in (it changes the prompt); /seek drives the
// same machinery from the frontend, so it needs no tool and no re-gate.
const makeTools = (mgr: SubagentManager) =>
subagents
? [readTool, bashTool, editTool, writeTool, makeTaskTool(mgr)]
: [readTool, bashTool, editTool, writeTool];
// The skill tool registers only when discovery found something — same
// rule, same reason: an inert tool would be dead prefix weight.
const makeTools = (mgr: SubagentManager) => [
readTool,
bashTool,
editTool,
writeTool,
...(subagents ? [makeTaskTool(mgr)] : []),
...(skillset.skills.length > 0 ? [makeSkillTool(skillset.skills)] : []),
];

if (interactive) {
const code = await runRepl({
store: store!,
session: session!,
makeManager,
makeTools,
skills: skillset.skills,
model,
cwd,
apiKey,
Expand All @@ -216,6 +253,7 @@ const runOpts = {
apiKey,
baseUrl,
tools: makeTools(manager),
skills: skillset.skills,
maxTurns,
contextBudget,
onEvent: verbose
Expand Down
7 changes: 6 additions & 1 deletion src/engine/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { streamMessage } from "../provider/client";
import type { ToolContext, ToolDefinition } from "../tools/index";
import { validateInput } from "../tools/index";
import { buildSystemPrompt, toWireTools } from "./prompt";
import type { SkillIndexEntry } from "./prompt";
import { ContextMeter, estimateTokens } from "./context";
import {
compactedView,
Expand Down Expand Up @@ -47,6 +48,10 @@ export type RunOptions = {
apiKey: string;
baseUrl: string;
tools: ToolDefinition[];
/** Discovered skill index, rendered into the system prompt. Discovery
* happens once per process, so the prompt stays byte-stable across the
* runs of a session (epoch rule: never re-rendered mid-session). */
skills?: SkillIndexEntry[];
maxTurns?: number;
maxTokens?: number;
signal?: AbortSignal;
Expand Down Expand Up @@ -139,7 +144,7 @@ export class TurnSeam {
export async function runLoop(opts: RunOptions): Promise<RunResult> {
const emit: EventSink = opts.onEvent ?? (() => {});
const maxTurns = opts.maxTurns ?? 100;
const system = buildSystemPrompt(opts.tools, opts.cwd);
const system = buildSystemPrompt(opts.tools, opts.cwd, opts.skills);
const wireTools = toWireTools(opts.tools);
const toolCtx: ToolContext = {
cwd: opts.cwd,
Expand Down
19 changes: 18 additions & 1 deletion src/engine/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ const GENERIC_GUIDELINES = [
"Your final text message is the deliverable. Keep it brief. If the task asks a question, end with exactly the answer requested.",
];

export function buildSystemPrompt(tools: ToolDefinition[], cwd: string): string {
/** One index line per discovered skill (DESIGN.md "Skills": the index is
* the only skill surface in the stable prefix; bodies load on invoke). */
export type SkillIndexEntry = { name: string; description: string };

export function buildSystemPrompt(
tools: ToolDefinition[],
cwd: string,
skills: SkillIndexEntry[] = [],
): string {
const toolLines = tools.map(
(t) => `- ${t.name}: ${t.promptSnippet ?? firstSentence(t.description)}`,
);
Expand All @@ -38,6 +46,15 @@ export function buildSystemPrompt(tools: ToolDefinition[], cwd: string): string
"",
"Guidelines:",
...guidelines.map((g) => `- ${g}`),
// With no skills discovered this section is absent and the prompt is
// byte-identical to the gated golden — same rule as the task tool.
...(skills.length > 0
? [
"",
"Skills (reusable instructions; load the full text with the skill tool):",
...skills.map((s) => `- ${s.name}: ${s.description}`),
]
: []),
"",
`Current working directory: ${cwd}`,
].join("\n");
Expand Down
Loading