dsh-logicprobe 0.7.0 → 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.
package/README.en-US.md CHANGED
@@ -182,7 +182,8 @@ Skills are invoked with `$logicprobe`. See `.zcode/INSTALL.md` for details.
182
182
  ## Requirements
183
183
 
184
184
  - Claude Code v2.1+ / Codex CLI latest / Cursor 2.5+ / Kimi CLI latest / OpenCode latest / ZCode 3.0+
185
- - DeepSeek Harness (dsh): dev preview — verified on mainline 2026-08-14 (gate bundle loaded and injected in-session)
185
+ - DeepSeek Harness (dsh): dev preview — supports `>= 0.1.0-rc.7` (the standing declaration; this round re-measured 0.1.5-rc.2 / 0.1.5-rc.3 / 0.1.6-alpha.2 / 0.1.7-alpha.1 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2 — per-release evidence in [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md))
186
+ - The Web Plugins-page "Gate injection" switch requires **dsh ≥ 0.1.7-alpha.1** — its settings service must project live fields. On older dsh the plugin still loads and still injects, with the switch simply absent and **no error**: below schemastery 3.18.3 the field degrades to an ordinary boolean, and a settings service without `whileServed` makes the client half register nothing.
186
187
  - Python 3.6+ optional (only for the automated harness; manual fallback mode requires none)
187
188
 
188
189
  ## Configuration
@@ -195,6 +196,8 @@ In DeepSeek Harness, the bundle registers the `logicprobe_verify` tool (through
195
196
  | `gateContent` | string | built-in gate text | Override the text injected into the first model step. |
196
197
  | `interaction` | `ask` \| `auto` \| `follow-approval` | `follow-approval` | Model-confirmation policy. `follow-approval` resolves to `auto` when the session approval policy is `never`. |
197
198
 
199
+ The switch is editable live in the dsh Web GUI: sidebar **Plugins** → this plugin's card → "Gate injection". It takes effect without a profile restart and controls only the injected text — turning it off leaves the skills and the verification tools registered. The same card also carries a coarser row switch: turning that off unmounts the whole row (skills, tools, and this switch go with it). Persistent overrides still go through the profile patch below.
200
+
198
201
  To change it, override the row by id in your profile's `cordis.patch.yml`:
199
202
 
200
203
  ```yaml
@@ -239,7 +242,7 @@ npm run build
239
242
 
240
243
  Test chain:
241
244
 
242
- - `npm run test:engine` — state-machine / data-model engine regression (`tests/engine`, `tests/data-engine`, `tests/concurrency`, `tests/apply-smoke`, `tests/exporters`, `tests/external`) plus byte-for-byte Python parity (`tests/python/run.mjs`: the same fixtures are compared between the TS engine and `skills/logicprobe/references/logicprobe-engine.py` across reports / composition / exporter output; auto-SKIP when Python is absent)
245
+ - `npm run test:engine` — state-machine / data-model engine regression (`tests/engine`, `tests/data-engine`, `tests/concurrency`, `tests/apply-smoke`, `tests/dsh-client-half`, `tests/exporters`, `tests/external`) plus byte-for-byte Python parity (`tests/python/run.mjs`: the same fixtures are compared between the TS engine and `skills/logicprobe/references/logicprobe-engine.py` across reports / composition / exporter output; auto-SKIP when Python is absent)
243
246
  - `npm run test:full` — `tests/full-suite.mjs` combined end-to-end suite
244
247
  - `npm run test:python` — Python parity only (build + `tests/python/run.mjs`)
245
248
  - Trigger tests are under `tests/skill-triggering/`: `bash tests/skill-triggering/run-all.sh`
package/README.md CHANGED
@@ -181,7 +181,8 @@ cp -r logicprobe/skills/* .zcode/skills/
181
181
  ## 环境要求
182
182
 
183
183
  - Claude Code v2.1+ / Codex CLI 最新 / Cursor 2.5+ / Kimi CLI 最新 / OpenCode 最新 / ZCode 3.0+
184
- - DeepSeek Harness (dsh): dev preview — 已实测 mainline 2026-08-14(gate bundle 加载并注入会话成功)
184
+ - DeepSeek Harness (dsh): dev preview — 支持 `>= 0.1.0-rc.7`(沿用既有声明;本轮实测覆盖 0.1.5-rc.2 / 0.1.5-rc.3 / 0.1.6-alpha.2 / 0.1.7-alpha.1 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2,逐版本证据见 [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md))
185
+ - Web 端的「Gate 注入」开关需要 **dsh ≥ 0.1.7-alpha.1**(设置服务必须能投影即时字段)。更早的 dsh 上插件照常加载、照常注入,只是开关不出现、**也不报错**:schemastery 早于 3.18.3 时该字段退化为普通布尔值;设置服务没有 `whileServed` 时客户端半侧不注册任何东西。
185
186
  - Python 3.6+ 可选(仅自动验证工具需要;手动兜底模式无需任何依赖)
186
187
 
187
188
  ## 配置
@@ -194,6 +195,8 @@ cp -r logicprobe/skills/* .zcode/skills/
194
195
  | `gateContent` | string | 内置 gate 文本 | 覆盖注入到首轮模型上下文中的文本。 |
195
196
  | `interaction` | `ask` \| `auto` \| `follow-approval` | `follow-approval` | 模型确认策略;`follow-approval` 在会话 approval policy 为 `never` 时解析为 `auto`。 |
196
197
 
198
+ 在 dsh Web GUI 里可以直接改这个开关:侧边栏 **插件** → 本插件卡片 → 「Gate 注入」。它实时生效,不必重启 profile,而且只管注入的那段文本——关掉后 skills 与验证工具照常注册。同一张卡片上还有一个更粗粒度的行开关:关掉它会整行卸载插件(技能、工具和这个开关一起消失)。要持久化覆盖,仍按下面的 profile patch 写。
199
+
197
200
  在 profile 的 `cordis.patch.yml` 中按 row id 覆盖:
198
201
 
199
202
  ```yaml
@@ -237,7 +240,7 @@ npm run build
237
240
 
238
241
  测试链:
239
242
 
240
- - `npm run test:engine` — 状态机/数据模型引擎回归(`tests/engine`、`tests/data-engine`、`tests/concurrency`、`tests/apply-smoke`、`tests/exporters`、`tests/external`)+ Python 逐字节一致性对照(`tests/python/run.mjs`:同一批 fixture 在 TS 引擎与 `skills/logicprobe/references/logicprobe-engine.py` 之间比对报告/组合/导出产物;无 Python 时自动 SKIP)
243
+ - `npm run test:engine` — 状态机/数据模型引擎回归(`tests/engine`、`tests/data-engine`、`tests/concurrency`、`tests/apply-smoke`、`tests/dsh-client-half`、`tests/exporters`、`tests/external`)+ Python 逐字节一致性对照(`tests/python/run.mjs`:同一批 fixture 在 TS 引擎与 `skills/logicprobe/references/logicprobe-engine.py` 之间比对报告/组合/导出产物;无 Python 时自动 SKIP)
241
244
  - `npm run test:full` — `tests/full-suite.mjs` 端到端合并套件
242
245
  - `npm run test:python` — 仅 Python parity(构建 + `tests/python/run.mjs`)
243
246
  - 触发测试位于 `tests/skill-triggering/`:`bash tests/skill-triggering/run-all.sh`
package/lib/client.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * logicprobe — browser half: the gate-injection switch on the dsh Web client's
3
+ * Plugins page.
4
+ *
5
+ * The Plugins page (`@deepseek-ai/dsh-client-ui-plugin-manager`) owns the
6
+ * sidebar **Plugins** entry and declares the slots a bundle's own configuration
7
+ * registers into. This module contributes one `plugins.bundle.config` entry,
8
+ * keyed by this package's npm name, so the switch renders on logicprobe's own
9
+ * page between its description and its rows.
10
+ *
11
+ * Why the switch writes through `configForms` rather than reaching for the
12
+ * profile file: dsh's settings service exposes only the Config fields declared
13
+ * `.volatile()`, and it rejects a write to any other path. `enabled` is such a
14
+ * field (see `src/index.ts`), so flipping the switch is an ordinary
15
+ * revision-fenced settings write that the running Host picks up in place — the
16
+ * injection there re-reads the reference on every model step.
17
+ *
18
+ * Shape: this is a prebuilt module-system bundle, not a source module. It calls
19
+ * `window.__ModuleLoader__.load({ id, factory })` with this package's resolved
20
+ * npm name, and `factory` returns the cordis plugin face. Only the client
21
+ * baseline is requested (`react` and
22
+ * `@deepseek-ai/dsh-client-ui-primitives`); every other capability arrives
23
+ * through cordis `inject`. `scripts/build-client.mjs` publishes this file
24
+ * verbatim as `lib/client.js`.
25
+ *
26
+ * @module dsh-logicprobe/client
27
+ */
28
+
29
+ window.__ModuleLoader__.load({
30
+ id: 'dsh-logicprobe',
31
+ factory: (require) => {
32
+ const React = require('react')
33
+ const { Button, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
34
+
35
+ /** Settings namespace: the Loader entry id this bundle's patch declares. */
36
+ const NS = 'logicprobe'
37
+ /** `plugins.bundle.config` key: the bundle's npm package name. */
38
+ const PACKAGE = 'dsh-logicprobe'
39
+ /** This page's dictionary namespace. */
40
+ const LOCALE_NS = 'logicprobe.plugins'
41
+ /** The Config field the switch writes inside the namespace's section. */
42
+ const FIELD = 'enabled'
43
+
44
+ /** English copy. */
45
+ const en = {
46
+ title: 'Gate injection',
47
+ label: 'Inject the gate text',
48
+ hint: 'Folds the claim-verification doctrine into the first model step of every session. Turning it off leaves the skills and the verification tools registered — only the injected text is dropped.',
49
+ overridden: 'Overridden',
50
+ reset: 'Reset to default',
51
+ readOnly: 'This deployment stores settings read-only.',
52
+ unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
53
+ saveFailed: 'The deployment did not accept that value; the switch shows what is stored.',
54
+ }
55
+ /** Simplified Chinese copy. */
56
+ const zh = {
57
+ title: 'Gate 注入',
58
+ label: '注入 gate 文本',
59
+ hint: '把 claim 核查铁律折进每个会话的第一个模型步。关掉后 skills 与验证工具仍然注册,只是不再注入那段提示文本。',
60
+ overridden: '已覆盖',
61
+ reset: '恢复默认',
62
+ readOnly: '本部署的设置为只读。',
63
+ unavailable: '该插件当前未加载,暂时无法配置。',
64
+ saveFailed: '本部署没有接受这个值,开关显示的是已存下的状态。',
65
+ }
66
+
67
+ /** Required cordis services. */
68
+ const inject = ['slots', 'locale', 'configForms']
69
+
70
+ const GROUP = { display: 'flex', flexDirection: 'column', gap: '8px' }
71
+ const TITLE = { margin: 0, fontSize: '14px', fontWeight: '500', lineHeight: '22px' }
72
+ const ROW = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px' }
73
+ const LABEL = { fontSize: '13px', lineHeight: '20px' }
74
+ const NOTE = { margin: 0, fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' }
75
+ const FAILED = { margin: 0, fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary)' }
76
+
77
+ /**
78
+ * Whether a settings-layer value carries this field, which is what marks it
79
+ * overridden: an override equal to the default is still an override.
80
+ * @param layer - the raw user layer the form snapshot carries.
81
+ * @returns whether the layer holds the field.
82
+ */
83
+ function carries(layer) {
84
+ return layer !== null && typeof layer === 'object' && Object.prototype.hasOwnProperty.call(layer, FIELD)
85
+ }
86
+
87
+ /**
88
+ * Render the gate-injection switch, or the note saying why it cannot render.
89
+ * @param props - the page's `t` seat, the bound form snapshot hook, and the write actions.
90
+ * @returns the body of this bundle's configuration section.
91
+ */
92
+ function InjectionCard(props) {
93
+ const t = props.t
94
+ const state = props.useInjectionForm((snapshot) => snapshot)
95
+ const [pending, setPending] = React.useState(false)
96
+ const [failed, setFailed] = React.useState(false)
97
+
98
+ /** Run one settings write and report a refusal or a transport failure. */
99
+ const write = (run) => {
100
+ setPending(true)
101
+ setFailed(false)
102
+ Promise.resolve(run()).then(
103
+ (accepted) => {
104
+ setPending(false)
105
+ setFailed(accepted === false)
106
+ },
107
+ () => {
108
+ setPending(false)
109
+ setFailed(true)
110
+ },
111
+ )
112
+ }
113
+
114
+ if (state.status !== 'ready') {
115
+ return React.createElement('p', { style: NOTE }, t('unavailable'))
116
+ }
117
+
118
+ const section = state.value !== null && typeof state.value === 'object' ? state.value : {}
119
+ // The schema default is `true`; only an explicit false means off.
120
+ const checked = section[FIELD] !== false
121
+ const overridden = carries(state.user)
122
+ const locked = state.writable !== true || pending
123
+
124
+ const children = [
125
+ React.createElement('h4', { key: 'title', style: TITLE }, t('title')),
126
+ React.createElement('div', { key: 'row', style: ROW }, [
127
+ React.createElement('span', { key: 'label', style: LABEL }, t('label')),
128
+ React.createElement(Switch, {
129
+ key: 'switch',
130
+ checked,
131
+ disabled: locked,
132
+ label: t('label'),
133
+ onChange: (next) => write(() => props.setEnabled(next)),
134
+ }),
135
+ ]),
136
+ React.createElement('p', { key: 'hint', style: NOTE }, state.writable === true ? t('hint') : t('readOnly')),
137
+ ]
138
+
139
+ if (overridden) {
140
+ children.push(
141
+ React.createElement('div', { key: 'overridden', style: ROW }, [
142
+ React.createElement('span', { key: 'badge', style: NOTE }, t('overridden')),
143
+ React.createElement(
144
+ Button,
145
+ {
146
+ key: 'reset',
147
+ variant: 'outline',
148
+ size: 'sm',
149
+ disabled: locked,
150
+ onClick: () => write(() => props.resetEnabled()),
151
+ },
152
+ t('reset'),
153
+ ),
154
+ ]),
155
+ )
156
+ }
157
+
158
+ if (failed) {
159
+ children.push(React.createElement('p', { key: 'failed', style: FAILED, role: 'alert' }, t('saveFailed')))
160
+ }
161
+
162
+ return React.createElement('div', { style: GROUP }, children)
163
+ }
164
+
165
+ /**
166
+ * Mount the switch while the Host serves logicprobe's settings namespace.
167
+ * @param ctx - the browser plugin context.
168
+ */
169
+ function apply(ctx) {
170
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'dsh-logicprobe: dictionaries')
171
+ // `whileServed` is the registration barrier that keeps this page alive only
172
+ // while the Host serves the namespace. Hosts predating it (measured: dsh
173
+ // 0.1.5-rc.3 and 0.1.6-alpha.2) have no live settings field to offer at all,
174
+ // so there is nothing to register — and calling it there would throw during
175
+ // this plugin's own activation, which the Web boot audit then reports as a
176
+ // failed client entry. Degrade to no page instead.
177
+ if (typeof ctx.configForms.whileServed !== 'function') return
178
+ // The page renders the section only for a bundle whose package name is in
179
+ // its configuration ledger, and the ledger follows this registration. The
180
+ // registration in turn waits for the namespace to be served, so a profile
181
+ // whose logicprobe row is switched off shows no trace of the switch.
182
+ ctx.effect(
183
+ () =>
184
+ ctx.configForms.whileServed([NS], () => {
185
+ const form = ctx.configForms.get(NS)
186
+ const source = {
187
+ getSnapshot: () => form.getSnapshot(),
188
+ subscribe: (listener) => form.subscribe(listener),
189
+ }
190
+ return ctx.slots.inject('plugins.bundle.config', () =>
191
+ ctx.slots.register(
192
+ {
193
+ name: 'plugins.bundle.config',
194
+ key: PACKAGE,
195
+ locale: LOCALE_NS,
196
+ inject: () => ({
197
+ hooks: { injectionForm: source },
198
+ setEnabled: (next) => form.set(FIELD, next),
199
+ resetEnabled: () => form.unset(FIELD),
200
+ }),
201
+ },
202
+ InjectionCard,
203
+ ),
204
+ )
205
+ }),
206
+ 'dsh-logicprobe: gate-injection switch',
207
+ )
208
+ }
209
+
210
+ return { inject, apply }
211
+ },
212
+ })
package/lib/index.js CHANGED
@@ -63,11 +63,43 @@ Plugin logicprobe is active. Documents are not truth — code is. Verify every v
63
63
 
64
64
  **Proactive suggestion**: When a user asks code-level behavioral questions — "could this state machine deadlock", "is this retry limit safe", "check this timing sequence for bugs", "is this migration non-breaking", "does this copy cover all required fields" — suggest logicprobe as an optional verification pass (do not auto-escalate).
65
65
  </EXTREMELY_IMPORTANT>`;
66
+ /**
67
+ * Declare a field as live where this host's schemastery can — `.volatile()`
68
+ * arrived in 3.18.3 — and leave it an ordinary field where it cannot.
69
+ *
70
+ * The fallback is load-bearing, not defensive padding. `Config` below is built
71
+ * while this module is still being evaluated, so an unconditional `.volatile()`
72
+ * on a host shipping schemastery 3.18.2 (measured: dsh 0.1.5-rc.2 and
73
+ * 0.1.5-rc.3) throws during import; the loader entry then fails and takes the
74
+ * WHOLE plugin tree — and the host's boot — down with it. Degrading costs only
75
+ * the Plugins-page switch, because the settings service projects nothing but
76
+ * fields under a `.volatile()` node; skills, tools and the gate injection are
77
+ * untouched. The returned schema keeps the plain field's static type; the
78
+ * `Config` interface below carries the union the host actually hands over.
79
+ */
80
+ function live(field) {
81
+ const probe = field;
82
+ return typeof probe.volatile === 'function' ? probe.volatile() : field;
83
+ }
66
84
  export const Config = z.object({
67
- enabled: z.boolean().default(true),
85
+ // Live so the Web Plugins page can flip the gate injection inside a running
86
+ // session: dsh's settings service projects ONLY fields under a `.volatile()`
87
+ // node and rejects writes to every other path. The price is that the injection
88
+ // reads the reference per step instead of deciding once at mount, which is also
89
+ // what lets a toggle take effect without remounting the row.
90
+ enabled: live(z.boolean().default(true)),
68
91
  gateContent: z.string().default(DEFAULT_GATE_CONTENT),
69
92
  interaction: z.union(['ask', 'auto', 'follow-approval']).default('follow-approval'),
70
93
  });
94
+ /**
95
+ * Read the injection switch as a boolean, whichever shape this host produced.
96
+ * @param config - the resolved plugin configuration.
97
+ * @returns whether the gate may be injected.
98
+ */
99
+ function injectionEnabled(config) {
100
+ const value = config.enabled;
101
+ return typeof value === 'boolean' ? value : value.get();
102
+ }
71
103
  function gateMessage(text) {
72
104
  return createUserMessage({
73
105
  content: [{ type: 'text', text }],
@@ -166,7 +198,7 @@ function inspectProvider(config, isToolRegistered, isDataToolRegistered, isConcu
166
198
  query: async (method) => {
167
199
  if (method === 'status') {
168
200
  return {
169
- enabled: config.enabled,
201
+ enabled: injectionEnabled(config),
170
202
  gateContentLength: config.gateContent.length,
171
203
  interaction: config.interaction,
172
204
  toolRegistered: isToolRegistered(),
@@ -272,8 +304,12 @@ export function apply(ctx, config) {
272
304
  customSkillDirs: [SKILLS_DIR],
273
305
  });
274
306
  });
275
- if (!config.enabled)
276
- return;
307
+ // Injection listens unconditionally, including while the switch is off: the
308
+ // switch is volatile, so `apply` runs once and the value behind it can turn on
309
+ // later from the Web Plugins page. Returning early on a false value here would
310
+ // freeze that decision for the lifetime of the mount, and turning the switch
311
+ // back on could never take effect without a profile restart.
312
+ //
277
313
  // Inject the gate once per session on the FIRST model step that runs,
278
314
  // instead of at session-start: session-start injection lands in the agent's
279
315
  // inbox, which a blank-session preset switch (agentPreset.select ->
@@ -290,6 +326,8 @@ export function apply(ctx, config) {
290
326
  if (decision.kind === 'reject')
291
327
  return decision;
292
328
  registerIntegrations();
329
+ if (!injectionEnabled(config))
330
+ return decision;
293
331
  if (gateInHistory(agent.session))
294
332
  return decision;
295
333
  return {
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * @module logicprobe-dsh
26
26
  */
27
- import type { Context } from '@deepseek-ai/cordis';
27
+ import type { Context, Volatile } from '@deepseek-ai/cordis';
28
28
  import z from '@deepseek-ai/schemastery';
29
29
  import type { ContextFormed } from '@deepseek-ai/dsh-llm';
30
30
  declare module '@deepseek-ai/dsh-llm' {
@@ -38,17 +38,23 @@ export declare const name = "logicprobe";
38
38
  export declare const inject: string[];
39
39
  export type InteractionMode = 'ask' | 'auto' | 'follow-approval';
40
40
  export interface Config {
41
- enabled: boolean;
41
+ /**
42
+ * The injection switch the Web client's Plugins page edits live: a `Volatile`
43
+ * reference on a host whose schemastery supports one, an ordinary boolean on a
44
+ * host that predates `.volatile()`. Read it through {@link injectionEnabled},
45
+ * which accepts both shapes.
46
+ */
47
+ enabled: Volatile<boolean> | boolean;
42
48
  gateContent: string;
43
49
  interaction: InteractionMode;
44
50
  }
45
- export declare const Config: z<Schemastery.ObjectS<{
46
- enabled: z<boolean, boolean>;
47
- gateContent: z<string, string>;
48
- interaction: z<"ask" | "auto" | "follow-approval", "ask" | "auto" | "follow-approval">;
49
- }>, Schemastery.ObjectT<{
50
- enabled: z<boolean, boolean>;
51
- gateContent: z<string, string>;
52
- interaction: z<"ask" | "auto" | "follow-approval", "ask" | "auto" | "follow-approval">;
53
- }>>;
51
+ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
52
+ enabled: z<boolean, boolean, "defined">;
53
+ gateContent: z<string, string, "defined">;
54
+ interaction: z<"ask" | "auto" | "follow-approval", "ask" | "auto" | "follow-approval", "defined">;
55
+ }>>, Schemastery.ObjectT<NoInfer<{
56
+ enabled: z<boolean, boolean, "defined">;
57
+ gateContent: z<string, string, "defined">;
58
+ interaction: z<"ask" | "auto" | "follow-approval", "ask" | "auto" | "follow-approval", "defined">;
59
+ }>>, "plain">;
54
60
  export declare function apply(ctx: Context, config: Config): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-logicprobe",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Design document & plan claim verification — enumerate claims, verify against codebase facts, then escalate to state-machine verification (S1-S8/A1-A12, including budget/worst-case path-cost checks) and data-model verification (DS/DA/DD) for behavioral claims. Supports before/after regression, idempotency/monotonic/sequence/leads-to/atomicity constraints, and concurrency risk mining. Ships a native DeepSeek Harness (dsh) bundle that injects the claim-verification gate into the first model step.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,6 +11,7 @@
11
11
  "default": "./lib/index.js"
12
12
  },
13
13
  "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./client": "./lib/client.js",
14
15
  "./package.json": "./package.json"
15
16
  },
16
17
  "files": [
@@ -28,8 +29,16 @@
28
29
  "bundle": {
29
30
  "patch": "./cordis.patch.yml"
30
31
  },
32
+ "client": {
33
+ "platform": "web",
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-settings",
37
+ "@deepseek-ai/dsh-client-ui-plugin-manager"
38
+ ]
39
+ },
31
40
  "compatibility": {
32
- "dsh": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.2-alpha.3 || ^0.1.2-alpha.4 || ^0.1.2-alpha.5 || ^0.1.2-rc.1 || ^0.1.3-alpha.1 || ^0.1.3-alpha.2 || ^0.1.5-alpha.1 || ^0.1.5-rc.1 || ^0.1.5-alpha.2 || ^0.1.5-rc.2 || ^0.1.5-rc.3 || ^0.1.6-alpha.1 || ^0.1.6-alpha.2 || ^0.1.7-alpha.1 || ^0.1.7-alpha.2 || ^0.1.7-rc.1 || ^0.1.7-rc.2",
41
+ "dsh": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.2-alpha.3 || ^0.1.2-alpha.4 || ^0.1.2-alpha.5 || ^0.1.2-rc.1 || ^0.1.3-alpha.1 || ^0.1.3-alpha.2 || ^0.1.5-alpha.1 || ^0.1.5-rc.1 || ^0.1.5-alpha.2 || ^0.1.5-rc.2 || ^0.1.5-rc.3 || ^0.1.6-alpha.1 || ^0.1.6-alpha.2 || ^0.1.7-alpha.1 || ^0.1.7-alpha.2 || ^0.1.7-rc.1 || ^0.1.7-rc.2 || ^0.2.0-rc.1",
33
42
  "dshReleases": {
34
43
  "0.1.0-rc.7": "compatible",
35
44
  "0.1.0-rc.8": "compatible",
@@ -52,32 +61,35 @@
52
61
  "0.1.7-alpha.1": "compatible",
53
62
  "0.1.7-alpha.2": "compatible",
54
63
  "0.1.7-rc.1": "compatible",
55
- "0.1.7-rc.2": "compatible"
64
+ "0.1.7-rc.2": "compatible",
65
+ "0.2.0-rc.1": "compatible",
66
+ "0.2.0-rc.2": "compatible"
56
67
  },
57
68
  "profiles": [
58
- "headless"
69
+ "headless",
70
+ "web"
59
71
  ]
60
72
  }
61
73
  },
62
74
  "scripts": {
63
- "build": "tsc -p tsconfig.json",
75
+ "build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
64
76
  "typecheck": "tsc -p tsconfig.json --noEmit",
65
77
  "bump": "node scripts/bump-version.mjs",
66
- "test:engine": "npm run build && node tests/engine/run.mjs && node tests/data-engine/run.mjs && node tests/concurrency/run.mjs && node tests/apply-smoke.mjs && node tests/exporters/run.mjs && node tests/external/run.mjs && node tests/python/run.mjs",
78
+ "test:engine": "npm run build && node tests/engine/run.mjs && node tests/data-engine/run.mjs && node tests/concurrency/run.mjs && node tests/apply-smoke.mjs && node tests/dsh-client-half.test.mjs && node tests/dsh-volatile-fallback.test.mjs && node tests/exporters/run.mjs && node tests/external/run.mjs && node tests/python/run.mjs",
67
79
  "test:full": "npm run build && node tests/full-suite.mjs",
68
80
  "test:python": "npm run build && node tests/python/run.mjs"
69
81
  },
70
82
  "peerDependencies": {
71
- "@deepseek-ai/cordis": "^4.0.2",
72
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
73
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
74
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
75
- "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.8",
76
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
77
- "@deepseek-ai/schemastery": "^3.18.2"
83
+ "@deepseek-ai/cordis": "^4.0.4",
84
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1",
85
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1",
86
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
87
+ "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.8 || ^0.2.0-rc.1",
88
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.2.0-rc.1",
89
+ "@deepseek-ai/schemastery": "^3.18.4"
78
90
  },
79
91
  "devDependencies": {
80
- "@deepseek-ai/cordis": "^4.0.2",
92
+ "@deepseek-ai/cordis": "^4.0.4",
81
93
  "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
82
94
  "@deepseek-ai/dsh-cordis-host-runner": "^0.1.0-rc.8",
83
95
  "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
@@ -89,7 +101,7 @@
89
101
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
90
102
  "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6",
91
103
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
92
- "@deepseek-ai/schemastery": "^3.18.2",
104
+ "@deepseek-ai/schemastery": "^3.18.4",
93
105
  "@types/node": "^26.6.2",
94
106
  "typescript": "^7.0.2"
95
107
  },
package/src/client.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * logicprobe — browser half: the gate-injection switch on the dsh Web client's
3
+ * Plugins page.
4
+ *
5
+ * The Plugins page (`@deepseek-ai/dsh-client-ui-plugin-manager`) owns the
6
+ * sidebar **Plugins** entry and declares the slots a bundle's own configuration
7
+ * registers into. This module contributes one `plugins.bundle.config` entry,
8
+ * keyed by this package's npm name, so the switch renders on logicprobe's own
9
+ * page between its description and its rows.
10
+ *
11
+ * Why the switch writes through `configForms` rather than reaching for the
12
+ * profile file: dsh's settings service exposes only the Config fields declared
13
+ * `.volatile()`, and it rejects a write to any other path. `enabled` is such a
14
+ * field (see `src/index.ts`), so flipping the switch is an ordinary
15
+ * revision-fenced settings write that the running Host picks up in place — the
16
+ * injection there re-reads the reference on every model step.
17
+ *
18
+ * Shape: this is a prebuilt module-system bundle, not a source module. It calls
19
+ * `window.__ModuleLoader__.load({ id, factory })` with this package's resolved
20
+ * npm name, and `factory` returns the cordis plugin face. Only the client
21
+ * baseline is requested (`react` and
22
+ * `@deepseek-ai/dsh-client-ui-primitives`); every other capability arrives
23
+ * through cordis `inject`. `scripts/build-client.mjs` publishes this file
24
+ * verbatim as `lib/client.js`.
25
+ *
26
+ * @module dsh-logicprobe/client
27
+ */
28
+
29
+ window.__ModuleLoader__.load({
30
+ id: 'dsh-logicprobe',
31
+ factory: (require) => {
32
+ const React = require('react')
33
+ const { Button, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
34
+
35
+ /** Settings namespace: the Loader entry id this bundle's patch declares. */
36
+ const NS = 'logicprobe'
37
+ /** `plugins.bundle.config` key: the bundle's npm package name. */
38
+ const PACKAGE = 'dsh-logicprobe'
39
+ /** This page's dictionary namespace. */
40
+ const LOCALE_NS = 'logicprobe.plugins'
41
+ /** The Config field the switch writes inside the namespace's section. */
42
+ const FIELD = 'enabled'
43
+
44
+ /** English copy. */
45
+ const en = {
46
+ title: 'Gate injection',
47
+ label: 'Inject the gate text',
48
+ hint: 'Folds the claim-verification doctrine into the first model step of every session. Turning it off leaves the skills and the verification tools registered — only the injected text is dropped.',
49
+ overridden: 'Overridden',
50
+ reset: 'Reset to default',
51
+ readOnly: 'This deployment stores settings read-only.',
52
+ unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
53
+ saveFailed: 'The deployment did not accept that value; the switch shows what is stored.',
54
+ }
55
+ /** Simplified Chinese copy. */
56
+ const zh = {
57
+ title: 'Gate 注入',
58
+ label: '注入 gate 文本',
59
+ hint: '把 claim 核查铁律折进每个会话的第一个模型步。关掉后 skills 与验证工具仍然注册,只是不再注入那段提示文本。',
60
+ overridden: '已覆盖',
61
+ reset: '恢复默认',
62
+ readOnly: '本部署的设置为只读。',
63
+ unavailable: '该插件当前未加载,暂时无法配置。',
64
+ saveFailed: '本部署没有接受这个值,开关显示的是已存下的状态。',
65
+ }
66
+
67
+ /** Required cordis services. */
68
+ const inject = ['slots', 'locale', 'configForms']
69
+
70
+ const GROUP = { display: 'flex', flexDirection: 'column', gap: '8px' }
71
+ const TITLE = { margin: 0, fontSize: '14px', fontWeight: '500', lineHeight: '22px' }
72
+ const ROW = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px' }
73
+ const LABEL = { fontSize: '13px', lineHeight: '20px' }
74
+ const NOTE = { margin: 0, fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' }
75
+ const FAILED = { margin: 0, fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary)' }
76
+
77
+ /**
78
+ * Whether a settings-layer value carries this field, which is what marks it
79
+ * overridden: an override equal to the default is still an override.
80
+ * @param layer - the raw user layer the form snapshot carries.
81
+ * @returns whether the layer holds the field.
82
+ */
83
+ function carries(layer) {
84
+ return layer !== null && typeof layer === 'object' && Object.prototype.hasOwnProperty.call(layer, FIELD)
85
+ }
86
+
87
+ /**
88
+ * Render the gate-injection switch, or the note saying why it cannot render.
89
+ * @param props - the page's `t` seat, the bound form snapshot hook, and the write actions.
90
+ * @returns the body of this bundle's configuration section.
91
+ */
92
+ function InjectionCard(props) {
93
+ const t = props.t
94
+ const state = props.useInjectionForm((snapshot) => snapshot)
95
+ const [pending, setPending] = React.useState(false)
96
+ const [failed, setFailed] = React.useState(false)
97
+
98
+ /** Run one settings write and report a refusal or a transport failure. */
99
+ const write = (run) => {
100
+ setPending(true)
101
+ setFailed(false)
102
+ Promise.resolve(run()).then(
103
+ (accepted) => {
104
+ setPending(false)
105
+ setFailed(accepted === false)
106
+ },
107
+ () => {
108
+ setPending(false)
109
+ setFailed(true)
110
+ },
111
+ )
112
+ }
113
+
114
+ if (state.status !== 'ready') {
115
+ return React.createElement('p', { style: NOTE }, t('unavailable'))
116
+ }
117
+
118
+ const section = state.value !== null && typeof state.value === 'object' ? state.value : {}
119
+ // The schema default is `true`; only an explicit false means off.
120
+ const checked = section[FIELD] !== false
121
+ const overridden = carries(state.user)
122
+ const locked = state.writable !== true || pending
123
+
124
+ const children = [
125
+ React.createElement('h4', { key: 'title', style: TITLE }, t('title')),
126
+ React.createElement('div', { key: 'row', style: ROW }, [
127
+ React.createElement('span', { key: 'label', style: LABEL }, t('label')),
128
+ React.createElement(Switch, {
129
+ key: 'switch',
130
+ checked,
131
+ disabled: locked,
132
+ label: t('label'),
133
+ onChange: (next) => write(() => props.setEnabled(next)),
134
+ }),
135
+ ]),
136
+ React.createElement('p', { key: 'hint', style: NOTE }, state.writable === true ? t('hint') : t('readOnly')),
137
+ ]
138
+
139
+ if (overridden) {
140
+ children.push(
141
+ React.createElement('div', { key: 'overridden', style: ROW }, [
142
+ React.createElement('span', { key: 'badge', style: NOTE }, t('overridden')),
143
+ React.createElement(
144
+ Button,
145
+ {
146
+ key: 'reset',
147
+ variant: 'outline',
148
+ size: 'sm',
149
+ disabled: locked,
150
+ onClick: () => write(() => props.resetEnabled()),
151
+ },
152
+ t('reset'),
153
+ ),
154
+ ]),
155
+ )
156
+ }
157
+
158
+ if (failed) {
159
+ children.push(React.createElement('p', { key: 'failed', style: FAILED, role: 'alert' }, t('saveFailed')))
160
+ }
161
+
162
+ return React.createElement('div', { style: GROUP }, children)
163
+ }
164
+
165
+ /**
166
+ * Mount the switch while the Host serves logicprobe's settings namespace.
167
+ * @param ctx - the browser plugin context.
168
+ */
169
+ function apply(ctx) {
170
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'dsh-logicprobe: dictionaries')
171
+ // `whileServed` is the registration barrier that keeps this page alive only
172
+ // while the Host serves the namespace. Hosts predating it (measured: dsh
173
+ // 0.1.5-rc.3 and 0.1.6-alpha.2) have no live settings field to offer at all,
174
+ // so there is nothing to register — and calling it there would throw during
175
+ // this plugin's own activation, which the Web boot audit then reports as a
176
+ // failed client entry. Degrade to no page instead.
177
+ if (typeof ctx.configForms.whileServed !== 'function') return
178
+ // The page renders the section only for a bundle whose package name is in
179
+ // its configuration ledger, and the ledger follows this registration. The
180
+ // registration in turn waits for the namespace to be served, so a profile
181
+ // whose logicprobe row is switched off shows no trace of the switch.
182
+ ctx.effect(
183
+ () =>
184
+ ctx.configForms.whileServed([NS], () => {
185
+ const form = ctx.configForms.get(NS)
186
+ const source = {
187
+ getSnapshot: () => form.getSnapshot(),
188
+ subscribe: (listener) => form.subscribe(listener),
189
+ }
190
+ return ctx.slots.inject('plugins.bundle.config', () =>
191
+ ctx.slots.register(
192
+ {
193
+ name: 'plugins.bundle.config',
194
+ key: PACKAGE,
195
+ locale: LOCALE_NS,
196
+ inject: () => ({
197
+ hooks: { injectionForm: source },
198
+ setEnabled: (next) => form.set(FIELD, next),
199
+ resetEnabled: () => form.unset(FIELD),
200
+ }),
201
+ },
202
+ InjectionCard,
203
+ ),
204
+ )
205
+ }),
206
+ 'dsh-logicprobe: gate-injection switch',
207
+ )
208
+ }
209
+
210
+ return { inject, apply }
211
+ },
212
+ })
package/src/index.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { fileURLToPath } from 'node:url'
29
- import type { Context } from '@deepseek-ai/cordis'
29
+ import type { Context, Volatile } from '@deepseek-ai/cordis'
30
30
  import z from '@deepseek-ai/schemastery'
31
31
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
32
32
  import type { ContextFormed } from '@deepseek-ai/dsh-llm'
@@ -90,18 +90,63 @@ Plugin logicprobe is active. Documents are not truth — code is. Verify every v
90
90
  **Proactive suggestion**: When a user asks code-level behavioral questions — "could this state machine deadlock", "is this retry limit safe", "check this timing sequence for bugs", "is this migration non-breaking", "does this copy cover all required fields" — suggest logicprobe as an optional verification pass (do not auto-escalate).
91
91
  </EXTREMELY_IMPORTANT>`
92
92
 
93
+ /** A schemastery field that may or may not carry `.volatile()`. */
94
+ interface LiveField {
95
+ volatile?: () => unknown
96
+ }
97
+
98
+ /**
99
+ * Declare a field as live where this host's schemastery can — `.volatile()`
100
+ * arrived in 3.18.3 — and leave it an ordinary field where it cannot.
101
+ *
102
+ * The fallback is load-bearing, not defensive padding. `Config` below is built
103
+ * while this module is still being evaluated, so an unconditional `.volatile()`
104
+ * on a host shipping schemastery 3.18.2 (measured: dsh 0.1.5-rc.2 and
105
+ * 0.1.5-rc.3) throws during import; the loader entry then fails and takes the
106
+ * WHOLE plugin tree — and the host's boot — down with it. Degrading costs only
107
+ * the Plugins-page switch, because the settings service projects nothing but
108
+ * fields under a `.volatile()` node; skills, tools and the gate injection are
109
+ * untouched. The returned schema keeps the plain field's static type; the
110
+ * `Config` interface below carries the union the host actually hands over.
111
+ */
112
+ function live<T>(field: T): T {
113
+ const probe = field as T & LiveField
114
+ return typeof probe.volatile === 'function' ? (probe.volatile() as T) : field
115
+ }
116
+
93
117
  export interface Config {
94
- enabled: boolean
118
+ /**
119
+ * The injection switch the Web client's Plugins page edits live: a `Volatile`
120
+ * reference on a host whose schemastery supports one, an ordinary boolean on a
121
+ * host that predates `.volatile()`. Read it through {@link injectionEnabled},
122
+ * which accepts both shapes.
123
+ */
124
+ enabled: Volatile<boolean> | boolean
95
125
  gateContent: string
96
126
  interaction: InteractionMode
97
127
  }
98
128
 
99
129
  export const Config = z.object({
100
- enabled: z.boolean().default(true),
130
+ // Live so the Web Plugins page can flip the gate injection inside a running
131
+ // session: dsh's settings service projects ONLY fields under a `.volatile()`
132
+ // node and rejects writes to every other path. The price is that the injection
133
+ // reads the reference per step instead of deciding once at mount, which is also
134
+ // what lets a toggle take effect without remounting the row.
135
+ enabled: live(z.boolean().default(true)),
101
136
  gateContent: z.string().default(DEFAULT_GATE_CONTENT),
102
137
  interaction: z.union(['ask', 'auto', 'follow-approval']).default('follow-approval'),
103
138
  })
104
139
 
140
+ /**
141
+ * Read the injection switch as a boolean, whichever shape this host produced.
142
+ * @param config - the resolved plugin configuration.
143
+ * @returns whether the gate may be injected.
144
+ */
145
+ function injectionEnabled(config: Config): boolean {
146
+ const value = config.enabled
147
+ return typeof value === 'boolean' ? value : value.get()
148
+ }
149
+
105
150
  function gateMessage(text: string): UserMessage {
106
151
  return createUserMessage({
107
152
  content: [{ type: 'text', text }],
@@ -227,7 +272,7 @@ function inspectProvider(config: Config, isToolRegistered: () => boolean, isData
227
272
  query: async (method) => {
228
273
  if (method === 'status') {
229
274
  return {
230
- enabled: config.enabled,
275
+ enabled: injectionEnabled(config),
231
276
  gateContentLength: config.gateContent.length,
232
277
  interaction: config.interaction,
233
278
  toolRegistered: isToolRegistered(),
@@ -324,7 +369,12 @@ export function apply(ctx: Context, config: Config): void {
324
369
  customSkillDirs: [SKILLS_DIR],
325
370
  })
326
371
  })
327
- if (!config.enabled) return
372
+ // Injection listens unconditionally, including while the switch is off: the
373
+ // switch is volatile, so `apply` runs once and the value behind it can turn on
374
+ // later from the Web Plugins page. Returning early on a false value here would
375
+ // freeze that decision for the lifetime of the mount, and turning the switch
376
+ // back on could never take effect without a profile restart.
377
+ //
328
378
  // Inject the gate once per session on the FIRST model step that runs,
329
379
  // instead of at session-start: session-start injection lands in the agent's
330
380
  // inbox, which a blank-session preset switch (agentPreset.select ->
@@ -340,6 +390,7 @@ export function apply(ctx: Context, config: Config): void {
340
390
  const decision = await next()
341
391
  if (decision.kind === 'reject') return decision
342
392
  registerIntegrations()
393
+ if (!injectionEnabled(config)) return decision
343
394
  if (gateInHistory(agent.session)) return decision
344
395
  return {
345
396
  kind: 'enter',