Add DeepSeek Harness (dsh) integration to SkillOpt-Sleep plugins (#237)

* Add DeepSeek Harness (dsh) integration

New plugins/dsh/ integration wrapping the shared skillopt_sleep engine
for DeepSeek Harness: a Cordis plugin registering 7 native skillopt_*
tools (status/dry-run/run/adopt/harvest/schedule/unschedule), a bundled
SKILL.md, a bundle patch layer (cordis.patch.yml), and a bootstrap
script. Register the plugin in the plugins/README.md integration table.

* Fix dsh integration per review: safe argv, operator-only auto-adopt, parity tests, English skill

Addresses all review points from the SkillOpt maintainer.

Blocker 1 — shell injection / broken documented example:
- Replace buildCommand() (string join, no quoting) with buildArgv() returning
  an argv array; execute() quotes every element with the POSIX-safe '\'' spelling
  before shell.resolve(). Model/config-controlled values (project, model,
  preferences, source) cannot break out of their argument — verified with a
  real-bash injection audit (7 payloads). The documented preferences example
  now round-trips as one argument.
- Resolve scripts/sleep.py via an absolute path from the plugin dir so it works
  regardless of the dsh cwd.

Blocker 2 — auto-adopt no longer model-callable:
- autoAdopt was a model-facing tool parameter forwarding --auto-adopt. Moved to
  operator-only config (default false); the tool parameter is removed. The
  canary asserts a model-supplied autoAdopt is ignored.

Should fix — plugin registry parity test:
- Register dsh SKILL.md in tests/test_plugin_sync.py PLUGIN_SKILL_MDS. The
  parity tests now cover dsh (backends, schedule/unschedule, memory
  consolidation). 13/13 pass.

Minor — English-first skill doc:
- SKILL.md rewritten in English; Chinese README stays as README.zh.md.

Runtime correctness (from the first review round):
- execute() goes through shell.resolve() so workdir/output-cap/sandbox defaults apply.
- Consumes rc.8 CollectedOutput { text, truncated, spillPath }; distinguishes
  timeout (exit=timeout) from abort (exit=signal).
- package.json includes cordis.patch.yml in files and declares schemastery.
- scripts/sleep.py mirrors the official runner (repo-root resolution, Python >=
  3.10 selection, CLI/installed-package fallback).
- New scripts/canary.mjs: pack + load + invoke checks.

Tested locally: canary 21 checks, real-bash quoting 10 checks, real-DSH (rc.6)
13 checks, repo parity 13/13 — no regressions, nothing touches the shared engine.

* Add LICENSE, portable test scripts; align README.zh.md and pack files with the established plugin pattern

* Security: strip control chars in argv quoting (defense in depth)

Model-controlled values containing \\r, \\r\\n or other control characters
would split a single-quoted word into multiple argv words (broken command,
not RCE — quotes never execute), and corrupt the engine's arg parsing. Strip
C0 control characters to a space so every value arrives as exactly one
argument. Verified: new audit-control-chars.mjs covers \\n, \\r, \\r\\n, tab,
NUL, backtick, quotes — all neutralized (single arg, no file, no execution).

* Fix dsh install command in READMEs: dsh is a global CLI, not a pnpm dependency

The previous form 'pnpm dsh web --patch ...' made pnpm try to fetch a
nonexistent @deepseek-ai/dsh-type-meta package and fail with 404. dsh is
installed as a global CLI; the correct overlay invocation is
'dsh web --patch ./plugins/dsh/cordis.patch.yml' (verified with --dump-config).

* Security: enforce per-tool parameter whitelist (block undeclared arg injection)

dsh's parameter schema accepts undeclared properties by default (no
additionalProperties:false), and buildArgv() forwarded both model-supplied
values and operator config defaults for every known key to the engine. A
model (or prompt-injected transcript) could therefore pass backend/model/
json/editBudget/etc. to tools that do not declare them — including
skillopt_adopt, the live-change boundary.

- buildArgv() now takes an explicit per-tool llowed key set; keys outside
  it are neither read from args nor filled from config defaults.
- Each tool's build() passes exactly the keys it declares (whitelist).
- canary.mjs: new 7b step asserts adopt drops undeclared backend/model/
  maxTasks/json while keeping declared project; step 4 now drives the
  nonzero-exit path via preferences (a declared run parameter).
- audit-*.mjs: BASH_PATH env override for non-Windows portability.

* Security: value-domain guard for path params; unschedule --all is operator-only

The engine re-interpolates model-supplied values into its OWN shell command
strings: scheduler.py splices --project into a crontab line and a Windows
run.cmd executed by schtasks (no escaping), and write_tasks_file() turns an
arbitrary --output into abspath+makedirs+overwrite. argv-level quoting in the
plugin protects the dsh bash -c boundary but cannot protect those secondarysplices. A model-controlled project containing shell metacharacters (quote,
ampersand, semicolon, pipe, dollar, backtick, angle brackets, braces, glob,
control chars) would break out and execute as a separate command under thescheduler shell; an absolute or traversal output would overwrite an arbitrary
file.

- assertSafePath(): rejects shell metacharacters in project and output values.
- assertSafeOutput(): refuses absolute paths and .. traversal for --output.
- execute() runs both guards before buildArgv, so a bad value never reaches
  the engine; the rejection is returned to the model as tool output.
- skillopt_unschedule: removed model-callable --all; now operator-only via
  config.unscheduleAll (same pattern as autoAdopt).
- canary.mjs: new 7c step asserts injected project / absolute / traversal
  output are rejected and legit paths pass (32 checks total).

* Security: clock range guard for schedule; pin dependency versions

- schedule hour/minute were spliced by the engine into a crontab line and a
  schtasks start time without validation; out-of-range values (99, -1) would
  create broken scheduled entries. execute() now enforces hour in [0,23] and
  minute in [0,59] before building argv.
- package.json: replace bare '*' dependency ranges with known-good pinned
  versions (@deepseek-ai/schemastery ^3.18.1, cordis ^4.0.1, dsh-tools
  ^0.1.0-rc.8) so installs are reproducible and not silently broken by a
  future upstream release.
- canary.mjs: new 7d step asserts hour=99 / minute=-1 are rejected and legit
  clock values pass (35 checks total).

* Align with DSH ecosystem plugin conventions; document both patch-invocation forms

- package.json: add peerDependenciesMeta marking @deepseek-ai/cordis and
  @deepseek-ai/dsh-tools optional, matching the official ecosystem practice
  (dsh-office-tools et al. declare host-provided peers optional). Without it a
  plain 'npm install dsh-skillopt' would hard-fail when the host DSH version
  differs from the pinned peer range, instead of warning.
- README.md / plugins/README.md: document BOTH overlay forms - 'pnpm dsh web
  --patch' for a DeepSeek Harness source checkout (the official dev workflow)
  and 'dsh web --patch' for a globally installed dsh.

* Docs: fix parameter name in SKILL.md (maxTasks, not max_tasks)

The skill's parameter table listed max_tasks (snake_case) but the tools declare
maxTasks (camelCase); a model following the skill doc would send max_tasks and be
rejected by dsh's parameter validation (undeclared property).

* Canary: actually pack + extract and load the packed bundle (review requirement)

The review asked for a clean-package canary that 'loads the packed bundle'.
The previous canary verified the pack file list via --dry-run but then imported
the plugin from the source tree. It now runs 'npm pack --json', extracts the
tarball, and loads src/index.js FROM THE EXTRACTED package/ artifact for every
step (register, status, error paths, quoting, whitelist, value guard, clock),
so the artifact under test is exactly what the 'files' list ships. Tarball and
scratch dir are removed on exit.

* Docs: complete README config keys table (all schema keys, corrected module default)

The config keys table now lists every Config schema key (added engineScript,
scope, autoAdopt, unscheduleAll, timeoutMs) and no longer claims module defaults
to 'skillopt_sleep' (the default path is the scripts/sleep.py bootstrap; module
is an explicit override).

---------

Co-authored-by: WODE25500 <WODE25500@users.noreply.github.com>
This commit is contained in:
WODE25500
2026-08-21 22:18:46 +08:00
committed by GitHub
parent 3c8873f016
commit 6fc20f33c6
14 changed files with 1316 additions and 1 deletions
+3 -1
View File
@@ -10,7 +10,7 @@ runtime dependency on the paper's `skillopt/` experiment package.
## Available integrations
Five integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate
Six integrations wrap the shared `skillopt_sleep` CLI. OpenClaw is a separate
reference adaptation with its own backend and setup assumptions.
| Platform | Folder | Mechanism | Status |
@@ -20,6 +20,7 @@ reference adaptation with its own backend and setup assumptions.
| **Cursor** | [`cursor/`](cursor) | native command and skill, project skill target, and shared runner | installable shared-engine integration |
| **GitHub Copilot** | [`copilot/`](copilot) | MCP server exposing seven `sleep_*` tools | shared-engine MCP integration |
| **Devin** | [`devin/`](devin) | MCP server plus Devin transcript conversion | shared-engine MCP integration |
| **DeepSeek Harness** | [`dsh/`](dsh) | Cordis plugin: 7 native `skillopt_*` tools, skill, bundle patch layer | installable shared-engine integration |
| **OpenClaw** | [`openclaw/`](openclaw) | custom DeepSeek/Ollama wrapper | independent reference adaptation; review and adapt before use |
## Install
@@ -34,6 +35,7 @@ for your workflow.
| **Cursor** | `bash plugins/cursor/install.sh` (macOS/Linux) or `powershell -File plugins/cursor/install.ps1` (Windows) | `/skillopt-sleep status` |
| **Copilot** | register `plugins/copilot/mcp_server.py` using its example MCP config | ask Copilot to run `sleep_status` |
| **Devin** | register `plugins/devin/mcp_server.py` using its example MCP config | ask Devin to run `sleep_status` |
| **DeepSeek Harness** | add `dsh-skillopt` to the profile's bundles, or patch it in — from a DSH source checkout: `pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml`; with global dsh: `dsh web --patch ./plugins/dsh/cordis.patch.yml` | ask the agent to use `skillopt_status` |
| **OpenClaw** | follow and adapt [`openclaw/README.md`](openclaw/README.md) | validate paths, credentials, and tasks locally |
Python 3.10 or newer is required. Real CLI backends also require the selected
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+119
View File
@@ -0,0 +1,119 @@
# SkillOpt-Sleep — DeepSeek Harness (dsh) integration
Give your **DeepSeek Harness** agent a nightly **sleep cycle**: it reviews past
sessions offline, replays your recurring tasks on your own API budget, and
consolidates what it learns into validated skills behind a held-out gate. Same
engine as the Claude Code / Codex / Cursor integrations (`skillopt_sleep`),
wired into dsh's plugin system as native tools plus a bundled skill.
DeepSeek Harness is the "everything is a plugin" agent framework
([deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)).
Plugins are TypeScript modules exporting an `apply(ctx)` function that register
capabilities (tools, services, events, settings) on the Cordis context.
## What this integration adds
| Component | Purpose |
|---|---|
| `src/index.js` | dsh plugin entry: registers 7 `skillopt_*` tools + Schemastery config |
| `cordis.patch.yml` | bundle patch layer — drop `dsh-skillopt` into any profile's bundles |
| `skills/skillopt-sleep/SKILL.md` | agent skill: when to use the tools, operating rules, data-boundary rules |
| `scripts/sleep.py` | bootstrap/self-check runner (same command shape the tools use) |
| `package.json` | npm package metadata (bundle manifest) |
## Tools
| Tool | skillopt_sleep action | Behavior |
|---|---|---|
| `skillopt_status` | `status` | state, engine availability, latest staged proposal & report |
| `skillopt_dry_run` | `dry-run` | full preview (harvest+mine+replay), stages nothing |
| `skillopt_run` | `run` | full cycle, stages a proposal (live files unchanged) |
| `skillopt_adopt` | `adopt` | apply latest staged proposal (with backup) — the live-change boundary |
| `skillopt_harvest` | `harvest` | read-only show/export of mined tasks |
| `skillopt_schedule` / `skillopt_unschedule` | `schedule` / `unschedule` | install/remove the nightly cron entry |
## Prerequisites
- DeepSeek Harness (dsh) installed
- Python 3.10+ with the SkillOpt-Sleep engine:
```bash
pip install skillopt # or use this source checkout
```
## Install
### As a bundle in a profile
Add `dsh-skillopt` to the profile's bundles, or in the profile `cordis.patch.yml`:
```yaml
- insert:
- id: skillopt
name: './src/index.js'
config:
backend: mock # or codex / claude / cursor / pi / opencode / handoff …
project: /path/to/project
preferences: 'Always use async/await'
```
### Local patch overlay (dev)
Run from a DeepSeek Harness **source checkout** (the official dev workflow,
`pnpm` resolves the workspace `dsh` bin):
```bash
pnpm dsh web --patch ./plugins/dsh/cordis.patch.yml
```
If `dsh` is installed **globally** (npm install -g), use it directly:
```bash
dsh web --patch ./plugins/dsh/cordis.patch.yml
```
Either way the patch inserts the `skillopt` plugin row into the profile; then
ask the agent: "Use skillopt_status to check the sleep cycle state."
## Config keys
| Key | Default | Purpose |
|---|---|---|
| `pythonCmd` | `python` | Python interpreter for the engine |
| `module` | — (bootstrap) | engine Python module override (`python -m <module>`) |
| `engineScript` | — (scripts/sleep.py) | engine bootstrap script override |
| `project` | — | default project directory |
| `scope` | — | harvest scope: `all` \| `invoked` |
| `backend` | — | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai` |
| `source` | — | `claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` |
| `model` | — | backend model override |
| `maxTasks` / `maxSessions` | — | mine/harvest caps |
| `editBudget` | — | bounded edits per cycle |
| `preferences` | — | house rules for the reflection prior |
| `jsonOutput` | `false` | machine-readable JSON output |
| `autoAdopt` | `false` | OPERATOR-ONLY: auto-adopt a passed proposal without asking |
| `unscheduleAll` | `false` | OPERATOR-ONLY: allow `skillopt_unschedule` to remove every managed entry |
| `timeoutMs` | `600000` | per-call engine timeout in milliseconds |
Advanced engine keys (`gate_mode`, `gate_metric`, `gate_no_regression`,
`dream_rollouts`, `recall_k`, `evolve_memory`/`evolve_skill`) go in
`~/.skillopt-sleep/config.json` — the same file shared by all integrations.
## Data boundary
- Harvest is read-only; `mock`/`handoff` make no network calls.
- `run` stages proposals; `adopt` is the normal live-change boundary and backs up first.
- Real backends send truncated transcript excerpts and derived tasks to the
selected provider. For sensitive sessions, export tasks first (`skillopt_harvest`
with `output=`), redact, set `"reviewed": true`, then replay — real backends
refuse unreviewed task files.
- Outbound prompts are not guaranteed secret-free; review source & provider policy.
## Validate (no API spend)
```bash
python -m skillopt_sleep.experiments.run_experiment --persona researcher --assert-improves
```
See the [SkillOpt-Sleep documentation](../../docs/sleep/README.md) for recorded
results, limitations, and the supported integration surface.
+36
View File
@@ -0,0 +1,36 @@
# dsh-skillopt 文档
## 快速上手
1. 安装引擎:`pip install skillopt`(或克隆 [microsoft/SkillOpt](https://github.com/microsoft/SkillOpt) 并把其根目录加入 `PYTHONPATH`
2. 在 profile 的 `cordis.patch.yml` 插入插件(见根 README
3. 启动 dsh 后向 agent 提问:"用 skillopt_status 查看睡眠循环状态"
## 工具与引擎命令对照
| dsh 工具 | skillopt_sleep 动作 | 说明 |
|---|---|---|
| `skillopt_status` | `status` | 状态与暂存提案 |
| `skillopt_dry_run` | `dry-run` | 预览,不暂存 |
| `skillopt_run` | `run` | 完整循环并暂存 |
| `skillopt_adopt` | `adopt` | 应用提案(先备份) |
| `skillopt_harvest` | `harvest` | 只读导出任务 |
| `skillopt_schedule` | `schedule` | 安装夜间 cron |
| `skillopt_unschedule` | `unschedule` | 移除 cron |
## 引擎进阶配置(`~/.skillopt-sleep/config.json`
```json
{
"gate_mode": "on",
"gate_metric": "mixed",
"gate_no_regression": false,
"dream_rollouts": 1,
"recall_k": 0,
"evolve_memory": true,
"evolve_skill": true,
"preferences": "Prefer pytest. Keep commits imperative."
}
```
详见上游文档:https://github.com/microsoft/SkillOpt/tree/main/docs/sleep
+18
View File
@@ -0,0 +1,18 @@
# dsh-skillopt bundle patch layer.
# When a profile lists this bundle, this patch inserts the plugin rows below.
#
# Usage in a profile's cordis.patch.yml / dsh.profile bundles list:
# bundles:
# - dsh-skillopt
# or with a local checkout:
# - insert:
# - id: skillopt
# name: './src/index.js'
- insert:
- id: skillopt
name: './src/index.js'
# config:
# backend: mock # mock = no provider calls (default)
# project: /path/to/project
# preferences: 'Prefer pytest. Keep commits imperative.'
+43
View File
@@ -0,0 +1,43 @@
{
"name": "dsh-skillopt",
"version": "0.1.0",
"description": "Microsoft SkillOpt-Sleep integration for DeepSeek Harness: give your dsh agent a nightly sleep cycle that harvests past sessions, replays recurring tasks, and consolidates validated skills behind a held-out gate.",
"type": "module",
"main": "src/index.js",
"files": [
"src",
"skills",
"scripts",
"cordis.patch.yml",
"README.md",
"README.zh.md"
],
"keywords": [
"dsh",
"deepseek-harness",
"cordis",
"plugin",
"skillopt",
"skill-optimization",
"self-improvement",
"memory-consolidation",
"sleep"
],
"license": "MIT",
"dsh": {
"bundle": {
"patch": "cordis.patch.yml"
}
},
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis": { "optional": true },
"@deepseek-ai/dsh-tools": { "optional": true }
}
}
@@ -0,0 +1,41 @@
// 安全审核:控制字符/换行不能逃逸单引号边界,且不产生副作用。
// 修复后的期望:控制字符被剥离为空格,值仍作为单个参数到达,无文件/命令执行。
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
const m = await import('../src/index.js')
const { quoteArgv } = m
const BASH = process.env.BASH_PATH || 'C:/Program Files/Git/bin/bash.exe'
const payloads = [
'x\n touch /tmp/nl-pwned',
'x\r echo PWNED',
'x\ttab',
'x`id`',
'x\x00null',
"'; touch /tmp/semi-pwned;'",
'normal\r\ntouch /tmp/crnl-pwned',
]
let fail = 0
for (const p of payloads) {
const argv = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', p]
const quoted = quoteArgv(argv)
const script = `for a in ${quoted}; do printf '[%s]\\n' "$a"; done`
const out = execFileSync(BASH, ['-c', script], { encoding: 'utf8' })
const args = out.trim().split('\n').map((l) => l.slice(1, -1))
// 安全断言 1--preferences 后恰好一个参数(无参数分裂)
const idx = args.indexOf('--preferences')
const singleArg = idx >= 0 && idx + 1 < args.length && idx + 2 === args.length
// 安全断言 2:到达的值不含原始控制字符(已被剥离为空格)
const arrived = idx >= 0 ? args[idx + 1] : ''
const stripped = !/[\r\n\t\x00]/.test(arrived)
// 安全断言 3:没有注入命令出现在参数列表外
const noInjection = !args.some((a) => /touch|PWNED|rm\s/.test(a) && a !== arrived)
if (!singleArg || !stripped || !noInjection) {
fail++
console.log('FAIL:', JSON.stringify(p), '-> args:', JSON.stringify(args))
}
}
for (const f of ['/tmp/nl-pwned', '/tmp/semi-pwned', '/tmp/crnl-pwned']) {
if (existsSync(f)) { fail++; console.log('FILE CREATED:', f) }
}
console.log(fail === 0 ? 'ALL CONTROL-CHAR PAYLOADS NEUTRALIZED' : `${fail} FAILURES`)
process.exit(fail === 0 ? 0 : 1)
+31
View File
@@ -0,0 +1,31 @@
// 独立注入审计:各种恶意 payload 过 quoteArgv → 真实 bash → 验证不逃逸
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
const m = await import('../src/index.js')
const { quoteArgv } = m
const BASH = process.env.BASH_PATH || 'C:/Program Files/Git/bin/bash.exe'
const payloads = [
'x; touch /tmp/pwned',
'x$(touch /tmp/pwned2)',
'x`touch /tmp/pwned3`',
'x|cat /etc/passwd',
'x&&rm -rf /',
"' OR 1=1 --",
'x > /tmp/redirected',
]
let fail = 0
for (const p of payloads) {
const argv = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', p]
const quoted = quoteArgv(argv)
const script = `for a in ${quoted}; do printf '[%s]\\n' "$a"; done`
const out = execFileSync(BASH, ['-c', script], { encoding: 'utf8' })
const args = out.trim().split('\n').map((l) => l.slice(1, -1))
const pref = args[args.indexOf('--preferences') + 1]
const ok = pref === p
if (!ok) { fail++; console.log('FAIL:', JSON.stringify(p), '->', JSON.stringify(pref)) }
}
for (const f of ['/tmp/pwned', '/tmp/pwned2', '/tmp/pwned3', '/tmp/redirected', '/tmp/pwnedx']) {
if (existsSync(f)) { fail++; console.log('FILE CREATED:', f) }
}
console.log(fail === 0 ? 'ALL 7 INJECTION PAYLOADS INERT' : `${fail} FAILURES`)
process.exit(fail === 0 ? 0 : 1)
+291
View File
@@ -0,0 +1,291 @@
// dsh-skillopt canary — the clean-package check the SkillOpt review asked for.
//
// Packs the plugin with `npm pack --dry-run`, asserts the bundle manifest is
// complete (cordis.patch.yml present), then ACTUALLY packs it (npm pack),
// extracts the tarball, and loads the plugin FROM THE PACKED ARTIFACT into a
// mock Cordis context with a fake rc.8-shaped shell (CollectedOutput objects),
// invoking every tool and asserting real stdout/exit/error behavior. Loading
// the extracted bundle (not the source tree) is what the review's "loads the
// packed bundle" demands — the packed files are exactly what `files` ships.
//
// Run: node scripts/canary.mjs (requires npm + the plugin's deps resolvable)
import { execSync } from 'node:child_process'
import { mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const require = createRequire(import.meta.url)
const root = dirname(dirname(fileURLToPath(import.meta.url)))
const { Context } = require('@deepseek-ai/cordis')
let failures = 0
function check(name, cond, detail = '') {
if (cond) console.log(`${name}`)
else {
failures++
console.log(`${name}${detail ? `${detail}` : ''}`)
}
}
// ---------------------------------------------------------------------------
// 1. npm pack --dry-run: bundle must include cordis.patch.yml
// ---------------------------------------------------------------------------
console.log('1. bundle completeness (npm pack --dry-run)')
const packOut = execSync('npm pack --dry-run --json', { cwd: root, encoding: 'utf8' })
const packInfo = JSON.parse(packOut)
const packedFiles = packInfo.map((p) => p.files.map((f) => f.path)).flat()
check('cordis.patch.yml packed', packedFiles.includes('cordis.patch.yml'))
check('src/index.js packed', packedFiles.some((f) => f === 'src/index.js' || f.endsWith('/src/index.js')))
check('package.json packed', packedFiles.includes('package.json'))
// package.json declares dsh.bundle.patch → cordis.patch.yml
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
check('dsh.bundle.patch points at packed file', packedFiles.includes(pkg.dsh?.bundle?.patch))
check('schemastery declared as direct dependency', !!pkg.dependencies?.['@deepseek-ai/schemastery'])
// ---------------------------------------------------------------------------
// 1b. ACTUAL pack + extract: the rest of the canary runs against the packed
// artifact (what `files` ships), not the source tree — the review asked for a
// canary that "loads the packed bundle". npm pack --json prints the tarball
// name; extract into a scratch dir inside root so the extracted module can
// still resolve @deepseek-ai/* deps up the tree.
// ---------------------------------------------------------------------------
console.log('1b. real pack + extract (canary runs against the packed artifact)')
const tarball = JSON.parse(execSync('npm pack --json', { cwd: root, encoding: 'utf8' }))[0].filename
check('npm pack produced a tarball', !!tarball && existsSync(join(root, tarball)), tarball || 'no tarball')
const scratch = join(root, '.canary-pack')
rmSync(scratch, { recursive: true, force: true })
mkdirSync(scratch, { recursive: true })
execSync(`tar -xzf "${tarball}" -C "${scratch}"`, { cwd: root, encoding: 'utf8' })
const packedRoot = join(scratch, 'package')
check('extracted package/ contains src/index.js', existsSync(join(packedRoot, 'src/index.js')))
check('extracted package/ contains cordis.patch.yml', existsSync(join(packedRoot, 'cordis.patch.yml')))
check('extracted package.json matches files list', JSON.parse(readFileSync(join(packedRoot, 'package.json'), 'utf8')).name === pkg.name)
// Never leave the tarball or scratch dir behind.
process.on('exit', () => {
try { rmSync(join(root, tarball), { force: true }) } catch {}
try { rmSync(scratch, { recursive: true, force: true }) } catch {}
})
// ---------------------------------------------------------------------------
// 2. load the plugin against a mock rc.8-shaped shell
// ---------------------------------------------------------------------------
console.log('2. plugin loads and registers 7 tools')
const ctx = new Context()
const defs = {}
ctx.tools = {
register(def) {
defs[def.name] = def
return () => {}
},
}
// rc.8-shaped fake shell: resolve() applies defaults, run() returns
// CollectedOutput objects for stdout/stderr.
const called = { resolve: 0, run: 0, commands: [] }
ctx.shell = {
resolve(req) {
called.resolve++
return { ...req, workdir: '.', stdoutMaxBytes: 2_000_000, timeoutMs: req.timeoutMs ?? 600_000 }
},
async run(spec) {
called.run++
called.commands.push(spec.command)
if (spec.command.includes("'status'")) {
return {
exitCode: 0,
stdout: { text: '[sleep] nights so far: 0\n[sleep] no staged proposals yet.', truncated: false },
stderr: { text: '', truncated: false },
}
}
if (spec.command.includes("'dry-run'")) {
return {
exitCode: 0,
stdout: { text: '[sleep] night 1: 0 sessions -> 0 tasks', truncated: false },
stderr: { text: '', truncated: false },
}
}
if (spec.command.includes("'run'") && spec.command.includes('--bad-model')) {
// a real model value that makes the engine exit 2 (e.g. unknown provider)
return {
exitCode: 2,
stdout: { text: '', truncated: false },
stderr: { text: "error: unknown model '--bad-model'", truncated: false },
}
}
if (spec.command.includes('--timeout-trigger')) {
// executor timeout shape: exitCode null, timedOut flag, stderr explains
return {
exitCode: null,
timedOut: true,
stdout: { text: '', truncated: false },
stderr: { text: 'command timed out after 600000ms', truncated: false },
}
}
if (spec.signal?.aborted) {
// executor abort shape: killed by signal, no exit code
return {
exitCode: null,
killed: 'SIGTERM',
stdout: { text: '', truncated: false },
stderr: { text: 'process killed by signal SIGTERM', truncated: false },
}
}
// truncated output with spill path (dry-run and others)
return {
exitCode: 0,
stdout: { text: 'big output tail…', truncated: true, spillPath: 'C:/spill/stdout.log' },
stderr: { text: '', truncated: false },
}
},
}
ctx.logger = { info: () => {} }
const { apply } = await import(pathToFileURL(join(packedRoot, 'src/index.js')).href)
apply(ctx, { backend: 'mock' })
check('7 tools registered', Object.keys(defs).length === 7, `got ${Object.keys(defs).length}`)
// ---------------------------------------------------------------------------
// 3. skillopt_status: real stdout surfaced (also proves resolve() is used)
// ---------------------------------------------------------------------------
console.log('3. skillopt_status surfaces real stdout')
const status = await defs['skillopt_status'].execute({}, {})
check('exit=0 reported', status.includes('exit=0'), status.slice(0, 120))
check('real stdout present', status.includes('nights so far'), status.slice(0, 200))
check('no "(no output)" for real output', !status.includes('(no output)'))
check('shell.resolve used', called.resolve > 0, 'execute must go through resolve()')
// ---------------------------------------------------------------------------
// 4. nonzero exit: stderr surfaced with exit code
// ---------------------------------------------------------------------------
console.log('4. nonzero exit surfaces stderr')
// preferences is a REAL parameter of run; a bogus value makes the engine exit 2
const bad = await defs['skillopt_run'].execute({ preferences: '--bad-model' }, {})
check('exit code surfaced', bad.includes('exit=2'), bad.slice(0, 150))
check('stderr text surfaced', bad.includes('unknown model'), bad.slice(0, 200))
// ---------------------------------------------------------------------------
// 4b. timeout: executor timeout shape is surfaced, not swallowed as failure
// ---------------------------------------------------------------------------
console.log('4b. timeout surfaces executor timeout')
const to = await defs['skillopt_harvest'].execute({ output: '--timeout-trigger' }, {})
check('timeout reported', to.includes('exit=timeout') && to.includes('timed out'), to.slice(0, 200))
check('timeout stderr surfaced', to.includes('command timed out'), to.slice(0, 200))
// ---------------------------------------------------------------------------
// 4c. abort: signal-driven kill is surfaced as signal, not as a crash
// ---------------------------------------------------------------------------
console.log('4c. abort (signal) is surfaced')
const abortCtrl = { aborted: true, reason: 'user cancel' }
const ab = await defs['skillopt_adopt'].execute({}, { signal: abortCtrl })
check('abort run completed (no throw)', typeof ab === 'string')
check('abort marker surfaced', ab.includes('exit=null') || ab.includes('signal'), ab.slice(0, 120))
check('abort stderr surfaced', ab.includes('SIGTERM'), ab.slice(0, 200))
// ---------------------------------------------------------------------------
// 5. truncated output: spill path preserved
// ---------------------------------------------------------------------------
console.log('5. truncated output preserves spill path')
const trig = await defs['skillopt_adopt'].execute({}, {})
// adopt hits the fake shell's default branch (truncated + spill path)
check('truncated marker present', trig.includes('truncated'))
check('spill path present', trig.includes('C:/spill/stdout.log'))
// ---------------------------------------------------------------------------
// 6. argv quoting: spaces and metacharacters cannot break out
// ---------------------------------------------------------------------------
console.log('6. argv quoting is shell-safe')
const { buildArgv, quoteArgv } = await import(pathToFileURL(join(packedRoot, 'src/index.js')).href)
// Verify quoting directly: a preference with spaces and metacharacters must stay
// inside one argument (single-quoted, embedded quotes doubled).
const argv = buildArgv({}, 'run', { preferences: "never ' rm -rf /" })
const quoted = quoteArgv(argv)
const prefArg = argv[argv.indexOf('--preferences') + 1]
check('preference stays one argv element', argv.includes('--preferences') && argv[argv.indexOf('--preferences') + 1] === "never ' rm -rf /")
check('quoted form uses bash-safe escape', quoted.includes("'never '\\'' rm -rf /'"))
check('no unquoted shell metacharacters', !/;\s*rm\s+-rf/.test(quoted))
// ---------------------------------------------------------------------------
// 7. auto-adopt is OPERATOR-ONLY: the model cannot set it
// ---------------------------------------------------------------------------
console.log('7. auto-adopt is operator-only')
const before = called.commands.length
await defs['skillopt_run'].execute({ autoAdopt: true, backend: 'mock' }, {})
const runCmd = called.commands.slice(before).find((c) => c.includes("'run'"))
check('model-supplied autoAdopt ignored', runCmd ? !runCmd.includes('--auto-adopt') : true, runCmd || 'no run command')
// operator config enables it
const { apply: apply2 } = await import(pathToFileURL(join(packedRoot, 'src/index.js')).href)
// re-apply with a fresh capture to check config-driven --auto-adopt
const ctx2 = new Context()
const defs2 = {}
ctx2.tools = { register(d) { defs2[d.name] = d; return () => {} } }
const cmds2 = []
ctx2.shell = {
resolve(req) { return req },
async run(spec) { cmds2.push(spec.command); return { exitCode: 0, stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false } } },
}
ctx2.logger = { info: () => {} }
apply2(ctx2, { backend: 'mock', autoAdopt: true })
await defs2['skillopt_run'].execute({ backend: 'mock' }, {})
const runCmd2 = cmds2.find((c) => c.includes("'run'"))
check('operator config autoAdopt adds --auto-adopt', runCmd2 ? runCmd2.includes('--auto-adopt') : false, runCmd2 || 'no run command')
// ---------------------------------------------------------------------------
// 7b. undeclared parameters are filtered: the model cannot inject fields the
// tool does not declare (dsh's parameter schema allows extra properties by
// default, so the plugin's per-tool whitelist is what stops this). adopt
// declares only `project`; backend/model/maxTasks/json must not reach argv.
// ---------------------------------------------------------------------------
console.log('7b. undeclared tool parameters are filtered')
const before7b = called.commands.length
await defs['skillopt_adopt'].execute({ project: '/tmp/p', backend: 'codex', model: 'gpt-x', maxTasks: 99, json: true }, {})
const adoptCmd7b = called.commands.slice(before7b).find((c) => c.includes("'adopt'"))
check('adopt keeps declared project', adoptCmd7b ? adoptCmd7b.includes("'--project'") : false, adoptCmd7b || 'no adopt command')
check('adopt drops undeclared backend', adoptCmd7b ? !adoptCmd7b.includes("'--backend'") : true, adoptCmd7b || 'no adopt command')
check('adopt drops undeclared model', adoptCmd7b ? !adoptCmd7b.includes("'--model'") : true)
check('adopt drops undeclared maxTasks', adoptCmd7b ? !adoptCmd7b.includes("'--max-tasks'") : true)
check('adopt drops undeclared json', adoptCmd7b ? !adoptCmd7b.includes("'--json'") : true)
// ---------------------------------------------------------------------------
// 7c. value-domain guard: project/output with shell metacharacters are rejected
// before they reach the engine's own shell/crontab/schtasks interpolation
// (scheduler.py splices --project into a crontab line / Windows run.cmd, and
// write_tasks_file() writes --output to an arbitrary path). Legitimate values
// pass; metacharacter and traversal values are refused with an error message.
// ---------------------------------------------------------------------------
console.log('7c. path value-domain guard (engine re-interpolation / file write)')
const before7c = called.commands.length
// schedule with an injected project (would break out of the engine's own
// `--project "..."` splice and run a separate command under the scheduler)
const inj = await defs['skillopt_schedule'].execute({ project: 'C:/tmp/x" & echo PWNED > C:/tmp/pwned.txt & "', hour: 3 }, {})
check('schedule rejects injected project', /rejected/.test(inj), inj.slice(0, 160))
check('no schedule command reached the shell', called.commands.length === before7c)
// harvest output escaping the working area (absolute path / traversal)
const abs = await defs['skillopt_harvest'].execute({ project: '/tmp/p', output: 'C:/Windows/System32/drivers/etc/hosts' }, {})
check('harvest rejects absolute output', /rejected/.test(abs), abs.slice(0, 160))
const trav = await defs['skillopt_harvest'].execute({ project: '/tmp/p', output: '../../etc/hosts' }, {})
check('harvest rejects traversal output', /rejected/.test(trav), trav.slice(0, 160))
// legit values still pass through the guard
const ok7c = await defs['skillopt_harvest'].execute({ project: '/tmp/my proj', output: 'tasks.json', source: 'claude' }, {})
const okCmd7c = called.commands.slice(before7c).find((c) => c.includes("'harvest'"))
check('legit project/output pass', !/rejected/.test(ok7c) && !!okCmd7c, ok7c.slice(0, 120))
check('legit harvest cmd has project+output', okCmd7c ? okCmd7c.includes("'--output'") && okCmd7c.includes("'/tmp/my proj'") : false, okCmd7c || 'no harvest command')
// ---------------------------------------------------------------------------
// 7d. clock range guard: schedule's hour/minute are spliced by the engine into
// a crontab line and a schtasks start time without validation; out-of-range
// values would create broken scheduled entries. They must be rejected.
// ---------------------------------------------------------------------------
console.log('7d. schedule clock range guard')
const badHour = await defs['skillopt_schedule'].execute({ project: '/tmp/p', hour: 99, minute: 17 }, {})
check('schedule rejects hour=99', /rejected/.test(badHour), badHour.slice(0, 140))
const badMinute = await defs['skillopt_schedule'].execute({ project: '/tmp/p', hour: 3, minute: -1 }, {})
check('schedule rejects minute=-1', /rejected/.test(badMinute), badMinute.slice(0, 140))
const okSched = await defs['skillopt_schedule'].execute({ project: '/tmp/p', hour: 3, minute: 17 }, {})
const okSchedCmd = called.commands.slice(-1)[0]
check('legit clock passes and reaches shell', !/rejected/.test(okSched) && !!okSchedCmd && okSchedCmd.includes("'--hour'") && okSchedCmd.includes("'--minute'"), okSched.slice(0, 120))
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
process.exit(failures === 0 ? 0 : 1)
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""dsh-skillopt — engine bootstrap helper (mirrors the official run-sleep.sh).
Resolves the skillopt_sleep engine the same way the official SkillOpt plugin
runner does, so the dsh tools work in every install shape:
1. Source checkout: a `skillopt_sleep/` package next to this script (or under
SKILLOPT_SLEEP_REPO) is importable — run from that root.
2. A Python >= 3.10 interpreter is picked (python3.12 -> 3.11 -> 3.10 ->
python3), skipping Python 2 / too-old versions.
3. Fallbacks: `skillopt-sleep` CLI on PATH (uv tool / pipx / pip installs),
then `python -m skillopt_sleep` against an installed package.
Usage:
python scripts/sleep.py status
python scripts/sleep.py run --backend mock --project .
python scripts/sleep.py adopt --project .
"""
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO_ROOT_CANDIDATES = [
HERE.parent, # plugin repo root (dsh-skillopt/)
HERE / ".." / "..", # SkillOpt checkout: plugins/.../scripts -> repo root
]
PYTHON_CANDIDATES = ["python3.12", "python3.11", "python3.10", "python3"]
def find_repo_root() -> Path | None:
"""A directory containing an importable `skillopt_sleep` package."""
env = os.environ.get("SKILLOPT_SLEEP_REPO")
candidates = list(REPO_ROOT_CANDIDATES)
if env:
candidates.insert(0, Path(env))
for cand in candidates:
root = cand.resolve()
if (root / "skillopt_sleep").is_dir():
return root
# search upward from CWD (same last-resort as the official runner)
d = Path.cwd()
while d != d.parent:
if (d / "skillopt_sleep").is_dir():
return d
d = d.parent
return None
def pick_python() -> str | None:
"""First candidate with version >= 3.10, or None."""
for cand in PYTHON_CANDIDATES:
path = shutil.which(cand)
if not path:
continue
try:
ver = subprocess.run(
[path, "-c", "import sys; print('%d%d' % sys.version_info[:2])"],
capture_output=True, text=True, timeout=10,
).stdout.strip()
except Exception:
continue
if ver and int(ver) >= 310:
return path
# explicit python on PATH (may be < 3.10; let the engine fail loudly)
return shutil.which("python") or shutil.which("python3")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("action", nargs="?", default="status",
choices=["status", "dry-run", "run", "adopt", "harvest",
"schedule", "unschedule"])
ap.add_argument("args", nargs=argparse.REMAINDER)
args = ap.parse_args()
# 1. source checkout: run from repo root so skillopt_sleep/ is importable
repo_root = find_repo_root()
cwd = str(repo_root) if repo_root else None
# 2. python >= 3.10
python = pick_python()
if not python:
print("[sleep] ERROR: need Python >= 3.10 (found none).", file=sys.stderr)
return 1
# 3a. installed package via python -m
probe = subprocess.run(
[python, "-c", "import skillopt_sleep"],
capture_output=True, cwd=cwd,
)
if probe.returncode == 0:
cmd = [python, "-m", "skillopt_sleep", args.action, *args.args]
print("+", " ".join(cmd), file=sys.stderr)
return subprocess.call(cmd, cwd=cwd)
# 3b. skillopt-sleep CLI on PATH (uv tool / pipx / pip)
cli = shutil.which("skillopt-sleep")
if cli:
cmd = [cli, args.action, *args.args]
print("+", " ".join(cmd), file=sys.stderr)
return subprocess.call(cmd, cwd=cwd)
print(
"skillopt_sleep not importable and no skillopt-sleep CLI on PATH.\n"
"Install it with: pip install skillopt\n"
"or use a source checkout of https://github.com/microsoft/SkillOpt\n"
"(set SKILLOPT_SLEEP_REPO to its path).",
file=sys.stderr,
)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
// Real-bash quoting verification for dsh-skillopt.
//
// The review asked for "Bash and pwsh tests for spaces, quotes, and
// metacharacters". This runs the plugin's quoteArgv() output through a REAL
// bash (Git Bash on Windows) and asserts the shell sees exactly one argument
// per argv element — spaces stay inside one argument, embedded quotes are
// preserved, and metacharacters cannot break out.
//
// Usage: node scripts/test-quoting-bash.mjs (requires Git Bash)
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const require = createRequire(import.meta.url)
const root = dirname(dirname(fileURLToPath(import.meta.url)))
const BASH = process.env.BASH_PATH || 'C:/Program Files/Git/bin/bash.exe'
const { quoteArgv } = await import(pathToFileURL(join(root, 'src/index.js')).href)
let failures = 0
function check(name, cond, detail = '') {
if (cond) console.log(`${name}`)
else {
failures++
console.log(`${name}${detail ? `${detail}` : ''}`)
}
}
// Run a bash snippet that prints each received argument on its own line,
// then compare what the shell received against what we intended.
function bashRoundtrip(argv) {
const quoted = quoteArgv(argv)
// bash: for each arg, print a delimiter + the arg; newlines in args are
// escaped so the split stays unambiguous.
const script = `for a in ${quoted}; do printf '[%s]\\n' "$a"; done`
const out = execFileSync(BASH, ['-c', script], { encoding: 'utf8', cwd: root })
return out.trim().split('\n').map((l) => l.replace(/^\[/, '').replace(/\]$/, ''))
}
console.log('1. spaces stay inside one argument')
const spaced = ['python', '-m', 'skillopt_sleep', 'run', '--project', '/tmp/my proj', '--preferences', 'use async always']
const got1 = bashRoundtrip(spaced)
check('path with space intact', got1[5] === '/tmp/my proj', JSON.stringify(got1))
check('preference with space intact', got1[7] === 'use async always', JSON.stringify(got1))
console.log('2. embedded single quotes are preserved')
const quoted = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', "never ' rm -rf /"]
const got2 = bashRoundtrip(quoted)
check("embedded quote preserved", got2[5] === "never ' rm -rf /", JSON.stringify(got2))
console.log('3. metacharacters cannot break out (injection attempt)')
const inject = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', 'x; touch /tmp/dsh-injected; echo PWNED']
const got3 = bashRoundtrip(inject)
// The injection must arrive as ONE literal argument, and the touch/echo must
// NOT have executed as shell commands.
check('injection stays one argument', got3[5] === 'x; touch /tmp/dsh-injected; echo PWNED', JSON.stringify(got3))
// The bash loop prints the arg verbatim, so PWNED appears in the ARG text —
// the real assertion is that NO extra output line was produced (which would
// mean the `;` broke out and echo executed).
check('no extra output line from executed echo', got3.length === 6, `got ${got3.length} lines`)
// ensure no file was created by the injection attempt
const { existsSync } = await import('node:fs')
check('no /tmp/dsh-injected file created', !existsSync('/tmp/dsh-injected') && !existsSync('C:/tmp/dsh-injected'))
console.log('4. double quotes and backticks are inert')
const backtick = ['python', '-m', 'skillopt_sleep', 'run', '--preferences', 'echo `id` $(whoami) "x"']
const got4 = bashRoundtrip(backtick)
check('backticks/dollar stay literal', got4[5] === 'echo `id` $(whoami) "x"', JSON.stringify(got4))
console.log('5. empty and numeric values')
const mixed = ['python', '-m', 'skillopt_sleep', 'run', '--max-tasks', '40', '--hour', '3']
const got5 = bashRoundtrip(mixed)
check('numbers intact', got5[5] === '40' && got5[7] === '3', JSON.stringify(got5))
console.log(failures === 0 ? '\nALL BASH QUOTING CHECKS PASSED' : `\n${failures} CHECK(S) FAILED`)
process.exit(failures === 0 ? 0 : 1)
+121
View File
@@ -0,0 +1,121 @@
---
name: skillopt-sleep
description: "Use when the user wants the dsh agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, skill/memory consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule background self-optimization. Drives the skillopt_sleep engine through the skillopt_* tools: harvest past sessions -> mine recurring tasks -> replay via a selected backend -> consolidate validated skills behind a held-out gate."
---
# SkillOpt-Sleep: usage-driven self-evolution for the dsh agent
SkillOpt-Sleep is Microsoft's [SkillOpt](https://github.com/microsoft/SkillOpt)
deployment-time companion engine: it reviews your past sessions (harvest), mines
recurring tasks (mine), replays them through a selected backend (replay), and
consolidates what it learns into skill documents behind a **held-out validation
gate** (consolidate).
This skill drives the engine through the 7 `skillopt_*` tools exposed by the
dsh-skillopt plugin. The default `mock` backend makes no model calls, which is
useful for verifying the plumbing; a real backend consumes your API budget.
## When to use
- "make my agent better the more I use it" / "learn my preferences across sessions"
- a one-off **offline self-evolution / sleep / dream** run (immediate or scheduled)
- review past sessions/trajectories and distill recurring tasks
- consolidate feedback into `AGENTS.md` / `SKILL.md` / managed skills
- schedule (cron) the cycle, or adopt a staged proposal
## The cycle (six stages)
1. **Harvest** — read-only scan of supported local session records → digests
2. **Mine** — digests → recurring task records (intent + outcome labels + checkable refs)
3. **Replay** — re-run tasks under the current skill+memory with the selected backend → (hard, soft) scores
4. **Consolidate** — reflect on failures → propose bounded edits → **validation gate** on a held-out slice (default: accept only on strict improvement)
5. **Stage** — write accepted proposals to `<project>/.skillopt-sleep/staging/<timestamp>/`. **Live files are unchanged.** A rejected run still has a report but no proposal files.
6. **Adopt** — explicit (or operator-configured `--auto-adopt`) copies staged files over live ones, backing up first.
## Driving it
Prefer the tools over hand-editing files:
| Tool | Behavior |
|---|---|
| `skillopt_status` | state, engine availability, latest staged proposal & report |
| `skillopt_dry_run` | full preview (harvest+mine+replay), stages nothing |
| `skillopt_run` | full cycle, stages a proposal (live files unchanged by default) |
| `skillopt_adopt` | apply latest staged proposal (with backup) — the live-change boundary |
| `skillopt_harvest` | read-only show/export of mined tasks |
| `skillopt_schedule` / `skillopt_unschedule` | install/remove the nightly cron entry for this project |
Typical flow:
```text
# 1. check state (default mock backend, zero cost)
skillopt_status
# 2. preview the cycle
skillopt_dry_run project=<dir> source=<claude|codex|…>
# 3. real run (consumes the selected backend's API budget)
skillopt_run project=<dir> backend=<codex|claude|…> preferences="Prefer pytest; keep commits imperative."
# 4. review the report, then adopt
skillopt_adopt project=<dir>
# 5. schedule nightly at 03:17
skillopt_schedule project=<dir> hour=3 minute=17 backend=<codex>
```
## Parameters
| Parameter | Default | Meaning |
|---|---|---|
| `project` | config or cwd | project directory to evolve |
| `backend` | `mock` | `mock\|claude\|codex\|copilot\|cursor\|pi\|opencode\|handoff\|azure_openai` (mock = no model calls) |
| `source` | config | transcript source: `claude\|codex\|copilot\|cursor\|pi\|opencode\|auto` |
| `model` | backend default | replay model override |
| `maxTasks` | 40 | mined-task cap |
| `preferences` | empty | house rules for the reflection prior (e.g. "always use async/await") |
## Configuration (cordis.yml / bundle patch)
```yaml
- insert:
- id: skillopt
name: './src/index.js'
config:
backend: codex
project: /path/to/project
preferences: 'Always use async/await'
# auto-adopt is OPERATOR-ONLY — the model cannot set it
autoAdopt: false
```
Advanced engine keys go in `~/.skillopt-sleep/config.json`:
`gate_mode` (on/off), `gate_metric` (hard/soft/mixed), `gate_no_regression`,
`dream_rollouts`, `recall_k`, `evolve_memory` / `evolve_skill`.
## Hard rules
- **Never** hand-edit `AGENTS.md` / `SKILL.md` around `skillopt_adopt`; let the
engine's explicit adopt (or operator-configured `--auto-adopt`) apply the
staging manifest, backing up live files first.
- Harvest is read-only; `mock` replay has no side effects.
- Real backends send truncated transcript excerpts and derived tasks to the
selected provider for mining/replay/judging/reflection. For sensitive
sessions, export tasks first (`skillopt_harvest output=<file>`), redact, set
the top-level `"reviewed"` to `true`, then replay with `--tasks-file`; real
backends refuse unreviewed task files.
- Show the user the **held-out baseline → candidate** score and the exact
proposed edits before suggesting adoption. Evidence before adoption.
## Validate / demo (no API spend)
```bash
pip install skillopt
python -m skillopt_sleep.experiments.run_experiment --persona researcher --assert-improves
```
Deterministic synthetic demo: the score rises and the gate blocks a regression.
It validates the mechanism, not effectiveness on your own tasks.
See the [SkillOpt-Sleep docs](https://github.com/microsoft/SkillOpt/tree/main/docs/sleep)
for recorded results and limitations.
+394
View File
@@ -0,0 +1,394 @@
// dsh-skillopt — Microsoft SkillOpt-Sleep integration for DeepSeek Harness.
//
// Gives the dsh agent a "sleep cycle": harvest past sessions -> mine recurring
// tasks -> replay via a backend -> consolidate validated skills behind a
// held-out gate. The heavy lifting is done by the upstream `skillopt_sleep`
// Python engine (https://github.com/microsoft/SkillOpt); this plugin exposes
// it to the agent as native dsh tools, plus a skill and configuration.
import Schema from '@deepseek-ai/schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
export const name = 'skillopt'
// Plugin directory (absolute) — used to resolve the bundled engine script so
// it works regardless of the dsh process cwd.
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)) + '/..'
// Exported for the canary test (scripts/canary.mjs).
export { buildArgv, quoteArgv }
// Wait for the tool registry and the shell executor before applying.
export const inject = ['tools', 'shell']
// ---------------------------------------------------------------------------
// Config (Schemastery)
// ---------------------------------------------------------------------------
export const Config = Schema.object({
pythonCmd: Schema.string()
.default('python')
.description('Python interpreter used to run the engine bootstrap (scripts/sleep.py)'),
module: Schema.string()
.description('Override: run `python -m <module>` directly instead of the bootstrap script'),
engineScript: Schema.string()
.description('Override: path to the engine bootstrap script (default: scripts/sleep.py)'),
project: Schema.string()
.description('Default project directory for sleep cycles'),
scope: Schema.union(['all', 'invoked']).description('Harvest scope'),
backend: Schema.union([
'mock', 'claude', 'codex', 'copilot', 'cursor', 'pi', 'opencode',
'handoff', 'azure_openai',
]).description('Default backend (mock = no provider calls)'),
model: Schema.string().description('Default backend model override'),
source: Schema.union([
'claude', 'codex', 'copilot', 'cursor', 'pi', 'opencode', 'auto',
]).description('Default transcript source'),
maxTasks: Schema.number().description('Cap mined tasks (default 40)'),
maxSessions: Schema.number().description('Cap harvested sessions'),
editBudget: Schema.number().description('Max bounded edits per cycle (default 4)'),
preferences: Schema.string().description('House rules injected into the reflection prior'),
jsonOutput: Schema.boolean().default(false).description('Emit machine-readable JSON where supported'),
autoAdopt: Schema.boolean()
.default(false)
.description('OPERATOR-ONLY: auto-adopt a passed proposal without asking. The model cannot toggle this; set it in cordis.yml.'),
timeoutMs: Schema.number()
.default(600_000)
.description('Per-call engine timeout in milliseconds (default 10 min)'),
unscheduleAll: Schema.boolean()
.default(false)
.description('OPERATOR-ONLY: allow skillopt_unschedule to remove every managed entry (--all). The model cannot set this.'),
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Quote one argv element for a POSIX shell (bash). Single quotes are literal;
// an embedded single quote is expressed as '\'' (close quote, escaped quote,
// reopen quote) — the only portable POSIX spelling. PowerShell is not a target
// here: dsh's ctx.shell executes via `bash -c` (LocalBashExecutor), so the
// quoting only needs to be bash-correct.
//
// Control characters are stripped as defense in depth: \r and \r\n inside a
// single-quoted word would otherwise split the value into multiple argv words
// (broken command, not RCE — quotes never execute), and \n would corrupt the
// engine's own arg parsing. Model-controlled values must arrive as exactly
// one argument.
function q(value) {
const s = String(value).replace(/[\r\n\u0000-\u001f\u007f]/g, ' ')
return `'${s.replace(/'/g, "'\\''")}'`
}
/**
* Build the argv array for the engine with config defaults and per-call
* overrides. Returns an ARRAY (not a joined string); execute() quotes each
* element and lets shell.resolve() apply workdir/output-cap/sandbox defaults.
*
* The engine is invoked through scripts/sleep.py, which mirrors the official
* SkillOpt runner: it resolves a source checkout (repo root), picks a
* Python >= 3.10, and falls back to the `skillopt-sleep` CLI or an installed
* package. `config.module` still works as a direct `python -m <module>` escape
* hatch for users who prefer it.
*/
function buildArgv(config, action, explicit = {}, extras = [], allowed = null) {
const parts = [config.pythonCmd || 'python']
if (config.module) {
// explicit escape hatch: python -m <module>
parts.push('-m', config.module)
} else {
// default: the bundled bootstrap mirrors the official runner; resolve it
// absolutely so it works no matter what cwd dsh was started from.
parts.push(config.engineScript || join(PLUGIN_DIR, 'scripts', 'sleep.py'))
}
parts.push(action)
const push = (flag, value) => {
if (value !== undefined && value !== null && value !== '') parts.push(flag, String(value))
}
const has = (v) => v !== undefined && v !== null && v !== ''
// `allowed` is the tool's declared parameter set (null = everything, the
// pre-whitelist behavior). Both the model-supplied value AND the operator
// config default are gated on it, so a tool like skillopt_adopt (declares
// only `project`) never receives --backend/--model/--json/… from either
// source — the config default must not leak into tools that do not declare
// the key.
const permits = (key) => !allowed || allowed.includes(key)
const withDefault = (key, flag) => {
if (!permits(key)) return
if (has(explicit[key])) push(flag, explicit[key])
else push(flag, config[key])
}
withDefault('project', '--project')
withDefault('scope', '--scope')
withDefault('source', '--source')
withDefault('backend', '--backend')
withDefault('model', '--model')
withDefault('maxTasks', '--max-tasks')
withDefault('maxSessions', '--max-sessions')
withDefault('editBudget', '--edit-budget')
withDefault('preferences', '--preferences')
if (permits('json') && (config.jsonOutput || explicit.json)) parts.push('--json')
parts.push(...extras)
return parts
}
/** Join argv with safe quoting for the platform shell. */
function quoteArgv(argv) {
return argv.map(q).join(' ')
}
// Pick exactly the parameters a tool declares. dsh's parameter schema does
// not reject undeclared properties by default (no additionalProperties:false),
// so without this filter the model could inject fields (backend, model, json,
// editBudget, …) that buildArgv would forward to the engine — crossing the
// per-tool surface and, for skillopt_adopt, the live-change boundary. Each
// tool's build() must pass through exactly its declared keys.
function pick(obj, keys) {
const out = {}
for (const key of keys) {
if (obj[key] !== undefined) out[key] = obj[key]
}
return out
}
// Value-domain guard for model-supplied path-like strings.
//
// argv-level quoting (quoteArgv) protects the dsh `bash -c` boundary, but the
// engine re-interpolates these values into its OWN shell/command strings:
// scheduler.py builds `--project "{project}"` inside a crontab line and a
// Windows run.cmd executed by schtasks, and write_tasks_file() turns an
// arbitrary `output` into a file write (abspath + makedirs + overwrite). A
// model-controlled value containing `"`, `&`, `;`, `|`, `$`, backticks or
// other shell metacharacters would break out of that splice and execute as a
// separate command under the scheduler's shell, or overwrite an arbitrary
// file. Legitimate paths contain letters, digits, spaces, and `- _ . / \ :`
// only — reject everything else up front.
const UNSAFE_PATH = /["'&;|$`<>()\[\]{}*\u0000-\u001f\u007f]/
/** Throws on a path-like value carrying shell metacharacters. */
function assertSafePath(value, what) {
if (value === undefined || value === null || value === '') return
if (UNSAFE_PATH.test(String(value))) {
throw new Error(
`[skillopt] ${what} rejected: contains shell metacharacters (` +
`" ' & ; | $ \` < > ( ) [ ] { } * or control chars). ` +
`Use a plain directory/file path.`,
)
}
}
/**
* Reject an output path that could write outside the working area:
* absolute paths and `..` traversal are refused; only a bare relative
* file name (or a simple relative path) is accepted.
*/
function assertSafeOutput(value) {
if (value === undefined || value === null || value === '') return
const s = String(value)
assertSafePath(s, 'output path')
if (s.startsWith('/') || s.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(s) || s.includes('..')) {
throw new Error(
`[skillopt] output path rejected: absolute paths and ".." traversal are not allowed; ` +
`give a relative file name (e.g. "tasks.json").`,
)
}
}
/**
* Range guard for schedule's clock parameters. The engine does not validate
* hour/minute itself and splices them straight into a crontab line and a
* schtasks start time; an out-of-range value (99, -1, …) would create a
* broken scheduled-task entry. Reject anything outside the real clock.
*/
function assertSafeClock(value, what, min, max) {
if (value === undefined || value === null || value === '') return
const n = Number(value)
if (!Number.isInteger(n) || n < min || n > max) {
throw new Error(
`[skillopt] ${what} rejected: must be an integer in [${min}, ${max}], got ${JSON.stringify(value)}.`,
)
}
}
function renderOutput(_args, value) {
return [{ type: 'text', text: value }]
}
// ---------------------------------------------------------------------------
// Plugin entry
// ---------------------------------------------------------------------------
export function apply(ctx, config = {}) {
const shell = ctx.shell
const tools = [
{
name: 'skillopt_status',
description:
'Show SkillOpt-Sleep state: engine availability, latest staged proposal, last run report.',
parameters: {
project: { type: 'string', description: 'Project directory (defaults to config.project or cwd)' },
json: { type: 'boolean', description: 'Emit machine-readable JSON' },
},
build: (a) => buildArgv(config, 'status', pick(a, ['project', 'json']), [], ['project', 'json']),
},
{
name: 'skillopt_dry_run',
description:
'Preview a full sleep cycle without staging anything: harvest, mine, replay, report.',
parameters: {
project: { type: 'string', description: 'Project directory' },
source: { type: 'string', description: 'Transcript source: claude|codex|copilot|cursor|pi|opencode|auto' },
backend: { type: 'string', description: 'Backend: mock|claude|codex|copilot|cursor|pi|opencode|handoff|azure_openai' },
model: { type: 'string', description: 'Backend model override' },
maxTasks: { type: 'number', description: 'Cap mined tasks (default 40)' },
progress: { type: 'boolean', description: 'Print phase progress to stderr' },
},
build: (a) => buildArgv(config, 'dry-run', pick(a, ['project', 'source', 'backend', 'model', 'maxTasks']), a.progress ? ['--progress'] : [], ['project', 'source', 'backend', 'model', 'maxTasks']),
},
{
name: 'skillopt_run',
description:
'Run the full sleep cycle and stage a proposal. Nothing live changes until skillopt_adopt.',
parameters: {
project: { type: 'string', description: 'Project directory' },
backend: { type: 'string', description: 'Backend for model calls' },
source: { type: 'string', description: 'Transcript source' },
preferences: { type: 'string', description: 'House rules for the reflection prior' },
progress: { type: 'boolean', description: 'Print phase progress to stderr' },
},
build: (a) => {
const extra = []
// auto-adopt is OPERATOR-ONLY (config.autoAdopt); the model cannot set it.
if (config.autoAdopt) extra.push('--auto-adopt')
if (a.progress) extra.push('--progress')
return buildArgv(config, 'run', pick(a, ['project', 'backend', 'source', 'preferences']), extra, ['project', 'backend', 'source', 'preferences'])
},
},
{
name: 'skillopt_adopt',
description:
'Apply the latest staged proposal, backing up existing target files first. This is the live-change boundary.',
parameters: {
project: { type: 'string', description: 'Project directory' },
},
build: (a) => buildArgv(config, 'adopt', pick(a, ['project']), [], ['project']),
},
{
name: 'skillopt_harvest',
description:
'Harvest past sessions and show or export mined recurring tasks. Read-only.',
parameters: {
project: { type: 'string', description: 'Project directory' },
source: { type: 'string', description: 'Transcript source' },
output: { type: 'string', description: 'Export tasks JSON to this file' },
maxTasks: { type: 'number', description: 'Cap mined tasks' },
},
build: (a) => {
const extra = []
if (a.output) extra.push('--output', a.output)
return buildArgv(config, 'harvest', pick(a, ['project', 'source', 'maxTasks']), extra, ['project', 'source', 'maxTasks'])
},
},
{
name: 'skillopt_schedule',
description:
'Install a nightly cron entry that runs the sleep cycle for this project.',
parameters: {
project: { type: 'string', description: 'Project directory' },
hour: { type: 'number', description: 'Hour (0-23, default 3)' },
minute: { type: 'number', description: 'Minute (default 17)' },
backend: { type: 'string', description: 'Backend for scheduled runs' },
},
build: (a) => {
const extra = []
if (a.hour !== undefined) extra.push('--hour', String(a.hour))
if (a.minute !== undefined) extra.push('--minute', String(a.minute))
return buildArgv(config, 'schedule', pick(a, ['project', 'backend']), extra, ['project', 'backend'])
},
},
{
name: 'skillopt_unschedule',
description:
'Remove the nightly cron entry for this project.',
parameters: {
project: { type: 'string', description: 'Project directory' },
},
build: (a) => buildArgv(config, 'unschedule', pick(a, ['project']), config.unscheduleAll ? ['--all'] : [], ['project']),
},
]
for (const t of tools) {
ctx.tools.register(
defineTool({
name: t.name,
description: t.description,
parameters: t.parameters,
output: { schema: { type: 'string' }, render: renderOutput },
async execute(args, exec) {
const a = args || {}
// Value-domain guard BEFORE building argv: project paths reach the
// engine's own shell/crontab/schtasks string interpolation and the
// filesystem; output writes a file. Model-controlled values with
// shell metacharacters (or output escaping the working area) are
// rejected here, never forwarded.
try {
assertSafePath(a.project, 'project')
assertSafeOutput(a.output)
// schedule clock params: the engine splices them into crontab /
// schtasks verbatim, so keep them inside the real clock range.
assertSafeClock(a.hour, 'hour', 0, 23)
assertSafeClock(a.minute, 'minute', 0, 59)
} catch (err) {
return `[skillopt ${t.name}] ${err.message}`
}
const argv = t.build(a)
// `command` must be the shell-quoted form for the platform executor;
// resolve() applies the executor's workdir/output-cap/sandbox defaults.
const request = {
command: quoteArgv(argv),
timeoutMs: config.timeoutMs,
signal: exec?.signal,
}
const spec = typeof shell.resolve === 'function' ? shell.resolve(request) : request
try {
const result = await shell.run(spec)
// Distinguish the executor's timeout (timedOut: true, exitCode null)
// from an abort/kill (exitCode null, no timedOut) so the marker is
// honest instead of lumping both under "signal".
const status = result?.timedOut
? 'timeout'
: (result?.exitCode ?? 'signal')
// rc.8 returns stdout/stderr as CollectedOutput { text, truncated, spillPath }
const fmt = (co) => {
if (co === undefined || co === null) return ''
if (typeof co === 'string') return co
const parts = []
if (co.text) parts.push(co.text)
if (co.truncated) {
parts.push(`[truncated${co.spillPath ? ` — full output at ${co.spillPath}` : ''}]`)
}
return parts.join('\n')
}
const stdout = fmt(result?.stdout)
const stderr = fmt(result?.stderr)
const tail = [stdout, stderr].filter(Boolean).join('\n').trim()
// Do NOT slice here: fmt() already carries the executor's truncation
// marker + spill path when output was capped. A second slice would
// hide data the executor already bounded and contradict the marker.
return [
`[skillopt ${t.name}] exit=${status}`,
tail ? tail : '(no output)',
].join('\n')
} catch (err) {
return `[skillopt ${t.name}] engine call failed: ${err?.message || String(err)}`
}
},
}),
)
}
ctx.logger?.info?.('[dsh-skillopt] registered 7 skillopt tools (status/dry-run/run/adopt/harvest/schedule/unschedule)')
}
+1
View File
@@ -14,6 +14,7 @@ PLUGIN_SKILL_MDS = {
"claude-code": os.path.join(REPO, "plugins/claude-code/skills/skillopt-sleep/SKILL.md"),
"codex": os.path.join(REPO, "plugins/codex/skills/skillopt-sleep/SKILL.md"),
"cursor": os.path.join(REPO, "plugins/cursor/skills/skillopt-sleep/SKILL.md"),
"dsh": os.path.join(REPO, "plugins/dsh/skills/skillopt-sleep/SKILL.md"),
"openclaw": os.path.join(REPO, "plugins/openclaw/SKILL.md"),
}