dsh-plugin-prompt-tool 0.6.2 → 0.7.1

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.
Files changed (87) hide show
  1. package/README.md +11 -9
  2. package/engine/anchor-match.mjs +38 -12
  3. package/engine/anchor-turn.mjs +68 -0
  4. package/engine/classify-task.mjs +35 -0
  5. package/engine/code-presentation.mjs +103 -0
  6. package/engine/compositions/library/anchor-turn.yml +10 -0
  7. package/engine/compositions/library/bootstrap-filesystem.yml +1 -1
  8. package/engine/compositions/library/code-presentation.yml +8 -0
  9. package/engine/compositions/library/command-goal.yml +11 -0
  10. package/engine/compositions/library/compaction.yml +1 -1
  11. package/engine/compositions/library/context-gate.yml +1 -2
  12. package/engine/compositions/library/cot-drip.yml +11 -0
  13. package/engine/compositions/library/custom-bash.yml +1 -1
  14. package/engine/compositions/library/delegation.yml +11 -7
  15. package/engine/compositions/library/deliberation-gate.yml +11 -0
  16. package/engine/compositions/library/persistent-shell.yml +25 -2
  17. package/engine/compositions/library/persona.yml +6 -2
  18. package/engine/compositions/library/planning.yml +1 -1
  19. package/engine/compositions/library/prompt-config-engine.yml +1 -1
  20. package/engine/compositions/library/run-code-env.yml +1 -1
  21. package/engine/compositions/library/skill-filesystem.yml +1 -1
  22. package/engine/compositions/library/skill-search.yml +1 -1
  23. package/engine/compositions/library/str-replace-editor.yml +2 -2
  24. package/engine/compositions/library/tool-ask-user.yml +1 -1
  25. package/engine/compositions/library/tool-bash.yml +1 -1
  26. package/engine/compositions/library/tool-bootstrap.yml +1 -3
  27. package/engine/compositions/library/tool-config-engine.yml +9 -0
  28. package/engine/compositions/library/tool-filter.yml +3 -7
  29. package/engine/compositions/library/tool-fs-search.yml +1 -1
  30. package/engine/compositions/library/tool-fs.yml +1 -1
  31. package/engine/compositions/library/tool-goal.yml +1 -9
  32. package/engine/compositions/library/tool-jobs.yml +1 -1
  33. package/engine/compositions/library/tool-pwsh.yml +1 -1
  34. package/engine/compositions/library/tool-todo.yml +1 -1
  35. package/engine/compositions/library/tool-web.yml +2 -2
  36. package/engine/compositions/source/local/code-presentation.yml +8 -0
  37. package/engine/compositions/source/local/context-gate.yml +0 -1
  38. package/engine/compositions/source/local/str-replace-editor.yml +10 -0
  39. package/engine/compositions/source/local/tool-bootstrap.yml +1 -3
  40. package/engine/compositions/source/local/tool-config-engine.yml +9 -0
  41. package/engine/compositions/source/local/tool-filter.yml +8 -0
  42. package/engine/cot-drip.mjs +137 -0
  43. package/engine/custom-bash.mjs +73 -21
  44. package/engine/deliberation-gate.mjs +127 -0
  45. package/engine/layers.mjs +223 -198
  46. package/engine/run-code-env.mjs +8 -2
  47. package/engine/schema.mjs +1 -1
  48. package/engine/strategies.mjs +25 -14
  49. package/engine/tool-bootstrap.mjs +185 -59
  50. package/engine/tool-config-engine.mjs +390 -0
  51. package/lib/client.js +3840 -2311
  52. package/lib/client.js.map +1 -1
  53. package/lib/index.d.mts +186 -101
  54. package/lib/index.mjs +1012 -413
  55. package/lib/preset-core.d.mts +1 -1
  56. package/lib/preset-core.mjs +29 -3
  57. package/lib/{prompt-configs-BgFw_bFf.mjs → prompt-configs-BnQKRHVb.mjs} +211 -136
  58. package/lib/prompt-configs-CN0sJTUL.d.mts +193 -0
  59. package/package.json +121 -105
  60. package/preset/anchored/preset.yml +262 -308
  61. package/preset/creative/preset.yml +34 -32
  62. package/preset/custom/preset.yml +0 -1
  63. package/preset/liangshen/preset.yml +53 -50
  64. package/preset/minimal/preset.yml +18 -17
  65. package/preset/ptc/preset.yml +35 -33
  66. package/preset/standard/preset.yml +34 -32
  67. package/templates/10-pre-step.yml +18 -15
  68. package/templates/14-first-turn-anchor.yml +13 -13
  69. package/templates/15-guide-auto.yml +13 -13
  70. package/templates/16-custom-fallback.yml +19 -18
  71. package/templates/18-placeholder-env-facts.yml +13 -2
  72. package/templates/30-runtime-context.yml +3 -0
  73. package/templates/40-agent-request.yml +9 -9
  74. package/templates/60-tool-pipeline.yml +9 -8
  75. package/templates/tools/10-shell-command.yml +20 -0
  76. package/templates/tools/20-http-fetch.yml +24 -0
  77. package/templates/tools/30-fs-read.yml +20 -0
  78. package/templates/tools/40-delegate-character.yml +15 -0
  79. package/templates/tools/50-delegate-worldbook.yml +15 -0
  80. package/templates/tools/60-delegate-session-var.yml +15 -0
  81. package/templates/tools/70-delegate-worldbook-upsert.yml +36 -0
  82. package/lib/prompt-configs-B4vH09wx.d.mts +0 -100
  83. package/templates/11-merged-a.yml +0 -11
  84. package/templates/13-anchor.yml +0 -19
  85. package/templates/17-instruction-hint.yml +0 -23
  86. package/templates/19-placeholder-skill-catalog.yml +0 -19
  87. package/templates/31-runtime-context-placeholder.yml +0 -18
@@ -1,5 +1,5 @@
1
1
  # module: tool-bootstrap
2
- # source: preset/engine/compositions/source/local/tool-bootstrap.yml (本地附加,非官方模块)
2
+ # source: engine/compositions/source/local/tool-bootstrap.yml (本地附加,非官方模块)
3
3
 
4
4
  - id: tool-bootstrap
5
5
  name: ./engine/tool-bootstrap.mjs
@@ -7,8 +7,6 @@
7
7
  bootstrapTools: [bash, str_replace_editor]
8
8
  promoteOn: either
9
9
  includeSubagents: false
10
- usePtcMode: __usePtcMode__
11
- __bootstrapMaxTokens__
12
10
  # Post-compaction core work set: the model is mid-task and needs to keep
13
11
  # working, but faces a small catalog instead of the full Standard set.
14
12
  compactionTools: [read, write, edit, glob, grep, todo_write, ask_user_question]
@@ -0,0 +1,9 @@
1
+ # module: tool-config-engine
2
+ # source: 本地新增(自定义工具引擎:preset.yml customTools 段 → 渲染 custom-tools/ → 运行时注册)
3
+ # 工具定义在预设目录 custom-tools/*.yml;执行器 shell/http/delegate/fs/ask-user。
4
+ # requireApproval 数组可列出需 approval 门的执行器(默认空 = 不要求)。
5
+
6
+ - id: tool-config-engine
7
+ name: ./engine/tool-config-engine.mjs
8
+ config:
9
+ configsDir: ../custom-tools
@@ -1,12 +1,8 @@
1
1
  # module: tool-filter
2
- # source: 本地附加(本项目主会话工具过滤;非官方模块)
2
+ # source: engine/compositions/source/local/tool-filter.yml (本地附加,非官方模块)
3
3
  # 主会话常驻工具白名单/黑名单:作用于任意注册工具(含第三方插件工具)。
4
- # allow 非空 = 白名单(只保留列表内工具);deny 非空 = 黑名单(移除列表内工具);
5
- # 两者都空 = 不过滤(官方默认)。includeSubagents=true 时子代理同样过滤。
4
+ # config 由参数桥按 params.toolFilterAllow/Deny/Subagents 合并;
5
+ # 未声明 = 不过滤(官方默认),见 tool-filter.mjs。
6
6
 
7
7
  - id: tool-filter
8
8
  name: ./engine/tool-filter.mjs
9
- config:
10
- allow: __mainToolFilterAllow__
11
- deny: __mainToolFilterDeny__
12
- includeSubagents: __mainToolFilterSubagents__
@@ -1,5 +1,5 @@
1
1
  # module: tool-fs-search
2
- # source: E:\Documents\GitHub\deepseek-harness/apps/cli/config/agent-presets/standard/agent.cordis.yml
2
+ # source: D:\AI\GitHub\deepseek-harness/packages/preset/agent-presets/presets/standard/agent.cordis.yml
3
3
  # local patches: 0
4
4
 
5
5
  - id: tool-fs-search
@@ -1,5 +1,5 @@
1
1
  # module: tool-fs
2
- # source: E:\Documents\GitHub\deepseek-harness/apps/cli/config/agent-presets/standard/agent.cordis.yml
2
+ # source: D:\AI\GitHub\deepseek-harness/packages/preset/agent-presets/presets/standard/agent.cordis.yml
3
3
  # local patches: 0
4
4
 
5
5
  # ── filesystem ──────────────────────────────────────────────────────────────
@@ -1,15 +1,7 @@
1
1
  # module: tool-goal
2
- # source: E:\Documents\GitHub\deepseek-harness/apps/cli/config/agent-presets/standard/agent.cordis.yml
2
+ # source: D:\AI\GitHub\deepseek-harness/packages/preset/agent-presets/presets/standard/agent.cordis.yml
3
3
  # local patches: 0
4
4
 
5
- # ── goals ───────────────────────────────────────────────────────────────────
6
-
7
- # Only the model-facing tool. The goal SERVICE, its session driver, and the
8
- # `/goal` command stay on the host plane: the Gateway serves the goal domain as
9
- # Remote endpoints whose receiver comes from a generated descriptor, so it
10
- # resolves `goals` on the host and an entry-local realm here would hide it. The
11
- # registry is keyed by session anyway, so one host instance serves every
12
- # session. What a preset chooses is whether its agent can call the goal tool.
13
5
  - id: tool-goal
14
6
  name: '@deepseek-ai/dsh-tool-goal'
15
7
 
@@ -1,5 +1,5 @@
1
1
  # module: tool-jobs
2
- # source: E:\Documents\GitHub\deepseek-harness/apps/cli/config/agent-presets/standard/agent.cordis.yml
2
+ # source: D:\AI\GitHub\deepseek-harness/packages/preset/agent-presets/presets/standard/agent.cordis.yml
3
3
  # local patches: 0
4
4
 
5
5
  # ── background jobs ────────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  # module: tool-pwsh
2
- # source: E:\Documents\GitHub\deepseek-harness/apps/cli/config/agent-presets/standard/agent.cordis.yml
2
+ # source: D:\AI\GitHub\deepseek-harness/packages/preset/agent-presets/presets/standard/agent.cordis.yml
3
3
  # local patches: 0
4
4
 
5
5
  - id: tool-pwsh
@@ -1,5 +1,5 @@
1
1
  # module: tool-todo
2
- # source: E:\Documents\GitHub\deepseek-harness/apps/cli/config/agent-presets/standard/agent.cordis.yml
2
+ # source: D:\AI\GitHub\deepseek-harness/packages/preset/agent-presets/presets/standard/agent.cordis.yml
3
3
  # local patches: 0
4
4
 
5
5
  - id: tool-todo
@@ -1,9 +1,9 @@
1
1
  # module: tool-web
2
- # source: E:\Documents\GitHub\deepseek-harness/apps/cli/config/agent-presets/standard/agent.cordis.yml
2
+ # source: D:\AI\GitHub\deepseek-harness/packages/preset/agent-presets/presets/standard/agent.cordis.yml
3
3
  # local patches: 0
4
4
 
5
5
  - id: tool-web
6
6
  name: '@deepseek-ai/dsh-tool-web'
7
7
  config:
8
- fetch: false
8
+ fetch: true
9
9
  searchTimeoutMs: 60000
@@ -0,0 +1,8 @@
1
+ # module: code-presentation
2
+ # source: engine/compositions/source/local/code-presentation.yml (本地附加,非官方模块)
3
+ # 晋升后 Code Mode (PTC) wire 呈现(从 tool-bootstrap 拆出,与目录窄化解耦):
4
+ # "只要 PTC 不要 bootstrap" = modules 只挂本行;PTC+bootstrap = 同时挂两行。
5
+ # usePtcMode 默认 false(opt-in);params.usePtcMode 显式覆盖(参数桥)。
6
+
7
+ - id: code-presentation
8
+ name: ./engine/code-presentation.mjs
@@ -3,7 +3,6 @@
3
3
  config:
4
4
  promoteOn: either
5
5
  includeSubagents: false
6
- allowKinds: __allowKinds__
7
6
 
8
7
  # ── bootstrap ───────────────────────────────────────────────────────────────
9
8
 
@@ -0,0 +1,10 @@
1
+ # module: str-replace-editor
2
+ # source: engine/compositions/source/local/str-replace-editor.yml (本地附加,非官方模块)
3
+ # 官方 minimal 内置工具(filesystem group 内的 str-replace-editor 行)拆出为独立模块:
4
+ # 复用宿主 sandbox 的 ctx.fs,不挂 fs-local 裸文件系统。
5
+ # maxOutputChars 参数化:params.strReplaceEditorMaxOutputChars(默认官方值 16000)。
6
+
7
+ - id: str-replace-editor
8
+ name: '@deepseek-ai/dsh-tool-str-replace-editor'
9
+ config:
10
+ maxOutputChars: 16000
@@ -3,9 +3,7 @@
3
3
  config:
4
4
  bootstrapTools: [bash, str_replace_editor]
5
5
  promoteOn: either
6
- includeSubagents: true
7
- usePtcMode: __usePtcMode__
8
- __bootstrapMaxTokens__
6
+ includeSubagents: false
9
7
  # Post-compaction core work set: the model is mid-task and needs to keep
10
8
  # working, but faces a small catalog instead of the full Standard set.
11
9
  compactionTools: [read, write, edit, glob, grep, todo_write, ask_user_question]
@@ -0,0 +1,9 @@
1
+ # module: tool-config-engine
2
+ # source: 本地新增(自定义工具引擎:preset.yml customTools 段 → 渲染 custom-tools/ → 运行时注册)
3
+ # 工具定义在预设目录 custom-tools/*.yml;执行器 shell/http/delegate/fs/ask-user。
4
+ # requireApproval 数组可列出需 approval 门的执行器(默认空 = 不要求)。
5
+
6
+ - id: tool-config-engine
7
+ name: ./engine/tool-config-engine.mjs
8
+ config:
9
+ configsDir: ../custom-tools
@@ -0,0 +1,8 @@
1
+ # module: tool-filter
2
+ # source: engine/compositions/source/local/tool-filter.yml (本地附加,非官方模块)
3
+ # 主会话常驻工具白名单/黑名单:作用于任意注册工具(含第三方插件工具)。
4
+ # config 由参数桥按 params.toolFilterAllow/Deny/Subagents 合并;
5
+ # 未声明 = 不过滤(官方默认),见 tool-filter.mjs。
6
+
7
+ - id: tool-filter
8
+ name: ./engine/tool-filter.mjs
@@ -0,0 +1,137 @@
1
+ /**
2
+ * cot-drip — 执行期深思维持节拍(上游 dsh-anchored-standard 移植, MIT)。
3
+ *
4
+ * 锚定模式在每轮开场有深思考,但长工具循环中深思会衰减(后期步骤退化
5
+ * 回 "Let me…")。本模块每 N 次工具结果向对话滴入一条短 user 提醒——
6
+ * 永不阻塞、永不报错、不碰工具目录:
7
+ *
8
+ * `tools/post-execute` → { kind: 'accept', additionalContexts: [notice] }
9
+ *
10
+ * harness 把 additionalContexts 作为 durable user 消息追加在整批工具结果
11
+ * 之后,落在下一请求的规划节拍位(与 Code Mode 嵌套上下文同形状)。
12
+ * 模型读到后以一句 "We …" 重申剩余目标并继续。
13
+ *
14
+ * 节奏刻意温和:默认 every: 4 次结果、每轮最多 maxPerTurn: 1 条;
15
+ * every: 0 禁用。计数在 await 前同步自增(并行调用无法竞态越过节奏);
16
+ * 轮边界由 durable turn/start + assistant/chunk 双路跟踪(无轮号时降级
17
+ * session 全局);子代理默认不滴(brief 即计划)。
18
+ */
19
+
20
+ import { booleanOption, validateConfig } from './shared.mjs'
21
+
22
+ /** Cordis plugin name used by loader diagnostics. */
23
+ export const name = 'cot-drip'
24
+
25
+ /** 默认提醒文本——一条规划节拍,措辞维持 "We" 语态。 */
26
+ export const DRIP_TEXT = [
27
+ 'Progress check: before the next action, restate in one "We …" sentence what remains of the goal and why the next step is the right one.',
28
+ ].join(' ')
29
+
30
+ /** Every config key this plugin accepts — anything else is a typo. */
31
+ const ALLOWED_KEYS = new Set(['enabled', 'every', 'maxPerTurn', 'includeSubagents', 'text'])
32
+
33
+ function parseCounter(value, field, fallback, minimum) {
34
+ if (value === undefined) return fallback
35
+ if (!Number.isInteger(value) || value < minimum) {
36
+ throw new TypeError(`${name}: ${field} must be an integer >= ${minimum}; got ${JSON.stringify(value)}`)
37
+ }
38
+ return value
39
+ }
40
+
41
+ /** 注册执行后深思滴入。 */
42
+ export function apply(ctx, config) {
43
+ const source = validateConfig(name, config, ALLOWED_KEYS)
44
+ if (source.enabled === false) return
45
+ const every = parseCounter(source.every, 'every', 4, 0)
46
+ const maxPerTurn = parseCounter(source.maxPerTurn, 'maxPerTurn', 1, 1)
47
+ const includeSubagents = booleanOption(name, source.includeSubagents, 'includeSubagents', false)
48
+ const text = typeof source.text === 'string' && source.text.length > 0 ? source.text : DRIP_TEXT
49
+
50
+ /** sessionId -> { results, drips, lastTurn } — 每轮计数。 */
51
+ const state = new Map()
52
+
53
+ const countersOf = (sessionId) => {
54
+ let entry = state.get(sessionId)
55
+ if (entry === undefined) {
56
+ entry = { results: 0, drips: 0, lastTurn: undefined }
57
+ state.set(sessionId, entry)
58
+ }
59
+ return entry
60
+ }
61
+
62
+ let warned = false
63
+ const warnOnce = (message) => {
64
+ if (warned) return
65
+ warned = true
66
+ try {
67
+ ctx.logger.warn(message)
68
+ } catch {
69
+ // Logger unavailable — the guard exists only to avoid spamming.
70
+ }
71
+ }
72
+
73
+ // 轮跟踪:turn/start 重置计数;无可用轮号时用 assistant/chunk 的新轮。
74
+ ctx.on('session/event', (session, event) => {
75
+ if (session === undefined || session.id === undefined) return
76
+ if (event.type === 'turn/start') {
77
+ const turn = event.data?.turn
78
+ const entry = countersOf(session.id)
79
+ if (entry.lastTurn !== turn) {
80
+ entry.lastTurn = typeof turn === 'number' ? turn : entry.lastTurn
81
+ entry.results = 0
82
+ entry.drips = 0
83
+ }
84
+ return
85
+ }
86
+ if (event.type === 'assistant/chunk') {
87
+ const turn = event.data?.turn
88
+ if (typeof turn !== 'number') return
89
+ const entry = countersOf(session.id)
90
+ if (entry.lastTurn === undefined || turn > entry.lastTurn) {
91
+ entry.lastTurn = turn
92
+ entry.results = 0
93
+ entry.drips = 0
94
+ }
95
+ }
96
+ })
97
+
98
+ ctx.on('tools/post-execute', async (exec, result, next) => {
99
+ // await 前同步计数,并行调用不能竞态越过节奏。
100
+ const session = exec?.agent?.session
101
+ const eligible = session !== undefined
102
+ && session.id !== undefined
103
+ && (includeSubagents || (session.header?.delegationDepth ?? 0) === 0)
104
+ const entry = eligible ? countersOf(session.id) : undefined
105
+ if (entry !== undefined) entry.results += 1
106
+ const due = entry !== undefined
107
+ && every > 0
108
+ && entry.results % every === 0
109
+ && entry.drips < maxPerTurn
110
+
111
+ const decision = await next()
112
+ try {
113
+ if (!due || decision?.kind !== 'accept') return decision
114
+ entry.drips += 1
115
+ const notice = {
116
+ id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
117
+ ? crypto.randomUUID()
118
+ : `cot-drip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
119
+ role: 'user',
120
+ content: [{ type: 'text', text }],
121
+ source: {
122
+ kind: 'plugin',
123
+ plugin: name,
124
+ form: 'notice',
125
+ summary: 'deliberation maintenance beat',
126
+ },
127
+ }
128
+ return {
129
+ ...decision,
130
+ additionalContexts: [...(decision.additionalContexts ?? []), notice],
131
+ }
132
+ } catch (error) {
133
+ warnOnce(`${name}: drip injection failed, keeping the plain result: ${String((error && error.message) || error)}`)
134
+ return decision
135
+ }
136
+ })
137
+ }
@@ -43,6 +43,7 @@
43
43
 
44
44
  import { access } from 'node:fs/promises'
45
45
  import { dirname, join } from 'node:path'
46
+ import { validateConfig } from './shared.mjs'
46
47
 
47
48
  /** Cordis plugin name used by loader diagnostics. */
48
49
  export const name = 'custom-bash'
@@ -53,6 +54,9 @@ export const inject = ['subprocess', 'tools']
53
54
  const DEFAULT_TIMEOUT_MS = 120000
54
55
  const DEFAULT_MAX_OUTPUT_BYTES = 64000
55
56
 
57
+ /** Every config key this plugin accepts — anything else is a typo. */
58
+ const ALLOWED_KEYS = new Set(['bashPath', 'timeoutMs', 'maxOutputBytes'])
59
+
56
60
  /**
57
61
  * Git Bash candidate paths, in probe order (see the header): the `git`
58
62
  * executable's install root first, then the well-known env-derived roots.
@@ -81,6 +85,24 @@ export function bashCandidates(env, gitExe) {
81
85
  return [...new Set(candidates)]
82
86
  }
83
87
 
88
+ /**
89
+ * Git Bash / MSYS drive path (`/e/foo`) → Windows path (`E:\foo`).
90
+ * Unix paths like `/usr/bin` or `/tmp` are left unchanged. Exported for tests.
91
+ * (上游 anchored-standard 移植, issue #72)
92
+ */
93
+ export function normalizeGitBashWorkdir(workdir, platform = process.platform) {
94
+ if (typeof workdir !== 'string' || workdir.length === 0) return workdir
95
+ if (platform !== 'win32') return workdir
96
+ const match = workdir.match(/^\/([a-zA-Z])(?:\/(.*))?$/)
97
+ if (!match) return workdir
98
+ const rest = match[2] ? match[2].replace(/\//g, '\\') : ''
99
+ return rest ? `${match[1].toUpperCase()}:\\${rest}` : `${match[1].toUpperCase()}:\\`
100
+ }
101
+
102
+ function isWorkdirEnoent(error) {
103
+ return /\bENOENT\b/.test(String((error && error.message) || error || ''))
104
+ }
105
+
84
106
  /** Tool parameter schema for the model-facing command. */
85
107
  const commandSchema = {
86
108
  type: 'object',
@@ -91,7 +113,7 @@ const commandSchema = {
91
113
  },
92
114
  workdir: {
93
115
  type: 'string',
94
- description: 'Optional working directory; defaults to the session cwd.',
116
+ description: 'Optional working directory; defaults to the session cwd. On Windows, Git Bash paths like /e/foo are accepted and converted to E:\\foo.',
95
117
  },
96
118
  },
97
119
  required: ['command'],
@@ -100,9 +122,10 @@ const commandSchema = {
100
122
 
101
123
  /** Register the model-facing `bash` tool. */
102
124
  export function apply(ctx, config) {
103
- const explicitBashPath = typeof config?.bashPath === 'string' && config.bashPath.length > 0 ? config.bashPath : undefined
104
- const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS
105
- const maxOutputBytes = Number.isSafeInteger(config?.maxOutputBytes) && config.maxOutputBytes > 0 ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
125
+ const source = validateConfig(name, config, ALLOWED_KEYS)
126
+ const explicitBashPath = typeof source.bashPath === 'string' && source.bashPath.length > 0 ? source.bashPath : undefined
127
+ const timeoutMs = Number.isSafeInteger(source.timeoutMs) && source.timeoutMs > 0 ? source.timeoutMs : DEFAULT_TIMEOUT_MS
128
+ const maxOutputBytes = Number.isSafeInteger(source.maxOutputBytes) && source.maxOutputBytes > 0 ? source.maxOutputBytes : DEFAULT_MAX_OUTPUT_BYTES
106
129
 
107
130
  // The inferred executable is memoized per plugin instance: candidate probing
108
131
  // walks the filesystem, and the answer cannot change within a mount. A
@@ -159,6 +182,7 @@ export function apply(ctx, config) {
159
182
  "* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.",
160
183
  '* Please avoid commands that may produce a very large amount of output.',
161
184
  '* NOTE: runs without OS sandbox confinement on Windows (no landlock); treat output as untrusted.',
185
+ '* workdir accepts a Windows path or a Git Bash drive path (/e/foo).',
162
186
  ].join('\n'),
163
187
  parameters: commandSchema,
164
188
  output: {
@@ -174,9 +198,24 @@ export function apply(ctx, config) {
174
198
  },
175
199
  async execute(args, exec) {
176
200
  const shell = await resolveShell(exec?.signal)
177
- const workdir = typeof args.workdir === 'string' && args.workdir.length > 0
201
+ const sessionCwd = exec?.agent?.session?.header?.cwd
202
+ const requested = typeof args.workdir === 'string' && args.workdir.length > 0
178
203
  ? args.workdir
179
- : exec?.agent?.session?.header?.cwd
204
+ : sessionCwd
205
+ const workdir = normalizeGitBashWorkdir(requested)
206
+
207
+ // spawnOnce:单次 spawn 封装(ENOENT 回退重试复用同一配置)。
208
+ const spawnOnce = (cwd) => ctx.subprocess.spawn({
209
+ argv: [shell, '-c', args.command],
210
+ ...cwd !== undefined ? { cwd } : {},
211
+ stdio: {
212
+ stdin: 'ignore',
213
+ stdout: { maxBytes: maxOutputBytes },
214
+ stderr: { maxBytes: maxOutputBytes },
215
+ },
216
+ signal: abort.signal,
217
+ graceMs: 3000,
218
+ })
180
219
 
181
220
  // timeoutMs is a foreground deadline. subprocess only reacts to an abort
182
221
  // signal, so we own the classification here: a timeout aborts the tree
@@ -191,25 +230,35 @@ export function apply(ctx, config) {
191
230
  if (exec?.signal?.aborted) onExecAbort()
192
231
  else exec?.signal?.addEventListener('abort', onExecAbort, { once: true })
193
232
 
233
+ let handle
194
234
  let outcome
235
+ let usedFallback = false
195
236
  try {
196
- const handle = ctx.subprocess.spawn({
197
- argv: [shell, '-c', args.command],
198
- ...workdir !== undefined ? { cwd: workdir } : {},
199
- stdio: {
200
- stdin: 'ignore',
201
- stdout: { maxBytes: maxOutputBytes },
202
- stderr: { maxBytes: maxOutputBytes },
203
- },
204
- signal: abort.signal,
205
- graceMs: 3000,
206
- })
207
237
  try {
238
+ handle = spawnOnce(workdir)
208
239
  outcome = await handle.done
209
240
  } catch (error) {
210
- // A spawn-level failure (bad executable, EPERM) surfaces as a throw,
211
- // which the runtime turns into an isError result.
212
- throw new Error(`bash spawn failed: ${String(error)}`)
241
+ // workdir 归一化后仍 ENOENT(如 /e/foo 目录不存在)→ 回退会话 cwd 重试
242
+ // (上游 anchored-standard issue #72)。
243
+ const fallback = sessionCwd !== undefined ? normalizeGitBashWorkdir(sessionCwd) : undefined
244
+ if (
245
+ requested !== undefined
246
+ && fallback !== undefined
247
+ && fallback !== workdir
248
+ && isWorkdirEnoent(error)
249
+ ) {
250
+ usedFallback = true
251
+ try {
252
+ handle = spawnOnce(fallback)
253
+ outcome = await handle.done
254
+ } catch (retryError) {
255
+ throw new Error(`bash spawn failed: ${String(retryError)}`)
256
+ }
257
+ } else {
258
+ // A spawn-level failure (bad executable, EPERM) surfaces as a throw,
259
+ // which the runtime turns into an isError result.
260
+ throw new Error(`bash spawn failed: ${String(error)}`)
261
+ }
213
262
  }
214
263
  let stdout = ''
215
264
  let stderr = ''
@@ -219,8 +268,11 @@ export function apply(ctx, config) {
219
268
  } catch {
220
269
  // Collected readers may be unavailable on some backends; tolerate.
221
270
  }
271
+ const note = usedFallback
272
+ ? `[custom-bash] workdir ${requested} was unusable (ENOENT); fell back to session cwd\n`
273
+ : ''
222
274
  const text = [stdout, stderr].filter((part) => part.length > 0).join('\n')
223
- const tail = text.length > 0 ? text : `exit code: ${outcome.exitCode} (no output)`
275
+ const tail = note + (text.length > 0 ? text : `exit code: ${outcome.exitCode} (no output)`)
224
276
  if (timedOut) {
225
277
  throw new Error(`bash timed out after ${timeoutMs}ms${tail ? `\n${tail}` : ''}`)
226
278
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * deliberation-gate — 轨迹深度门(上游 dsh-anchored-standard 移植, MIT)。
3
+ *
4
+ * 完整目录保持可见可调(会话观感正常),但首工具调用前深思不足就拒绝
5
+ * 一次。以 durable `assistant/chunk` 的 text/reasoning delta 累计长度作为
6
+ * "深思深度"代理(adapter 无 logprobs,观测层只有流式 chunk)。
7
+ *
8
+ * 行为:
9
+ * - session/event 累计每轮流式长度;tools/pre-execute 时当前轮深度
10
+ * < minChars(默认 400)→ { kind:'deny', reason: gateText }(规划式
11
+ * 提示,措辞明示"非工具失败"),每轮最多 maxGatesPerTurn 次(默认 1)。
12
+ * - retry 把强制深思带进历史;无流式文本的轮 = 深度 0,恰好门一次
13
+ * (fail-safe 向更多深思)。
14
+ * - 冷启动从 durable log 扫描(重启保持深度);turn map 修剪
15
+ * (MAX_TRACKED_TURNS=8);deny 前无 await(并行调用无法竞态越过预算)。
16
+ * - 子代理默认不门控(brief 即计划);includeSubagents: true 同门控。
17
+ */
18
+
19
+ import { booleanOption, validateConfig } from './shared.mjs'
20
+
21
+ /** Cordis plugin name used by loader diagnostics. */
22
+ export const name = 'deliberation-gate'
23
+
24
+ /** 默认深思下限(每轮首工具调用前,字符数)。 */
25
+ export const DEFAULT_MIN_CHARS = 400
26
+
27
+ /** 默认门控指令(拒绝回传的规划提示)。 */
28
+ export const GATE_TEXT = [
29
+ 'Deliberation gate: this turn has not shown its reasoning yet.',
30
+ 'Before retrying this tool call, write out your full reasoning in your reply — start with "We", restate the goal, weigh the approaches, and lay out the concrete steps and risks — then issue the tool call again.',
31
+ 'This message is a planning prompt, not a tool failure.',
32
+ ].join(' ')
33
+
34
+ /** 每会话最多保留的轮次深度状态。 */
35
+ const MAX_TRACKED_TURNS = 8
36
+
37
+ /** Every config key this plugin accepts — anything else is a typo. */
38
+ const ALLOWED_KEYS = new Set(['enabled', 'minChars', 'maxGatesPerTurn', 'includeSubagents', 'gateText'])
39
+
40
+ function parseCounter(value, field, fallback, minimum) {
41
+ if (value === undefined) return fallback
42
+ if (!Number.isInteger(value) || value < minimum) {
43
+ throw new TypeError(`${name}: ${field} must be an integer >= ${minimum}; got ${JSON.stringify(value)}`)
44
+ }
45
+ return value
46
+ }
47
+
48
+ /** 注册轨迹深度门。 */
49
+ export function apply(ctx, config) {
50
+ const source = validateConfig(name, config, ALLOWED_KEYS)
51
+ if (source.enabled === false) return
52
+ const minChars = parseCounter(source.minChars, 'minChars', DEFAULT_MIN_CHARS, 0)
53
+ const maxGatesPerTurn = parseCounter(source.maxGatesPerTurn, 'maxGatesPerTurn', 1, 1)
54
+ const includeSubagents = booleanOption(name, source.includeSubagents, 'includeSubagents', false)
55
+ const gateText = typeof source.gateText === 'string' && source.gateText.length > 0 ? source.gateText : GATE_TEXT
56
+
57
+ /** sessionId -> { turns: Map<turn, { chars, gates }>, lastTurn } */
58
+ const state = new Map()
59
+
60
+ /** 实时路径:每条流式 chunk 扩展轮条目。 */
61
+ const observeChunk = (sessionId, turn, textLength) => {
62
+ let entry = state.get(sessionId)
63
+ if (entry === undefined) {
64
+ entry = { turns: new Map(), lastTurn: turn }
65
+ state.set(sessionId, entry)
66
+ }
67
+ let turnEntry = entry.turns.get(turn)
68
+ if (turnEntry === undefined) {
69
+ // 修剪旧轮,长会话不累积状态。
70
+ if (entry.turns.size >= MAX_TRACKED_TURNS) {
71
+ const oldest = [...entry.turns.keys()].sort((a, b) => a - b).slice(0, entry.turns.size - MAX_TRACKED_TURNS + 1)
72
+ for (const key of oldest) entry.turns.delete(key)
73
+ }
74
+ turnEntry = { chars: 0, gates: 0 }
75
+ entry.turns.set(turn, turnEntry)
76
+ }
77
+ turnEntry.chars += textLength
78
+ if (turn > entry.lastTurn) entry.lastTurn = turn
79
+ }
80
+
81
+ /** 会话深度状态;首见冷扫 durable log(重启保持已流式深度)。 */
82
+ const depthOf = (session) => {
83
+ let entry = state.get(session.id)
84
+ if (entry === undefined) {
85
+ entry = { turns: new Map(), lastTurn: -1 }
86
+ if (Array.isArray(session.events)) {
87
+ for (const event of session.events) {
88
+ if (event.type !== 'assistant/chunk') continue
89
+ const turn = event.data?.turn
90
+ if (typeof turn !== 'number' || !Number.isFinite(turn)) continue
91
+ const text = event.data?.chunk?.text
92
+ observeChunk(session.id, turn, typeof text === 'string' ? text.length : 0)
93
+ }
94
+ entry = state.get(session.id) ?? entry
95
+ }
96
+ if (entry.turns.size === 0) {
97
+ // 无任何流式文本:深度视为 0 于哨兵轮,会话恰好门一次后放行。
98
+ entry.turns.set(entry.lastTurn, { chars: 0, gates: 0 })
99
+ }
100
+ state.set(session.id, entry)
101
+ }
102
+ return entry
103
+ }
104
+
105
+ // 深度代理:累计 durable assistant/chunk 的流式长度。
106
+ ctx.on('session/event', (session, event) => {
107
+ if (event.type !== 'assistant/chunk') return
108
+ const turn = event.data?.turn
109
+ if (typeof turn !== 'number' || !Number.isFinite(turn)) return
110
+ const text = event.data?.chunk?.text
111
+ observeChunk(session.id, turn, typeof text === 'string' ? text.length : 0)
112
+ })
113
+
114
+ // 门:同步决策,当前轮累计深度 < minChars 时最多 deny maxGatesPerTurn 次。
115
+ ctx.on('tools/pre-execute', (exec, next) => {
116
+ const session = exec?.agent?.session
117
+ if (session === undefined || session.id === undefined) return next()
118
+ if (!includeSubagents && (session.header?.delegationDepth ?? 0) > 0) return next()
119
+ const entry = depthOf(session)
120
+ const turnEntry = entry.turns.get(entry.lastTurn)
121
+ if (turnEntry === undefined) return next()
122
+ if (turnEntry.gates >= maxGatesPerTurn) return next()
123
+ if (turnEntry.chars >= minChars) return next()
124
+ turnEntry.gates += 1
125
+ return { kind: 'deny', reason: gateText }
126
+ })
127
+ }