dsh-root-planner-preset 1.1.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INSTALL.md CHANGED
@@ -44,6 +44,26 @@ dsh plugin --profile web add dsh-root-planner-preset
44
44
 
45
45
  > 也可以钉版本:`dsh plugin --profile web add dsh-root-planner-preset@1.0.0`
46
46
 
47
+ ### 已内置:子代理工作区围栏
48
+
49
+ 预设自带一个守卫行,**不需要另装**。它把"子代理只写自己的 `_agents/` 目录"从人设规定变成**调用级拒绝**:
50
+
51
+ - 子代理 `write` / `edit` / `ops_replace` 的目标路径落在 `_agents/` 之外 → **调用被拒,代码根本没跑**;
52
+ - 子代理的 shell 调用 `workdir` 不在工作区内、或命令里写着指向你文件的绝对路径 → **被拒**;
53
+ - 根 agent **不受限制**(它必须能在自己工作区任意位置写,才能把产物并入)。
54
+
55
+ 被告拒时它收到的是一句说明边界的错误,例如:
56
+
57
+ ```
58
+ write: outside your workspace. You may write only inside "...\_agents"; everything else
59
+ in the parent's directory is read-only to you. Send your parent a change request instead
60
+ of writing "...\report.md" directly.
61
+ ```
62
+
63
+ **一个仍然存在的限制**:shell 命令能在运行时拼出路径(`$env:X` 拼 `'..'`、脚本内部写、Here-String),参数扫描看不见。要真正关住子代理的 shell 需要给它更窄的沙箱写域,而 DSH 给不了(`sandboxPolicy.resolve()` 只接受 `{session, mode}`,`workspaceRoot` 恒等于不可变的会话 cwd)。所以父 agent 的时间戳核验仍是兜底——人设里已经写了。
64
+
65
+ > 守卫以**预设行**形式挂载,所以只有用 `root-planner` 预设的 agent 才受约束:`_agents/` 这套约定只有这个预设的人设定义过。
66
+
47
67
  ### 方式 2:GitHub 源码
48
68
 
49
69
  ```sh
package/README.md CHANGED
@@ -36,13 +36,27 @@ the root decides which to copy in — merging into an existing file where that i
36
36
  change means, keeping them separate where it is not. `_agents/` stays (and belongs in
37
37
  `.gitignore`) so the same agent can be continued later.
38
38
 
39
- **The boundary is agreed, not enforced, because of the shell.** Write tools could be
40
- fenced structurally — a scoped plugin can shadow `write`/`edit`/`ops_replace` with
41
- path-checked versions, since scoped registrations shadow globals — but a shell writes
42
- whatever the sandbox allows, and a child cannot be given its own cwd: `AgentOptions`
43
- carries no `cwd`, the spawn provider's config has only `providerName`, and the one
44
- `cwd` override is static per plugin. So the read-only side rests on the persona, and
45
- the root **checks** it:
39
+ **The boundary is enforced, not just agreed.** This preset ships the fence as its own
40
+ plugin row (`dsh-root-planner-preset/guard`), so one install carries the protocol and its
41
+ enforcement together. It refuses a `write`, `edit` or `ops_replace` whose target resolves
42
+ outside `<workspace>/_agents/`, and refuses a shell call whose `workdir` is outside it or
43
+ whose command names an absolute path into your files:
44
+
45
+ ```
46
+ write: outside your workspace. You may write only inside "...\_agents"; everything else
47
+ in the parent's directory is read-only to you. Send your parent a change request instead
48
+ of writing "...\report.md" directly.
49
+ ```
50
+
51
+ The root session is never fenced: it owns the workspace and has to be able to write
52
+ anywhere in it to integrate a child's artifacts.
53
+
54
+ One limitation survives, and it is worth knowing before you rely on this: a shell command
55
+ can build a path at runtime (`$env:X` + `'..'`, a script that writes, a Here-String), and
56
+ no argument scan can see it. Confining a child's shell properly would need a narrower
57
+ sandbox write root, which DSH cannot give a child — `sandboxPolicy.resolve()` accepts only
58
+ `{ session, mode }` and derives `workspaceRoot` from the immutable, inherited
59
+ `session.header.cwd`. So keep the parent's check as the backstop:
46
60
 
47
61
  ```sh
48
62
  # before delegating
package/README.zh.md CHANGED
@@ -30,7 +30,17 @@
30
30
 
31
31
  并发问题是用**隔离**解决的,不是用协调。派活之前,根创建 `_agents/<label>/` 并把**绝对路径**写进任务;子代理**只往那里写**,父目录的文件随便读,要改就发申请。完成后它报告产物清单,由根决定复制哪些进自己的工作区——该合并进现有文件的就合并,该保持独立的就独立。`_agents/` 会保留(并应进 `.gitignore`),以便日后继续使用同一个子代理。
32
32
 
33
- **这条边界是约定,不是围栏,原因是 shell。** 写工具本来可以做成结构性围栏——插件能在 agent 作用域里用**同名工具盖掉全局**的 `write`/`edit`/`ops_replace`,在里面检查路径——但 shell 能写沙箱允许的任何位置,而**子代理没法拥有自己的 cwd**:`AgentOptions` 里没有 `cwd`,spawn 提供方的 config 只有 `providerName`,唯一的 `cwd` 覆盖是**插件加载时定死的静态值**。所以"只读"这一侧靠人设约定,由根**核验**:
33
+ **这条边界是强制执行的,不只是约定。** 预设自带围栏(自己的插件行 `dsh-root-planner-preset/guard`),一次安装同时得到协议和它的执行。它拒绝目标落在 `<工作区>/_agents/` 之外的 `write`、`edit`、`ops_replace`;也拒绝 `workdir` 在工作区之外的 shell 调用,以及命令里写着你文件的绝对路径:
34
+
35
+ ```
36
+ write: outside your workspace. You may write only inside "...\_agents"; everything else
37
+ in the parent's directory is read-only to you. Send your parent a change request instead
38
+ of writing "...\report.md" directly.
39
+ ```
40
+
41
+ 根会话**不受围栏限制**——它必须能在自己工作区任意位置写,才能把子代理的产物并入。
42
+
43
+ **有一个限制必须知道**:shell 命令能在运行时拼出路径(`$env:X` 拼 `'..'`、脚本内部写、Here-String),参数扫描看不见。要真正关住子代理的 shell,需要给它一个更窄的沙箱写域,而 DSH 给不了——`sandboxPolicy.resolve()` 只接受 `{ session, mode }`,`workspaceRoot` 恒等于不可变、从父会话继承的 `session.header.cwd`。所以父 agent 的核验仍然是兜底:
34
44
 
35
45
  ```sh
36
46
  # 派活前
package/build-patch.mjs CHANGED
@@ -226,6 +226,17 @@ const patch = `# Generated by build-patch.mjs from persona.txt. Do not hand-edit
226
226
  - id: tool-plugin-manager
227
227
  name: '@deepseek-ai/dsh-plugin-manager/tools'
228
228
  disabled: true
229
+ # The workspace fence. Mounted as a preset row so one install carries the
230
+ # protocol and its enforcement together: the persona tells a child to write
231
+ # under its own _agents/ directory, and this refuses anything else before
232
+ # the call runs. It is preset-scoped rather than a separate profile bundle
233
+ # because the _agents/ convention only exists here — a guard without this
234
+ # persona would fence agents that were never told where they may write.
235
+ - id: root-planner-guard
236
+ name: 'dsh-root-planner-preset/guard'
237
+ config:
238
+ directoryName: _agents
239
+ guardShell: true
229
240
  `;
230
241
 
231
242
  // Substitution happens after the template so the emitted indentation is owned by
package/cordis.patch.yml CHANGED
@@ -289,3 +289,14 @@
289
289
  - id: tool-plugin-manager
290
290
  name: '@deepseek-ai/dsh-plugin-manager/tools'
291
291
  disabled: true
292
+ # The workspace fence. Mounted as a preset row so one install carries the
293
+ # protocol and its enforcement together: the persona tells a child to write
294
+ # under its own _agents/ directory, and this refuses anything else before
295
+ # the call runs. It is preset-scoped rather than a separate profile bundle
296
+ # because the _agents/ convention only exists here — a guard without this
297
+ # persona would fence agents that were never told where they may write.
298
+ - id: root-planner-guard
299
+ name: 'dsh-root-planner-preset/guard'
300
+ config:
301
+ directoryName: _agents
302
+ guardShell: true
package/guard.js ADDED
@@ -0,0 +1,190 @@
1
+ // Keeps a delegated subagent inside the workspace its parent gave it.
2
+ //
3
+ // This module ships inside `dsh-root-planner-preset` and is mounted as one of the
4
+ // preset's own plugin rows (`dsh-root-planner-preset/guard`), so installing the preset
5
+ // brings the protocol and its enforcement together. That placement is deliberate: the
6
+ // `_agents/` convention is defined by this preset's persona, and a guard installed
7
+ // without it would fence agents that were never told where they may write.
8
+ //
9
+ // One guard, registered once on the plugin's own context, inspects every tool call
10
+ // and decides from the calling agent's session header. That is deliberately simpler
11
+ // than wiring per-agent listeners: `ToolExecution` already carries `agent`, so the
12
+ // guard needs neither the `agent/created` payload shape nor a per-agent context, and
13
+ // nothing has to be registered or torn down when a child appears or disappears.
14
+ //
15
+ // Why a guard rather than a prompt rule: `tools.guard()` is synchronous and monotonic.
16
+ // It refuses the call instead of asking the model not to make it, and no later guard
17
+ // can force-allow a call this one denied.
18
+ //
19
+ // Two strengths are in play and the difference is not cosmetic:
20
+ //
21
+ // * File tools carry the target path in their arguments, so a write, edit or
22
+ // replace outside `_agents/` is refused structurally — the call never runs.
23
+ // * Shell tools carry a command string, and a command can name a path no argument
24
+ // scan will see (`$env:HOME` + '..', a script that writes, a Here-String). For
25
+ // those the guard enforces the two things it can prove: the working directory
26
+ // must be inside the workspace, and an absolute path written into the command
27
+ // must not point at the parent's files. Anything assembled at runtime still gets
28
+ // past — the parent's timestamp check is the backstop for that, and this file
29
+ // says so rather than pretending the shell is confined.
30
+
31
+ import { appendFileSync } from 'node:fs'
32
+ import { isAbsolute, join, resolve, sep } from 'node:path'
33
+
34
+ /** Tools that take a target file path as an argument. */
35
+ const FILE_TOOLS = new Set(['write', 'edit', 'ops_replace'])
36
+
37
+ /** Tools that take a command string, where the guard is best-effort. */
38
+ const SHELL_TOOLS = new Set(['pwsh', 'ops_bash'])
39
+
40
+ /** Argument keys the file tools use for their target. */
41
+ const PATH_KEYS = ['path', 'file_path', 'filePath', 'target', 'targetPath']
42
+
43
+ /** Argument keys the shell tools use for the working directory. */
44
+ const WORKDIR_KEYS = ['workdir', 'cwd']
45
+
46
+ /**
47
+ * Compare two absolute paths for containment, case-insensitively because this runs
48
+ * on Windows where `D:\X` and `d:\x` are the same directory.
49
+ * @param parent - the directory that must contain `child`.
50
+ * @param child - the candidate path.
51
+ * @returns whether `child` is `parent` or below it.
52
+ */
53
+ function contains(parent, child) {
54
+ const p = parent.replace(/[\\/]+$/, '').toLowerCase()
55
+ const c = child.replace(/[\\/]+$/, '').toLowerCase()
56
+ if (c === p) return true
57
+ return c.startsWith(`${p}${sep}`) || c.startsWith(`${p}/`) || c.startsWith(`${p}\\`)
58
+ }
59
+
60
+ /**
61
+ * Pull the first string argument under any of the given keys.
62
+ * @param args - the parsed call arguments.
63
+ * @param keys - candidate keys in priority order.
64
+ * @returns the value, or `undefined` when none is a string.
65
+ */
66
+ function firstString(args, keys) {
67
+ if (typeof args !== 'object' || args === null) return undefined
68
+ for (const key of keys) {
69
+ const value = args[key]
70
+ if (typeof value === 'string' && value.length > 0) return value
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /**
76
+ * Resolve one guard for a single agent.
77
+ * @param cwd - the session working directory, used to resolve relative paths.
78
+ * @param workspace - the absolute directory the agent may write in.
79
+ * @returns a synchronous guard.
80
+ */
81
+ function makeGuard(cwd, workspace) {
82
+ const describe = (target) =>
83
+ `outside your workspace. You may write only inside ${JSON.stringify(workspace)}; ` +
84
+ `everything else in the parent's directory is read-only to you. ` +
85
+ `Send your parent a change request instead of writing ${JSON.stringify(target)} directly.`
86
+
87
+ return (exec) => {
88
+ const name = exec?.name
89
+ const args = exec?.arguments
90
+ if (typeof name !== 'string') return undefined
91
+
92
+ if (FILE_TOOLS.has(name)) {
93
+ const raw = firstString(args, PATH_KEYS)
94
+ // No path argument at all is the tool's own validation problem, not a boundary
95
+ // violation — refusing here would only hide the real error message.
96
+ if (raw === undefined) return undefined
97
+ const abs = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw)
98
+ if (!contains(workspace, abs)) return `${name}: ${describe(abs)}`
99
+ return undefined
100
+ }
101
+
102
+ if (SHELL_TOOLS.has(name)) {
103
+ // A shell with no explicit workdir runs in the session cwd, which is the
104
+ // parent's directory — precisely the place the child may not write. Requiring
105
+ // the workspace as workdir makes every relative path in the command land where
106
+ // it belongs.
107
+ const workdir = firstString(args, WORKDIR_KEYS)
108
+ const effective = workdir === undefined ? cwd : isAbsolute(workdir) ? resolve(workdir) : resolve(cwd, workdir)
109
+ if (!contains(workspace, effective)) {
110
+ return (
111
+ `${name}: the working directory ${JSON.stringify(effective)} is outside your workspace. ` +
112
+ `Pass workdir: ${JSON.stringify(workspace)} (or a directory inside it) and use relative paths ` +
113
+ `so everything you create stays in your workspace.`
114
+ )
115
+ }
116
+ // Best effort: refuse an absolute path in the command that points at the
117
+ // parent's files. A path assembled at runtime defeats this; the parent's
118
+ // timestamp check is the backstop.
119
+ const command = typeof args?.command === 'string' ? args.command : ''
120
+ const escaped = [...command.matchAll(/[A-Za-z]:[\\/][^\s"'`;|&)]*/g)].map((match) => match[0])
121
+ const outside = escaped.find((candidate) => {
122
+ const abs = resolve(candidate)
123
+ return !contains(workspace, abs) && contains(cwd, abs)
124
+ })
125
+ if (outside !== undefined) return `${name}: the command writes ${JSON.stringify(outside)}, ${describe(outside)}`
126
+ return undefined
127
+ }
128
+
129
+ return undefined
130
+ }
131
+ }
132
+
133
+ export const name = 'root-planner-guard'
134
+
135
+ export const inject = ['tools']
136
+
137
+ // No `Config` export on purpose: Cordis validates a declared Config with a zod schema
138
+ // (`schema.validate`), and a plain JSON Schema object throws at activation with
139
+ // "Cannot read properties of undefined (reading 'validate')". Shipping no schema and
140
+ // coercing the optional fields in `apply` keeps the row usable without pulling a
141
+ // dependency into a plugin that is installed by directory link.
142
+ const DEFAULTS = { enabled: true, directoryName: '_agents', guardShell: true }
143
+
144
+ /** Exported for the unit test: the boundary logic is worth testing without a session. */
145
+ export { contains, makeGuard }
146
+
147
+ export function apply(ctx, config = {}) {
148
+ const settings = { ...DEFAULTS, ...(typeof config === 'object' && config !== null ? config : {}) }
149
+ if (settings.enabled === false) return
150
+ const directoryName =
151
+ typeof settings.directoryName === 'string' && settings.directoryName.length > 0
152
+ ? settings.directoryName
153
+ : DEFAULTS.directoryName
154
+ const guardShell = settings.guardShell !== false
155
+
156
+ const trace = (line) => {
157
+ if (settings.debug !== true) return
158
+ try {
159
+ appendFileSync(
160
+ settings.debugFile ?? `${process.cwd()}\\guard-debug.log`,
161
+ `${new Date().toISOString()} ${line}\n`,
162
+ )
163
+ } catch {
164
+ /* a probe must never break the plugin */
165
+ }
166
+ }
167
+
168
+ trace(`apply: listening on the plugin context (directoryName=${directoryName})`)
169
+
170
+ // Registered on the plugin's own context: a plain-context guard applies globally,
171
+ // and the decision is made per call from `exec.agent`, so no per-agent wiring is
172
+ // needed and no registration leaks when an agent is disposed.
173
+ ctx.effect(() => {
174
+ const dispose = ctx.tools.guard((exec) => {
175
+ const header = exec?.agent?.session?.header
176
+ // Only delegated sessions are fenced: the root owns the workspace and must be
177
+ // able to write anywhere in it, or it could not integrate a child's artifacts.
178
+ if (header?.origin !== 'subagent') return undefined
179
+ const cwd = header.cwd ?? process.cwd()
180
+ if (typeof cwd !== 'string' || cwd.length === 0) return undefined
181
+ const workspace = join(cwd, directoryName)
182
+ const guard = makeGuard(cwd, workspace)
183
+ const verdict = guardShell ? guard(exec) : SHELL_TOOLS.has(exec?.name) ? undefined : guard(exec)
184
+ trace(`call ${exec?.name} from ${String(header.id)} -> ${verdict === undefined ? 'allow' : 'DENY'}`)
185
+ return verdict
186
+ })
187
+ trace('guard registered')
188
+ return dispose
189
+ })
190
+ }
package/package.json CHANGED
@@ -1,10 +1,32 @@
1
1
  {
2
2
  "name": "dsh-root-planner-preset",
3
- "version": "1.1.8",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
- "description": "DSH agent preset that outsources file reading to strictly read-only subagents, while the root agent keeps the plan and owns every file mutation.",
5
+ "description": "DSH agent preset: the root agent plans and decides, subagent workers read, write and run commands inside their own _agents/ workspace, and nothing outlives its task.",
6
6
  "license": "MIT",
7
7
  "author": "XiaoOu2024",
8
+ "exports": {
9
+ ".": "./guard.js",
10
+ "./guard": "./guard.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "guard.js",
15
+ "persona.txt",
16
+ "child-tools.mjs",
17
+ "build-patch.mjs",
18
+ "verify-patch.mjs",
19
+ "verify-depth-override.mjs",
20
+ "lib-yaml.mjs",
21
+ "release.mjs",
22
+ "cordis.patch.yml",
23
+ "tools/",
24
+ "docs/",
25
+ "README.md",
26
+ "README.zh.md",
27
+ "INSTALL.md",
28
+ "LICENSE"
29
+ ],
8
30
  "homepage": "https://github.com/XiaoOu2024/dsh-root-planner-preset#readme",
9
31
  "repository": {
10
32
  "type": "git",
@@ -20,7 +42,8 @@
20
42
  "cordis",
21
43
  "subagent",
22
44
  "delegation",
23
- "read-only"
45
+ "workspace-isolation",
46
+ "guard"
24
47
  ],
25
48
  "dsh": {
26
49
  "bundle": {
package/release.mjs CHANGED
@@ -166,15 +166,29 @@ if (dryRun) {
166
166
  // * The output must be shown even on failure. The first version filtered it
167
167
  // down to matching lines and printed a blank line when the command never
168
168
  // ran, hiding the real problem behind a successful-looking step.
169
- const published = run(PUBLISHER, ['publish', '--registry', OFFICIAL_REGISTRY, '--access', 'public'], {
170
- allowFailure: true,
169
+ const publishRun = spawnSync(PUBLISHER, ['publish', '--registry', OFFICIAL_REGISTRY, '--access', 'public'], {
170
+ cwd: here,
171
+ env,
172
+ encoding: 'utf8',
171
173
  shell: true,
172
174
  });
175
+ const published = `${publishRun.stdout ?? ''}${publishRun.stderr ?? ''}`;
173
176
  const summary = published
174
177
  .split('\n')
175
178
  .map((line) => line.trim())
176
179
  .filter((line) => line.length > 0 && !/^Progress:|^Packages:|^\+?$/.test(line));
177
- process.stdout.write(`${summary.map((line) => ` ${line}`).join('\n') || ' (the publisher printed nothing — treat that as a failure)'}\n`);
180
+ process.stdout.write(`${summary.map((line) => ` ${line}`).join('\n') || ' (the publisher printed nothing)'}\n`);
181
+ // Stop here on a failed publish. Continuing to the registry poll turned a network
182
+ // blip into ten minutes of waiting and then a confident but wrong diagnosis
183
+ // ("staged publishing?"), when the publish command had simply not run.
184
+ if (publishRun.status !== 0) {
185
+ const reason = publishRun.error ? `${publishRun.error.code}: ${publishRun.error.message}` : `exit ${publishRun.status}`;
186
+ throw new Error(
187
+ `the publisher failed (${reason}), so nothing was published and the registry check would only time out.\n` +
188
+ `Retry the publish by hand once the cause is clear:\n` +
189
+ ` ${PUBLISHER} publish --registry ${OFFICIAL_REGISTRY} --access public`,
190
+ );
191
+ }
178
192
  }
179
193
 
180
194
  // ── Confirm on the registry ──────────────────────────────────────────────────
package/verify-patch.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // Usage: node verify-patch.mjs
11
11
 
12
- import { readFileSync } from 'node:fs';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { CHILD_TOOLS, FORBIDDEN_CAPABILITIES, SCOPE_LOCAL_TOOLS } from './child-tools.mjs';
@@ -114,6 +114,25 @@ check(
114
114
 
115
115
  const delegation = plugins.find((row) => row.id === 'delegation');
116
116
  check(delegation?.group === true, 'delegation is a group row');
117
+
118
+ // The workspace fence travels with the preset, so one install carries the protocol and
119
+ // its enforcement. These assertions keep that promise honest: the row must exist, must
120
+ // resolve to this package's own module, and must ship that module.
121
+ const guardRow = plugins.find((row) => row.id === 'root-planner-guard');
122
+ check(guardRow !== undefined, 'the preset mounts the workspace guard as its own row');
123
+ check(
124
+ guardRow?.name === 'dsh-root-planner-preset/guard',
125
+ `the guard row resolves to this package's subpath (found ${JSON.stringify(guardRow?.name)})`,
126
+ );
127
+ check(guardRow?.disabled !== true, 'the guard row is enabled');
128
+ check(guardRow?.config?.directoryName === '_agents', 'the guard fences the _agents/ directory');
129
+ check(guardRow?.config?.guardShell === true, 'the guard also applies the shell rules');
130
+ check(existsSync(join(here, 'guard.js')), 'guard.js ships inside the package');
131
+ {
132
+ const manifest = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8'));
133
+ check(manifest.exports?.['./guard'] === './guard.js', 'package.json exports ./guard');
134
+ check((manifest.files ?? []).includes('guard.js'), 'guard.js is in the published files list');
135
+ }
117
136
  const delegationRows = delegation?.config ?? [];
118
137
 
119
138
  // Loader row id -> the model-facing tool name that row publishes.
package/.gitattributes DELETED
@@ -1,10 +0,0 @@
1
- # This bundle is verified by BYTE identity: verify-patch.mjs asserts that the
2
- # persona parsed out of cordis.patch.yml equals persona.txt exactly. Git's
3
- # autocrlf would rewrite both files on checkout and silently break that check on
4
- # every Windows clone, so line endings are pinned to LF for the whole repository.
5
- * text=auto eol=lf
6
-
7
- # Binary if anyone ever adds artwork.
8
- *.png binary
9
- *.jpg binary
10
- *.webp binary
@@ -1,126 +0,0 @@
1
- # Contributing
2
-
3
- Thanks for looking. This repository is meant to be maintained by more than its
4
- author — the license is [MIT](../LICENSE), and issues and pull requests are open
5
- to anyone.
6
- ## The one thing to know first
7
-
8
- **`cordis.patch.yml` is generated. Do not edit it by hand.**
9
-
10
- It is produced from `persona.txt` by `build-patch.mjs`, and it has to be indented
11
- into a YAML block scalar exactly — a single mis-indented line silently truncates
12
- the ~6 KB persona, which is very hard to spot in review and easy to introduce by
13
- hand. That is the whole reason the generator exists.
14
-
15
- So to change what the agent is told:
16
-
17
- ```sh
18
- $EDITOR persona.txt
19
- node build-patch.mjs # regenerates cordis.patch.yml
20
- node verify-patch.mjs # must print ALL CHECKS PASSED
21
- ```
22
-
23
- Commit **both** files. CI re-runs the generator and fails if the committed patch
24
- does not match `persona.txt` byte for byte, so forgetting the second command is
25
- caught for you.
26
-
27
- ## Setup
28
-
29
- The bundle itself ships no dependencies. The repository's scripts need one YAML
30
- parser, which `lib-yaml.mjs` resolves from your own DSH installation — or from a
31
- local install:
32
-
33
- ```sh
34
- npm ci # installs js-yaml, the only devDependency
35
- ```
36
-
37
- ## What the checks mean
38
-
39
- `node verify-patch.mjs` asserts, among other things:
40
-
41
- - the patch parses as loader YAML (including the `!!js` scalars, read as opaque);
42
- - the persona parsed out of the patch is **byte-identical** to `persona.txt`;
43
- - both delegation tools publish `subagent` / `subagent_fork`;
44
- - both `toolFilter.allow` masks exclude every mutation entry point
45
- (`write`, `edit`, `ops_replace`, `ops_bash`, `pwsh`) and name **only tools that
46
- exist** — a name that is not a registered global tool makes
47
- `tools.restrict()` throw the first time a subagent is created.
48
-
49
- That last point is the most likely thing to need changing on your machine: the
50
- mask was written against the author's profile. If your profile registers a
51
- different set of global tools, the mask is what has to adapt — edit the list near
52
- the bottom of `verify-patch.mjs` in the same commit.
53
-
54
- `node verify-depth-override.mjs` checks the delegation depth override. Depth is a
55
- **Host setting, not part of this bundle**, so on a machine that never set it the
56
- script prints `SKIP` and explains why. That is a pass, not a failure.
57
-
58
- ## Most valuable contributions, in order
59
-
60
- 1. **Bug reports with real error text**, plus your DSH version and which global
61
- tools your profile registers. The `Not verified` section of the
62
- [README](../README.md) names exactly where this matters: no subagent has been
63
- spawned under this preset in the author's environment to date.
64
- 2. **Mask fixes for other environments** — see above.
65
- 3. **Persona improvements** — the text itself, when you have an observed failure
66
- it would have prevented.
67
- 4. **Documentation and translations** — [README.zh.md](../README.zh.md) and
68
- [INSTALL.md](../INSTALL.md) especially.
69
-
70
- ## Ground rules
71
-
72
- - **One concern per pull request.** A persona change and a mask change are two
73
- pull requests unless one requires the other.
74
- - Say which preset you tested with, and on which platform.
75
- - Do not weaken the **process bound**. `enableRunInBackground: false` must stay on
76
- the preset's `tool-pwsh` row: a change that lets a subagent detach a server drops
77
- the one property this preset enforces structurally rather than by asking nicely.
78
- `verify-patch.mjs` fails if it is missing.
79
- - A subagent keeps `write`, `edit`, `ops_replace` and `pwsh`. They are workers —
80
- taking their tools away turns every executable step back into the root's own work,
81
- which is the failure mode this preset exists to avoid.
82
- - Keep `main` linear — the branch protection refuses merge commits, so rebase
83
- rather than merge.
84
-
85
- ## Releasing
86
-
87
- `node release.mjs <patch|minor|major|X.Y.Z>` does the whole sequence. Every step in
88
- it exists because the manual version went wrong at least once:
89
-
90
- 1. regenerates `cordis.patch.yml` and refuses to continue unless
91
- `verify-patch.mjs` passes;
92
- 2. bumps `package.json` and commits with a local-time stamp;
93
- 3. pushes (the owner bypasses branch protection; a collaborator's push is refused,
94
- so open a pull request instead);
95
- 4. publishes to `https://registry.npmjs.org/` **explicitly** — the configured
96
- registry is a read-only mirror that also lags behind, which is how an install
97
- right after a release fails with *No matching version found*;
98
- 5. **polls the registry for the exact version** rather than trusting the exit code,
99
- because publishing on this account is staged and `npm publish` can print success
100
- without the version ever appearing;
101
- 6. upgrades the local profile so the machine runs what was published.
102
-
103
- `--dry-run` walks the whole thing without changing anything; `--skip-install` and
104
- `--skip-gh` drop the last two steps.
105
-
106
- Run it from a terminal. Inside DSH's own sandbox its `spawn` calls fail with
107
- `EPERM` on Windows — that is the sandbox's piped-stdio boundary, not a defect in
108
- the script.
109
-
110
- **A release is not a verification.** The script can put a version on npm while the
111
- preset stays unproven. Restart `dsh web`, open a new session on Root Planner, and
112
- confirm the *root* actually composed with it — `node tools/verify-session.mjs
113
- <session-dir>` — before believing anything about behaviour.
114
-
115
- ## How `main` is protected
116
-
117
- `main` requires the `verify` status check (strict: the branch must be up to date),
118
- refuses force pushes and deletions, requires linear history, and requires
119
- conversations to be resolved. The repository owner can bypass these for a direct
120
- push in an emergency; **collaborators cannot** — send a pull request and let CI
121
- run.
122
-
123
- If you are added as a collaborator and want to be able to merge your own pull
124
- requests without waiting for the owner, say so: review requirements are off
125
- deliberately, because a single-maintainer repository that requires an approval it
126
- cannot get would deadlock.
@@ -1,51 +0,0 @@
1
- # Verifies the bundle on every push and pull request.
2
- #
3
- # Two failure modes this exists to catch, both of which are silent locally:
4
- #
5
- # 1. `cordis.patch.yml` is generated from `persona.txt`. Someone edits the
6
- # generated file by hand (or edits persona.txt and forgets to regenerate), and
7
- # the persona the preset actually ships no longer matches the source. The
8
- # generator is deterministic, so re-running it and diffing the result catches
9
- # exactly that drift.
10
- # 2. A change to the child tool mask names a tool that is not a registered global
11
- # tool. `tools.restrict()` throws on an unknown name at child-creation time, so
12
- # this only surfaces the first time a subagent is actually spawned — never in a
13
- # local syntax check.
14
- #
15
- # The bundle itself ships no dependencies; `js-yaml` is a devDependency of this
16
- # repository only, and `lib-yaml.mjs` picks it up from the checkout.
17
-
18
- name: verify
19
-
20
- on:
21
- push:
22
- branches: [main]
23
- pull_request:
24
- workflow_dispatch:
25
-
26
- permissions:
27
- contents: read
28
-
29
- jobs:
30
- verify:
31
- runs-on: ubuntu-latest
32
- steps:
33
- - uses: actions/checkout@v4
34
-
35
- - uses: actions/setup-node@v4
36
- with:
37
- node-version: '22'
38
-
39
- - name: Install the local YAML parser
40
- run: npm ci --ignore-scripts
41
-
42
- - name: The committed patch matches persona.txt
43
- run: |
44
- node build-patch.mjs
45
- if ! git diff --exit-code -- cordis.patch.yml; then
46
- echo "::error file=cordis.patch.yml::cordis.patch.yml is out of date. Run 'node build-patch.mjs' and commit the result instead of editing it by hand."
47
- exit 1
48
- fi
49
-
50
- - name: Patch structure, persona byte-identity and the child tool mask
51
- run: node verify-patch.mjs