@twinsearth/dsh-master 3.2.9 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +39 -6
  2. package/client.js +51 -1
  3. package/dist-host/cost/attribution.js +183 -0
  4. package/dist-host/cost/audit.js +163 -0
  5. package/dist-host/cost/budget.js +211 -0
  6. package/dist-host/cost/bus.js +95 -0
  7. package/dist-host/cost/cert-chain.js +100 -0
  8. package/dist-host/cost/experiment.js +132 -0
  9. package/dist-host/cost/gate.js +189 -0
  10. package/dist-host/cost/host-surface.js +80 -0
  11. package/dist-host/cost/index.js +312 -0
  12. package/dist-host/cost/ledger-report.js +193 -0
  13. package/dist-host/cost/ledger.js +401 -0
  14. package/dist-host/cost/metrics.js +198 -0
  15. package/dist-host/cost/milestones.js +171 -0
  16. package/dist-host/cost/modules/index.js +5 -0
  17. package/dist-host/cost/modules/manifests.js +130 -0
  18. package/dist-host/cost/policy.js +276 -0
  19. package/dist-host/cost/report.js +156 -0
  20. package/dist-host/cost/reuse-report.js +128 -0
  21. package/dist-host/cost/rsi.js +367 -0
  22. package/dist-host/cost/toolface.js +147 -0
  23. package/dist-host/cost/types.js +31 -0
  24. package/dist-host/kernel.js +38 -11
  25. package/dist-host/plugins/cost-optimizer.js +277 -27
  26. package/dist-host/plugins/superagent-router.js +89 -2
  27. package/dist-host/plugins/superagent-worker.js +1 -1
  28. package/dist-host/superagent/router.js +1 -1
  29. package/dist-host/superagent/types.js +8 -0
  30. package/dist-worker/agent-loop.js +449 -7
  31. package/dist-worker/baseline-fit.js +66 -0
  32. package/dist-worker/budget-guard.js +93 -0
  33. package/dist-worker/ccs.js +99 -0
  34. package/dist-worker/cost-hooks.js +115 -0
  35. package/dist-worker/cost-modules.js +330 -0
  36. package/dist-worker/cost-spi.js +3 -0
  37. package/dist-worker/edg.js +372 -0
  38. package/dist-worker/gdr.js +159 -0
  39. package/dist-worker/index.js +80 -2
  40. package/dist-worker/llm-http.js +20 -0
  41. package/dist-worker/sink-http.js +1 -0
  42. package/dist-worker/spill.js +115 -0
  43. package/dist-worker/tool-batch.js +66 -0
  44. package/dist-worker/tool-compact.js +275 -0
  45. package/dist-worker/tool-disclosure.js +90 -0
  46. package/dist-worker/tools.js +16 -0
  47. package/docs/ARCHITECTURE.md +32 -11
  48. package/docs/CHANGELOG.md +60 -0
  49. package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
  50. package/docs/EXPERIMENT-M5-real-ab.md +82 -0
  51. package/docs/RELEASE-v3.2.10.md +32 -0
  52. package/docs/ROADMAP.md +3 -3
  53. package/docs/SECURITY.md +9 -9
  54. package/docs/SUPERAGENT.md +5 -5
  55. package/docs/THIRD-PARTY.md +87 -0
  56. package/docs/WORKER-AGENT-LOOP.md +1 -1
  57. package/package.json +15 -3
  58. package/src/cost/attribution.ts +186 -0
  59. package/src/cost/audit.ts +170 -0
  60. package/src/cost/budget.ts +226 -0
  61. package/src/cost/bus.ts +97 -0
  62. package/src/cost/cert-chain.ts +102 -0
  63. package/src/cost/experiment.ts +136 -0
  64. package/src/cost/gate.ts +191 -0
  65. package/src/cost/host-surface.ts +97 -0
  66. package/src/cost/index.ts +316 -0
  67. package/src/cost/ledger-report.ts +186 -0
  68. package/src/cost/ledger.ts +385 -0
  69. package/src/cost/metrics.ts +193 -0
  70. package/src/cost/milestones.ts +170 -0
  71. package/src/cost/modules/index.ts +13 -0
  72. package/src/cost/modules/manifests.ts +129 -0
  73. package/src/cost/policy.ts +276 -0
  74. package/src/cost/report.ts +158 -0
  75. package/src/cost/reuse-report.ts +131 -0
  76. package/src/cost/rsi.ts +364 -0
  77. package/src/cost/toolface.ts +151 -0
  78. package/src/cost/types.ts +298 -0
  79. package/src/kernel.ts +37 -11
  80. package/src/plugins/cost-optimizer.ts +271 -26
  81. package/src/plugins/superagent-router.ts +83 -2
  82. package/src/plugins/superagent-worker.ts +1 -1
  83. package/src/superagent/router.ts +1 -1
  84. package/src/superagent/types.ts +70 -0
  85. package/worker/agent-loop.ts +444 -7
  86. package/worker/baseline-fit.ts +80 -0
  87. package/worker/budget-guard.ts +156 -0
  88. package/worker/ccs.ts +202 -0
  89. package/worker/cost-hooks.ts +133 -0
  90. package/worker/cost-modules.ts +356 -0
  91. package/worker/cost-spi.ts +283 -0
  92. package/worker/edg.ts +424 -0
  93. package/worker/gdr.ts +233 -0
  94. package/worker/index.ts +84 -3
  95. package/worker/llm-http.ts +18 -0
  96. package/worker/sink-http.ts +5 -2
  97. package/worker/spill.ts +133 -0
  98. package/worker/tool-batch.ts +124 -0
  99. package/worker/tool-compact.ts +317 -0
  100. package/worker/tool-disclosure.ts +131 -0
  101. package/worker/tools.ts +553 -531
  102. package/worker/types.ts +191 -0
package/README.md CHANGED
@@ -1,9 +1,12 @@
1
1
  # dsh-Master(房主)
2
2
 
3
3
  > 与瓮城 **dsh-Fang** 并行的主 Agent 插件。
4
- > 当前版本:`3.2.9`(面板交互修复线;tag / GitHub Release / NPM public)。
4
+ > 当前版本:`3.2.10`(角色矩阵放宽线;tag / GitHub Release / NPM public)。
5
5
  > 仓库:https://github.com/TwinsEarth/dsh-Master
6
6
 
7
+ [![CI](https://github.com/TwinsEarth/dsh-Master/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/TwinsEarth/dsh-Master/actions/workflows/ci.yml)
8
+ [![npm](https://img.shields.io/npm/v/@twinsearth/dsh-master?style=flat&label=npm&color=4D6BFE)](https://www.npmjs.com/package/@twinsearth/dsh-master)
9
+ [![license](https://img.shields.io/badge/license-AGPL--3.0--or--later-4D6BFE?style=flat)](https://github.com/TwinsEarth/dsh-Master/blob/main/LICENSE)
7
10
  [![DSH 0.2.2-rc.1](https://img.shields.io/badge/DSH-0.2.2--rc.1-4D6BFE?style=flat)](https://github.com/deepseek-ai/deepseek-harness)
8
11
  [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
9
12
 
@@ -16,8 +19,8 @@ dsh-Master 不接管瓮城的「母体 / 原身 / 分身」生命周期;它是
16
19
  DSH Host
17
20
  ├── 瓮城 bundle (dsh-Fang) 房主 bundle (dsh-Master)
18
21
  │ mirror / essence / derived core(主 Agent 控制面)
19
- │ fangForker / fangDecisionCenter ◄────── ├── superagent-router (essence)
20
- │ fangSessionCatalog ├── superagent-worker (fork)
22
+ │ fangForker / fangDecisionCenter ◄────── ├── superagent-router (essence/mother)
23
+ │ fangSessionCatalog ├── superagent-worker (fork/essence)
21
24
  │ ├── cost-optimizer (fork)
22
25
  │ └── dual-manager (mother/essence)
23
26
  └── 协作只走服务键;任一方向失败一律 fail-closed,不本地降级执行
@@ -27,9 +30,9 @@ DSH Host
27
30
 
28
31
  | 子插件 | 行 id | 允许角色 | 路线图 | 已交付 |
29
32
  |---|---|---|---|---|
30
- | SuperAgent Router | `master-superagent-router` | **essence only** | 3.2.1/3.2.3 | Webhook/Ticket/Mandate、渐进信任、本地或母体桥派发、Worker 实时事件、回写 |
31
- | SuperAgent Worker | `master-superagent-worker` | **fork only** | 3.2.1 | Ticket 上下文、Mandate 守卫 |
32
- | Worker Agent Loop | `master-superagent-loop` | **fork only** | 3.2.3 | tsc 编译 preset;有 Ticket 自动跑多轮工具循环并流式回传 |
33
+ | SuperAgent Router | `master-superagent-router` | **essence / mother** | 3.2.1/3.2.3 | Webhook/Ticket/Mandate、渐进信任、本地或母体桥派发、Worker 实时事件、回写 |
34
+ | SuperAgent Worker | `master-superagent-worker` | **fork / essence** | 3.2.1 | Ticket 上下文、Mandate 守卫 |
35
+ | Worker Agent Loop | `master-superagent-loop` | **fork / essence** | 3.2.3 | tsc 编译 preset;有 Ticket 自动跑多轮工具循环并流式回传 |
33
36
  | 成本优化 HarnessMM | `master-cost-optimizer` | **fork only** | 3.3.0 | observe 层:token 估算、实测记录 |
34
37
  | 双主管 PM + PJM | `master-dual-manager` | **mother / essence** | 3.4.0 | PRD/计划/调度/决策中心澄清 |
35
38
 
@@ -81,14 +84,44 @@ dsh plugin add <package> # 加载 dist-host/dist-worker
81
84
  - [Worker Agent Loop](docs/WORKER-AGENT-LOOP.md)
82
85
  - [SuperAgent v3.2.1 设计与契约](docs/SUPERAGENT.md)
83
86
  - [架构设计](docs/ARCHITECTURE.md)
87
+ - [3.3.0 成本优化架构(总体架构 / 实施进度)](docs/DESIGN-v3.3.0-cost-optimization.md)
88
+ - [第三方参考实现与移植边界](docs/THIRD-PARTY.md)
84
89
  - [路线图(3.2.0–3.4.0)](docs/ROADMAP.md)
85
90
  - [安全边界](docs/SECURITY.md)
86
91
  - [变更记录](docs/CHANGELOG.md)
87
92
 
88
93
  ## 状态
89
94
 
95
+ - 3.3.0 M1+M2(dev,未发布):成本优化系统层(ledger / budget / policy / audit)+ Worker 四个成本切点(observe-only)+ A1 工具输出紧凑化(默认 shadow,enforce 需白名单);A2–A3 与决策复用 B1–B4 在 M3–M4 接入,详见 [3.3.0 架构设计](docs/DESIGN-v3.3.0-cost-optimization.md)。
96
+ - 3.2.10 发布线:角色矩阵放宽——Router 进入母体(essence/mother),Worker 与 Worker Agent Loop 进入原身(fork/essence);母体仍不接纳执行体,fork 仍不注册 Router。
90
97
  - 3.2.9 发布线:房主面板改注册到 `shell.overlay` 常驻浮层——点击面板之外不收起、侧栏「房主」按钮二次点击收起、正文独立滚动;新增无浏览器 hook 运行时行为测试(开关、无 outside-click 监听、滚动容器、SSE 失败不卸载面板)。
91
98
  - 3.2.8 发布线:与瓮城通过 fang-master contract 1.0 互联(local/http/file/legacy 四级保底);GitHub Release + NPM public。
92
99
  - 当前工作树完成新一轮审计修复:工具名/死循环阈值、Router 鉴权与生命周期、真实路由分流、契约幂等与超时、PR 护栏与交付、飞书加密、长 Shell 后台作业、成本实测接线、PJM 派生契约。
93
100
  - 验收:`npm run check` / `npm run build` / `npm test` 全绿(188/188);真实双 Host/fork 落库、工具写读、SSE、回写、配额与桥清理断言通过。详见复盘文档。
94
101
  - 发布不等于安装:本流程不修改任何用户 profile(当前 desktop profile 仍为 3.2.8,升级需另行授权)。
102
+
103
+ ## 依赖、权限与兼容
104
+
105
+ - **外部依赖**:与瓮城 **dsh-Fang ≥ 3.1.14** 同宿主协作,通过 `fangForker` / `fangDecisionCenter` / `fangSessionCatalog` / `fangSessionBridge` 服务键派发与回收;瓮城不在线时子插件 **fail-closed**,不在本地降级执行。
106
+ - **安装产物**:npm 运行时依赖为空;无 `preinstall` / `install` / `postinstall` / `prepare` 生命周期脚本;安装只写目标 profile 的 bundle 层。
107
+ - **权限与边界**:Host 侧只注册受控 HTTP 路由(status / stream-token / worker-events / stream / session / webhook),worker-events POST 需要 ticket 匹配的 worker token;Worker 工具受 Mandate 作用域、Shell 白名单与工作区 jail 约束;Web Client 只读(面板不写宿主状态)。
108
+ - **默认状态**:SuperAgent 默认 **observe**,不会自动接单;Router 仅 essence、Worker/成本仅 fork、双主管仅 mother/essence,角色不匹配时该行明确报 `role-not-allowed`。
109
+ - **兼容**:`engines.dsh: >=0.2.2-rc.1 <0.3.0-0`(实测基线 DSH 0.2.2-rc.1、Node.js 26)。
110
+
111
+ ## CI
112
+
113
+ `.github/workflows/ci.yml` 在 `push main`、Pull Request 与手动触发时运行:
114
+
115
+ ```text
116
+ npm ci
117
+ npm run check # client.js 语法 + host/worker 类型检查
118
+ npm run build # dist-host + dist-worker
119
+ npm test # 全量测试
120
+ ```
121
+
122
+ 矩阵:Node 24 / 26(ubuntu),另加 Node 26(windows,覆盖平台相关用例)。
123
+
124
+ ## 许可证
125
+
126
+ 本项目以 [GNU Affero General Public License v3.0 or later](LICENSE) 发布。
127
+ `LICENSE-CORE` 是同一许可文本的副本,随核心源码一起分发。
package/client.js CHANGED
@@ -241,6 +241,55 @@ window.__ModuleLoader__.load({
241
241
  )
242
242
  }
243
243
 
244
+ /**
245
+ * §11:成本(只读)块。
246
+ *
247
+ * 只消费 `/dsh-master/api/superagent/cost` 的 `service` 段,**不写任何业务状态**、
248
+ * 不触发任何成本动作;A(单价项)与 B(跳过调用项)分列展示,绝不合并成一个数字。
249
+ */
250
+ function CostCard() {
251
+ const [cost, setCost] = React.useState(null)
252
+ const [failed, setFailed] = React.useState(false)
253
+
254
+ React.useEffect(() => {
255
+ let alive = true
256
+ const load = async () => {
257
+ try {
258
+ const response = await fetch('/dsh-master/api/superagent/cost', { headers: { accept: 'application/json' } })
259
+ const body = await response.json().catch(() => null)
260
+ if (!alive) return
261
+ setCost(response.ok && body ? body : null)
262
+ setFailed(!response.ok)
263
+ } catch { if (alive) setFailed(true) }
264
+ }
265
+ void load()
266
+ const timer = setInterval(load, 5000)
267
+ return () => { alive = false; clearInterval(timer) }
268
+ }, [])
269
+
270
+ const block = cost && cost.service ? cost.service : null
271
+ const columns = block && block.columns ? block.columns : null
272
+ const savings = block && block.savings ? block.savings : null
273
+ const fmt = (value) => (value === null || value === undefined ? '—' : Number(value).toLocaleString())
274
+
275
+ return h('div', { style: S.card, 'data-dsh-master': 'cost-block' },
276
+ h('div', { style: S.row },
277
+ h('span', { style: S.name }, '成本(只读)'),
278
+ tag(block ? '3.3.0' : '未启用', block ? undefined : '#B45309')),
279
+ failed ? h('div', { style: S.error }, '成本接口不可达') : null,
280
+ !block && !failed ? h('div', { style: S.desc }, '成本系统未装配(cost-optimizer 未启用或角色不匹配)') : null,
281
+ columns ? h('div', { style: S.desc },
282
+ '单价项 A:' + fmt(columns.unitPriceSaved) + ' · ',
283
+ '跳过调用 B:' + fmt(columns.skippedCallSaved) + '(' + fmt(columns.skippedCalls) + ' 次)') : null,
284
+ columns ? h('div', { style: S.desc },
285
+ 'governance:' + fmt(columns.governanceTotal) + ' · 净节省:' + fmt(columns.netSaved)) : null,
286
+ columns && columns.netLossTasks && columns.netLossTasks.length > 0
287
+ ? h('div', { style: S.off }, '净亏任务:' + columns.netLossTasks.slice(0, 3).join(', '))
288
+ : null,
289
+ savings ? h('div', { style: S.desc }, '命中率:' + (block.hitRate === undefined || block.hitRate === null ? '—' : (Number(block.hitRate) * 100).toFixed(1) + '%')) : null,
290
+ )
291
+ }
292
+
244
293
  function MasterPanel() {
245
294
  const [status, setStatus] = React.useState(null)
246
295
  const [error, setError] = React.useState('')
@@ -283,9 +332,10 @@ window.__ModuleLoader__.load({
283
332
  h('div', { style: S.desc }, '运行:' + Math.max(1, Math.round((status.uptimeMs || 0) / 60000)) + ' 分钟'),
284
333
  ) : null,
285
334
  ...items.map((item) => h(SubpluginCard, { key: item.id, item })),
335
+ h(CostCard),
286
336
  h(FangCard, { cooperation: status && status.cooperation }),
287
337
  h(LiveFeed),
288
- h('div', { style: S.desc }, '3.2.9:面板常驻(点面板外不收起、按钮二次点击收起、长内容可滚动);实时流(SSE)+ token 轮换/审计 + Worker 并发配额。'),
338
+ h('div', { style: S.desc }, '3.2.10:角色矩阵放宽(Router→essence/mother,Worker/Loop→fork/essence);面板常驻、按钮二次点击收起、正文可滚动;实时流(SSE)+ token 轮换/审计。'),
289
339
  ))
290
340
  }
291
341
 
@@ -0,0 +1,183 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+ /**
5
+ * 3.3.0 系统层 · attribution(归因,设计 §5.4)。
6
+ *
7
+ * 三条硬规则:
8
+ * 1. **每条 savedTokens 必须带 moduleId**(缺失 ⇒ `module-missing`,不猜、不默认);
9
+ * 2. 差额进 `unattributed`,报表**单列**——未归因收益不得算作任何模块收益;
10
+ * 3. 归因算法(工具面):`baselineOutputTokens(toolName, 长度) − actualOutputTokens`,
11
+ * baseline 由 A1 固定拟合式给出(`worker/baseline-fit.ts`,参数在 docs §5.4.1 冻结)。
12
+ *
13
+ * 另守 §5.3 口径:`shadow` 只累计「本可节省」,`enforce` 才写入 execution 账本且
14
+ * `totalTokens=0`(不重复计入已消耗 token)。
15
+ */
16
+ import { COST_MODULES } from "./types.js";
17
+ function finiteNonNegative(value) {
18
+ const n = Number(value);
19
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
20
+ }
21
+ /**
22
+ * @param {{ledger: {append: Function}, now?: () => number}} options
23
+ */
24
+ export function createAttribution(options = {}) {
25
+ const ledger = options.ledger;
26
+ if (!ledger || typeof ledger.append !== 'function')
27
+ throw new Error('attribution requires a ledger');
28
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
29
+ const allowed = new Set(COST_MODULES);
30
+ const modules = new Map();
31
+ const tasks = new Map();
32
+ const moduleRow = (module) => {
33
+ if (!modules.has(module))
34
+ modules.set(module, { module, events: 0, projectedTokens: 0, enforceSavedTokens: 0 });
35
+ return modules.get(module);
36
+ };
37
+ const taskRow = (taskId, module) => {
38
+ const key = `${taskId ?? ''}|${module}`;
39
+ if (!tasks.has(key))
40
+ tasks.set(key, { key, taskId: taskId === undefined ? null : String(taskId), module, events: 0, projectedTokens: 0, enforceSavedTokens: 0 });
41
+ return tasks.get(key);
42
+ };
43
+ /**
44
+ * @param {{taskId?: string, module: string, savedTokens: number, mode?: string, meta?: object,
45
+ * forkId?: string, sessionId?: string}} input
46
+ */
47
+ function attribute(input = {}) {
48
+ const saved = finiteNonNegative(input.savedTokens);
49
+ if (saved === null)
50
+ return { ok: false, code: 'saved-tokens-invalid', message: 'savedTokens must be a non-negative number' };
51
+ // §5.4-1:moduleId 必填,缺失不猜默认值。
52
+ if (input.module === undefined || input.module === null || String(input.module).trim() === '') {
53
+ return { ok: false, code: 'module-missing', message: 'every savedTokens must carry a moduleId' };
54
+ }
55
+ const module = String(input.module);
56
+ if (!allowed.has(module))
57
+ return { ok: false, code: 'module-invalid', message: `module must be one of ${COST_MODULES.join('/')}` };
58
+ const enforce = String(input.mode ?? 'shadow') === 'enforce';
59
+ const row = moduleRow(module);
60
+ const perTask = taskRow(input.taskId, module);
61
+ if (!enforce) {
62
+ row.events += 1;
63
+ row.projectedTokens += saved;
64
+ perTask.events += 1;
65
+ perTask.projectedTokens += saved;
66
+ return {
67
+ ok: true,
68
+ mode: 'shadow',
69
+ module,
70
+ projectedTokens: saved,
71
+ shadow: { events: row.events, projectedTokens: row.projectedTokens, appliedTokens: row.enforceSavedTokens },
72
+ at: now(),
73
+ };
74
+ }
75
+ const appended = ledger.append({
76
+ account: 'execution',
77
+ module,
78
+ source: 'tool',
79
+ phase: 'tool',
80
+ taskId: input.taskId === undefined ? null : String(input.taskId),
81
+ forkId: input.forkId,
82
+ sessionId: input.sessionId,
83
+ step: input.step,
84
+ turn: input.turn,
85
+ totalTokens: 0,
86
+ savedTokens: saved,
87
+ meta: { kind: 'attribution', ...(input.meta && typeof input.meta === 'object' ? input.meta : {}), mode: 'enforce' },
88
+ });
89
+ if (!appended.ok)
90
+ return { ok: false, code: appended.code, message: appended.message };
91
+ row.events += 1;
92
+ row.enforceSavedTokens += saved;
93
+ perTask.events += 1;
94
+ perTask.enforceSavedTokens += saved;
95
+ return {
96
+ ok: true,
97
+ mode: 'enforce',
98
+ module,
99
+ savedTokens: saved,
100
+ ledger: { id: appended.entry.id },
101
+ shadow: { events: row.events, projectedTokens: row.projectedTokens, appliedTokens: row.enforceSavedTokens },
102
+ at: now(),
103
+ };
104
+ }
105
+ /**
106
+ * §5.4-2:按「总额 − 各模块之和」结算,差额自动进 `unattributed`。
107
+ * 任何未提供 moduleId 的条目都会被拒绝(模块收益不得凭空出现)。
108
+ *
109
+ * @param {{taskId?: string, totalSavedTokens: number, items?: Array<{module: string, savedTokens: number}>,
110
+ * mode?: string, forkId?: string, sessionId?: string}} input
111
+ */
112
+ function attributeTotal(input = {}) {
113
+ const total = finiteNonNegative(input.totalSavedTokens);
114
+ if (total === null)
115
+ return { ok: false, code: 'saved-tokens-invalid', message: 'totalSavedTokens must be a non-negative number' };
116
+ const items = Array.isArray(input.items) ? input.items : [];
117
+ let attributed = 0;
118
+ const applied = [];
119
+ for (const item of items) {
120
+ const out = attribute({
121
+ taskId: input.taskId,
122
+ module: item?.module,
123
+ savedTokens: item?.savedTokens,
124
+ mode: input.mode,
125
+ forkId: input.forkId,
126
+ sessionId: input.sessionId,
127
+ meta: { kind: 'module-share' },
128
+ });
129
+ if (!out.ok)
130
+ return out;
131
+ attributed += finiteNonNegative(item?.savedTokens) ?? 0;
132
+ applied.push({ module: String(item.module), savedTokens: finiteNonNegative(item?.savedTokens) ?? 0 });
133
+ }
134
+ if (attributed > total) {
135
+ return { ok: false, code: 'attribution-overflow', message: `attributed ${attributed} exceeds total ${total}` };
136
+ }
137
+ const residual = total - attributed;
138
+ if (residual > 0) {
139
+ const out = attribute({
140
+ taskId: input.taskId,
141
+ module: 'unattributed',
142
+ savedTokens: residual,
143
+ mode: input.mode,
144
+ forkId: input.forkId,
145
+ sessionId: input.sessionId,
146
+ meta: { kind: 'residual' },
147
+ });
148
+ if (!out.ok)
149
+ return out;
150
+ }
151
+ return { ok: true, totalSavedTokens: total, attributedTokens: attributed, unattributedTokens: residual, items: applied, at: now() };
152
+ }
153
+ function snapshot() {
154
+ const moduleRows = [...modules.values()]
155
+ .map((item) => Object.freeze({ ...item }))
156
+ .sort((a, b) => b.projectedTokens + b.enforceSavedTokens - (a.projectedTokens + a.enforceSavedTokens));
157
+ const taskRows = [...tasks.values()]
158
+ .map((item) => Object.freeze({ ...item }))
159
+ .sort((a, b) => b.projectedTokens + b.enforceSavedTokens - (a.projectedTokens + a.enforceSavedTokens));
160
+ const named = moduleRows.filter((row) => row.module !== 'unattributed');
161
+ const unattributed = moduleRows.filter((row) => row.module === 'unattributed');
162
+ const sum = (rows, key) => rows.reduce((total, row) => total + row[key], 0);
163
+ return Object.freeze({
164
+ ok: true,
165
+ modules: moduleRows,
166
+ tasks: taskRows,
167
+ /** 模块收益(不含 unattributed)——报表只能把这一项当「模块业绩」。 */
168
+ attributedSavedTotal: sum(named, 'enforceSavedTokens'),
169
+ attributedProjectedTotal: sum(named, 'projectedTokens'),
170
+ /** 未归因收益:单列,永不并入上面的模块业绩。 */
171
+ unattributedSavedTotal: sum(unattributed, 'enforceSavedTokens'),
172
+ unattributedProjectedTotal: sum(unattributed, 'projectedTokens'),
173
+ projectedTotal: sum(moduleRows, 'projectedTokens'),
174
+ enforceSavedTotal: sum(moduleRows, 'enforceSavedTokens'),
175
+ });
176
+ }
177
+ function reset() {
178
+ modules.clear();
179
+ tasks.clear();
180
+ return { ok: true };
181
+ }
182
+ return Object.freeze({ attribute, attributeTotal, snapshot, reset });
183
+ }
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+ /**
5
+ * 3.3.0 系统层 · audit(哈希链审计,设计 §5.5)。
6
+ *
7
+ * 结构约束:
8
+ * * **只追加**:没有 update/delete/rewrite API,记录 `Object.freeze`;
9
+ * * 每条记录含 `prevHash` 与自身 `hash`,任何篡改或截断都会在 `verify()` 断链;
10
+ * * 每次替换写一条 `SubstitutionCert`(edgeId / toolName / schemaHash / guardExpr /
11
+ * receiptDigest / replacedCallbackId / viaNativeDispatcher: true),
12
+ * 并与 ledger 事件用同一 `taskId + step` 关联 —— 报表可回放「这一省是怎么来的」。
13
+ */
14
+ import crypto from 'node:crypto';
15
+ /** SubstitutionCert 必填字段;缺任何一项都不得落链(否则证书无法复核)。 */
16
+ const CERT_REQUIRED = Object.freeze([
17
+ 'edgeId',
18
+ 'toolName',
19
+ 'schemaHash',
20
+ 'guardExpr',
21
+ 'receiptDigest',
22
+ 'replacedCallbackId',
23
+ 'taskId',
24
+ ]);
25
+ function canonical(value) {
26
+ if (Array.isArray(value))
27
+ return `[${value.map((item) => canonical(item)).join(',')}]`;
28
+ if (value && typeof value === 'object') {
29
+ const keys = Object.keys(value).sort();
30
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
31
+ }
32
+ if (typeof value === 'number')
33
+ return Number.isFinite(value) ? String(value) : 'null';
34
+ if (value === undefined)
35
+ return 'null';
36
+ return JSON.stringify(value) ?? 'null';
37
+ }
38
+ function hashOf(seq, at, type, actor, payload, prevHash) {
39
+ const body = canonical([seq, at, type, actor, payload, prevHash]);
40
+ return `sha256:${crypto.createHash('sha256').update(body).digest('hex')}`;
41
+ }
42
+ /**
43
+ * @param {{now?: () => number, maxRecords?: number}} [options]
44
+ */
45
+ export function createAuditChain(options = {}) {
46
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
47
+ const maxRecords = Math.max(1, Number(options.maxRecords ?? 2000) || 2000);
48
+ const records = [];
49
+ let seq = 0;
50
+ let dropped = 0;
51
+ function append({ type, actor = 'cost-system', payload = {} } = {}) {
52
+ seq += 1;
53
+ const at = now();
54
+ const prevHash = records.length > 0 ? records[records.length - 1].hash : '';
55
+ const record = Object.freeze({
56
+ seq,
57
+ at,
58
+ type: String(type ?? 'unknown'),
59
+ actor: String(actor ?? 'cost-system'),
60
+ payload: Object.freeze({ ...payload }),
61
+ prevHash,
62
+ hash: hashOf(seq, at, String(type ?? 'unknown'), String(actor ?? 'cost-system'), payload, prevHash),
63
+ });
64
+ records.push(record);
65
+ if (records.length > maxRecords) {
66
+ records.shift();
67
+ dropped += 1;
68
+ }
69
+ return record;
70
+ }
71
+ /**
72
+ * 写一条 SubstitutionCert(§5.5)。fail-closed:
73
+ * * 必填字段缺失/空白 ⇒ 拒绝;
74
+ * * `viaNativeDispatcher` 必须严格为 `true`(非原生派发不得替换);
75
+ * * `taskId + step` 是回放键,step 必须是非负整数。
76
+ */
77
+ function appendSubstitution(cert = {}) {
78
+ const missing = CERT_REQUIRED.filter((key) => {
79
+ const value = cert?.[key];
80
+ return value === undefined || value === null || String(value).trim() === '';
81
+ });
82
+ if (missing.length > 0) {
83
+ return { ok: false, code: 'cert-invalid', message: `substitution cert missing ${missing.join(',')}`, missing };
84
+ }
85
+ if (cert.viaNativeDispatcher !== true) {
86
+ return { ok: false, code: 'cert-not-native', message: 'substitution must be executed via the native dispatcher' };
87
+ }
88
+ const step = Number(cert.step);
89
+ if (!Number.isInteger(step) || step < 0) {
90
+ return { ok: false, code: 'cert-step-invalid', message: 'step must be a non-negative integer' };
91
+ }
92
+ const savedTokens = Number(cert.savedTokens ?? 0);
93
+ if (!Number.isFinite(savedTokens) || savedTokens < 0) {
94
+ return { ok: false, code: 'cert-saved-invalid', message: 'savedTokens must be a non-negative number' };
95
+ }
96
+ const payload = Object.freeze({
97
+ kind: 'substitution-cert',
98
+ edgeId: String(cert.edgeId),
99
+ toolName: String(cert.toolName),
100
+ schemaHash: String(cert.schemaHash),
101
+ guardExpr: String(cert.guardExpr),
102
+ receiptDigest: String(cert.receiptDigest),
103
+ replacedCallbackId: String(cert.replacedCallbackId),
104
+ /** M4:落链即代表「已经过原生派发器真的派发」;证书链核对直接读这一位。 */
105
+ dispatched: true,
106
+ viaNativeDispatcher: true,
107
+ taskId: String(cert.taskId),
108
+ step,
109
+ forkId: cert.forkId === undefined ? null : String(cert.forkId),
110
+ sessionId: cert.sessionId === undefined ? null : String(cert.sessionId),
111
+ ticketId: cert.ticketId === undefined ? null : String(cert.ticketId),
112
+ moduleId: cert.moduleId === undefined ? null : String(cert.moduleId),
113
+ savedTokens,
114
+ });
115
+ const record = append({ type: 'cost.substitution', actor: 'cost-ccs', payload });
116
+ return { ok: true, cert: payload, record };
117
+ }
118
+ function verify(source) {
119
+ const list = Array.isArray(source) ? source : records;
120
+ let prevHash = '';
121
+ let expectedSeq = 1;
122
+ for (const record of list) {
123
+ if (record.seq !== expectedSeq)
124
+ return { ok: false, count: list.length, brokenAt: record.seq, reason: 'seq-gap' };
125
+ if (record.prevHash !== prevHash)
126
+ return { ok: false, count: list.length, brokenAt: record.seq, reason: 'prev-hash-mismatch' };
127
+ const expected = hashOf(record.seq, record.at, record.type, record.actor, record.payload, record.prevHash);
128
+ if (record.hash !== expected)
129
+ return { ok: false, count: list.length, brokenAt: record.seq, reason: 'hash-mismatch' };
130
+ prevHash = record.hash;
131
+ expectedSeq += 1;
132
+ }
133
+ return { ok: true, count: list.length, brokenAt: null, reason: null };
134
+ }
135
+ function snapshot(limit = 50) {
136
+ const n = Math.max(1, Number(limit) || 50);
137
+ return Object.freeze({
138
+ ok: true,
139
+ count: records.length,
140
+ dropped,
141
+ verified: verify(),
142
+ records: records.slice(-n),
143
+ });
144
+ }
145
+ function reset() {
146
+ records.length = 0;
147
+ seq = 0;
148
+ dropped = 0;
149
+ return { ok: true };
150
+ }
151
+ /** 取某任务/步骤的证书(回放用;不改链)。 */
152
+ function substitutions(filter = {}) {
153
+ const taskId = filter.taskId === undefined ? null : String(filter.taskId);
154
+ const step = filter.step === undefined ? null : Number(filter.step);
155
+ const rows = records
156
+ .filter((record) => record.type === 'cost.substitution')
157
+ .filter((record) => (taskId === null ? true : record.payload.taskId === taskId))
158
+ .filter((record) => (step === null ? true : record.payload.step === step));
159
+ return Object.freeze(rows.map((record) => Object.freeze({ ...record.payload, seq: record.seq, at: record.at, hash: record.hash })));
160
+ }
161
+ // 只追加:对外**不暴露**任何修改/删除入口(records() 返回副本)。
162
+ return Object.freeze({ append, appendSubstitution, verify, snapshot, reset, records: () => records.slice(), substitutions });
163
+ }