瀏覽代碼

fix: harden skill discovery

Yichen Jiang 2 月之前
父節點
當前提交
dca2cc257d
共有 29 個文件被更改,包括 555 次插入61 次删除
  1. 1 1
      docs/cordis-catalog/services.md
  2. 2 0
      docs/core-data-structures/skills.md
  3. 17 2
      examples/acp-agent/tests/acp.e2e.ts
  4. 1 0
      examples/acp-agent/tests/acp.snapshot.ts
  5. 2 0
      examples/acp-agent/tests/snapshot-harness.ts
  6. 7 0
      examples/acp-agent/tests/snapshots/skill-load/input.json
  7. 28 0
      examples/acp-agent/tests/snapshots/skill-load/replay.override.json
  8. 28 0
      examples/acp-agent/tests/snapshots/skill-load/session.jsonl
  9. 8 0
      examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl
  10. 2 0
      examples/coding-agent/tests/keyless-smoke.e2e.ts
  11. 10 1
      examples/echo-agent/tests/echo.e2e.ts
  12. 2 1
      packages/core/agent-core/package.json
  13. 37 10
      packages/core/agent-core/src/index.ts
  14. 22 0
      packages/core/agent-core/tests/agent-core.spec.ts
  15. 3 0
      packages/core/agent-core/tsconfig.json
  16. 11 0
      packages/core/skill/README.md
  17. 1 0
      packages/core/skill/package.json
  18. 94 25
      packages/core/skill/src/index.ts
  19. 187 13
      packages/core/skill/tests/skill.spec.ts
  20. 1 0
      packages/core/skill/tsconfig.json
  21. 13 1
      packages/ui/acp-agent/src/index.ts
  22. 16 2
      packages/ui/acp-agent/tests/acp-agent.spec.ts
  23. 12 2
      packages/ui/acp-agent/tests/built-bin.e2e.ts
  24. 2 0
      packages/ui/acp-agent/tests/load-path.e2e.ts
  25. 13 0
      packages/ui/stdio-agent/src/index.ts
  26. 1 1
      packages/ui/stdio-agent/tests/built-bin.e2e.ts
  27. 17 2
      packages/ui/stdio-agent/tests/stdio-agent.spec.ts
  28. 6 0
      pnpm-lock.yaml
  29. 11 0
      vitest.config.ts

+ 1 - 1
docs/cordis-catalog/services.md

@@ -176,7 +176,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefiniti
 async renderModelListing(options: SkillLookupOptions = {}): Promise<string>
 async renderModelListing(options: SkillLookupOptions = {}): Promise<string>
 ```
 ```
 
 
-Source: [`packages/core/skill/src/index.ts:128`](../../packages/core/skill/src/index.ts)
+Source: [`packages/core/skill/src/index.ts:132`](../../packages/core/skill/src/index.ts)
 
 
 ## `ctx.subagents` — `SubagentService`
 ## `ctx.subagents` — `SubagentService`
 
 

+ 2 - 0
docs/core-data-structures/skills.md

@@ -79,6 +79,8 @@ interface Config {
   agentsHome?: string
   agentsHome?: string
   extraRoots?: string[]
   extraRoots?: string[]
   installSystemSkills?: boolean
   installSystemSkills?: boolean
+  promptFieldMaxLength?: number
+  collectCacheMaxEntries?: number
 }
 }
 ```
 ```
 
 

+ 17 - 2
examples/acp-agent/tests/acp.e2e.ts

@@ -63,7 +63,16 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn
   const child = spawn(
   const child = spawn(
     process.execPath,
     process.execPath,
     ['--import', tsxLoader, binScript, configPath],
     ['--import', tsxLoader, binScript, configPath],
-    { cwd, env: { ...env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] },
+    {
+      cwd,
+      env: {
+        ...env,
+        TSX_TSCONFIG_PATH: repoTsconfig,
+        DSH_HOME: join(cwd, '.dsh'),
+        DSH_AGENTS_HOME: join(cwd, '.agents'),
+      },
+      stdio: ['pipe', 'pipe', 'pipe'],
+    },
   )
   )
   const stderr: string[] = []
   const stderr: string[] = []
   child.stderr.setEncoding('utf8')
   child.stderr.setEncoding('utf8')
@@ -110,7 +119,13 @@ describe('acp-agent over real stdio (no key required)', () => {
     // which this purity test never triggers). So this runs WITHOUT real creds.
     // which this purity test never triggers). So this runs WITHOUT real creds.
     const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
     const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], {
       cwd: workdir,
       cwd: workdir,
-      env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig },
+      env: {
+        ...process.env,
+        DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
+        TSX_TSCONFIG_PATH: repoTsconfig,
+        DSH_HOME: join(workdir, '.dsh'),
+        DSH_AGENTS_HOME: join(workdir, '.agents'),
+      },
       stdio: ['pipe', 'pipe', 'pipe'],
       stdio: ['pipe', 'pipe', 'pipe'],
     })
     })
     const out: string[] = []
     const out: string[] = []

+ 1 - 0
examples/acp-agent/tests/acp.snapshot.ts

@@ -64,6 +64,7 @@ const SCENARIOS: Scenario[] = [
   { name: 'tool-call-turn', hasModelTurn: true, recorded: true },
   { name: 'tool-call-turn', hasModelTurn: true, recorded: true },
   { name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
   { name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
   { name: 'todo-plan', hasModelTurn: true, recorded: true },
   { name: 'todo-plan', hasModelTurn: true, recorded: true },
+  { name: 'skill-load', hasModelTurn: true, recorded: false },
   { name: 'workspace-edit', hasModelTurn: true, recorded: true },
   { name: 'workspace-edit', hasModelTurn: true, recorded: true },
   { name: 'fs-read', hasModelTurn: true, recorded: true },
   { name: 'fs-read', hasModelTurn: true, recorded: true },
   { name: 'fs-write', hasModelTurn: true, recorded: true },
   { name: 'fs-write', hasModelTurn: true, recorded: true },

+ 2 - 0
examples/acp-agent/tests/snapshot-harness.ts

@@ -155,6 +155,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
       DSH_SNAPSHOT: opts.mode,
       DSH_SNAPSHOT: opts.mode,
       DSH_SNAPSHOT_FILE: opts.fixtureFile,
       DSH_SNAPSHOT_FILE: opts.fixtureFile,
       DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
       DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
+      DSH_HOME: join(cwd, '.dsh'),
+      DSH_AGENTS_HOME: join(cwd, '.agents'),
       ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
       ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
       ...opts.childFiles !== undefined && opts.childFiles.length > 0
       ...opts.childFiles !== undefined && opts.childFiles.length > 0
         ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
         ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }

+ 7 - 0
examples/acp-agent/tests/snapshots/skill-load/input.json

@@ -0,0 +1,7 @@
+{
+  "steps": [
+    { "op": "initialize" },
+    { "op": "newSession" },
+    { "op": "prompt", "text": "Load the dsh-skill-creator skill with the skill tool, then reply DONE." }
+  ]
+}

+ 28 - 0
examples/acp-agent/tests/snapshots/skill-load/replay.override.json

@@ -0,0 +1,28 @@
+[
+  {
+    "kind": "chunks",
+    "chunks": [
+      { "type": "block-start", "index": 0, "blockType": "reasoning" },
+      { "type": "reasoning-delta", "index": 0, "text": "Load the requested skill." },
+      { "type": "block-start", "index": 1, "blockType": "tool-call" },
+      { "type": "tool-call-delta", "index": 1, "id": "call_skill_load", "name": "skill", "argumentsDelta": "{\"name\":\"dsh-skill-creator\"}" },
+      { "type": "block-end", "index": 0, "block": { "type": "reasoning", "text": "Load the requested skill." } },
+      { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skill_load", "name": "skill", "arguments": "{\"name\":\"dsh-skill-creator\"}" } },
+      { "type": "usage", "usage": { "inputTokens": 100, "outputTokens": 20, "cacheReadTokens": 0, "reasoningTokens": 5 } },
+      { "type": "finish", "reason": { "kind": "tool-calls" } }
+    ]
+  },
+  {
+    "kind": "chunks",
+    "chunks": [
+      { "type": "block-start", "index": 0, "blockType": "reasoning" },
+      { "type": "reasoning-delta", "index": 0, "text": "The skill is loaded." },
+      { "type": "block-start", "index": 1, "blockType": "text" },
+      { "type": "text-delta", "index": 1, "text": "DONE" },
+      { "type": "block-end", "index": 0, "block": { "type": "reasoning", "text": "The skill is loaded." } },
+      { "type": "block-end", "index": 1, "block": { "type": "text", "text": "DONE" } },
+      { "type": "usage", "usage": { "inputTokens": 180, "outputTokens": 10, "cacheReadTokens": 0, "reasoningTokens": 4 } },
+      { "type": "finish", "reason": { "kind": "stop" } }
+    ]
+  }
+]

+ 28 - 0
examples/acp-agent/tests/snapshots/skill-load/session.jsonl

@@ -0,0 +1,28 @@
+{"type":"session","version":0,"id":"5ca6619f-135d-4d55-814b-35ac6524a1b2","createdAt":1783246260513,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-nJAoRn"}
+{"type":"turn/start","seq":0,"time":1783246260515,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
+{"type":"user/message","seq":1,"time":1783246260516,"data":{"content":[{"type":"text","text":"Load the dsh-skill-creator skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
+{"type":"step/start","seq":2,"time":1783246260516,"data":{"turn":1,"step":1}}
+{"type":"assistant/chunk","seq":3,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":4,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}}
+{"type":"assistant/chunk","seq":5,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":6,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skill_load","name":"skill","argumentsDelta":"{\"name\":\"dsh-skill-creator\"}"}}}
+{"type":"assistant/chunk","seq":7,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}}}
+{"type":"assistant/chunk","seq":8,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"dsh-skill-creator\"}"}}}}
+{"type":"assistant/chunk","seq":9,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}}}
+{"type":"assistant/chunk","seq":10,"time":1783246260534,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":11,"time":1783246260534,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"dsh-skill-creator\"}"}],"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[3,4,5,6,7,8,9,10],"surfaceOp":"append"}
+{"type":"tool/call","seq":12,"time":1783246260534,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"dsh-skill-creator\"}"}}
+{"type":"tool/result","seq":13,"time":1783246260535,"data":{"turn":1,"step":1,"callId":"call_skill_load","content":[{"type":"text","text":"<skill_content name=\"dsh-skill-creator\">\n# Skill: dsh-skill-creator\n\nUse this skill to write focused DeepSeek Harness skills.\n\nA skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.\nFrontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.\nUse optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.\nKeep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-nJAoRn/.dsh/skills/.system/dsh-skill-creator\nResolve relative files mentioned by this skill against the base directory before using them.\n</skill_content>"}],"isError":false},"sourceEventSeqs":[12],"surfaceOp":"append"}
+{"type":"step/end","seq":14,"time":1783246260535,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":15,"time":1783246260535,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":16,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"assistant/chunk","seq":17,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The skill is loaded."}}}
+{"type":"assistant/chunk","seq":18,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":19,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"DONE"}}}
+{"type":"assistant/chunk","seq":20,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}}}
+{"type":"assistant/chunk","seq":21,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":22,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}}}
+{"type":"assistant/chunk","seq":23,"time":1783246260535,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":24,"time":1783246260536,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}},"sourceEventSeqs":[16,17,18,19,20,21,22,23],"surfaceOp":"append"}
+{"type":"step/end","seq":25,"time":1783246260536,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":26,"time":1783246260536,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 8 - 0
examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl

@@ -0,0 +1,8 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill dsh-skill-creator","kind":"read","status":"in_progress","rawInput":"dsh-skill-creator"}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<skill_content name=\"dsh-skill-creator\">\n# Skill: dsh-skill-creator\n\nUse this skill to write focused DeepSeek Harness skills.\n\nA skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.\nFrontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.\nUse optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.\nKeep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.\n\nBase directory for this skill: {{cwd}}/.dsh/skills/.system/dsh-skill-creator\nResolve relative files mentioned by this skill against the base directory before using them.\n</skill_content>"}}]}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The skill is loaded."}}}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
+{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

+ 2 - 0
examples/coding-agent/tests/keyless-smoke.e2e.ts

@@ -62,6 +62,8 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> {
           // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
           // A dummy key so llm-deepseek's apply() (key-PRESENT check only) boots.
           // No prompt is sent, so the adapter never streams — no network call.
           // No prompt is sent, so the adapter never streams — no network call.
           DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
           DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
+          DSH_HOME: join(cwd, '.dsh'),
+          DSH_AGENTS_HOME: join(cwd, '.agents'),
         },
         },
         stdio: ['pipe', 'pipe', 'pipe'],
         stdio: ['pipe', 'pipe', 'pipe'],
       },
       },

+ 10 - 1
examples/echo-agent/tests/echo.e2e.ts

@@ -63,7 +63,16 @@ async function runEcho(lines: string[]): Promise<{ stdout: string; code: number
       // requires it (mirrors the `demo:echo` script). The whole point is to boot
       // requires it (mirrors the `demo:echo` script). The whole point is to boot
       // the example EXACTLY as it really runs, through the bin + Loader.
       // the example EXACTLY as it really runs, through the bin + Loader.
       ['--expose-internals', '--import', tsxLoader, binScript, configPath],
       ['--expose-internals', '--import', tsxLoader, binScript, configPath],
-      { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] },
+      {
+        cwd,
+        env: {
+          ...process.env,
+          TSX_TSCONFIG_PATH: repoTsconfig,
+          DSH_HOME: join(cwd, '.dsh'),
+          DSH_AGENTS_HOME: join(cwd, '.agents'),
+        },
+        stdio: ['pipe', 'pipe', 'pipe'],
+      },
     )
     )
     child = proc
     child = proc
     let stdout = ''
     let stdout = ''

+ 2 - 1
packages/core/agent-core/package.json

@@ -47,6 +47,7 @@
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
     "@deepseek-ai/dsh-tool-skill": "workspace:^",
     "@deepseek-ai/dsh-tool-skill": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "cordis": "^4.0.0-rc.6"
+    "cordis": "^4.0.0-rc.6",
+    "schemastery": "^3.18.0"
   }
   }
 }
 }

+ 37 - 10
packages/core/agent-core/src/index.ts

@@ -44,11 +44,13 @@
 
 
 import type { Context } from 'cordis'
 import type { Context } from 'cordis'
 import Timer from '@cordisjs/plugin-timer'
 import Timer from '@cordisjs/plugin-timer'
+import z from 'schemastery'
+import type Schema from 'schemastery'
 import LlmService from '@deepseek-ai/dsh-llm'
 import LlmService from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import SessionStore from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
-import SkillService from '@deepseek-ai/dsh-skill'
+import SkillService, { type Config as SkillConfig } from '@deepseek-ai/dsh-skill'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import * as invariants from '@deepseek-ai/dsh-invariants'
 import * as invariants from '@deepseek-ai/dsh-invariants'
 import * as toolBash from '@deepseek-ai/dsh-tool-bash'
 import * as toolBash from '@deepseek-ai/dsh-tool-bash'
@@ -58,16 +60,39 @@ import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agen
 export const name = 'agent-core'
 export const name = 'agent-core'
 
 
 /**
 /**
- * Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
- * — an app that pre-creates no agents (the ACP bridge creates them on demand at
- * `session/new`) simply omits it; an app that needs a pre-created `main` (the
- * stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
- * the forwarded shape can never drift.
+ * Bundle config: the agent-loop `agents` list plus skill discovery config.
+ * Default `agents: []` means an app that pre-creates no agents (the ACP bridge
+ * creates them on demand at `session/new`) can omit it; an app that needs a
+ * pre-created `main` (the stdio chat) supplies one. `skills` is forwarded to
+ * {@link @deepseek-ai/dsh-skill}, so leaf cordis.yml files can change DSH/user
+ * skill roots and caps without code changes.
  */
  */
-export type Config = AgentLoopConfig
+export interface Config extends AgentLoopConfig {
+  /** Skill discovery roots, system-skill installation, and prompt/cache bounds. */
+  skills?: SkillConfig
+}
+
+/** Local schema for the forwarded skill config. Keep this in sync with `SkillService.Config`. */
+export const SkillConfigSchema: Schema<SkillConfig> = z.object({
+  dshHome: z.string(),
+  agentsHome: z.string(),
+  extraRoots: z.array(z.string()).default([]),
+  installSystemSkills: z.boolean().default(true),
+  promptFieldMaxLength: z.number().default(500),
+  collectCacheMaxEntries: z.number().default(128),
+})
 
 
-/** Forward the loop's own schema so validation + defaulting stay identical. */
-export const Config = AgentLoop.Config
+/** Bundle schema: keep the loop agent shape aligned and expose skill config. */
+export const Config: Schema<Config> = z.object({
+  agents: z.array(z.object({
+    id: z.string().required(),
+    model: z.string(),
+    systemPrompt: z.string(),
+    cwd: z.string(),
+    resumeSessionId: z.string(),
+  })).default([]),
+  skills: SkillConfigSchema,
+}) as unknown as Schema<Config>
 
 
 /**
 /**
  * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
  * Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
@@ -83,10 +108,12 @@ export function apply(ctx: Context, config: Config): void {
   ctx.plugin(SessionStore)
   ctx.plugin(SessionStore)
   ctx.plugin(SystemPrompt)
   ctx.plugin(SystemPrompt)
   ctx.plugin(ToolRegistry)
   ctx.plugin(ToolRegistry)
-  ctx.plugin(SkillService)
+  ctx.plugin(SkillService, config.skills ?? {})
   ctx.plugin(AgentRegistry)
   ctx.plugin(AgentRegistry)
   ctx.plugin(invariants)
   ctx.plugin(invariants)
   ctx.plugin(toolBash)
   ctx.plugin(toolBash)
   ctx.plugin(toolSkill)
   ctx.plugin(toolSkill)
   ctx.plugin(AgentLoop, { agents: config.agents })
   ctx.plugin(AgentLoop, { agents: config.agents })
 }
 }
+
+export type { SkillConfig }

+ 22 - 0
packages/core/agent-core/tests/agent-core.spec.ts

@@ -19,7 +19,9 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
  */
  */
 async function mount(config?: agentCore.Config): Promise<Context> {
 async function mount(config?: agentCore.Config): Promise<Context> {
   const oldDshHome = process.env.DSH_HOME
   const oldDshHome = process.env.DSH_HOME
+  const oldAgentsHome = process.env.DSH_AGENTS_HOME
   process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
   process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
+  process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
   const ctx = new Context()
   const ctx = new Context()
   try {
   try {
     await ctx.plugin(agentCore, config)
     await ctx.plugin(agentCore, config)
@@ -33,6 +35,11 @@ async function mount(config?: agentCore.Config): Promise<Context> {
     } else {
     } else {
       process.env.DSH_HOME = oldDshHome
       process.env.DSH_HOME = oldDshHome
     }
     }
+    if (oldAgentsHome === undefined) {
+      delete process.env.DSH_AGENTS_HOME
+    } else {
+      process.env.DSH_AGENTS_HOME = oldAgentsHome
+    }
   }
   }
 }
 }
 
 
@@ -78,6 +85,21 @@ describe('dsh-agent-core bundle', () => {
     await ctx.fiber.dispose()
     await ctx.fiber.dispose()
   })
   })
 
 
+  it('forwards skill config to the skill service', async () => {
+    const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
+    const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
+    const ctx = await mount({
+      agents: [],
+      skills: {
+        dshHome: join(home, '.dsh'),
+        agentsHome: join(agentsHome, '.agents'),
+        installSystemSkills: false,
+      },
+    })
+    expect(await ctx.skills.list()).toEqual([])
+    await ctx.fiber.dispose()
+  })
+
   it('re-exports the loop config schema as its own', () => {
   it('re-exports the loop config schema as its own', () => {
     expect(agentCore.Config).toBeDefined()
     expect(agentCore.Config).toBeDefined()
     expect(agentCore.name).toBe('agent-core')
     expect(agentCore.name).toBe('agent-core')

+ 3 - 0
packages/core/agent-core/tsconfig.json

@@ -14,6 +14,9 @@
     {
     {
       "path": "../../../vendor/timer"
       "path": "../../../vendor/timer"
     },
     },
+    {
+      "path": "../../../vendor/schemastery"
+    },
     {
     {
       "path": "../../llm/llm"
       "path": "../../llm/llm"
     },
     },

+ 11 - 0
packages/core/skill/README.md

@@ -10,6 +10,17 @@ Agent skill discovery and model-facing skill guidance.
 - `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills.
 - `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills.
 - `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber.
 - `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber.
 
 
+### Config
+
+| Field | Default | Meaning |
+|---|---|---|
+| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; system skills live under `skills/.system`. |
+| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
+| `extraRoots` | `[]` | Additional skill roots scanned after user roots and before system skills. |
+| `installSystemSkills` | `true` | Whether startup materializes bundled system skills under `dshHome`. |
+| `promptFieldMaxLength` | `500` | Maximum rendered `description` / `whenToUse` length in the prompt listing. |
+| `collectCacheMaxEntries` | `128` | Maximum cwd/root discovery promises kept in memory. |
+
 ### Discovery
 ### Discovery
 
 
 Default roots are resolved in this conflict priority order:
 Default roots are resolved in this conflict priority order:

+ 1 - 0
packages/core/skill/package.json

@@ -28,6 +28,7 @@
     "cordis": "^4.0.0-rc.6"
     "cordis": "^4.0.0-rc.6"
   },
   },
   "dependencies": {
   "dependencies": {
+    "schemastery": "^3.18.0",
     "yaml": "^2.4.2"
     "yaml": "^2.4.2"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 94 - 25
packages/core/skill/src/index.ts

@@ -8,18 +8,20 @@
  * @module @deepseek-ai/dsh-skill
  * @module @deepseek-ai/dsh-skill
  */
  */
 
 
-import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
+import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { dirname, join, resolve } from 'node:path'
 import { dirname, join, resolve } from 'node:path'
 import { homedir } from 'node:os'
 import { homedir } from 'node:os'
 import { Context, Service } from 'cordis'
 import { Context, Service } from 'cordis'
+import z from 'schemastery'
+import type Schema from 'schemastery'
 import { parse as parseYaml } from 'yaml'
 import { parse as parseYaml } from 'yaml'
 import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
 import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
 import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
 import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent'
 
 
 const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
 const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
-const MAX_PROMPT_FIELD_LENGTH = 500
-const MAX_COLLECT_CACHE_ENTRIES = 128
+const DEFAULT_PROMPT_FIELD_LENGTH = 500
+const DEFAULT_COLLECT_CACHE_ENTRIES = 128
 
 
 export function isSkillName(name: string): boolean {
 export function isSkillName(name: string): boolean {
   return SKILL_NAME.test(name)
   return SKILL_NAME.test(name)
@@ -55,9 +57,7 @@ export interface SkillDefinition extends SkillSummary {
 }
 }
 
 
 /** Runtime skill contribution accepted by `ctx.skills.register()`. */
 /** Runtime skill contribution accepted by `ctx.skills.register()`. */
-export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & {
-  disableModelInvocation?: boolean
-}
+export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & { disableModelInvocation?: boolean }
 
 
 /** Workspace selector used for cwd-sensitive project-root discovery. */
 /** Workspace selector used for cwd-sensitive project-root discovery. */
 export interface SkillLookupOptions {
 export interface SkillLookupOptions {
@@ -68,12 +68,16 @@ export interface SkillLookupOptions {
 export interface Config {
 export interface Config {
   /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
   /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
   dshHome?: string
   dshHome?: string
-  /** Shared agent config root. Defaults to `~/.agents`. */
+  /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
   agentsHome?: string
   agentsHome?: string
   /** Extra skill roots, scanned after user roots and before system skills. */
   /** Extra skill roots, scanned after user roots and before system skills. */
   extraRoots?: string[]
   extraRoots?: string[]
   /** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
   /** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
   installSystemSkills?: boolean
   installSystemSkills?: boolean
+  /** Maximum rendered description/whenToUse length in the prompt listing. */
+  promptFieldMaxLength?: number
+  /** Maximum number of cwd/root discovery promises kept in the in-memory cache. */
+  collectCacheMaxEntries?: number
 }
 }
 
 
 declare module 'cordis' {
 declare module 'cordis' {
@@ -126,10 +130,21 @@ const SYSTEM_SKILLS: SkillDefinition[] = [
  * stable `## Skills` listing into each agent request.
  * stable `## Skills` listing into each agent request.
  */
  */
 export class SkillService extends Service {
 export class SkillService extends Service {
+  static Config: Schema<Config> = z.object({
+    dshHome: z.string(),
+    agentsHome: z.string(),
+    extraRoots: z.array(z.string()).default([]),
+    installSystemSkills: z.boolean().default(true),
+    promptFieldMaxLength: z.number().default(DEFAULT_PROMPT_FIELD_LENGTH),
+    collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
+  })
+
   private readonly dshHome: string
   private readonly dshHome: string
   private readonly agentsHome: string
   private readonly agentsHome: string
   private readonly extraRoots: string[]
   private readonly extraRoots: string[]
   private readonly installSystemSkills: boolean
   private readonly installSystemSkills: boolean
+  private readonly promptFieldMaxLength: number
+  private readonly collectCacheMaxEntries: number
   private readonly runtime = new Map<string, SkillDefinition>()
   private readonly runtime = new Map<string, SkillDefinition>()
   private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
   private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
   private runtimeRevision = 0
   private runtimeRevision = 0
@@ -138,9 +153,13 @@ export class SkillService extends Service {
   constructor(ctx: Context, config: Config = {}) {
   constructor(ctx: Context, config: Config = {}) {
     super(ctx, 'skills')
     super(ctx, 'skills')
     this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
     this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
-    this.agentsHome = resolve(config.agentsHome ?? join(homedir(), '.agents'))
+    this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
     this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
     this.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
     this.installSystemSkills = config.installSystemSkills ?? true
     this.installSystemSkills = config.installSystemSkills ?? true
+    this.promptFieldMaxLength = config.promptFieldMaxLength ?? DEFAULT_PROMPT_FIELD_LENGTH
+    this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
+    assertPositiveInteger('promptFieldMaxLength', this.promptFieldMaxLength)
+    assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
     if (this.installSystemSkills) {
     if (this.installSystemSkills) {
       const systemRoot = join(this.dshHome, 'skills/.system')
       const systemRoot = join(this.dshHome, 'skills/.system')
       this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
       this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
@@ -208,8 +227,8 @@ export class SkillService extends Service {
     const entries = skills.map((skill) => {
     const entries = skills.map((skill) => {
       const lines = [
       const lines = [
         `<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
         `<skill name="${escapeAttr(skill.name)}" source="${escapeAttr(skill.source)}">`,
-        `description: ${promptLine(skill.description)}`,
-        ...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse)}`] : [],
+        `description: ${promptLine(skill.description, this.promptFieldMaxLength)}`,
+        ...skill.whenToUse ? [`whenToUse: ${promptLine(skill.whenToUse, this.promptFieldMaxLength)}`] : [],
         '</skill>',
         '</skill>',
       ]
       ]
       return lines.join('\n')
       return lines.join('\n')
@@ -231,12 +250,16 @@ export class SkillService extends Service {
     if (cached !== undefined) return cached
     if (cached !== undefined) return cached
 
 
     const collected = this.collectFresh(roots)
     const collected = this.collectFresh(roots)
-    this.collectCache.set(key, collected)
-    if (this.collectCache.size > MAX_COLLECT_CACHE_ENTRIES) {
+    const cachedPromise = collected.catch((error: unknown) => {
+      this.collectCache.delete(key)
+      throw error
+    })
+    this.collectCache.set(key, cachedPromise)
+    if (this.collectCache.size > this.collectCacheMaxEntries) {
       const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
       const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
       this.collectCache.delete(oldest.value)
       this.collectCache.delete(oldest.value)
     }
     }
-    return collected
+    return cachedPromise
   }
   }
 
 
   private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
   private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
@@ -326,9 +349,10 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinit
   for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
   for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
     if (root.skipSystem && entry.name === '.system') continue
     if (root.skipSystem && entry.name === '.system') continue
     const fullPath = join(root.path, entry.name)
     const fullPath = join(root.path, entry.name)
-    const parsed = entry.isDirectory()
+    const kind = await entryKind(fullPath, entry, ctx)
+    const parsed = kind === 'directory'
       ? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
       ? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
-      : entry.isFile() && entry.name.endsWith('.md')
+      : kind === 'file' && entry.name.endsWith('.md')
         ? await parseSkillFile(fullPath, root.path, root.source, ctx)
         ? await parseSkillFile(fullPath, root.path, root.source, ctx)
         : undefined
         : undefined
     if (parsed) skills.push(parsed)
     if (parsed) skills.push(parsed)
@@ -341,7 +365,13 @@ async function parseSkillFile(path: string, directory: string, source: SkillSour
   if (raw === undefined) {
   if (raw === undefined) {
     return undefined
     return undefined
   }
   }
-  const parsed = parseFrontmatter(raw)
+  let parsed
+  try {
+    parsed = parseFrontmatter(raw)
+  } catch (error) {
+    ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
+    return undefined
+  }
   if (!parsed) {
   if (!parsed) {
     ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
     ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
     return undefined
     return undefined
@@ -426,15 +456,48 @@ function fsReadErrorMessage(target: FsTarget, error: unknown): string {
   return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
   return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
 }
 }
 
 
+async function entryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
+  if (entry.isDirectory()) return 'directory'
+  if (entry.isFile()) return 'file'
+  /* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
+  if (!entry.isSymbolicLink()) return undefined
+  try {
+    const info = await stat(fullPath)
+    if (info.isDirectory()) return 'directory'
+    if (info.isFile()) return 'file'
+    return undefined
+  } catch (error) {
+    ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
+    return undefined
+  }
+}
+
 function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
 function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
-  if (!raw.startsWith('---\n')) return undefined
-  const end = raw.indexOf('\n---', 4)
-  if (end < 0) return undefined
-  const yaml = raw.slice(4, end)
+  const firstLineEnd = raw.indexOf('\n')
+  if (firstLineEnd < 0) return undefined
+  const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
+  if (firstLine !== '---') return undefined
+  const start = firstLineEnd + 1
+  const closing = findClosingFrontmatter(raw, start)
+  if (closing === undefined) return undefined
+  const yaml = raw.slice(start, closing.start)
   const parsed = parseYaml(yaml) as unknown
   const parsed = parseYaml(yaml) as unknown
   if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
   if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
-  const body = raw.slice(end + 4)
-  return { data: parsed as Record<string, unknown>, body: body.startsWith('\n') ? body.slice(1) : body }
+  return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
+}
+
+function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
+  let lineStart = start
+  while (lineStart <= raw.length) {
+    const nextNewline = raw.indexOf('\n', lineStart)
+    const lineEnd = nextNewline < 0 ? raw.length : nextNewline
+    const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
+    if (line === '---') {
+      return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
+    }
+    if (nextNewline < 0) return undefined
+    lineStart = nextNewline + 1
+  }
 }
 }
 
 
 async function findProjectRoot(cwd: string): Promise<string> {
 async function findProjectRoot(cwd: string): Promise<string> {
@@ -474,14 +537,20 @@ function compareSummary(left: SkillSummary, right: SkillSummary): number {
   return left.name.localeCompare(right.name)
   return left.name.localeCompare(right.name)
 }
 }
 
 
-function promptLine(value: string): string {
+function promptLine(value: string, maxLength: number): string {
   const normalized = value.replaceAll(/\s+/g, ' ').trim()
   const normalized = value.replaceAll(/\s+/g, ' ').trim()
-  const truncated = normalized.length <= MAX_PROMPT_FIELD_LENGTH
+  const truncated = normalized.length <= maxLength
     ? normalized
     ? normalized
-    : `${normalized.slice(0, MAX_PROMPT_FIELD_LENGTH - 3)}...`
+    : `${normalized.slice(0, maxLength - 3)}...`
   return escapeText(truncated)
   return escapeText(truncated)
 }
 }
 
 
+function assertPositiveInteger(name: string, value: number): void {
+  if (!Number.isInteger(value) || value < 1) {
+    throw new Error(`skill: ${name} must be a positive integer`)
+  }
+}
+
 function stringField(data: Record<string, unknown>, key: string): string | undefined {
 function stringField(data: Record<string, unknown>, key: string): string | undefined {
   const value = data[key]
   const value = data[key]
   return typeof value === 'string' && value.length > 0 ? value : undefined
   return typeof value === 'string' && value.length > 0 ? value : undefined

+ 187 - 13
packages/core/skill/tests/skill.spec.ts

@@ -1,10 +1,10 @@
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
-import { mkdir, readFile, writeFile } from 'node:fs/promises'
-import { join } from 'node:path'
+import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { tmpdir } from 'node:os'
 import { Context } from 'cordis'
 import { Context } from 'cordis'
 import SkillService from '@deepseek-ai/dsh-skill'
 import SkillService from '@deepseek-ai/dsh-skill'
-import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
+import { FileSystem, FsVersion, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
 
 
 async function tempDir(name: string): Promise<string> {
 async function tempDir(name: string): Promise<string> {
   return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
   return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
@@ -21,6 +21,50 @@ async function writeFlatSkill(root: string, name: string, description: string, b
   await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
   await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
 }
 }
 
 
+class TestFileSystem extends FileSystem {
+  override async resolve(path: string): Promise<FsTarget> {
+    return { targetKey: path as never, displayPath: path }
+  }
+
+  override async stat(target: FsTarget): Promise<FsInfo | undefined> {
+    try {
+      const fs = await import('node:fs/promises')
+      const info = await fs.stat(target.displayPath)
+      return {
+        version: FsVersion(String(info.mtimeMs)),
+        type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
+        size: info.size,
+      }
+    } catch {
+      return undefined
+    }
+  }
+
+  override async readText(target: FsTarget): Promise<string> {
+    const text = await readFile(target.displayPath, 'utf8')
+    if (text.includes('\uFFFD')) throw new Error('not text')
+    return text
+  }
+
+  override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
+    throw new Error('not needed in skill tests')
+  }
+
+  override async listDir(): Promise<never> {
+    throw new Error('not needed in skill tests')
+  }
+
+  override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
+    await mkdir(dirname(target.displayPath), { recursive: true })
+    await writeFile(target.displayPath, content)
+    return { operation: 'create', version: FsVersion('test'), before: null, after: content }
+  }
+
+  override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
+    throw new Error('not needed in skill tests')
+  }
+}
+
 describe('SkillService', () => {
 describe('SkillService', () => {
   it('discovers project, user, agents, and system skill roots in priority order', async () => {
   it('discovers project, user, agents, and system skill roots in priority order', async () => {
     const home = await tempDir('skill-home')
     const home = await tempDir('skill-home')
@@ -113,6 +157,7 @@ describe('SkillService', () => {
     await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
     await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
     await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad')
     await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad')
     await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.')
     await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.')
+    await writeFile(join(home, '.dsh/skills/plain-markdown.md'), '# Notes\nNot a skill.')
     await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter')
     await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter')
     await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad')
     await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad')
     await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
     await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
@@ -129,22 +174,141 @@ describe('SkillService', () => {
     expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
     expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
   })
   })
 
 
-  it('keeps skill body text that begins immediately after the closing frontmatter delimiter', async () => {
-    const home = await tempDir('skill-frontmatter-body')
+  it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
+    const home = await tempDir('skill-frontmatter-crlf')
     const root = join(home, '.dsh/skills')
     const root = join(home, '.dsh/skills')
     await mkdir(root, { recursive: true })
     await mkdir(root, { recursive: true })
-    await writeFile(join(root, 'tight-body.md'), [
+    await writeFile(join(root, 'crlf-skill.md'), [
+      '---',
+      'name: crlf-skill',
+      'description: CRLF skill',
+      'metadata:',
+      '  marker: "----"',
       '---',
       '---',
-      'name: tight-body',
-      'description: Tight body',
-      '---First line must survive.',
-      'Second line.',
+      '',
+      'CRLF body.',
+    ].join('\r\n'))
+    await writeFile(join(root, 'block-skill.md'), [
+      '---',
+      'name: block-skill',
+      'description: |',
+      '  Includes a ---- marker that is not a delimiter.',
+      '---',
+      '',
+      'Block body.',
     ].join('\n'))
     ].join('\n'))
 
 
     const ctx = new Context()
     const ctx = new Context()
     await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
     await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
 
 
-    expect((await ctx.skills.get('tight-body'))?.content).toBe('First line must survive.\nSecond line.')
+    expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
+    expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
+    expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
+    expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
+  })
+
+  it('skips invalid YAML skill files without poisoning discovery cache', async () => {
+    const home = await tempDir('skill-invalid-yaml')
+    const root = join(home, '.dsh/skills')
+    await writeSkill(root, 'good-skill', 'Good skill')
+    await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
+    await writeFile(join(root, 'bad-yaml.md'), '---\nname: fixed-skill\ndescription: Fixed skill\n---\n\nFixed body.\n')
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
+    const dispose = ctx.skills.register({
+      name: 'runtime-skill',
+      description: 'Runtime skill',
+      content: 'Runtime body.',
+      directory: 'memory://runtime',
+      source: 'runtime',
+    })
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fixed-skill', 'good-skill', 'runtime-skill'])
+    dispose()
+  })
+
+  it('does not cache a rejected discovery promise', async () => {
+    const home = await tempDir('skill-rejected-cache')
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+    const internals = ctx.skills as unknown as {
+      collectFresh(roots: unknown): Promise<unknown[]>
+    }
+    const original = internals.collectFresh.bind(ctx.skills)
+    let fail = true
+    internals.collectFresh = async (roots: unknown) => {
+      if (fail) throw new Error('transient discovery failure')
+      return await original(roots)
+    }
+
+    await expect(ctx.skills.list()).rejects.toThrow('transient discovery failure')
+    fail = false
+    await writeSkill(join(home, '.dsh/skills'), 'late-good', 'Late good')
+    await expect(ctx.skills.list()).resolves.toMatchObject([{ name: 'late-good' }])
+  })
+
+  it('discovers symlinked skill directories and flat files', async () => {
+    const home = await tempDir('skill-symlink-home')
+    const external = await tempDir('skill-symlink-external')
+    await writeSkill(external, 'linked-dir', 'Linked directory')
+    await writeFlatSkill(external, 'linked-flat', 'Linked flat')
+    await mkdir(join(home, '.dsh/skills'), { recursive: true })
+    await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
+    await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
+    await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
+    await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
+
+    expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
+  })
+
+  it('honors prompt and cache bounds from config', async () => {
+    const home = await tempDir('skill-config-bounds')
+    const firstProject = await tempDir('skill-config-first')
+    const secondProject = await tempDir('skill-config-second')
+    await mkdir(join(firstProject, '.git'), { recursive: true })
+    await mkdir(join(secondProject, '.git'), { recursive: true })
+    await writeSkill(join(firstProject, '.dsh/skills'), 'first-skill', 'abcdefghij')
+    await writeSkill(join(secondProject, '.dsh/skills'), 'second-skill', 'Second')
+
+    const ctx = new Context()
+    await ctx.plugin(SkillService, {
+      dshHome: join(home, '.dsh'),
+      agentsHome: join(home, '.agents'),
+      installSystemSkills: false,
+      promptFieldMaxLength: 6,
+      collectCacheMaxEntries: 1,
+    })
+
+    expect(await ctx.skills.renderModelListing({ cwd: firstProject })).toContain('description: abc...')
+    expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
+    await writeSkill(join(firstProject, '.dsh/skills'), 'late-first', 'Late first')
+    expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
+    await ctx.skills.list({ cwd: secondProject })
+    expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill', 'late-first'])
+  })
+
+  it('rejects invalid positive-integer config caps', async () => {
+    const home = await tempDir('skill-invalid-config')
+    const ctx = new Context()
+    await expect(ctx.plugin(SkillService, {
+      dshHome: join(home, '.dsh'),
+      agentsHome: join(home, '.agents'),
+      installSystemSkills: false,
+      promptFieldMaxLength: 0,
+    })).rejects.toThrow('promptFieldMaxLength')
+    await expect(ctx.plugin(SkillService, {
+      dshHome: join(home, '.dsh'),
+      agentsHome: join(home, '.agents'),
+      installSystemSkills: false,
+      collectCacheMaxEntries: 1.5,
+    })).rejects.toThrow('collectCacheMaxEntries')
   })
   })
 
 
   it('renders no model listing when no model-invocable skills exist', async () => {
   it('renders no model listing when no model-invocable skills exist', async () => {
@@ -178,6 +342,16 @@ describe('SkillService', () => {
     }
     }
   })
   })
 
 
+  it('keeps constructor defaults when schema preprocessing is not involved', async () => {
+    const home = await tempDir('skill-constructor-defaults')
+    const service = new SkillService(new Context(), {
+      dshHome: join(home, '.dsh'),
+      agentsHome: join(home, '.agents'),
+    })
+
+    expect((await service.list()).map(skill => skill.name)).toEqual(['dsh-plugin-creator', 'dsh-skill-creator'])
+  })
+
   it('installs system skills into the DSH home without overwriting existing files', async () => {
   it('installs system skills into the DSH home without overwriting existing files', async () => {
     const home = await tempDir('skill-install')
     const home = await tempDir('skill-install')
     const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
     const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
@@ -202,7 +376,7 @@ describe('SkillService', () => {
     await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
     await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
 
 
     const ctx = new Context()
     const ctx = new Context()
-    await ctx.plugin(LocalFileSystem, { cwd: home })
+    await ctx.plugin(TestFileSystem)
     await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
     await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
 
 
     expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
     expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
@@ -237,7 +411,7 @@ describe('SkillService', () => {
     ]))
     ]))
 
 
     const ctx = new Context()
     const ctx = new Context()
-    await ctx.plugin(LocalFileSystem, { cwd: home })
+    await ctx.plugin(TestFileSystem)
     await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
     await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
 
 
     expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])
     expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])

+ 1 - 0
packages/core/skill/tsconfig.json

@@ -8,6 +8,7 @@
   "references": [
   "references": [
     { "path": "../../../vendor/cosmokit" },
     { "path": "../../../vendor/cosmokit" },
     { "path": "../../../vendor/cordis" },
     { "path": "../../../vendor/cordis" },
+    { "path": "../../../vendor/schemastery" },
     { "path": "../../fs/fs" },
     { "path": "../../fs/fs" },
     { "path": "../../llm/llm" },
     { "path": "../../llm/llm" },
     { "path": "../agent" }
     { "path": "../agent" }

+ 13 - 1
packages/ui/acp-agent/src/index.ts

@@ -38,6 +38,15 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
 
 
 export const name = 'acp-agent'
 export const name = 'acp-agent'
 
 
+const SkillConfigSchema: z<agentCore.SkillConfig> = z.object({
+  dshHome: z.string(),
+  agentsHome: z.string(),
+  extraRoots: z.array(z.string()).default([]),
+  installSystemSkills: z.boolean().default(true),
+  promptFieldMaxLength: z.number().default(500),
+  collectCacheMaxEntries: z.number().default(128),
+})
+
 /**
 /**
  * App config: the swappable per-deployment values. `model`/`systemPrompt`
  * App config: the swappable per-deployment values. `model`/`systemPrompt`
  * configure the agent template the ACP bridge creates each session's agent from
  * configure the agent template the ACP bridge creates each session's agent from
@@ -51,12 +60,15 @@ export interface Config {
   systemPrompt: string
   systemPrompt: string
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
   persistenceRoot?: string
   persistenceRoot?: string
+  /** Skill discovery config forwarded to the shared agent-core spine. */
+  skills?: agentCore.SkillConfig
 }
 }
 
 
 export const Config: z<Config> = z.object({
 export const Config: z<Config> = z.object({
   model: z.string().required(),
   model: z.string().required(),
   systemPrompt: z.string().required(),
   systemPrompt: z.string().required(),
   persistenceRoot: z.string().default('./.sessions'),
   persistenceRoot: z.string().default('./.sessions'),
+  skills: SkillConfigSchema,
 })
 })
 
 
 /**
 /**
@@ -67,7 +79,7 @@ export const Config: z<Config> = z.object({
  * stdout stays pure.
  * stdout stays pure.
  */
  */
 export function apply(ctx: Context, config: Config): void {
 export function apply(ctx: Context, config: Config): void {
-  ctx.plugin(agentCore)
+  ctx.plugin(agentCore, { agents: [], ...config.skills === undefined ? {} : { skills: config.skills } })
   ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
   ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
   ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
   ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
 }
 }

+ 16 - 2
packages/ui/acp-agent/tests/acp-agent.spec.ts

@@ -1,4 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { describe, expect, it } from 'vitest'
+import { mkdtemp } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
 import { Context } from 'cordis'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import Loader from '@cordisjs/plugin-loader'
 import * as acpAgent from '../src/index.ts'
 import * as acpAgent from '../src/index.ts'
@@ -22,9 +25,14 @@ async function mount(config: acpAgent.Config): Promise<Context> {
   return ctx
   return ctx
 }
 }
 
 
+async function isolatedSkillsConfig(): Promise<NonNullable<acpAgent.Config['skills']>> {
+  const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-'))
+  return { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }
+}
+
 describe('dsh-acp-agent composition', () => {
 describe('dsh-acp-agent composition', () => {
   it('brings up the spine + persistence + the ACP bridge', async () => {
   it('brings up the spine + persistence + the ACP bridge', async () => {
-    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' })
+    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
     expect(ctx.get('agents')).toBeDefined()
     expect(ctx.get('agents')).toBeDefined()
     expect(ctx.get('sessions')).toBeDefined()
     expect(ctx.get('sessions')).toBeDefined()
     expect(ctx.get('sessionPersistence')).toBeDefined()
     expect(ctx.get('sessionPersistence')).toBeDefined()
@@ -40,12 +48,18 @@ describe('dsh-acp-agent composition', () => {
     // `ctx.plugin`, which validates+defaults the config first) with no
     // `ctx.plugin`, which validates+defaults the config first) with no
     // persistenceRoot, so the runtime fallback is the one that fires.
     // persistenceRoot, so the runtime fallback is the one that fires.
     const ctx = new Context()
     const ctx = new Context()
-    acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
+    acpAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
     await new Promise(resolve => setTimeout(resolve, 50))
     await new Promise(resolve => setTimeout(resolve, 50))
     expect(ctx.get('sessionPersistence')).toBeDefined()
     expect(ctx.get('sessionPersistence')).toBeDefined()
     await ctx.fiber.dispose()
     await ctx.fiber.dispose()
   })
   })
 
 
+  it('forwards skill config into agent-core', async () => {
+    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
+    expect(await ctx.skills.list()).toEqual([])
+    await ctx.fiber.dispose()
+  })
+
   it('exposes its plugin shape', () => {
   it('exposes its plugin shape', () => {
     expect(acpAgent.name).toBe('acp-agent')
     expect(acpAgent.name).toBe('acp-agent')
     expect(acpAgent.Config).toBeDefined()
     expect(acpAgent.Config).toBeDefined()

+ 12 - 2
packages/ui/acp-agent/tests/built-bin.e2e.ts

@@ -120,7 +120,12 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
     child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
     child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
       cwd: consumer,
       cwd: consumer,
       // Dummy key: initialize never reaches the model, so it is never used.
       // Dummy key: initialize never reaches the model, so it is never used.
-      env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
+      env: {
+        ...process.env,
+        DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
+        DSH_HOME: join(consumer, '.dsh'),
+        DSH_AGENTS_HOME: join(consumer, '.agents'),
+      },
       stdio: ['pipe', 'pipe', 'pipe'],
       stdio: ['pipe', 'pipe', 'pipe'],
     })
     })
     const stderr: string[] = []
     const stderr: string[] = []
@@ -180,7 +185,12 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
     const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
     const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
       cwd,
       cwd,
-      env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
+      env: {
+        ...process.env,
+        DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
+        DSH_HOME: join(cwd, '.dsh'),
+        DSH_AGENTS_HOME: join(cwd, '.agents'),
+      },
       stdio: ['pipe', 'pipe', 'pipe'],
       stdio: ['pipe', 'pipe', 'pipe'],
     })
     })
     child = proc
     child = proc

+ 2 - 0
packages/ui/acp-agent/tests/load-path.e2e.ts

@@ -90,6 +90,8 @@ async function boot(): Promise<Spawned & { cwd: string }> {
         TSX_TSCONFIG_PATH: repoTsconfig,
         TSX_TSCONFIG_PATH: repoTsconfig,
         // Key-present check only; no prompt is sent, so the model is never called.
         // Key-present check only; no prompt is sent, so the model is never called.
         DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
         DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
+        DSH_HOME: join(cwd, '.dsh'),
+        DSH_AGENTS_HOME: join(cwd, '.agents'),
       },
       },
       stdio: ['pipe', 'pipe', 'pipe'],
       stdio: ['pipe', 'pipe', 'pipe'],
     },
     },

+ 13 - 0
packages/ui/stdio-agent/src/index.ts

@@ -49,6 +49,15 @@ import * as uiStdio from './stdio-chat.ts'
 
 
 export const name = 'stdio-agent'
 export const name = 'stdio-agent'
 
 
+const SkillConfigSchema: z<agentCore.SkillConfig> = z.object({
+  dshHome: z.string(),
+  agentsHome: z.string(),
+  extraRoots: z.array(z.string()).default([]),
+  installSystemSkills: z.boolean().default(true),
+  promptFieldMaxLength: z.number().default(500),
+  collectCacheMaxEntries: z.number().default(128),
+})
+
 /**
 /**
  * App config: the swappable per-demo values, each routed to where the app wires
  * App config: the swappable per-demo values, each routed to where the app wires
  * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
  * it. `model`/`systemPrompt`/`resumeSessionId` configure the pre-created `main`
@@ -66,6 +75,8 @@ export interface Config {
   persistenceRoot?: string
   persistenceRoot?: string
   /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
   /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
   welcome?: string
   welcome?: string
+  /** Skill discovery config forwarded to the shared agent-core spine. */
+  skills?: agentCore.SkillConfig
   /**
   /**
    * If set, the `main` agent RESUMES this persisted session id instead of
    * If set, the `main` agent RESUMES this persisted session id instead of
    * starting fresh. Sourced from an env var in the leaf `cordis.yml`
    * starting fresh. Sourced from an env var in the leaf `cordis.yml`
@@ -79,6 +90,7 @@ export const Config: z<Config> = z.object({
   systemPrompt: z.string().required(),
   systemPrompt: z.string().required(),
   persistenceRoot: z.string().default('./.sessions'),
   persistenceRoot: z.string().default('./.sessions'),
   welcome: z.string().default('ready.'),
   welcome: z.string().default('ready.'),
+  skills: SkillConfigSchema,
   resumeSessionId: z.string(),
   resumeSessionId: z.string(),
 })
 })
 
 
@@ -99,6 +111,7 @@ export function apply(ctx: Context, config: Config): void {
       cwd: process.cwd(),
       cwd: process.cwd(),
       ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
       ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
     }],
     }],
+    ...config.skills !== undefined ? { skills: config.skills } : {},
   })
   })
   ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
   ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
   ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
   ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })

+ 1 - 1
packages/ui/stdio-agent/tests/built-bin.e2e.ts

@@ -110,7 +110,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st
     const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
     const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
       cwd,
       cwd,
       // Mock model: never calls the network, so no key needed.
       // Mock model: never calls the network, so no key needed.
-      env: { ...process.env },
+      env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
       stdio: ['pipe', 'pipe', 'pipe'],
       stdio: ['pipe', 'pipe', 'pipe'],
     })
     })
     let stdout = ''
     let stdout = ''

+ 17 - 2
packages/ui/stdio-agent/tests/stdio-agent.spec.ts

@@ -1,4 +1,7 @@
 import { describe, it, expect } from 'vitest'
 import { describe, it, expect } from 'vitest'
+import { mkdtemp } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
 import { Context } from 'cordis'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import Loader from '@cordisjs/plugin-loader'
 import { AgentId } from '@deepseek-ai/dsh-agent'
 import { AgentId } from '@deepseek-ai/dsh-agent'
@@ -28,9 +31,14 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
   return ctx
   return ctx
 }
 }
 
 
+async function isolatedSkillsConfig(): Promise<NonNullable<stdioAgent.Config['skills']>> {
+  const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
+  return { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false }
+}
+
 describe('dsh-stdio-agent app', () => {
 describe('dsh-stdio-agent app', () => {
   it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
   it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
-    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
+    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
     // The spine services (brought up by the agent-core bundle) are all present.
     // The spine services (brought up by the agent-core bundle) are all present.
     expect(ctx.get('agents')).toBeDefined()
     expect(ctx.get('agents')).toBeDefined()
     expect(ctx.get('agentLoop')).toBeDefined()
     expect(ctx.get('agentLoop')).toBeDefined()
@@ -48,7 +56,7 @@ describe('dsh-stdio-agent app', () => {
     // apply()'s last two lines are the ones that fire — covering a
     // apply()'s last two lines are the ones that fire — covering a
     // schema-bypassing direct-mount caller.
     // schema-bypassing direct-mount caller.
     const ctx = new Context()
     const ctx = new Context()
-    stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi' })
+    stdioAgent.apply(ctx, { model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
     await new Promise(resolve => setTimeout(resolve, 80))
     await new Promise(resolve => setTimeout(resolve, 80))
     expect(ctx.get('sessionPersistence')).toBeDefined()
     expect(ctx.get('sessionPersistence')).toBeDefined()
     expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
     expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
@@ -64,11 +72,18 @@ describe('dsh-stdio-agent app', () => {
       systemPrompt: 'hi',
       systemPrompt: 'hi',
       persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
       persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
       resumeSessionId: 'no-such-session',
       resumeSessionId: 'no-such-session',
+      skills: await isolatedSkillsConfig(),
     })
     })
     expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
     expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
     await ctx.fiber.dispose()
     await ctx.fiber.dispose()
   })
   })
 
 
+  it('forwards skill config into agent-core', async () => {
+    const ctx = await mount({ model: 'mock', systemPrompt: 'hi', skills: await isolatedSkillsConfig() })
+    expect(await ctx.skills.list()).toEqual([])
+    await ctx.fiber.dispose()
+  })
+
   it('exposes its name and Config schema', () => {
   it('exposes its name and Config schema', () => {
     expect(stdioAgent.name).toBe('stdio-agent')
     expect(stdioAgent.name).toBe('stdio-agent')
     expect(stdioAgent.Config).toBeDefined()
     expect(stdioAgent.Config).toBeDefined()

+ 6 - 0
pnpm-lock.yaml

@@ -222,6 +222,9 @@ importers:
       cordis:
       cordis:
         specifier: ^4.0.0-rc.6
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
 
 
   packages/core/agent-loop:
   packages/core/agent-loop:
     dependencies:
     dependencies:
@@ -271,6 +274,9 @@ importers:
 
 
   packages/core/skill:
   packages/core/skill:
     dependencies:
     dependencies:
+      schemastery:
+        specifier: ^3.18.0
+        version: 3.18.0
       yaml:
       yaml:
         specifier: ^2.4.2
         specifier: ^2.4.2
         version: 2.9.0
         version: 2.9.0

+ 11 - 0
vitest.config.ts

@@ -1,4 +1,5 @@
 import tsconfigPaths from 'vite-tsconfig-paths'
 import tsconfigPaths from 'vite-tsconfig-paths'
+import { fileURLToPath } from 'node:url'
 import { defineConfig } from 'vitest/config'
 import { defineConfig } from 'vitest/config'
 
 
 export default defineConfig({
 export default defineConfig({
@@ -18,6 +19,16 @@ export default defineConfig({
   // upstream copies (vendor/README.md). The plugin's `projects` option
   // upstream copies (vendor/README.md). The plugin's `projects` option
   // instead applies the one root map to every importer.
   // instead applies the one root map to every importer.
   plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })],
   plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })],
+  resolve: {
+    // The root tsconfig maps `schemastery` to its vendored TS source, whose
+    // upstream module shape is `export = Schema`. Vite/Vitest does not synthesize
+    // a default export for that source file consistently, while the package's
+    // built ESM artifact does. Tests exercise harness source but can use the
+    // vendored dependency's built artifact for this CJS-interop boundary.
+    alias: {
+      schemastery: fileURLToPath(new URL('./vendor/schemastery/lib/index.mjs', import.meta.url)),
+    },
+  },
   test: {
   test: {
     include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'],
     include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'],
     coverage: {
     coverage: {