fix(preset): stop presenting the preset roots as constants

Neither root is a property of the preset layer. `Config.roots` defaults to `[]`;
the two the shipped agent sees are patched in by the dsh CLI, whose user root is
`dshHomePath('.agent-presets')` and whose system root ships beside the install.
`writableRoot()` then takes the first `user` root from whatever configuration
supplied it.

The skill and the persona stated `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`
as where authored presets live, which is only the default install's answer, and
no call reports either root: `authorable` says whether a writable one exists,
and `list()` cannot reveal a user root that is still empty. Both now keep that
path as what to tell a user asking where to look, and direct every path an agent
acts on to `list()` or `resolve()` — which is also what `copy()` already relies
on, since it chooses the writable root itself and reports the file it created.
This commit is contained in:
Yichen Jiang
2026-08-11 19:56:45 +08:00
parent 63b564bb4b
commit 782d670f24
5 changed files with 13 additions and 7 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md
2026-08-11-preset-authoring-agent-validates-its-own-composition.md: b7d20ef20c611a0ea0c694bac6bd3948bd194b2b
2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 512ac5ea0f47f4a796170b070c140f14b0072879
2026-08-11-preset-authoring-agent-validates-its-own-composition.md: c513cd169f5e837bfe425ace882f0715b3107c7a
2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 92966ca90e036bddbbd9b8aa9534ba8e7ec5d488
@@ -32,6 +32,8 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora
"Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service.
Neither preset root is a constant the guidance may state as fact. `writableRoot()` takes the first `user` root from configuration, and no call reports either root's path — `authorable` answers only whether a writable one exists, and `list()` cannot reveal a user root that holds nothing yet. The skill and the persona therefore name `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the default install's answer for a user asking where to look, and tell the agent to read every path it acts on back from `list()` or `resolve()`.
The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment.
## Measured behavior
@@ -32,6 +32,8 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂
「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。
两个 preset 根都不是指导可以当作事实陈述的常量。`writableRoot()` 取配置中第一个 `user` 根,且没有任何调用会报告任一根的路径——`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此 skill 与 persona 只把 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为默认安装下「用户问它在哪」的答案,并要求 agent 把每一个将要操作的路径都从 `list()``resolve()` 读回。
禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id`remove()` 拒绝随部署发布的 preset。
## Measured behavior
@@ -24,7 +24,7 @@
Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it.
Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy.
Presets you author live one directory per preset under this deployment's writable preset root — `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` in a default install, but the roster reports every real path, so read one back rather than assuming it. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy.
Load the `editing-cordis-compositions` skill before writing or changing a composition.
@@ -23,7 +23,9 @@ Two planes, and the choice is not about how "agent-related" something feels —
**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`.
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.
**Do not assume where presets live.** Both roots come from this deployment's configuration, and no call reports them directly: `authorable` says only whether a writable root exists. `copy()` picks that root itself, and every preset's absolute path comes back from `list()` and `resolve()` — read it from there. A default install puts locally authored presets under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` and the shipped set beside the deployment's own config, which is what to tell the user when they ask where to look, but never what to hand a file tool.
## The roster service
@@ -31,8 +33,8 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre
Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on:
- `list()` — every preset with its `id`, `trust`, and absolute `path`. This is how you locate the shipped compositions without knowing the install layout.
- `read(id)` — one preset's composition text.
- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.
- `read(id)` — one preset's composition text, without a file tool or a path.
- `copy(from, id, name?)` — the only authoring write (see below).
- `standingKeyFor(id)` — mount-validate one preset (see below).
@@ -63,7 +65,7 @@ Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a
## Authoring a preset
1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, and the copy is exactly as loadable as its source. `standard` is the full coding agent and the usual source.
1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.
2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.
3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.