dsh-decision-layer 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.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  `dsh-decision-layer` adds structured, explicitly scoped decisions to the DeepSeek Harness (DSH) agent loop, backed by a pluggable adjudication backend. Dangerous-call gating, output self-check, tool narrowing, and loop prevention hook into the agent loop as automatic interventions.
6
6
 
7
- > **0.7.0 is a preview release.** The plugin works, but how much real value the adjudication interventions add is still being explored — every threshold is an unvalidated heuristic default. Treat it as something to observe, not a proven guarantee of impact. See [ROADMAP.md](<ROADMAP.md>) for scope and progress.
7
+ > **0.7.1 is a preview release.** The plugin works, but how much real value the adjudication interventions add is still being explored — every threshold is an unvalidated heuristic default. Treat it as something to observe, not a proven guarantee of impact. See [ROADMAP.md](<ROADMAP.md>) for scope and progress.
8
8
 
9
9
  ## Installation
10
10
 
11
- Published to npm and GitHub.
11
+ Published to npm and GitHub Release.
12
12
 
13
13
  From npm (append `@latest`):
14
14
 
@@ -16,12 +16,14 @@ From npm (append `@latest`):
16
16
  dsh plugin --profile web add dsh-decision-layer@latest
17
17
  ```
18
18
 
19
- Or from GitHub (no version — follows the branch or tag; the repository ships the built `lib/client.js`):
19
+ Or from a GitHub Release: install directly from the version's tgz asset URL.
20
20
 
21
21
  ```sh
22
- dsh plugin --profile web add github:xbzbing/dsh-decision-layer
22
+ dsh plugin --profile web add https://github.com/xbzbing/dsh-decision-layer/releases/download/v<version>/dsh-decision-layer-<version>.tgz
23
23
  ```
24
24
 
25
+ > The repository no longer commits the built `lib/client.js` ([ADR-0012](<docs/adr/0012-distribution-via-github-actions.md>)), so installing `github:xbzbing/dsh-decision-layer` without a ref does **not** work — that tree has no `lib/`, and while the host half still loads, the panel never appears. Use the Release tarball on the GitHub side.
26
+
25
27
  Or build locally and install the `file:` path:
26
28
 
27
29
  ```sh
@@ -34,20 +36,32 @@ Restart `dsh web` and refresh after installing or updating: the host and client
34
36
 
35
37
  ## Features
36
38
 
37
- - **Manual verdicts** — `decision_evaluate` and the `decision-layer` skill submit `noul`, `score`, or `choice` questions; `decision_check_connection` tests connectivity. Neither counts as an automatic decision or changes agent behavior.
38
- - **Configurable backend** — defaults to `https://api.typesafe.ai` and `jev-latest`, with per-field URL/key/model overrides under the same protocol. No request without a key. Custom HTTP endpoints require address-bound plaintext-risk confirmation. For the full steps to run a local ollama + `tev1` backend (adjudication stays on-machine, nothing sent out), see [Running with a local tev1](<docs/en/deploy-tev1-with-plugin.md>).
39
- - **Dangerous-call gate** — before a destructive tool call, a redacted summary asks the backend for a verdict, using a **two-tier model**: only a high-confidence deny is enforced monotonically through the guard to block that call; every other case (a high-confidence allow/ask, low confidence, backend failure, or an invalid result) does not intervene and runs the host's original path. The gate is a safety net that only blocks when the model is confident of danger — it never authorizes on the host's behalf or downgrades approval. Rules are configurable, and so is the **block confidence floor** (`gateSettings.minConfidence`, default 0.6): lower blocks more and may block legitimate commands, higher is more permissive.
40
- - **Output self-check** — scores the final output at turn end against a rubric (0–2, a probability-weighted mean across levels, so it can be fractional). Observe-only by default; an opt-in low-score re-prompt appends one prompt on your behalf when the score is low and the model is confident, at most once per turn. **The rubric, the low-score threshold (default 1), and the re-prompt confidence floor (default 0.4) are all configurable.**
41
- - **Task-completion review** — when the user spells out multiple delivery conditions (numbered / bulleted / multi-command), each is judged satisfied / unsatisfied / insufficient-evidence via a choice before turn end, with tool evidence primary and self-reported replies not counting as evidence; extraction is deterministic and never infers implicit expectations. Observe-only by default, steer configurable, coexisting with self-check and tracked as separate metrics. **The minimum condition count (default 2) and the re-prompt confidence floor (default 0.4) are configurable.**
42
- - **Output-quality trend alert** — a read-only reminder built on self-check data. After several consecutive high-confidence low scores, the trigger button's icon shifts from grey to amber to red, with a hover hint that the task may have grown harder or the model may be fluctuating and suggesting a prompt tweak or model swap. Purely a hint, never driving automatic intervention; the three thresholds (high-confidence bar / consecutive rounds / severe tier) are configurable (defaults 0.6 / 3 / 5), conservative, and pending real-data calibration.
43
- - **Tool narrowing** — at turn start, a noul judges each optional tool's relevance to the task and drops the ones not needed (relevance probability below the drop threshold, default 0.3, configurable), intersecting the host-allowed set and never expanding it. Enforced by default; switch to observe-only to log without changing the tool set. Three safeguards: core tools (read/write/edit/shell/search/todos, plus agent meta-abilities such as asking the user, goals, skills, delivery, background jobs, and teamwork) are always kept and never judged; more optional tools than the candidate cap (default 20, configurable) falls back to observe only; fewer than 5 tools remaining is treated as untrustworthy and skipped. Tools matching a **keep-prefix** allowlist (defaults to `mcp__openviking` and `team_task_`) are also always kept and never judged — good for resident, on-demand tools like memory, retrieval, and teamwork.
44
- - **Loop guard** — deterministic and backend-free; denies the same call repeated consecutively past a threshold. Always on and outside the per-feature switches.
45
- - **Session panel** — a dock trigger below the composer opens the panel (it appears together with the official context meter; while a session has no context reading yet, this row stays empty) with automatic-decision metrics (attempts, fallbacks, model suggestions vs actual outcomes), the master switch, and the decision log, with an empty state before any automatic decision. The master switch pauses only automatic intervention.
46
- - **Decision-analysis view** — an in-session "Decision analysis" tab (`conversation.view`, alongside chat and trajectory, naturally scoped per session) that reads the persisted log read-only and shows a process profile (per-point attempts and outcome distribution, mean self-check score), a quality-trend threshold backtest, and a decision-accuracy summary, and lets you rate each decision correct / wrong / unsure. Ratings share one source with the intervention panel: a rating on either surface writes to the same log, keyed by decision id, latest wins. Read-only over logs, append-only for annotations; it changes no decision behavior.
47
- - **Per-feature switches and intervention thresholds** — the config page has an independent switch for the danger gate, output self-check, tool narrowing, and task-completion review, all on by default; turning one off drops that intervention. The loop guard is not covered by these switches. Every configurable decision point's threshold is adjustable under "Advanced settings" and stored in `~/.config/dsh-decision-layer/config.json` (editing that file directly also works, taking effect live with no restart): gate block confidence `gateSettings.minConfidence` (0.6), self-check low-score threshold `lowScoreThreshold` (1) / re-prompt confidence `minConfidence` (0.4) / trend thresholds `trendMinConfidence`·`trendRun`·`trendSevereRun` (0.6·3·5) / grading levels `rubric`, narrowing drop threshold `threshold` (0.3) / candidate cap `maxCandidates` (20) / batch strategy `batchMode`, completion minimum conditions `minConditions` (2) / re-prompt confidence `minConfidence` (0.4). The loop guard's repeat threshold (3) is deterministic local logic and is not covered. In the form, a blank or out-of-range value is clamped or falls back to its default; editing `config.json` by hand gets no such fallback — a bad value (out of range, misspelled field, wrong type) makes the whole config fail validation, which shows up as an unreadable config page and every decision point reverting to its own safe default (e.g. the gate back to 0.6 with the built-in rules), so hand-edit only valid values. The form corrects the consecutive-run pair whenever the severe tier would not sit above the warn tier.
48
- - **Persisted decision log** — beyond the in-memory panel, decisions are written in full per session to `~/.config/dsh-decision-layer/logs` for research. Files roll by running-instance port and date (`decisions-<port>-YYYY-MM-DD.jsonl`), are batched, and retained for 30 days. Writing is best-effort: a failed flush drops that batch and never breaks the decision path.
49
- - **Persisted session metrics** — the panel's live metrics (dock count, per-decision-point cards, recent decision log) no longer reset to zero after a restart. Each session's live state is mirrored, keyed by session id (not port), to `~/.config/dsh-decision-layer/state/<sessionId>.json`, written debounced and atomically on every change and retained for 30 days. A resumed session restores from that snapshot; if it has no snapshot yet but has persisted log history (a session predating this mechanism, or whose snapshot was pruned), a seed is reconstructed from the logs across all ports by session id — the one exception is "actual" gate outcomes (observed allow/deny), which are not in the log and resume from zero. Writing is likewise best-effort and never enters the decision path.
39
+ - **Manual verdicts** — `decision_evaluate` and the `decision-layer` skill submit `noul` / `score` / `choice` questions; `decision_check_connection` tests connectivity. Manual calls do not count as automatic decisions and do not change agent behavior.
40
+ - **Dangerous-call gate** — before a destructive tool call, a redacted summary asks the backend for a verdict; only a high-confidence deny blocks the call. Allow, ask, low confidence, backend failure, and invalid responses do not intervene and run the host's original path. Rules and the block confidence floor are configurable.
41
+ - **Output self-check and quality trend** — scores the final reply at turn end (0–2), observe-only by default; an opt-in low-score re-prompt fires at most once per turn. Several consecutive high-confidence low scores tint the dock trigger grey → amber → red as a reminder.
42
+ - **Tool narrowing** — at turn start, drops optional tools by task relevance, intersecting the host-allowed set and never expanding it; core tools and keep-prefix matches are always kept, over-cap falls back to observe-only, too-few-kept is treated as untrustworthy and skipped, and small-context backends are batched automatically. Enforced by default; observe-only is available.
43
+ - **Task-completion review** — when the user spells out multiple delivery conditions, each is judged satisfied / unsatisfied / insufficient evidence, with tool evidence primary. Observe-only by default, with an opt-in re-prompt.
44
+ - **Loop guard** — denies the same call repeated consecutively past a threshold; deterministic, local, and backend-free. The per-feature switches do not affect it, but the session master switch pauses it too.
45
+ - **Session panel and decision-analysis view** — the panel shows automatic-decision metrics, the session master switch, and the recent log; the "Decision analysis" tab shows a per-session process profile, trend backtest, and decision accuracy, with per-row ratings and a session intervention switch.
46
+ - **Configuration and persistence** — each decision point has its own switch and configurable thresholds in the plugin details, written to `~/.config/dsh-decision-layer/config.json` and applied live. Defaults and field names are in [ADR-0006](<docs/adr/0006-configurable-thresholds.md>). You can also edit that file by hand, but only with valid values: an out-of-range value, a misspelled field, or a wrong type makes the whole config fail validation, the config page cannot load it, and every decision point reverts to its own safe default (see [ADR-0005](<docs/adr/0005-config-wholesale-replace-and-live-reload.md>)). Decision logs and session metrics persist for 30 days for review and calibration.
47
+ - **Pluggable backend** — defaults to `https://api.typesafe.ai` and `jev-latest`, with per-field URL/key/model overrides under the same protocol; no request without a key. A custom `http://` endpoint requires confirming the plaintext-transport risk for that exact URL, and again whenever the URL changes.
48
+
49
+ ## Privacy and backend
50
+
51
+ Once a key is configured, the plugin sends adjudication requests to the remote endpoint `https://api.typesafe.ai` (model `jev-latest`) by default. The payload carries real context:
52
+
53
+ - Gate: the tool name plus redacted command/path summaries (up to 2048 characters each).
54
+ - Self-check: the turn's final reply (up to about 24K characters).
55
+ - Narrowing: the text of this step's user messages (up to about 8K characters) plus candidate tool names/descriptions.
56
+ - Task-completion review: delivery conditions extracted from this turn's user messages (up to 20), tool names with their ok/error status (no arguments or output), and the first ~8K characters of the reply.
57
+ - Manual verdicts: `decision_evaluate` and the `decision-layer` skill send whatever the agent submits, unchanged.
58
+
59
+ User-message text may include context the host injects, such as workspace instructions (AGENTS.md), runtime details, and memory snippets. This content leaves your machine, and the remote provider may log, retain, or otherwise use it depending on its terms; redaction and truncation reduce risk but are not anonymization.
60
+
61
+ Two kinds of data are also stored locally for 30 days: decision logs in `~/.config/dsh-decision-layer/logs/` and session snapshots in `~/.config/dsh-decision-layer/state/`. They include redacted command/path summaries and short request subjects; delete them yourself if needed.
62
+
63
+ **If your session content is sensitive, prefer a locally deployed model** so adjudication data never leaves the machine. `tev1` is served natively by ollama at `/v1/systemone`; see [Running with a local tev1](<docs/en/deploy-tev1-with-plugin.md>). Any other local model works as long as it exposes a compatible `/v1/systemone` endpoint, pointed to from the config page.
50
64
 
51
65
  ## Safety boundary and development
52
66
 
53
- Automatic intervention only subtracts: the gate only denies, narrowing only removes tools, and neither grants new permissions. The gate defers to native DSH approval on backend failure rather than allowing; self-check, narrowing, and loop-guard errors skip intervention. The redacted summary sent to the backend carries only tool name and command/path with secret redaction — never file content. Node.js `>=20.11`; `npm test` runs the tests, `npm run build:client` builds `lib/client.js` (this is also where strict `tsc` runs), and `npm run check` runs both. **Build output is not committed** ([ADR-0012](<docs/adr/0012-distribution-via-github-actions.md>)): `lib/` is gitignored and the source is the only source of truth. Releases run through `.github/workflows/release.yml`, which builds, gates, `npm pack`s and then asserts with `scripts/assert-pack.mjs` that the tarball really carries `lib/client.js` and leaks no sources — only then does it publish to npm or open a Release. That workflow is manual-only and defaults to `dry_run=true`, so a stray click publishes nothing. Architecture decisions live in [docs/adr/](<docs/adr/README.md>): why each rule exists and what was rejected. 0.7.0 is a preview: the interventions work, but their real value and thresholds are still being explored — feedback on misjudgments and feel observed in the decision log is welcome.
67
+ Automatic intervention only subtracts: the gate only denies, narrowing only removes tools, and neither grants new permissions. The gate only blocks on a high-confidence deny; on backend failure, low confidence, or an invalid response it does not intervene and runs the host's original path. Self-check, narrowing, and loop-guard errors skip intervention. The redacted gate summary sent to the backend carries only tool name and command/path with secret redaction; the plugin never reads file content, but the command itself (for example a heredoc or text written with echo) is included as-is. Node.js `>=20.11`; `npm test` runs the tests, `npm run build:client` builds `lib/client.js` (this is also where strict `tsc` runs), and `npm run check` runs both. **Build output is not committed** ([ADR-0012](<docs/adr/0012-distribution-via-github-actions.md>)): `lib/` is gitignored and the source is the only source of truth. Releases run through `.github/workflows/release.yml`, which builds, gates, `npm pack`s and then asserts with `scripts/assert-pack.mjs` that the tarball really carries `lib/client.js` and leaks no sources — only then does it publish to npm or open a Release. It runs when a version bump lands on main and publishes through npm Trusted Publishing (OIDC), so no long-lived token is involved. Architecture decisions live in [docs/adr/](<docs/adr/README.md>): why each rule exists and what was rejected. 0.7.1 is a preview: the interventions work, but their real value and thresholds are still being explored — feedback on misjudgments and feel observed in the decision log is welcome.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  DeepSeek Harness(DSH)的结构化裁决插件。它把有明确判断标准的问题交给同协议的裁决后端,供 agent 在需要时使用,并在 agent loop 上接入危险门控、产出自检、工具收窄和防循环等自动介入。
6
6
 
7
- > **0.7.0 是体验版。** 插件功能已可用,但裁决介入能带来多少实际价值仍在探索中——各决策点的阈值都是未经验证的启发式默认值,建议先以观察心态体验,别当成经过验证的效果保证。功能与后续进度以 [ROADMAP.md](<ROADMAP.md>) 为准。
7
+ > **0.7.1 是体验版。** 插件功能已可用,但裁决介入能带来多少实际价值仍在探索中——各决策点的阈值都是未经验证的启发式默认值,建议先以观察心态体验,别当成经过验证的效果保证。功能与后续进度以 [ROADMAP.md](<ROADMAP.md>) 为准。
8
8
 
9
9
  ## 安装
10
10
 
@@ -16,7 +16,7 @@ DeepSeek Harness(DSH)的结构化裁决插件。它把有明确判断标准
16
16
  dsh plugin --profile web add dsh-decision-layer@latest
17
17
  ```
18
18
 
19
- 或从 GitHub Release 安装:下载对应版本的 tgz 附件,直接装本地包。
19
+ 或从 GitHub Release 安装:直接用对应版本 tgz 附件的 URL 安装。
20
20
 
21
21
  ```sh
22
22
  dsh plugin --profile web add https://github.com/xbzbing/dsh-decision-layer/releases/download/v<版本>/dsh-decision-layer-<版本>.tgz
@@ -36,28 +36,40 @@ dsh plugin --profile web add file:/绝对路径/dsh-decision-layer
36
36
 
37
37
  ## 功能
38
38
 
39
- - **手动裁决**:`decision_evaluate` 工具与 `decision-layer` skill 支持显式提交 `noul`、`score`、`choice` 问题,`decision_check_connection` 做连通测试。手动调用和连通测试不计入自动裁决指标,也不改变 agent 行为。
40
- - **可配置后端**:默认 `https://api.typesafe.ai`、`jev-latest`,URL、Key、model 可逐字段覆盖;仅支持相同的结构化裁决协议。未配置 Key 时不发送请求。自定义 HTTP 地址需针对实际目标确认明文传输风险,地址变更须重新确认。用本地 ollama + `tev1` 作为后端(裁决不出本机、无数据外发)的完整部署步骤见 [用本地 tev1 配合插件(部署指南)](<docs/zh/deploy-tev1-with-plugin.md>)。
41
- - **危险门控**:破坏性工具调用执行前用脱敏摘要请后端裁决,采用**两档模型**——只有模型高置信判定「拒绝」时才经监控守卫单调拦截该调用;其余情况(高置信放行 / 询问、低置信、后端失败或响应不合法)一律不介入,按宿主原路径执行。门控只做「确信危险才拦」的安全网,不替宿主授权、不降级审批。危险规则可增删配置;**拦截置信度门槛可配**(`gateSettings.minConfidence`,默认 0.6),调低更容易拦、也可能误拦正常命令,调高更宽松。
42
- - **产出自检**:回合结束用评分量表给最终产出打分(0–2,按各档位概率加权,可能是小数)。默认只提示、不打断;可开启「低分补完」,得分偏低且模型有把握时以你的名义追加一次补完提示,同一回合最多补一次。**评分档位、低分阈值(默认 1)、补完置信度门槛(默认 0.4)均可配。**
43
- - **任务完成核对**:当用户明确列出多个交付条件(编号 / 项目符号 / 多命令)时,回合结束前用单选逐项判定「已满足 / 未满足 / 证据不足」,工具证据优先、回复自述不算证据;确定性提取、不推断隐含期望。默认只观察、可配 steer,与产出自检并存、指标分列。**最少条件数(默认 2)与补完置信度门槛(默认 0.4)可配。**
44
- - **产出质量趋势告警**:建在自检数据上的只读提醒。连续多次高置信低分时,把输入框旁触发按钮的图标从灰变橙、变红,hover 提示可能是任务变难或模型波动、建议调整 prompt 或更换模型。纯提示、不驱动自动干预;高置信门槛 / 连续轮数 / 严重档三个阈值可配(默认 0.6 / 3 / 5),偏保守、待实测校准。
45
- - **工具收窄**:回合开始时用 noul 逐个判断可选工具与本轮任务的相关性,摘掉用不上的(相关性概率低于摘除阈值,默认 0.3、可配),只在宿主已允许的集合内取交集、不扩权。默认真正生效,可切到「仅观察」只记录不改工具集。三重保护:核心工具(读写、编辑、Shell、检索、待办,以及问用户、目标、技能、交付、后台作业、协作等 agent 元能力)始终保留、不参与判定;可选工具超过候选上限(默认 20,可配)时自动降为仅观察;保留下来不足 5 个时视为判定不可信、跳过。**保留工具前缀**白名单里的工具(默认含 `mcp__openviking`、`team_task_`)也始终保留、不参与判定,适合记忆 / 检索、协作这类常驻、按需触发的工具。**分批策略**按后端上下文容量自适应(`batchMode`,默认 `auto`):大上下文后端(如 Jev)一次请求判完全部工具;小上下文后端(如本地 tev1,约 2050 token 单请求上限)在遇到上下文超限错误时自动降级为「每工具一次请求、并发执行」并记住,之后直接走拆分,不再反复探测。也可显式设 `single`(始终整批)或 `split`(始终逐工具)。某个工具的拆分请求失败时该工具保守保留。
46
- - **防循环**:确定性判断,不走后端;同一调用连续重复超过阈值即拒绝,打断死循环。始终生效,不在决策点开关范围内。
47
- - **会话面板**:输入框下方的 dock 触发器弹出面板(与官方上下文用量指示同时出现,会话刚开始、还没有上下文读数时这一行是空的),展示自动裁决指标(评估数、失败回退、模型建议与实际结果分列)、会话总开关和决策日志;无自动裁决时显示空态。总开关关闭只暂停自动介入,不影响手动裁决。
48
- - **决策分析页**:会话内「决策分析」tab(`conversation.view`,与对话/轨迹平级、天然按会话隔离),基于已落盘日志做只读聚合,展示过程画像(各决策点触发数与结果分布、自检平均分)、质量趋势阈值回测、判断准确度汇总,并可对每条决策标注「判对 / 判错 / 说不准」。标注与介入面板同源:任一处标注都写入同一份日志、按决策 id 关联、取最新。只读日志、只追加标注,不改任何决策行为。
49
- - **决策点开关与介入阈值**:详情页配置里,危险门控、产出自检、工具收窄、任务完成核对各有独立开关,默认全开;关掉某个决策点就不再对应地介入。防循环不在此范围。可配决策点的介入阈值都在配置页「高级配置」里可调,落到 `~/.config/dsh-decision-layer/config.json`(也可直接改该文件,热生效、无需重启):门控拦截置信度 `gateSettings.minConfidence`(0.6)、自检低分阈值 `lowScoreThreshold`(1)/ 补完置信度 `minConfidence`(0.4)/ 趋势门槛 `trendMinConfidence`·`trendRun`·`trendSevereRun`(0.6·3·5)/ 评分档位 `rubric`、收窄摘除阈值 `threshold`(0.3)/ 候选上限 `maxCandidates`(20)/ 分批策略 `batchMode`、完成核对最少条件数 `minConditions`(2)/ 补完置信度 `minConfidence`(0.4)。防循环的重复阈值(3)是确定性本地逻辑,不在此范围。在配置页填写时,留空或越界的值会被夹取或回落到默认;直接手改 `config.json` 不会兜底——写错值(越界、拼错字段名、类型不符)会让整份配置校验失败,表现为配置页读不出来、各决策点退回各自的安全默认(如门控回 0.6 与内置规则),所以手改只写合法值即可。连续次数要求「严重档 > 告警档」,配置页会自动纠正。
50
- - **决策日志落盘**:决策记录在内存面板之外,还按会话全量落盘到 `~/.config/dsh-decision-layer/logs`,供研究回看。文件按运行实例端口和日期滚动切分(`decisions-<端口>-YYYY-MM-DD.jsonl`),批量写入,保留 30 天。写盘为尽力而为,失败只丢当批、不影响决策路径。
51
- - **会话指标持久化**:面板的实时指标(dock 计数、各决策点卡片、最近决策日志)在重启后不再归零。每个会话的实时状态按会话 id(非端口)另存一份快照到 `~/.config/dsh-decision-layer/state/<会话id>.json`,每次计数变化防抖写盘、原子落盘,保留 30 天。同一会话恢复时优先读该快照续算;若尚无快照但有历史决策日志(如早于本机制、或快照已被清理的会话),则跨全部端口按会话 id 从日志重建种子续算——唯一例外是「实际结果」(gate 观测到的放行 / 拒绝)无法从日志重建,重启后从零重新累计。写盘同为尽力而为,失败绝不进入决策路径。
39
+ - **手动裁决**:`decision_evaluate` 工具与 `decision-layer` skill 可显式提交 `noul` / `score` / `choice` 问题,`decision_check_connection` 做连通测试;手动调用不计入自动裁决指标,也不改变 agent 行为。
40
+ - **危险门控**:破坏性工具调用执行前用脱敏摘要请后端裁决,只有高置信「拒绝」才拦截;放行、询问、低置信、后端失败或响应不合法一律不介入,按宿主原路径执行。危险规则与拦截置信度门槛可配。
41
+ - **产出自检与质量趋势**:回合结束给最终回复打分(0–2),默认只记录;可开启低分补完,同一回合最多补一次。连续多次高置信低分时,dock 触发器图标会从灰变橙、变红作为提醒。
42
+ - **工具收窄**:回合开始时按任务相关性摘掉可选工具,只在宿主已允许的集合内取交集、不扩权;核心工具与保留前缀命中的工具始终保留,可选工具过多时自动降为仅观察、保留过少时视为判定不可信并跳过,对小上下文后端自动分批。默认真正生效,也可切到仅观察。
43
+ - **任务完成核对**:用户明确列出多个交付条件时,逐项判定「已满足 / 未满足 / 证据不足」,以工具证据为准;默认只观察,可配补完。
44
+ - **防循环**:同一调用连续重复超过阈值即拒绝,确定性本地逻辑,不依赖后端;不受各决策点开关影响,但会话总开关关闭时同样暂停。
45
+ - **会话面板与决策分析页**:面板展示自动裁决指标、会话总开关和最近日志;「决策分析」tab 按会话展示过程画像、趋势回测与判断准确度,并支持逐条标注和开关本会话的自动介入。
46
+ - **配置与持久化**:各决策点可独立开关,阈值在插件详情页调整,写入 `~/.config/dsh-decision-layer/config.json` 并热生效。默认值与字段说明见 [ADR-0006](<docs/adr/0006-configurable-thresholds.md>)。也可直接手改该文件,但只能写合法值:越界、拼错字段名或类型不符会让整份配置校验失败,配置页读不出来,各决策点退回各自的安全默认(见 [ADR-0005](<docs/adr/0005-config-wholesale-replace-and-live-reload.md>))。决策日志与会话指标落盘 30 天,供回看和校准。
47
+ - **可插拔后端**:默认 `https://api.typesafe.ai`、`jev-latest`,URL、Key、model 可逐字段覆盖;仅支持同协议端点,未配置 Key 时不发请求。自定义 `http://` 地址需针对该 URL 确认明文传输风险,地址变更须重新确认。
48
+
49
+ ## 隐私与后端
50
+
51
+ 配置 Key 后,插件默认把裁决请求发往远程端点 `https://api.typesafe.ai`(模型 `jev-latest`)。裁决会携带具体上下文:
52
+
53
+ - 门控:工具名与脱敏后的命令 / 路径摘要(各最多 2048 字符)。
54
+ - 产出自检:本轮最终回复(最多约 24K 字符)。
55
+ - 工具收窄:本步 user 消息的文本(最多约 8K 字符)与候选工具的名称 / 描述。
56
+ - 完成核对:从本回合 user 消息中提取的交付条件(最多 20 条)、工具名与成败状态(不含参数和输出)、回复前约 8K 字符。
57
+ - 手动裁决:`decision_evaluate` 与 `decision-layer` skill 会把 agent 提交的内容原样发送。
58
+
59
+ user 消息的文本里可能含有宿主注入的上下文,例如工作区指令(AGENTS.md)、运行时信息和记忆片段。这些内容会离开本机,远程服务提供方可能记录、留存或用于其他用途,具体取决于其服务条款;脱敏和截断只降低风险,不等于匿名化。
60
+
61
+ 本地还会落盘两类数据,各保留 30 天:决策日志在 `~/.config/dsh-decision-layer/logs/`,会话快照在 `~/.config/dsh-decision-layer/state/`,其中包含脱敏后的命令 / 路径和请求主题摘要,可按需自行清理。
62
+
63
+ **会话内容敏感时,推荐改用本地部署的模型**,让裁决数据不出本机。`tev1` 可经 ollama 原生的 `/v1/systemone` 端点直接接入,部署步骤见 [用本地 tev1 配合插件(部署指南)](<docs/zh/deploy-tev1-with-plugin.md>);其他本地模型只要提供兼容 `/v1/systemone` 的端点,同样可以在配置页把服务地址指向本机。
52
64
 
53
65
  ## 安全边界
54
66
 
55
- 自动介入只减不增:门控只拒绝、收窄只删工具,都不给 agent 新权限。危险门控采用两档模型,只在模型高置信判定拒绝时拦截;后端失败、低置信或响应不合法时一律不介入、按原路径执行,不因裁决故障阻断,也不替宿主放行。自检、收窄与防循环异常则跳过介入。发送到后端的危险摘要只含工具名与命令 / 路径并对疑似密钥脱敏,不含文件内容。
67
+ 自动介入只减不增:门控只拒绝、收窄只删工具,都不给 agent 新权限。危险门控采用两档模型,只在模型高置信判定拒绝时拦截;后端失败、低置信或响应不合法时一律不介入、按原路径执行,不因裁决故障阻断,也不替宿主放行。自检、收窄与防循环异常则跳过介入。发送到后端的危险摘要只含工具名与命令 / 路径并对疑似密钥脱敏,插件不读取文件内容;但命令本身(如 heredoc、echo 写入的文本)会原样计入摘要。
56
68
 
57
69
  ## 开发
58
70
 
59
71
  服务端使用 Node.js `>=20.11`(`.mjs`,无需构建);客户端用 TypeScript + esbuild 构建。`npm test` 跑本地测试,`npm run build:client` 生成 `lib/client.js`(`tsc` 严格模式就在这一步),`npm run check` 跑完两者。
60
72
 
61
- **构建产物不入库**([ADR-0012](<docs/adr/0012-distribution-via-github-actions.md>)):`lib/` 已被 `.gitignore` 覆盖,源码是唯一事实来源。发布由 GitHub Actions 的 `release.yml` 完成——构建、跑门禁、`npm pack`,并用 `scripts/assert-pack.mjs` 断言产物里确实有 `lib/client.js` 且没有源码泄漏,之后才发 npm / 建 Release。该 workflow 只手动触发且默认 `dry_run=true`,误点不会发版。
73
+ **构建产物不入库**([ADR-0012](<docs/adr/0012-distribution-via-github-actions.md>)):`lib/` 已被 `.gitignore` 覆盖,源码是唯一事实来源。发布由 GitHub Actions 的 `release.yml` 完成——构建、跑门禁、`npm pack`,并用 `scripts/assert-pack.mjs` 断言产物里确实有 `lib/client.js` 且没有源码泄漏,之后才发 npm / 建 Release。该 workflow 在版本号变更推入 main 时自动执行;npm 侧使用 Trusted Publishing(OIDC),不依赖长期 token。
62
74
 
63
- 架构决策记录见 [docs/adr/](<docs/adr/README.md>):每条规则为什么存在、当时放弃了哪些备选。0.7.0 为体验版:介入能力已可用,但实际价值与阈值仍在探索,欢迎反馈决策日志观察到的误判与体感。
75
+ 架构决策记录见 [docs/adr/](<docs/adr/README.md>):每条规则为什么存在、当时放弃了哪些备选。0.7.1 为体验版:介入能力已可用,但实际价值与阈值仍在探索,欢迎反馈决策日志观察到的误判与体感。
@@ -0,0 +1,20 @@
1
+ # Decision Layer
2
+
3
+ A structured set of explicitly scoped decisions for the DeepSeek Harness agent loop: whether a destructive call should be blocked, whether the current tool set is enough, and whether this turn's output is complete are all judged by a pluggable adjudication model, while the host always owns the resulting behavior.
4
+
5
+ ## Features
6
+
7
+ - **Dangerous-call gate** — before a destructive tool call, a redacted summary asks the backend for a verdict; only a high-confidence deny blocks the call, while backend failures, low confidence, and invalid responses fall back to the host's original path.
8
+ - **Output self-check and quality trend** — scores the final reply at turn end and can append one re-prompt when the score is low and the model is confident; several consecutive confident low scores raise an alert on the trigger button.
9
+ - **Task-completion review** — when the user lists multiple delivery conditions, each is judged satisfied / unsatisfied / insufficient evidence, with tool evidence primary and self-reported completion not counting.
10
+ - **Tool narrowing** — at turn start, judges each optional tool's relevance to the task and drops the ones not needed, intersecting the host-allowed set and never expanding it.
11
+ - **Loop guard** — denies the same call repeated consecutively past a threshold; deterministic and backend-free.
12
+ - **Decision panel and analysis view** — shows automatic-decision metrics and the decision log, and lets you rate each decision correct / wrong / unsure; the analysis view's header can enable or disable automatic interventions for the current session.
13
+
14
+ The backend, per-decision-point switches, and intervention thresholds are configurable; defaults remain uncalibrated heuristics.
15
+
16
+ ## Repository
17
+
18
+ <https://github.com/xbzbing/dsh-decision-layer>
19
+
20
+ Installation and privacy notes live in the repository README; threshold defaults and field names are in `docs/adr/0006-configurable-thresholds.md`, and design trade-offs in `docs/adr/`.
@@ -0,0 +1,20 @@
1
+ # 决策层
2
+
3
+ 为 DeepSeek Harness 的 agent 循环提供一组边界清晰的结构化裁决:危险调用要不要拦、当前工具够不够用、这轮产出有没有遗漏,都由可插拔的裁决模型给出建议,具体行为始终由宿主决定。
4
+
5
+ ## 功能
6
+
7
+ - **危险调用门控**:破坏性工具调用执行前,用脱敏摘要请后端裁决;只有高置信「拒绝」才拦截,后端失败、低置信或响应不合法时一律按宿主原路径执行。
8
+ - **产出自检与质量趋势**:回合结束给最终回复打分,低分且模型有把握时可自动补一轮;连续多次高置信低分会在触发按钮上给出告警。
9
+ - **任务完成核对**:用户明确列出多个交付条件时,逐项判定「已满足 / 未满足 / 证据不足」,以工具证据为准,回复自述不算证据。
10
+ - **工具收窄**:回合开始时判断各可选工具与本轮任务的相关性,摘掉用不上的;只在宿主已允许的工具集内取交集,绝不扩权。
11
+ - **防循环**:同一调用连续重复超过阈值即拒绝,确定性本地逻辑,不依赖后端。
12
+ - **决策面板与分析页**:展示自动裁决指标与决策日志,并可逐条标注「判对 / 判错 / 说不准」;分析页顶部可直接开关本会话的自动介入。
13
+
14
+ 裁决后端、各决策点开关与介入阈值均可配置;默认值仍是未标定的启发式。
15
+
16
+ ## 仓库
17
+
18
+ <https://github.com/xbzbing/dsh-decision-layer>
19
+
20
+ 安装与隐私说明见仓库内的 README;各阈值的默认值与字段说明见 `docs/adr/0006-configurable-thresholds.md`,设计取舍见 `docs/adr/`。
package/lib/client.js CHANGED
@@ -65,6 +65,7 @@ var zh = {
65
65
  narrowMore: " \u7B49 {count} \u4E2A",
66
66
  narrowObserve: "\u4EC5\u89C2\u5BDF",
67
67
  narrowEnforce: "\u5DF2\u751F\u6548",
68
+ narrowNotApplied: "\u672A\u751F\u6548",
68
69
  checkPassRate: "\u901A\u8FC7\u7387",
69
70
  narrowApplyRate: "\u6536\u7A84\u7387",
70
71
  rateNa: "\u2014",
@@ -108,7 +109,7 @@ var zh = {
108
109
  reasonConfig: "\u914D\u7F6E\u65E0\u6548",
109
110
  reasonTooMany: "\u5019\u9009\u8FC7\u591A",
110
111
  reasonTooManyCount: "\u5019\u9009 {count} \u4E2A\u504F\u591A",
111
- reasonLowKeep: "\u4FDD\u7559\u8FC7\u5C11\uFF0C\u672A\u751F\u6548",
112
+ reasonLowKeep: "\u4FDD\u7559\u8FC7\u5C11",
112
113
  selfCheck: "\u4EA7\u51FA\u81EA\u68C0",
113
114
  advancedSwitches: "\u4ECB\u5165\u5F00\u5173",
114
115
  advancedParams: "\u6536\u7A84\u53C2\u6570",
@@ -118,11 +119,11 @@ var zh = {
118
119
  narrowObserveLabel: "\u4EC5\u89C2\u5BDF\uFF08\u4E0D\u771F\u6B63\u9650\u5236\u5DE5\u5177\uFF09",
119
120
  narrowHint: "\u6BCF\u56DE\u5408\u5F00\u59CB\u65F6\uFF0C\u7528\u88C1\u51B3\u6A21\u578B\u5224\u65AD\u5F53\u524D\u53EF\u9009\u5DE5\u5177\u4E0E\u8FD9\u8F6E\u4EFB\u52A1\u7684\u76F8\u5173\u6027\uFF0C\u6458\u6389\u7528\u4E0D\u4E0A\u7684\uFF1B\u8BFB\u5199\u3001\u7F16\u8F91\u3001Shell\u3001\u68C0\u7D22\u3001\u5F85\u529E\u7B49\u6838\u5FC3\u5DE5\u5177\u59CB\u7EC8\u4FDD\u7559\u3001\u4E0D\u53C2\u4E0E\u5224\u5B9A\u3002\u9ED8\u8BA4\u771F\u6B63\u751F\u6548\uFF1A\u672C\u56DE\u5408\u53EA\u4FDD\u7559\u5224\u4E3A\u76F8\u5173\u7684\u5DE5\u5177\uFF0C\u4E14\u53EA\u5728\u5BBF\u4E3B\u5DF2\u5141\u8BB8\u7684\u8303\u56F4\u5185\u53D6\u4EA4\u96C6\u3001\u4E0D\u6269\u6743\u3002\u5F00\u542F\u300C\u4EC5\u89C2\u5BDF\u300D\u540E\u53EA\u628A\u53EF\u6458\u6389\u7684\u5DE5\u5177\u8BB0\u8FDB\u65E5\u5FD7\u548C\u7EDF\u8BA1\uFF0C\u4E0D\u6539\u52A8\u5DE5\u5177\u96C6\u3002\u4E09\u91CD\u4FDD\u62A4\uFF1A\u6838\u5FC3\u5DE5\u5177\u59CB\u7EC8\u4FDD\u7559\uFF1B\u53EF\u9009\u5DE5\u5177\u8D85\u8FC7 20 \u4E2A\u65F6\u81EA\u52A8\u53EA\u89C2\u5BDF\u4E0D\u9650\u5236\uFF1B\u4FDD\u7559\u4E0B\u6765\u4E0D\u8DB3 5 \u4E2A\u65F6\u89C6\u4E3A\u5224\u5B9A\u4E0D\u53EF\u4FE1\u3001\u8DF3\u8FC7\u3002",
120
121
  narrowKeepLabel: "\u59CB\u7EC8\u4FDD\u7559\u7684\u5DE5\u5177\u524D\u7F00",
121
- narrowKeepHint: "\u540D\u5B57\u4EE5\u8FD9\u4E9B\u524D\u7F00\u5F00\u5934\u7684\u5DE5\u5177\u6C38\u8FDC\u4FDD\u7559\u3001\u4E0D\u53C2\u4E0E\u76F8\u5173\u6027\u5224\u5B9A\uFF0C\u9002\u5408\u8BB0\u5FC6/\u68C0\u7D22\u8FD9\u7C7B\u300C\u5E38\u9A7B\u3001\u6309\u9700\u89E6\u53D1\u300D\u7684\u5DE5\u5177\uFF08\u9ED8\u8BA4\u542B mcp__openviking\u3001team_task_\uFF09\u3002\u7528\u82F1\u6587\u9017\u53F7\u6216\u6362\u884C\u5206\u9694\u591A\u4E2A\u524D\u7F00\uFF1B\u7559\u7A7A\u5219\u4E0D\u989D\u5916\u4FDD\u7559\u3002",
122
+ narrowKeepHint: "\u540D\u5B57\u4EE5\u8FD9\u4E9B\u524D\u7F00\u5F00\u5934\u7684\u5DE5\u5177\u6C38\u8FDC\u4FDD\u7559\u3001\u4E0D\u53C2\u4E0E\u76F8\u5173\u6027\u5224\u5B9A\uFF0C\u9002\u5408\u8BB0\u5FC6/\u68C0\u7D22\u8FD9\u7C7B\u300C\u5E38\u9A7B\u3001\u6309\u9700\u89E6\u53D1\u300D\u7684\u5DE5\u5177\uFF08\u9ED8\u8BA4\u53EA\u6709 mcp__openviking\uFF09\u3002\u7528\u82F1\u6587\u9017\u53F7\u6216\u6362\u884C\u5206\u9694\u591A\u4E2A\u524D\u7F00\uFF1B\u7559\u7A7A\u5219\u4E0D\u989D\u5916\u4FDD\u7559\u3002",
122
123
  narrowMaxLabel: "\u5019\u9009\u5DE5\u5177\u4E0A\u9650",
123
124
  narrowMaxHint: "\u4E00\u56DE\u5408\u91CC\u53C2\u4E0E\u5224\u5B9A\u7684\u300C\u53EF\u9009\u5DE5\u5177\u300D\u8D85\u8FC7\u8FD9\u4E2A\u6570\uFF08\u9ED8\u8BA4 20\uFF09\uFF0C\u5C31\u81EA\u52A8\u964D\u7EA7\u4E3A\u4EC5\u89C2\u5BDF\u3001\u4E0D\u771F\u6B63\u6536\u7A84\uFF0C\u907F\u514D\u4E00\u6B21\u585E\u592A\u591A\u5DE5\u5177\u8BA9\u6A21\u578B\u8BEF\u6458\u3002\u53D6\u503C 1\u2013200\uFF0C\u8C03\u5927\u66F4\u503E\u5411\u771F\u6B63\u6536\u7A84\u3002\u6838\u5FC3\u5DE5\u5177\u4E0E\u4FDD\u7559\u524D\u7F00\u547D\u4E2D\u7684\u5DE5\u5177\u4E0D\u8BA1\u5165\u3002",
124
125
  narrowThresholdLabel: "\u6458\u9664\u9608\u503C",
125
- narrowThresholdHint: "\u76F8\u5173\u6027\u6982\u7387\u4F4E\u4E8E\u8FD9\u4E2A\u503C\u624D\u6458\u6389\u5DE5\u5177\uFF08\u9ED8\u8BA4 0.3\uFF09\u3002\u6982\u7387\u63A5\u8FD1 0.5 \u8868\u793A\u6A21\u578B\u62FF\u4E0D\u51C6\u3001\u5E76\u975E\u300C\u4E2D\u7B49\u76F8\u5173\u300D\uFF0C\u6B64\u65F6\u4FDD\u7559\u66F4\u7A33\u59A5\uFF1B\u8C03\u4F4E\u66F4\u4FDD\u5B88\uFF08\u5C11\u6458\u3001\u5B81\u53EF\u591A\u7559\uFF09\uFF0C\u8C03\u9AD8\u66F4\u6FC0\u8FDB\uFF08\u591A\u6458\uFF09\u3002\u53D6\u503C 0\u20131\u3002",
126
+ narrowThresholdHint: "\u76F8\u5173\u6027\u6982\u7387\u4F4E\u4E8E\u8FD9\u4E2A\u503C\u624D\u6458\u6389\u5DE5\u5177\uFF08\u9ED8\u8BA4 0.7\uFF0C\u504F\u6FC0\u8FDB\uFF1A\u53EA\u7559\u6A21\u578B\u8F83\u6709\u628A\u63E1\u5224\u4E3A\u76F8\u5173\u7684\u5DE5\u5177\uFF09\u3002\u8C03\u4F4E\u66F4\u4FDD\u5B88\uFF08\u5C11\u6458\u3001\u5B81\u53EF\u591A\u7559\uFF09\uFF0C\u8C03\u9AD8\u66F4\u6FC0\u8FDB\uFF08\u591A\u6458\uFF09\u3002\u6982\u7387\u63A5\u8FD1 0.5 \u8868\u793A\u6A21\u578B\u62FF\u4E0D\u51C6\uFF0C\u6309\u9ED8\u8BA4\u9608\u503C\u4F1A\u88AB\u6458\u6389\uFF1B\u60F3\u7559\u4F4F\u8FD9\u7C7B\u300C\u53EF\u80FD\u7528\u5230\u300D\u7684\u5DE5\u5177\uFF0C\u628A\u9608\u503C\u8C03\u4F4E\u3002\u53D6\u503C 0\u20131\u3002",
126
127
  batchModeLabel: "\u5206\u6279\u7B56\u7565",
127
128
  batchModeHint: "\u300C\u81EA\u52A8\u300D\uFF08\u9ED8\u8BA4\uFF09\u5148\u6574\u6279\u53D1\u4E00\u6B21\u8BF7\u6C42\uFF0C\u9047\u5230\u4E0A\u4E0B\u6587\u8D85\u9650\u518D\u81EA\u52A8\u964D\u7EA7\u4E3A\u9010\u5DE5\u5177\u5E76\u8BB0\u4F4F\uFF1B\u300C\u6574\u6279\u300D\u4E00\u6B21\u5224\u5B8C\u5168\u90E8\u5DE5\u5177\uFF0C\u9002\u5408\u5927\u4E0A\u4E0B\u6587\u540E\u7AEF\uFF08\u5982 Jev\uFF09\uFF1B\u300C\u9010\u5DE5\u5177\u300D\u6BCF\u4E2A\u5DE5\u5177\u4E00\u6B21\u8BF7\u6C42\u3001\u5E76\u53D1\u6267\u884C\uFF0C\u9002\u5408\u5C0F\u4E0A\u4E0B\u6587\u540E\u7AEF\uFF08\u5982\u672C\u5730 tev1\uFF0C\u7EA6 2050 token \u5355\u8BF7\u6C42\u4E0A\u9650\uFF09\u3002",
128
129
  batchModeAuto: "\u81EA\u52A8",
@@ -159,7 +160,7 @@ var zh = {
159
160
  featureCheck: "\u4EA7\u51FA\u81EA\u68C0",
160
161
  featureCheckHint: "\u5173\u95ED\u540E\u4E0D\u518D\u5728\u56DE\u5408\u7ED3\u675F\u65F6\u7ED9\u6700\u7EC8\u56DE\u590D\u6253\u5206\u3002",
161
162
  featureNarrow: "\u5DE5\u5177\u6536\u7A84",
162
- featureNarrowHint: "\u5173\u95ED\u540E\u4E0D\u518D\u5728\u56DE\u5408\u5F00\u59CB\u65F6\u8BC4\u4F30\u5DE5\u5177\u76F8\u5173\u6027\u3002\u9632\u5FAA\u73AF\u4E3A\u786E\u5B9A\u6027\u672C\u5730\u4FDD\u62A4\uFF0C\u59CB\u7EC8\u751F\u6548\uFF0C\u4E0D\u5728\u6B64\u5F00\u5173\u8303\u56F4\u3002",
163
+ featureNarrowHint: "\u5173\u95ED\u540E\u4E0D\u518D\u5728\u56DE\u5408\u5F00\u59CB\u65F6\u8BC4\u4F30\u5DE5\u5177\u76F8\u5173\u6027\u3002\u9632\u5FAA\u73AF\u4E3A\u786E\u5B9A\u6027\u672C\u5730\u4FDD\u62A4\uFF0C\u4E0D\u53D7\u5404\u51B3\u7B56\u70B9\u5F00\u5173\u5F71\u54CD\uFF0C\u4F46\u4F1A\u8BDD\u603B\u5F00\u5173\u5173\u95ED\u65F6\u540C\u6837\u6682\u505C\u3002",
163
164
  featureComplete: "\u4EFB\u52A1\u5B8C\u6210\u6838\u5BF9",
164
165
  featureCompleteHint: "\u5173\u95ED\u540E\u4E0D\u518D\u5728\u56DE\u5408\u7ED3\u675F\u65F6\u9010\u9879\u6838\u5BF9\u7528\u6237\u660E\u786E\u5217\u51FA\u7684\u4EA4\u4ED8\u6761\u4EF6\u3002",
165
166
  trendWarnHint: "\u672C\u4F1A\u8BDD\u6700\u8FD1\u8FDE\u7EED\u51E0\u8F6E\u4EA7\u51FA\u8D28\u91CF\u504F\u4F4E\uFF08\u9AD8\u7F6E\u4FE1\uFF09\u3002\u53EF\u80FD\u662F\u4EFB\u52A1\u96BE\u5EA6\u4E0A\u5347\u6216\u6A21\u578B\u72B6\u6001\u6CE2\u52A8\uFF0C\u5EFA\u8BAE\u5C1D\u8BD5\u8C03\u6574 prompt \u6216\u66F4\u6362\u6A21\u578B\u3002",
@@ -202,7 +203,15 @@ var zh = {
202
203
  httpWarning: "\u6B64\u5730\u5740\u4F7F\u7528\u672A\u52A0\u5BC6\u7684 HTTP\u3002\u786E\u8BA4\u540E\uFF0CAPI Key \u4E0E\u8BC4\u4F30\u4E0A\u4E0B\u6587\u5C06\u4EE5\u660E\u6587\u53D1\u9001\u81F3\u8BE5\u5730\u5740\u3002",
203
204
  analysisTab: "\u51B3\u7B56\u5206\u6790",
204
205
  analysisTitle: "\u51B3\u7B56\u5206\u6790",
205
- analysisIntro: "\u57FA\u4E8E\u672C\u4F1A\u8BDD\u5DF2\u843D\u76D8\u7684\u51B3\u7B56\u65E5\u5FD7\u505A\u53EA\u8BFB\u805A\u5408\u4E0E\u4EBA\u5DE5\u6807\u6CE8\uFF0C\u4E0D\u6539\u53D8\u4EFB\u4F55 agent \u884C\u4E3A\u3002",
206
+ analysisIntro: "\u57FA\u4E8E\u672C\u4F1A\u8BDD\u5DF2\u843D\u76D8\u7684\u51B3\u7B56\u65E5\u5FD7\u505A\u805A\u5408\u4E0E\u4EBA\u5DE5\u6807\u6CE8\uFF1B\u9875\u5934\u5F00\u5173\u53EA\u63A7\u5236\u672C\u4F1A\u8BDD\u662F\u5426\u81EA\u52A8\u4ECB\u5165\uFF0C\u4E0D\u6539\u52A8\u5386\u53F2\u8BB0\u5F55\u3002",
207
+ analysisDecisionSwitch: "\u672C\u4F1A\u8BDD\u81EA\u52A8\u4ECB\u5165",
208
+ analysisDecisionOn: "\u5DF2\u5F00\u542F",
209
+ analysisDecisionOff: "\u5DF2\u5173\u95ED",
210
+ analysisDecisionUnavailable: "\u4E0D\u53EF\u7528",
211
+ analysisDecisionRetry: "\u8BFB\u53D6\u4F1A\u8BDD\u72B6\u6001\u5931\u8D25\uFF0C\u70B9\u51FB\u91CD\u8BD5\u3002",
212
+ analysisDecisionHintOn: "\u70B9\u51FB\u5173\u95ED\u672C\u4F1A\u8BDD\u7684\u81EA\u52A8\u4ECB\u5165\uFF08\u542B\u9632\u5FAA\u73AF\uFF09\uFF1B\u5DF2\u4EA7\u751F\u7684\u51B3\u7B56\u8BB0\u5F55\u4E0D\u53D7\u5F71\u54CD\u3002",
213
+ analysisDecisionHintOff: "\u70B9\u51FB\u5F00\u542F\u540E\uFF0C\u672C\u4F1A\u8BDD\u7684\u95E8\u63A7\u3001\u81EA\u68C0\u3001\u6536\u7A84\u3001\u5B8C\u6210\u6838\u5BF9\u4E0E\u9632\u5FAA\u73AF\u4F1A\u81EA\u52A8\u4ECB\u5165\u3002",
214
+ analysisDecisionDisabled: "\u672C\u4F1A\u8BDD\u7684\u81EA\u52A8\u4ECB\u5165\u5DF2\u5173\u95ED\u3002\u5F00\u542F\u540E\uFF0C\u65B0\u7684\u56DE\u5408\u624D\u4F1A\u4EA7\u751F\u51B3\u7B56\u8BB0\u5F55\u3002",
206
215
  analysisProfile: "\u8FC7\u7A0B\u753B\u50CF",
207
216
  analysisMeanScore: "\u5E73\u5747\u5206",
208
217
  analysisTrend: "\u8D28\u91CF\u8D8B\u52BF\u56DE\u6D4B",
@@ -251,6 +260,7 @@ var en = {
251
260
  narrowMore: " and {count} in total",
252
261
  narrowObserve: "observe",
253
262
  narrowEnforce: "enforced",
263
+ narrowNotApplied: "not applied",
254
264
  checkPassRate: "Pass rate",
255
265
  narrowApplyRate: "Narrow rate",
256
266
  rateNa: "\u2014",
@@ -294,7 +304,7 @@ var en = {
294
304
  reasonConfig: "invalid configuration",
295
305
  reasonTooMany: "too many candidates",
296
306
  reasonTooManyCount: "{count} candidates is too many",
297
- reasonLowKeep: "too few kept, not enforced",
307
+ reasonLowKeep: "too few kept",
298
308
  selfCheck: "Output self-check",
299
309
  advancedSwitches: "Intervention switches",
300
310
  advancedParams: "Narrowing parameters",
@@ -304,11 +314,11 @@ var en = {
304
314
  narrowObserveLabel: "Observe only (do not restrict tools)",
305
315
  narrowHint: "At the start of each turn, the adjudication model judges how relevant each optional tool is to the task and drops the ones not needed; core tools (read, write, edit, shell, search, todos) are always kept and never judged. Enforced by default: only the relevant tools are kept for this turn, intersected with the host-allowed set and never expanded. Turn on Observe only to just log the tools that could be dropped, without changing the tool set. Three safeguards: core tools always kept; more than 20 optional tools falls back to observe only; fewer than 5 tools remaining is treated as untrustworthy and skipped.",
306
316
  narrowKeepLabel: "Always-kept tool prefixes",
307
- narrowKeepHint: "Tools whose names start with any of these prefixes are always kept and never judged for relevance \u2014 good for resident, on-demand tools like memory/retrieval (defaults to mcp__openviking and team_task_). Separate prefixes with commas or newlines; leave empty to keep nothing extra.",
317
+ narrowKeepHint: "Tools whose names start with any of these prefixes are always kept and never judged for relevance \u2014 good for resident, on-demand tools like memory/retrieval (defaults to mcp__openviking only). Separate prefixes with commas or newlines; leave empty to keep nothing extra.",
308
318
  narrowMaxLabel: "Candidate tool cap",
309
319
  narrowMaxHint: "When more than this many optional tools are judged in one turn (default 20), narrowing downgrades to observe only instead of actually restricting, so a large surface is never mass-pruned. Range 1\u2013200; a higher value leans toward actually narrowing. Core tools and keep-prefix matches do not count.",
310
320
  narrowThresholdLabel: "Drop threshold",
311
- narrowThresholdHint: 'A tool is dropped only when its relevance probability is below this value (default 0.3). A probability near 0.5 means the model is unsure, not "moderately relevant", so keeping is safer there; lower is more conservative (drops fewer), higher is more aggressive. Range 0\u20131.',
321
+ narrowThresholdHint: "A tool is dropped only when its relevance probability is below this value (default 0.7, leaning aggressive: only tools the model rates as fairly relevant survive). Lower is more conservative (drops fewer), higher is more aggressive. A probability near 0.5 means the model is unsure and falls below the default, so lower the threshold to keep such may-need tools. Range 0\u20131.",
312
322
  batchModeLabel: "Batch strategy",
313
323
  batchModeHint: "Auto (default) sends one whole-batch request first and, on a context overflow, falls back to one request per tool and remembers it; Single judges every tool in one request, best for a large-context backend (e.g. Jev); Split sends one request per tool concurrently, for a small-context backend (e.g. a local tev1 with a ~2050-token request limit).",
314
324
  batchModeAuto: "Auto",
@@ -345,7 +355,7 @@ var en = {
345
355
  featureCheck: "Output self-check",
346
356
  featureCheckHint: "When off, the final reply is no longer scored at the end of a turn.",
347
357
  featureNarrow: "Tool narrowing",
348
- featureNarrowHint: "When off, tool relevance is no longer evaluated at the start of a turn. The loop guard is a deterministic local safeguard that is always on and not covered by this switch.",
358
+ featureNarrowHint: "When off, tool relevance is no longer evaluated at the start of a turn. The loop guard is a deterministic local safeguard outside these switches, but the session master switch pauses it too.",
349
359
  featureComplete: "Task completion check",
350
360
  featureCompleteHint: "When off, the explicit deliverable conditions the user listed are no longer checked at turn end.",
351
361
  trendWarnHint: "Output quality has been low for a few consecutive turns this session (high confidence). It may be rising task difficulty or model drift \u2014 consider adjusting the prompt or switching models.",
@@ -388,7 +398,15 @@ var en = {
388
398
  httpWarning: "This address uses unencrypted HTTP. Your API key and evaluation context will be sent in plaintext to this address.",
389
399
  analysisTab: "Decision analysis",
390
400
  analysisTitle: "Decision analysis",
391
- analysisIntro: "A read-only aggregate and manual annotation over this session\u2019s persisted decision log. It changes no agent behavior.",
401
+ analysisIntro: "An aggregate and manual annotation over this session\u2019s persisted decision log. The switch in the header only controls automatic interventions for this session and never rewrites history.",
402
+ analysisDecisionSwitch: "Automatic interventions",
403
+ analysisDecisionOn: "on",
404
+ analysisDecisionOff: "off",
405
+ analysisDecisionUnavailable: "unavailable",
406
+ analysisDecisionRetry: "Could not read the session state. Click to retry.",
407
+ analysisDecisionHintOn: "Turn off automatic interventions for this session, including the loop guard. Existing decision records are unaffected.",
408
+ analysisDecisionHintOff: "Turn on automatic interventions (gate, self-check, narrowing, completion check, and loop guard) for this session.",
409
+ analysisDecisionDisabled: "Automatic interventions are off for this session. New decision records appear after you enable them.",
392
410
  analysisProfile: "Process profile",
393
411
  analysisMeanScore: "Mean score",
394
412
  analysisTrend: "Quality-trend backtest",
@@ -418,6 +436,23 @@ async function request(path, init) {
418
436
  return body.value;
419
437
  }
420
438
 
439
+ // src/client/session-events.ts
440
+ var SESSION_ENABLED_EVENT = "dsh-decision-layer:session-enabled";
441
+ function announceSessionEnabled(change) {
442
+ if (typeof window === "undefined") return;
443
+ window.dispatchEvent(new window.CustomEvent(SESSION_ENABLED_EVENT, { detail: change }));
444
+ }
445
+ function onSessionEnabled(listener) {
446
+ if (typeof window === "undefined") return () => {
447
+ };
448
+ const handler = (event) => {
449
+ const change = event.detail;
450
+ if (change && typeof change.sessionId === "string" && typeof change.enabled === "boolean") listener(change);
451
+ };
452
+ window.addEventListener(SESSION_ENABLED_EVENT, handler);
453
+ return () => window.removeEventListener(SESSION_ENABLED_EVENT, handler);
454
+ }
455
+
421
456
  // src/client/decision-log.tsx
422
457
  var import_jsx_runtime = require("react/jsx-runtime");
423
458
  var time = (at) => new Date(at).toLocaleTimeString();
@@ -574,9 +609,13 @@ function renderNarrow({ t }, entry) {
574
609
  " \xB7 ",
575
610
  guardText
576
611
  ] }) : null;
577
- const modeTag = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "decision-log-detail", children: [
612
+ const notApplied = entry.applied === false || entry.reason === "low-keep" || entry.reason === "too-many-candidates";
613
+ const modeTag = entry.mode === "observe" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "decision-log-detail", children: [
614
+ " \xB7 ",
615
+ t("narrowObserve")
616
+ ] }) : dropped === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "decision-log-detail", children: [
578
617
  " \xB7 ",
579
- entry.mode === "enforce" ? t("narrowEnforce") : t("narrowObserve")
618
+ notApplied ? t("narrowNotApplied") : t("narrowEnforce")
580
619
  ] });
581
620
  if (dropped === 0) {
582
621
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
@@ -796,6 +835,9 @@ function SessionPanel({ sessionId, t, useProjection }) {
796
835
  live = false;
797
836
  };
798
837
  }, [sessionId, t]);
838
+ (0, import_react2.useEffect)(() => onSessionEnabled((change) => {
839
+ if (change.sessionId === sessionId) setEnabled(change.enabled);
840
+ }), [sessionId]);
799
841
  const checkStatus = () => {
800
842
  setStatus("checking");
801
843
  void request("probe", { method: "POST" }).then((value) => {
@@ -837,6 +879,7 @@ function SessionPanel({ sessionId, t, useProjection }) {
837
879
  body: JSON.stringify({ sessionId, enabled: next })
838
880
  });
839
881
  setEnabled(value.enabled);
882
+ announceSessionEnabled({ sessionId, enabled: value.enabled });
840
883
  } catch {
841
884
  setMessage(t("error"));
842
885
  } finally {
@@ -1044,7 +1087,30 @@ function AnalysisView({ sessionId, t }) {
1044
1087
  const [logPage, setLogPage] = (0, import_react3.useState)(1);
1045
1088
  const [loaded, setLoaded] = (0, import_react3.useState)(false);
1046
1089
  const [message, setMessage] = (0, import_react3.useState)("");
1090
+ const [enabled, setEnabled] = (0, import_react3.useState)(null);
1091
+ const [sessionError, setSessionError] = (0, import_react3.useState)(false);
1092
+ const [busy, setBusy] = (0, import_react3.useState)(false);
1047
1093
  const { ratings, seed, annotate } = useAnnotations(sessionId, () => setMessage(t("annotateFailed")));
1094
+ const active = (0, import_react3.useRef)(sessionId);
1095
+ const loadSession = () => {
1096
+ const session = sessionId;
1097
+ setEnabled(null);
1098
+ setSessionError(false);
1099
+ void request(`session?sessionId=${encodeURIComponent(session)}`).then((value) => {
1100
+ if (active.current === session) setEnabled(value.enabled);
1101
+ }).catch(() => {
1102
+ if (active.current === session) setSessionError(true);
1103
+ });
1104
+ };
1105
+ (0, import_react3.useEffect)(() => {
1106
+ active.current = sessionId;
1107
+ setBusy(false);
1108
+ setMessage("");
1109
+ loadSession();
1110
+ }, [sessionId]);
1111
+ (0, import_react3.useEffect)(() => onSessionEnabled((change) => {
1112
+ if (change.sessionId === sessionId) setEnabled(change.enabled);
1113
+ }), [sessionId]);
1048
1114
  (0, import_react3.useEffect)(() => {
1049
1115
  let alive = true;
1050
1116
  setMessage("");
@@ -1064,6 +1130,32 @@ function AnalysisView({ sessionId, t }) {
1064
1130
  alive = false;
1065
1131
  };
1066
1132
  }, [sessionId, seed]);
1133
+ const toggleEnabled = async () => {
1134
+ if (sessionError) {
1135
+ loadSession();
1136
+ return;
1137
+ }
1138
+ if (enabled === null || busy) return;
1139
+ const next = !enabled;
1140
+ const session = sessionId;
1141
+ setBusy(true);
1142
+ try {
1143
+ const value = await request("session", {
1144
+ method: "PUT",
1145
+ headers: { "content-type": "application/json" },
1146
+ body: JSON.stringify({ sessionId: session, enabled: next })
1147
+ });
1148
+ if (active.current === session) {
1149
+ setEnabled(value.enabled);
1150
+ setMessage("");
1151
+ announceSessionEnabled({ sessionId: session, enabled: value.enabled });
1152
+ }
1153
+ } catch {
1154
+ if (active.current === session) setMessage(t("error"));
1155
+ } finally {
1156
+ if (active.current === session) setBusy(false);
1157
+ }
1158
+ };
1067
1159
  (0, import_react3.useEffect)(() => {
1068
1160
  let alive = true;
1069
1161
  void request(`logrows?sessionId=${encodeURIComponent(sessionId)}&page=${logPage}&pageSize=${LOG_PAGE_SIZE}`).then((value) => {
@@ -1080,9 +1172,30 @@ function AnalysisView({ sessionId, t }) {
1080
1172
  const meanScore = profile && profile.check.scoreCount > 0 ? Math.round(profile.check.scoreSum / profile.check.scoreCount * 10) / 10 : null;
1081
1173
  const completeTotal = profile ? profile.complete.satisfied + profile.complete.unsatisfied + profile.complete.insufficient : 0;
1082
1174
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "decision-analysis", role: "region", "aria-label": t("analysisTitle"), children: /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "decision-analysis-inner", children: [
1083
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h2", { className: "decision-analysis-title", children: t("analysisTitle") }),
1084
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-muted decision-analysis-intro", children: t("analysisIntro") }),
1085
- loaded && (!analysis || analysis.totalDecisions === 0) ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-empty", role: "status", children: t("logEmpty") }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
1175
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("header", { className: "decision-analysis-head", children: [
1176
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "decision-analysis-heading", children: [
1177
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h2", { className: "decision-analysis-title", children: t("analysisTitle") }),
1178
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-muted decision-analysis-intro", children: t("analysisIntro") })
1179
+ ] }),
1180
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
1181
+ "button",
1182
+ {
1183
+ type: "button",
1184
+ className: "decision-analysis-switch",
1185
+ "aria-pressed": enabled === true,
1186
+ "aria-busy": busy || enabled === null && !sessionError,
1187
+ disabled: busy || enabled === null && !sessionError,
1188
+ title: sessionError ? t("analysisDecisionRetry") : enabled === false ? t("analysisDecisionHintOff") : enabled === true ? t("analysisDecisionHintOn") : void 0,
1189
+ onClick: () => void toggleEnabled(),
1190
+ children: [
1191
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "decision-analysis-switch-dot", "aria-hidden": "true" }),
1192
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { children: t("analysisDecisionSwitch") }),
1193
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { className: "decision-analysis-switch-state", children: sessionError ? t("analysisDecisionUnavailable") : enabled === null ? t("loading") : enabled ? t("analysisDecisionOn") : t("analysisDecisionOff") })
1194
+ ]
1195
+ }
1196
+ )
1197
+ ] }),
1198
+ loaded && (!analysis || analysis.totalDecisions === 0) ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "decision-empty", role: "status", children: enabled === false ? t("analysisDecisionDisabled") : t("logEmpty") }) : /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(import_jsx_runtime4.Fragment, { children: [
1086
1199
  profile && /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("section", { className: "decision-analysis-section", children: [
1087
1200
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("h3", { children: t("analysisProfile") }),
1088
1201
  /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "decision-analysis-cards", children: [
@@ -1162,9 +1275,9 @@ async function request2(path, fetchFn, init) {
1162
1275
  if (!response.ok || !body.ok || body.value === void 0) throw new Error(body.error || "Request failed");
1163
1276
  return body.value;
1164
1277
  }
1165
- var DEFAULT_KEEP_PREFIXES = ["mcp__openviking", "team_task_"];
1278
+ var DEFAULT_KEEP_PREFIXES = ["mcp__openviking"];
1166
1279
  var DEFAULT_MAX_CANDIDATES = 20;
1167
- var DEFAULT_THRESHOLD = 0.3;
1280
+ var DEFAULT_THRESHOLD = 0.7;
1168
1281
  var DEFAULT_BATCH_MODE = "auto";
1169
1282
  var DEFAULT_RUBRIC = [
1170
1283
  "\u660E\u663E\u7B54\u975E\u6240\u95EE\uFF0C\u6216\u5B58\u5728\u4E25\u91CD\u9057\u6F0F / \u81EA\u76F8\u77DB\u76FE",
@@ -1641,7 +1754,7 @@ ${url}`)) return;
1641
1754
  step: "any",
1642
1755
  value: threshold,
1643
1756
  disabled: !canEdit || !narrowOn,
1644
- placeholder: "0.3",
1757
+ placeholder: "0.7",
1645
1758
  onChange: (event) => {
1646
1759
  setThreshold(event.target.value);
1647
1760
  setAdvancedDirty(true);
@@ -2101,10 +2214,22 @@ var css = `
2101
2214
  /* Conversation-view "Decision analysis" tab. It reuses the card / stat / log
2102
2215
  primitives above; this block only supplies the scroll container and section
2103
2216
  rhythm, since the tab is not inside the modal's .decision-body wrapper. */
2104
- .decision-analysis{height:100%;overflow:auto;color:var(--dsw-alias-label-primary,#202124);font:inherit}
2217
+ .decision-analysis{height:100%;overflow:auto;color:var(--dsw-alias-label-primary,#202124);font:inherit;--decision-analysis-accent:#1f5d50;--decision-analysis-accent-bg:rgba(31,93,80,.08);--decision-analysis-dot:#1f8a70;--decision-analysis-ring:rgba(31,138,112,.16)}
2105
2218
  .decision-analysis-inner{max-width:52rem;margin:0 auto;padding:1.4rem 1.5rem 3rem}
2219
+ .decision-analysis-head{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap}
2220
+ .decision-analysis-heading{flex:1 1 18rem;min-width:0}
2106
2221
  .decision-analysis-title{font-size:1.15rem;margin:0 0 .3rem}
2107
- .decision-analysis-intro{margin:0 0 .4rem}
2222
+ .decision-analysis-intro{margin:0}
2223
+ /* The session-level automatic-intervention switch, mirrored from the dock panel.
2224
+ A pill button with a status dot: pressed = decisions on for this session. */
2225
+ .decision-analysis-switch{display:inline-flex;align-items:center;gap:.45rem;flex:0 0 auto;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2,#ccd3da);border-radius:999px;background:transparent;color:var(--dsw-alias-label-secondary,#59636e);padding:.38rem .85rem;font:inherit;font-size:.8rem;font-weight:500;line-height:1.3;white-space:nowrap;cursor:pointer;transition:color .15s,border-color .15s,background .15s}
2226
+ .decision-analysis-switch:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#eef1f3);color:var(--dsw-alias-label-primary,#202124)}
2227
+ .decision-analysis-switch[aria-pressed="true"]{border-color:var(--decision-analysis-accent);color:var(--decision-analysis-accent);background:var(--decision-analysis-accent-bg)}
2228
+ .decision-analysis-switch-dot{flex:0 0 auto;width:.5rem;height:.5rem;border-radius:50%;background:var(--dsw-alias-label-tertiary,#9aa5b1);transition:background .15s,box-shadow .15s}
2229
+ .decision-analysis-switch[aria-pressed="true"] .decision-analysis-switch-dot{background:var(--decision-analysis-dot);box-shadow:0 0 0 2px var(--decision-analysis-ring)}
2230
+ .decision-analysis-switch:disabled{opacity:.55;cursor:default}
2231
+ .decision-analysis-switch-state{font-weight:400;opacity:.8}
2232
+ .decision-analysis-switch:focus-visible{outline:2px solid var(--decision-analysis-accent);outline-offset:2px}
2108
2233
  .decision-analysis-section{border-top:1px solid var(--dsw-alias-border-l2,#eceff1);margin-top:1.1rem;padding-top:1rem}
2109
2234
  .decision-analysis-section>h3{font-size:.92rem;margin:0 0 .6rem}
2110
2235
  .decision-analysis-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:.7rem}
@@ -2130,7 +2255,7 @@ var css = `
2130
2255
  .decision-pager button:disabled{opacity:.45;cursor:default}
2131
2256
  .decision-pager-status{font-size:.8rem;color:var(--dsw-alias-label-secondary,#59636e);font-variant-numeric:tabular-nums}
2132
2257
  @media (prefers-color-scheme:dark){
2133
- .decision-analysis{color:var(--dsw-alias-label-primary,#e6eaee)}
2258
+ .decision-analysis{color:var(--dsw-alias-label-primary,#e6eaee);--decision-analysis-accent:#8fdcc6;--decision-analysis-accent-bg:rgba(95,191,165,.14);--decision-analysis-dot:#3ecf9e;--decision-analysis-ring:rgba(62,207,158,.2)}
2134
2259
  }
2135
2260
  `;
2136
2261
  function installStyles() {
package/locale/en.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Decision Layer",
4
- "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, and loop guard backed by a pluggable adjudication model."
4
+ "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, task-completion review, and loop guard backed by a pluggable adjudication model.",
5
+ "detail": "docs/en/DETAIL.md"
5
6
  }
6
7
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "决策层",
4
- "description": "为 DeepSeek Harness agent 循环提供的结构化决策层:危险调用门控、产出自检与防循环,由可插拔的裁决模型驱动。"
4
+ "description": "为 DeepSeek Harness agent 循环提供的结构化决策层:危险调用门控、产出自检、工具收窄、任务完成核对与防循环,由可插拔的裁决模型驱动。",
5
+ "detail": "docs/zh/DETAIL.md"
5
6
  }
6
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-layer",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "A structured decision layer for the DeepSeek Harness agent loop: a danger-call gate, output self-check, tool narrowing, and loop guard backed by a pluggable adjudication model.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -20,6 +20,8 @@
20
20
  "skills",
21
21
  "lib/client.js",
22
22
  "locale/*.json",
23
+ "docs/zh/DETAIL.md",
24
+ "docs/en/DETAIL.md",
23
25
  "icon.svg",
24
26
  "cordis.patch.yml",
25
27
  "README.md",
package/service/dsh.mjs CHANGED
@@ -220,22 +220,28 @@ export async function apply(ctx, options = {}) {
220
220
  // process, before any gate/check/narrow/complete event mutates them, so a
221
221
  // resumed session continues from its saved totals instead of from zero.
222
222
  if (typeof id === 'string') await sessions.ensureLoaded(id);
223
- if (typeof id === 'string' && Number.isInteger(turn) && await featureOn('narrow')) {
223
+ if (typeof id === 'string' && Number.isInteger(turn)) {
224
224
  const state = narrowedTurns.get(id);
225
225
  if (!state || state.turn !== turn) {
226
+ // Release the previous turn's restriction first: restrict is agent-scoped
227
+ // and stays in force until disposed, so a turn where narrowing is now off
228
+ // must not inherit the earlier allow-list.
226
229
  state?.dispose?.();
227
- const entry = { turn, dispose: undefined };
228
- narrowedTurns.set(id, entry);
229
- if (narrowedTurns.size > 1024) { const oldest = narrowedTurns.keys().next().value; if (oldest !== id) { narrowedTurns.get(oldest)?.dispose?.(); narrowedTurns.delete(oldest); } }
230
- try {
231
- const result = await narrowing.review({
232
- agent, turn, signal: payload.signal,
233
- state: stepTaskText(payload.messages), tools: agentTools(ctx, agent),
234
- });
235
- if (result.mode === 'enforce' && result.applied && typeof agent?.ctx?.tools?.restrict === 'function') {
236
- entry.dispose = agent.ctx.tools.restrict({ allow: result.keep });
237
- }
238
- } catch { /* narrowing is best effort; never block the step */ }
230
+ narrowedTurns.delete(id);
231
+ if (await featureOn('narrow')) {
232
+ const entry = { turn, dispose: undefined };
233
+ narrowedTurns.set(id, entry);
234
+ if (narrowedTurns.size > 1024) { const oldest = narrowedTurns.keys().next().value; if (oldest !== id) { narrowedTurns.get(oldest)?.dispose?.(); narrowedTurns.delete(oldest); } }
235
+ try {
236
+ const result = await narrowing.review({
237
+ agent, turn, signal: payload.signal,
238
+ state: stepTaskText(payload.messages), tools: agentTools(ctx, agent),
239
+ });
240
+ if (result.mode === 'enforce' && result.applied && typeof agent?.ctx?.tools?.restrict === 'function') {
241
+ entry.dispose = agent.ctx.tools.restrict({ allow: result.keep });
242
+ }
243
+ } catch { /* narrowing is best effort; never block the step */ }
244
+ }
239
245
  }
240
246
  }
241
247
  return next();
@@ -1,5 +1,6 @@
1
1
  import { createReadableStream } from './log-read.mjs';
2
2
  import { advanceRun, severityOf } from './quality-trend.mjs';
3
+ import { narrowApplied } from './narrowing.mjs';
3
4
 
4
5
  // Offline analysis over the persisted decision JSONL logs. Pure aggregation:
5
6
  // given a stream of records (decision events + annotation events) for one
@@ -75,7 +76,7 @@ export function buildProfile(decisions) {
75
76
  if (cb) bucket.confidence[cb]++;
76
77
  } else if (kind === 'narrow') {
77
78
  if (record.outcome === 'error') bucket.error++;
78
- else if (record.outcome === 'applied') bucket.applied++;
79
+ else if (narrowApplied(record)) bucket.applied++;
79
80
  if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.droppedSum += record.dropped;
80
81
  if (record.reason === 'too-many-candidates') bucket.tooManyCandidates++;
81
82
  } else if (kind === 'complete') {
@@ -196,7 +197,7 @@ export function buildSeed(decisions, { maxLog = 50, trendConfig = {} } = {}) {
196
197
  }
197
198
  } else if (record.kind === 'narrow') {
198
199
  if (record.outcome === 'applied') {
199
- bucket.applied++;
200
+ if (narrowApplied(record)) bucket.applied++;
200
201
  if (Number.isSafeInteger(record.dropped) && record.dropped > 0) bucket.dropped += record.dropped;
201
202
  }
202
203
  } else if (record.kind === 'complete') {
@@ -234,9 +235,9 @@ export async function reconstructSessionSeed(sessionId, { dir, port, trendConfig
234
235
  // Read one session's persisted decision rows and return a single newest-first
235
236
  // page. Backs the analysis tab's full decision log (the dock panel still shows
236
237
  // only the in-memory recent rows). Decisions are folded by id (latest wins) so a
237
- // re-logged id is not double-counted. Rows carry only the panel fields — tool
238
- // name, outcome, counts — never gate command/path, which is never logged. The
239
- // internal sessionId tag is stripped before the row leaves the server.
238
+ // re-logged id is not double-counted. Rows carry only the panel fields, including
239
+ // the gate's redacted, length-bounded command/path. The internal sessionId tag is
240
+ // stripped before the row leaves the server.
240
241
  export async function listSessionDecisions(sessionId, { dir, port, page = 1, pageSize = 100, readStream = createReadableStream } = {}) {
241
242
  if (typeof sessionId !== 'string' || !sessionId.trim()) throw new Error('Invalid session id');
242
243
  const size = Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= 500 ? pageSize : 100;
@@ -91,9 +91,9 @@ export function createManagerRoutes({ path, sessions, backend, logStore, analyze
91
91
  },
92
92
  }),
93
93
  // One newest-first page of this session's full persisted decision rows, for
94
- // the analysis tab's paginated log. Rows carry only panel fields (tool name,
95
- // outcome, counts) — never gate command/path, which is never logged — so this
96
- // exposes nothing the recent-rows /log route does not already.
94
+ // the analysis tab's paginated log. Rows carry the same panel fields as /log,
95
+ // including the gate's already-redacted and length-bounded command/path, so
96
+ // this exposes nothing the recent-rows route does not already.
97
97
  route('logrows', {
98
98
  GET: async req => {
99
99
  if (typeof listRows !== 'function') { const error = new Error('log rows unavailable'); error.code = 'BAD_REQUEST'; throw error; }
@@ -1,5 +1,14 @@
1
1
  const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
2
2
  const probability = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
3
+
4
+ // Whether a narrowing pass actually restricted the tool set. `applied` is the
5
+ // authoritative field; a vetoed pass (low-keep, too many candidates) is never
6
+ // applied, and rows logged before `applied` existed are read from their reason.
7
+ export const narrowApplied = record => {
8
+ if (record?.applied === false) return false;
9
+ if (record?.reason === 'low-keep' || record?.reason === 'too-many-candidates') return false;
10
+ return record?.applied === true || record?.outcome === 'applied';
11
+ };
3
12
  const MAX_STATE = 8 * 1024;
4
13
  // The adjudication backend hard-rejects a request carrying more than 64 questions
5
14
  // (backend.mjs validateRequest). One relevance question is sent per optional tool,
@@ -25,21 +34,28 @@ export const DEFAULT_CORE_TOOLS = Object.freeze([
25
34
  // Tool-name prefixes that are always kept and never judged, on top of the core
26
35
  // tools. Memory/context tools like OpenViking are "keep resident, trigger on
27
36
  // demand": their relevance rarely shows in the literal task text, so per-tool
28
- // relevance judging would keep pruning them. `team_task_` covers the shared
29
- // task-board tools as a group. Users can override this list.
30
- export const DEFAULT_KEEP_PREFIXES = Object.freeze(['mcp__openviking', 'team_task_']);
37
+ // relevance judging would keep pruning them. Teamwork tools (`team_task_`) are
38
+ // deliberately NOT in this list: they are ordinary task-board capabilities and
39
+ // should be judged like any other optional tool. Users can override this list.
40
+ export const DEFAULT_KEEP_PREFIXES = Object.freeze(['mcp__openviking']);
31
41
 
32
42
  // Intersect the host-allowed tool set with the model's relevance verdict. Only
33
43
  // removes tools; never adds. Any missing or invalid noul keeps the tool, and a
34
44
  // verdict that would strip everything is skipped so the agent is never disabled.
35
- // Drop threshold: a tool is dropped only when the model is fairly confident it
36
- // is not needed (noul < threshold). Default 0.3 is deliberately conservative —
37
- // a noul near 0.5 means the model is unsure, and dropping there is the most
38
- // likely to strip a still-useful tool, so uncertain tools are kept.
39
- export function narrowTools(allowed, result, { threshold = 0.3 } = {}) {
45
+ // `keepBase` is the number of always-kept tools outside `allowed` (core tools and
46
+ // keep-prefix matches); the never-strip-everything guard must count them, or
47
+ // dropping every optional tool would be vetoed even though those tools remain.
48
+ // Drop threshold: a tool is dropped only when its relevance probability is below
49
+ // this value (noul < threshold). The default leans aggressive — only tools the
50
+ // model rates as fairly relevant survive, so the surface stays small. A noul near
51
+ // 0.5 means the model is unsure and is dropped under this default; lower the
52
+ // threshold to keep such may-need tools.
53
+ export const DEFAULT_NARROW_THRESHOLD = 0.7;
54
+ export function narrowTools(allowed, result, { threshold = DEFAULT_NARROW_THRESHOLD, keepBase = 0 } = {}) {
40
55
  const tools = Array.isArray(allowed) ? allowed.filter(name => typeof name === 'string') : [];
41
56
  const answers = isObject(result?.answers) ? result.answers : undefined;
42
57
  if (tools.length === 0 || !answers) return { applied: false, keep: [...tools], drop: [] };
58
+ const base = Number.isSafeInteger(keepBase) && keepBase > 0 ? keepBase : 0;
43
59
 
44
60
  const keep = [];
45
61
  const drop = [];
@@ -48,7 +64,7 @@ export function narrowTools(allowed, result, { threshold = 0.3 } = {}) {
48
64
  if (isObject(answer) && answer.type === 'noul' && probability(answer.noul) && answer.noul < threshold) drop.push(name);
49
65
  else keep.push(name);
50
66
  }
51
- if (drop.length === 0 || keep.length === 0) return { applied: false, keep: [...tools], drop: [] };
67
+ if (drop.length === 0 || keep.length + base === 0) return { applied: false, keep: [...tools], drop: [] };
52
68
  return { applied: true, keep, drop };
53
69
  }
54
70
 
@@ -96,7 +112,7 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
96
112
  let raw = {};
97
113
  try { raw = (typeof settings === 'function' ? await settings() : settings) ?? {}; } catch { raw = {}; }
98
114
  const mode = raw?.mode === 'observe' ? 'observe' : 'enforce';
99
- const threshold = typeof raw?.threshold === 'number' && Number.isFinite(raw.threshold) && raw.threshold >= 0 && raw.threshold <= 1 ? raw.threshold : 0.3;
115
+ const threshold = typeof raw?.threshold === 'number' && Number.isFinite(raw.threshold) && raw.threshold >= 0 && raw.threshold <= 1 ? raw.threshold : DEFAULT_NARROW_THRESHOLD;
100
116
  const batchMode = raw?.batchMode === 'single' || raw?.batchMode === 'split' ? raw.batchMode : 'auto';
101
117
  // An explicit array in settings (including empty) overrides the default set.
102
118
  const prefixes = Array.isArray(raw?.keepPrefixes)
@@ -112,7 +128,7 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
112
128
  const record = (turn, outcome, detail = {}) => {
113
129
  const id = turn?.agent?.session?.id;
114
130
  if (!sessions || typeof id !== 'string') return;
115
- try { if (typeof sessions.recordNarrow === 'function') sessions.recordNarrow(id, outcome, detail.dropped ?? 0); }
131
+ try { if (typeof sessions.recordNarrow === 'function') sessions.recordNarrow(id, outcome, detail.dropped ?? 0, narrowApplied({ ...detail, outcome })); }
116
132
  catch { /* telemetry is best effort */ }
117
133
  if (typeof sessions.log !== 'function') return;
118
134
  try { sessions.log(id, { kind: 'narrow', outcome, ...detail }); }
@@ -191,14 +207,14 @@ export function createNarrowing({ evaluate, sessions, settings, coreTools = DEFA
191
207
  }
192
208
  }
193
209
  }
194
- const narrowed = narrowTools(judgedNames, result, { threshold });
210
+ const narrowed = narrowTools(judgedNames, result, { threshold, keepBase: keptNames.length });
195
211
  const keep = [...keptNames, ...narrowed.keep];
196
212
  const drop = narrowed.drop;
197
213
  // C: too few tools left over is a sign the verdict over-pruned; do not apply.
198
214
  const trustworthy = keep.length >= minKeep;
199
215
  const applied = narrowed.applied && trustworthy && !overCap && mode === 'enforce';
200
216
  const dropped = drop.length;
201
- const detail = { dropped, kept: keep.length, mode: effectiveMode, tools: drop };
217
+ const detail = { dropped, kept: keep.length, mode: effectiveMode, tools: drop, applied };
202
218
  // The optional-candidate count (excludes core tools and keep-prefix matches),
203
219
  // recorded so the "too many candidates" reason shows the number that tripped it.
204
220
  if (overCap) { detail.reason = 'too-many-candidates'; detail.candidates = optional.length; }
@@ -172,14 +172,16 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
172
172
  }
173
173
  bump(sessionKey(id), state);
174
174
  },
175
- recordNarrow(id, outcome, dropped = 0) {
175
+ recordNarrow(id, outcome, dropped = 0, applied = outcome === 'applied') {
176
176
  if (!['ok', 'applied', 'error'].includes(outcome)) throw new Error('Invalid narrowing outcome');
177
177
  const state = writable(id);
178
178
  state.attempts++;
179
179
  state.narrow.attempts++;
180
180
  if (outcome === 'error') { state.failures++; state.narrow.failures++; }
181
181
  else if (outcome === 'applied') {
182
- state.narrow.applied++;
182
+ // A vetoed pass (low-keep, too many candidates) still dropped tools on
183
+ // paper, but the restriction never took effect; only count real ones.
184
+ if (applied) state.narrow.applied++;
183
185
  if (Number.isSafeInteger(dropped) && dropped > 0) state.narrow.dropped += dropped;
184
186
  }
185
187
  bump(sessionKey(id), state);
@@ -227,6 +229,7 @@ export function createSessionState({ maxSessions = 1024, maxLogEntries = 50, onL
227
229
  if (typeof entry.confidence === 'number' && Number.isFinite(entry.confidence)) record.confidence = Math.round(entry.confidence * 100) / 100;
228
230
  if (Number.isSafeInteger(entry.dropped) && entry.dropped >= 0) record.dropped = entry.dropped;
229
231
  if (Number.isSafeInteger(entry.kept) && entry.kept >= 0) record.kept = entry.kept;
232
+ if (typeof entry.applied === 'boolean') record.applied = entry.applied;
230
233
  if (Number.isSafeInteger(entry.candidates) && entry.candidates >= 0) record.candidates = entry.candidates;
231
234
  if (Number.isSafeInteger(entry.conditions) && entry.conditions >= 0) record.conditions = entry.conditions;
232
235
  if (Number.isSafeInteger(entry.satisfied) && entry.satisfied >= 0) record.satisfied = entry.satisfied;