dsh-task-workspace
DSH task workspace convention: one folder per task, one PROGRESS.md per task (overwritten each update), and per-task git once a task runs long.
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 2, 2026
- Updated
- Oct 4, 2026
Introduction
dsh-task-workspace
给 DSH 加一条落得下地的工程约定:一个会话只用一个文件夹,一个任务一个进度文件(每次修改整体覆盖更新),任务过长就在任务目录里建 git 仓库。
A DSH bundle that makes one convention stick: one folder per session, exactly one progress file per task (overwritten on every update), and per-task git once the task runs long.
- 中文文档:README.zh.md
- English docs: this file
What it does
| Piece | Effect |
|---|---|
Prompt section task-workspace | The convention is present in every turn, always, in every workspace |
Skill task-workspace-convention | The same convention, discoverable through the skill catalog |
Tool task_new | Creates <workspace>/<name>-<YYYYMMDD>/PROGRESS.md (optionally git init); a session that already owns a folder reuses it (reused: true) instead of creating a second one |
Tool task_progress | Whole-file overwrite of that one progress file; auto-compacts long summaries; optionally commits |
Tool task_git | git init + .gitignore + first commit, or a follow-up commit |
Tool task_list | What task folders already exist in this workspace |
Tool task_tidy | Classifies every top-level entry, rewrites the workspace index, and clears OS litter / caches / stale backups / empty folders / stray task folders (dry run unless apply: true) |
CLI dsh-task | The same operation without a model |
Install
# from a profile (web / desktop / cli …)
dsh plugin --profile desktop add dsh-task-workspace
Or install from a local checkout:
dsh plugin --profile desktop add /absolute/path/to/dsh-task-workspace
Then restart the profile (or start a new session) so the bundle patch is composed.
The on-disk shape
<workspace>/
├── .dsh-tasks.json # small task index, rebuilt/updated on write
├── 竞品调研-20261002/
│ ├── PROGRESS.md # the ONLY progress file of this task
│ ├── .dsh-session.json # which session owns this folder
│ ├── .git/ # only when the task ran long
│ └── data/… # task output
└── etl-refactor-20261003/
└── PROGRESS.md
One session owns at most one folder here. .dsh-session.json records the owner;
task_new from the same session reuses that folder (reused: true, the original
name is kept) and the workspace index carries the same sessionId as a fallback.
PROGRESS.md always has the same six sections: 目标 / 状态 / 当前进度 / 下一步 / 产出物 / 变更日志
(goal / status / current progress / next steps / deliverables / changelog).
Tools
task_new
{ "name": "竞品调研", "goal": "整理三家竞品的定价与功能差异", "init_git": true }
- Creates
竞品调研-<today>/and itsPROGRESS.md. - Idempotent for the same day: calling it again returns the existing task.
- One folder per session: when this session already owns a task folder in the
workspace, that folder is reused (
reused: true, folder name unchanged) rather than creating a second one — every change of this session stays inside it. force: trueadopts an existing non-empty folder of the same name.slugoverrides the folder slug.
task_progress
{
"task_dir": "竞品调研-20261002", // or "task": "竞品"
"summary": "已抓取 3 家官网定价页,A 家缺 API 价目",
"changelog": "完成定价页抓取",
"status": "进行中",
"next": "补齐 A 家 API 价目\n输出对比表",
"deliverables": "data/pricing.csv"
}
- Overwrites the
当前进度section instead of appending to it. A summary longer than ~18 lines is automatically compacted into a### 进度历史(压缩摘要)block plus a collapsible<details>archive, so nothing is lost and the file stays readable. - Appends one line to
变更日志(history is never rewritten). - Commits inside the task repository when one exists (
commit: falseto skip, or setautoCommit: falsein the config). - Passing nothing is an error, so the file never gets a meaningless touch.
task_git
{ "task_dir": "竞品调研-20261002", "message": "chore: 初始化任务仓库" }
Initializes the repository with a .gitignore (.DS_Store, __pycache__/, node_modules/,
.venv/, *.tmp) and commits; a second call just commits the current state.
task_list
Lists the task folders of the current workspace with status, last update time and whether the task has its own repository.
task_tidy
Tidies the workspace the convention created it in:
- classifies every top-level entry as 任务 / 产出 / 中间产物 / 工具 / 配置 / 散落文件;
- rewrites the index file (
工作区索引.mdby default) — a table per category with size, file count, last change, task status and goal; - reports the heavy intermediate folders (over 200 MB) without touching them;
- dry run unless
apply: true, and even then it only removes provably disposable things:.DS_Storeand friends,__pycache__/.pytest_cache,*.pyc/*.bak/*.orig/*.tmp/*.tgz, and a task folder that landed outside the workspace holding nothing but its ownPROGRESS.md(the "wrong cwd" accident) — never task content, never a README. - empty folders are listed but not deleted (an empty
parts/may be a placeholder a build script expects); passempty_dirs: trueto remove them too.
{ "workspace": "/path/to/workspace", "apply": true, "index": true, "empty_dirs": false }
The index is regenerated on every run and is meant to be read, not edited.
CLI
dsh-task new "竞品调研" "整理定价差异" --git
dsh-task progress 竞品调研 --summary "抓到 3 家" --changelog "完成抓取"
dsh-task git 竞品调研 --message "chore: 初始化任务仓库"
dsh-task list
dsh-task config --root "/path/to/workspace"
Exit codes: 0 ok, 1 usage error, 2 operation failed. Human-readable output goes to
stderr, the resulting path to stdout (so cd "$(dsh-task new …)" works).
Configuration
Resolution order for the workspace root: tool argument → config workspaceRoot →
$DSH_TASK_WORKSPACE_ROOT → $DSH_WORKSPACE_ROOT → the session's workspace directory →
$DSH_HOME. An explicit root is created when missing; node_modules/, /, the temp
directory and $HOME itself are refused.
The config file lives at ${DSH_HOME}/dsh-task-workspace/config.json:
{
"enabled": true,
"workspaceRoot": "",
"defaultGit": false,
"autoCommit": true,
"sectionOrder": 61,
"promptEnabled": true,
"skillEnabled": true,
"template": "",
"gitAuthor": { "name": "", "email": "" }
}
defaultGit: true— every new task gets a repository right away.promptEnabled: false/skillEnabled: false— keep the tools, drop the injected text.template— path-free inline markdown template forPROGRESS.md.gitAuthor— identity used forgit -c user.name=… -c user.email=…, for machines with no global git identity ($DSH_TASK_GIT_NAME/$DSH_TASK_GIT_EMAILalso work).
A profile can override the same keys through its own patch layer:
- id: task-workspace
name: 'dsh-task-workspace'
config:
workspaceRoot: /Users/me/notes
defaultGit: true
When to create a repository
The convention's trigger is deliberately explicit — any one of:
- more than ~3 turns expected,
- must continue across sessions,
- more than ~10 produced files,
- the user asks for it.
Development
node test/run.mjs # 47 checks: store, tools, real git, CLI, host JSON Schema subset, session reuse + session-cwd guards — no network
node test/run.mjs --keep # keep the temporary workspace for inspection
License
MIT
Profiles owned by another application
dsh plugin --profile desktop add … refuses to run because the Electron app owns that
profile. scripts/install-into-profile.sh does the two things that command would have
done, and nothing else:
bash scripts/install-into-profile.sh desktop --dry-run # show the plan
bash scripts/install-into-profile.sh desktop # link + append to bundles
It links the package into <profile>/node_modules and appends the package name to
dsh.profile.bundles, editing only that array in place so the rest of package.json
stays byte-identical (the app rewrites that file itself). A package.json.bak is written
before the first change. Restart the profile afterwards.
Runtime gotchas this plugin already handles
Both were only visible on a real boot, not in a stubbed unit test — worth knowing if you fork this plugin:
injectmust hang off the exported value.cordis-plugin-loadernormalizes module shapes withexports.default ?? exports, so a namespace-levelinjectexport is dropped as soon as the module has a default export. A bareexport const inject = […]next toexport default applygives youError: cannot get property "systemPrompt" without inject. Attach it to the function:apply.inject = ['tools', 'systemPrompt'].requiredis an object-level keyword. The host validates tool output schemas against its own JSON Schema subset (type/oneOf/properties/required/additionalProperties/items/enum/constplus annotations) and rejectsrequired: trueon a scalar property withJsonSchemaError: schema.properties.x.required is not supported on type "string". Put the list on the object:{ type: 'object', required: ['dir'], properties: { dir: { type: 'string' } } }.