@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2

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 (213) hide show
  1. package/INSTALL.md +89 -0
  2. package/INSTALL.zh.md +86 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +193 -0
  5. package/README.md +182 -2
  6. package/RELEASING.md +123 -0
  7. package/RELEASING.zh.md +115 -0
  8. package/contracts/fusion.ts +279 -0
  9. package/contracts/storage.sql +86 -0
  10. package/cordis.patch.yml +3 -0
  11. package/docs/COMPATIBILITY.md +162 -0
  12. package/docs/EVIDENCE.md +141 -0
  13. package/dshx.yml +39 -0
  14. package/lib/client.js +1920 -0
  15. package/lib/client.js.map +1 -0
  16. package/lib/dsh-fusion.js +12664 -0
  17. package/lib/types/activity.d.ts +20 -0
  18. package/lib/types/approval/logical.d.ts +10 -0
  19. package/lib/types/benchmark/naive.d.ts +37 -0
  20. package/lib/types/benchmark/native-run.d.ts +68 -0
  21. package/lib/types/benchmark/output-limits.d.ts +19 -0
  22. package/lib/types/bilingual.d.ts +2 -0
  23. package/lib/types/client/ActivityRows.d.ts +8 -0
  24. package/lib/types/client/FusionCache.d.ts +4 -0
  25. package/lib/types/client/FusionHint.d.ts +9 -0
  26. package/lib/types/client/FusionHistory.d.ts +5 -0
  27. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  28. package/lib/types/client/FusionRecovery.d.ts +5 -0
  29. package/lib/types/client/FusionSettings.d.ts +9 -0
  30. package/lib/types/client/FusionStatus.d.ts +5 -0
  31. package/lib/types/client/FusionTool.d.ts +4 -0
  32. package/lib/types/client/activity-model.d.ts +14 -0
  33. package/lib/types/client/i18n.d.ts +15 -0
  34. package/lib/types/client/index.d.ts +22 -0
  35. package/lib/types/client/settings-events.d.ts +2 -0
  36. package/lib/types/client/status-polling.d.ts +9 -0
  37. package/lib/types/context/guard.d.ts +21 -0
  38. package/lib/types/context/handoff.d.ts +17 -0
  39. package/lib/types/contracts.d.ts +449 -0
  40. package/lib/types/digest.d.ts +5 -0
  41. package/lib/types/dsh-fusion.d.ts +13 -0
  42. package/lib/types/errors.d.ts +28 -0
  43. package/lib/types/evidence/gate.d.ts +23 -0
  44. package/lib/types/evidence/receipts.d.ts +65 -0
  45. package/lib/types/execution/write-lease.d.ts +42 -0
  46. package/lib/types/host/bindings.d.ts +30 -0
  47. package/lib/types/host/cache-defaults.d.ts +48 -0
  48. package/lib/types/host/cache-policy.d.ts +155 -0
  49. package/lib/types/host/change-evidence.d.ts +38 -0
  50. package/lib/types/host/compaction-routing.d.ts +15 -0
  51. package/lib/types/host/coordinator.d.ts +169 -0
  52. package/lib/types/host/evidence-page.d.ts +13 -0
  53. package/lib/types/host/history.d.ts +88 -0
  54. package/lib/types/host/http.d.ts +4 -0
  55. package/lib/types/host/model-output.d.ts +13 -0
  56. package/lib/types/host/native-activity.d.ts +26 -0
  57. package/lib/types/host/native-approvals.d.ts +29 -0
  58. package/lib/types/host/native-auxiliary.d.ts +17 -0
  59. package/lib/types/host/native-budget.d.ts +33 -0
  60. package/lib/types/host/native-checks.d.ts +104 -0
  61. package/lib/types/host/native-compaction.d.ts +8 -0
  62. package/lib/types/host/native-context.d.ts +36 -0
  63. package/lib/types/host/native-effects.d.ts +75 -0
  64. package/lib/types/host/native-evidence.d.ts +4 -0
  65. package/lib/types/host/native-exploration.d.ts +15 -0
  66. package/lib/types/host/native-keepalive.d.ts +82 -0
  67. package/lib/types/host/native-model-control.d.ts +99 -0
  68. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  69. package/lib/types/host/native-request.d.ts +5 -0
  70. package/lib/types/host/native-review.d.ts +11 -0
  71. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  72. package/lib/types/host/native-scopes.d.ts +49 -0
  73. package/lib/types/host/native-selection.d.ts +29 -0
  74. package/lib/types/host/native-task-context.d.ts +39 -0
  75. package/lib/types/host/native-usage.d.ts +46 -0
  76. package/lib/types/host/native-worker-brief.d.ts +74 -0
  77. package/lib/types/host/native-worker-notices.d.ts +15 -0
  78. package/lib/types/host/native-worker.d.ts +44 -0
  79. package/lib/types/host/native-workflow.d.ts +41 -0
  80. package/lib/types/host/route-transport.d.ts +22 -0
  81. package/lib/types/host/settings.d.ts +39 -0
  82. package/lib/types/host/shell.d.ts +11 -0
  83. package/lib/types/host/status.d.ts +7 -0
  84. package/lib/types/host/subagent-seam.d.ts +10 -0
  85. package/lib/types/host/tool-table.d.ts +35 -0
  86. package/lib/types/host/worker-terminal.d.ts +68 -0
  87. package/lib/types/host/workspace-guard.d.ts +13 -0
  88. package/lib/types/host/workspace.d.ts +52 -0
  89. package/lib/types/identity.d.ts +64 -0
  90. package/lib/types/index.d.ts +34 -0
  91. package/lib/types/message-source.d.ts +29 -0
  92. package/lib/types/profile/compactor.d.ts +5 -0
  93. package/lib/types/profile/keepalive.d.ts +3 -0
  94. package/lib/types/profile/resolve.d.ts +17 -0
  95. package/lib/types/prompts.d.ts +8 -0
  96. package/lib/types/recovery/reconcile.d.ts +28 -0
  97. package/lib/types/review/dispatch.d.ts +20 -0
  98. package/lib/types/review/protocol.d.ts +51 -0
  99. package/lib/types/review/ticket.d.ts +32 -0
  100. package/lib/types/status.d.ts +88 -0
  101. package/lib/types/store-migration.d.ts +29 -0
  102. package/lib/types/task/deletion.d.ts +64 -0
  103. package/lib/types/task/reducer.d.ts +6 -0
  104. package/lib/types/task/sqlite-store.d.ts +71 -0
  105. package/lib/types/task/state.d.ts +51 -0
  106. package/lib/types/task/status-index.d.ts +28 -0
  107. package/lib/types/task/store.d.ts +48 -0
  108. package/lib/types/usage/budget.d.ts +27 -0
  109. package/lib/types/usage/ledger.d.ts +66 -0
  110. package/lib/types/usage/normalize.d.ts +50 -0
  111. package/lib/types/usage/pricing.d.ts +18 -0
  112. package/package.json +178 -4
  113. package/profiles/fusion-auto.example.json +45 -0
  114. package/prompts/compact.md +7 -0
  115. package/prompts/lead.md +42 -0
  116. package/prompts/model-lead.md +13 -0
  117. package/prompts/model-worker.md +5 -0
  118. package/prompts/worker.md +21 -0
  119. package/src/activity.ts +23 -0
  120. package/src/approval/logical.ts +44 -0
  121. package/src/benchmark/naive.ts +216 -0
  122. package/src/benchmark/native-run.ts +215 -0
  123. package/src/benchmark/output-limits.ts +33 -0
  124. package/src/bilingual.ts +2 -0
  125. package/src/client/ActivityRows.tsx +59 -0
  126. package/src/client/FusionCache.tsx +130 -0
  127. package/src/client/FusionHint.tsx +39 -0
  128. package/src/client/FusionHistory.tsx +247 -0
  129. package/src/client/FusionPanelIcon.tsx +39 -0
  130. package/src/client/FusionRecovery.tsx +69 -0
  131. package/src/client/FusionSettings.tsx +205 -0
  132. package/src/client/FusionStatus.tsx +103 -0
  133. package/src/client/FusionTool.tsx +66 -0
  134. package/src/client/activity-model.ts +31 -0
  135. package/src/client/i18n.ts +30 -0
  136. package/src/client/index.ts +50 -0
  137. package/src/client/settings-events.ts +3 -0
  138. package/src/client/status-polling.ts +88 -0
  139. package/src/context/guard.ts +81 -0
  140. package/src/context/handoff.ts +28 -0
  141. package/src/contracts.ts +449 -0
  142. package/src/digest.ts +28 -0
  143. package/src/dsh-fusion.ts +321 -0
  144. package/src/errors.ts +34 -0
  145. package/src/evidence/gate.ts +290 -0
  146. package/src/evidence/receipts.ts +175 -0
  147. package/src/execution/write-lease.ts +187 -0
  148. package/src/host/bindings.ts +91 -0
  149. package/src/host/cache-defaults.ts +103 -0
  150. package/src/host/cache-policy.ts +168 -0
  151. package/src/host/change-evidence.ts +156 -0
  152. package/src/host/compaction-routing.ts +46 -0
  153. package/src/host/coordinator.ts +2160 -0
  154. package/src/host/evidence-page.ts +25 -0
  155. package/src/host/history.ts +114 -0
  156. package/src/host/http.ts +30 -0
  157. package/src/host/model-output.ts +22 -0
  158. package/src/host/native-activity.ts +199 -0
  159. package/src/host/native-approvals.ts +196 -0
  160. package/src/host/native-auxiliary.ts +19 -0
  161. package/src/host/native-budget.ts +112 -0
  162. package/src/host/native-checks.ts +474 -0
  163. package/src/host/native-compaction.ts +34 -0
  164. package/src/host/native-context.ts +204 -0
  165. package/src/host/native-effects.ts +260 -0
  166. package/src/host/native-evidence.ts +31 -0
  167. package/src/host/native-exploration.ts +114 -0
  168. package/src/host/native-keepalive.ts +292 -0
  169. package/src/host/native-model-control.ts +298 -0
  170. package/src/host/native-on-demand-context.ts +74 -0
  171. package/src/host/native-request.ts +12 -0
  172. package/src/host/native-review.ts +51 -0
  173. package/src/host/native-role-sandbox.ts +171 -0
  174. package/src/host/native-scopes.ts +203 -0
  175. package/src/host/native-selection.ts +110 -0
  176. package/src/host/native-task-context.ts +264 -0
  177. package/src/host/native-usage.ts +121 -0
  178. package/src/host/native-worker-brief.ts +122 -0
  179. package/src/host/native-worker-notices.ts +72 -0
  180. package/src/host/native-worker.ts +215 -0
  181. package/src/host/native-workflow.ts +103 -0
  182. package/src/host/route-transport.ts +54 -0
  183. package/src/host/settings.ts +93 -0
  184. package/src/host/shell.ts +13 -0
  185. package/src/host/status.ts +112 -0
  186. package/src/host/subagent-seam.ts +20 -0
  187. package/src/host/tool-table.ts +56 -0
  188. package/src/host/worker-terminal.ts +91 -0
  189. package/src/host/workspace-guard.ts +17 -0
  190. package/src/host/workspace.ts +184 -0
  191. package/src/identity.ts +76 -0
  192. package/src/index.ts +50 -0
  193. package/src/message-source.ts +31 -0
  194. package/src/profile/compactor.ts +26 -0
  195. package/src/profile/keepalive.ts +13 -0
  196. package/src/profile/resolve.ts +131 -0
  197. package/src/prompts.ts +32 -0
  198. package/src/recovery/reconcile.ts +38 -0
  199. package/src/review/dispatch.ts +48 -0
  200. package/src/review/protocol.ts +212 -0
  201. package/src/review/ticket.ts +166 -0
  202. package/src/status.ts +55 -0
  203. package/src/store-migration.ts +65 -0
  204. package/src/task/deletion.ts +105 -0
  205. package/src/task/reducer.ts +679 -0
  206. package/src/task/sqlite-store.ts +386 -0
  207. package/src/task/state.ts +72 -0
  208. package/src/task/status-index.ts +198 -0
  209. package/src/task/store.ts +287 -0
  210. package/src/usage/budget.ts +88 -0
  211. package/src/usage/ledger.ts +218 -0
  212. package/src/usage/normalize.ts +308 -0
  213. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,321 @@
1
+ import { bi } from './bilingual.js'
2
+ import { FusionError, TASK_BUSY, TASK_NOT_FOUND } from './errors.js'
3
+ import { DeletionBlocked } from './task/deletion.js'
4
+ import { backfillHistory, readHistory } from './host/history.js'
5
+ import { homedir } from 'node:os'
6
+ import { isAbsolute, join } from 'node:path'
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import z from '@deepseek-ai/schemastery'
9
+ import { SessionId } from '@deepseek-ai/dsh-session'
10
+ import type { ModelModality } from '@deepseek-ai/dsh-llm'
11
+ import type {} from '@deepseek-ai/dsh-api-session-controller'
12
+ import type {} from '@deepseek-ai/dsh-host-webserver'
13
+ import type {} from '@deepseek-ai/dsh-commands'
14
+ import type {} from '@deepseek-ai/dsh-agent-default-model'
15
+ import { FusionCatalogAdapter, FUSION_PROVIDER, FUSION_MODEL, installNativeFusionSelection } from './host/native-selection.js'
16
+ import type { ResolvedProfile } from './profile/resolve.js'
17
+ import { choiceFromProfile, modelProfileFromChoice, validatePairChoice, savedLeadRoute } from './host/settings.js'
18
+ import { CachePolicy, cacheView } from './host/cache-policy.js'
19
+ import { CACHE_DEFAULTS_CHECKED } from './host/cache-defaults.js'
20
+ import { loadJsonObject, resolveProfile } from './profile/resolve.js'
21
+ import { profileRoutes } from './profile/compactor.js'
22
+ import { loadModelPromptBundle, loadPromptBundle } from './prompts.js'
23
+ import { SqliteFusionStore } from './task/sqlite-store.js'
24
+ import { BindingRepository, type SessionBinding } from './host/bindings.js'
25
+ import { FusionCoordinator } from './host/coordinator.js'
26
+ import { authorizeConfiguredPair, NativeRequestBudget } from './host/native-budget.js'
27
+ import { json, readJson, trustedRequest } from './host/http.js'
28
+ import { readFusionStatusAsync } from './host/status.js'
29
+ import { readHistoricalFusionActivity } from './host/native-activity.js'
30
+ import { nativeShellTool } from './host/shell.js'
31
+ import { adoptLegacyStateDirectory } from './store-migration.js'
32
+ import { FUSION_API_PATH, LEGACY_FUSION_API_PATH, LEGACY_PLUGIN_NAME, isFusionProvider } from './identity.js'
33
+
34
+ export * from './index.js'
35
+
36
+ export const name = 'dsh-fusion'
37
+ export const inject = ['agents', 'sessions', 'llm', 'tools', 'subagents', 'systemPrompt', 'webServer', 'sessionController', 'sessionQuery', 'commands', 'agentDefaultModel', 'tokenMeter']
38
+ export interface Config { profilePath?: string; authorizationPath?: string; databasePath?: string; workerTools?: string[] }
39
+ const defaultWorkerTools = [nativeShellTool, 'read', 'write', 'edit', 'glob', 'grep', 'job_output', 'job_kill']
40
+ // Schemastery otherwise normalizes an omitted array to [], bypassing ?? below.
41
+ // An explicitly empty list must remain empty, so the default belongs in the schema.
42
+ export const Config: z<Config> = z.object({ profilePath: z.string(), authorizationPath: z.string(), databasePath: z.string(),
43
+ workerTools: z.array(z.string()).default(defaultWorkerTools) })
44
+
45
+ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
46
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
47
+ const database = config.databasePath || join(dshHome, 'plugins', name, 'state.sqlite')
48
+ if (!isAbsolute(database)) throw new Error('Fusion databasePath must be absolute')
49
+ // The plugin was called `dsh-model-fusion` before it was renamed. Its whole
50
+ // durable ledger — tasks, briefs, artifacts, cache settings — lives in that
51
+ // directory, so an installation upgrade must keep reading it rather than
52
+ // start an empty store beside it.
53
+ if (!config.databasePath) adoptLegacyStateDirectory(join(dshHome, 'plugins', LEGACY_PLUGIN_NAME), join(dshHome, 'plugins', name))
54
+ const store = new SqliteFusionStore(database), bindings = new BindingRepository(store)
55
+ const cachePolicy = new CachePolicy(store)
56
+ backfillHistory(store)
57
+ const budget = new NativeRequestBudget(store, config.authorizationPath)
58
+ let defaultProfile: ResolvedProfile | undefined
59
+ const physicalCatalog = async () => {
60
+ const catalog = await ctx.sessionController.modelCatalog()
61
+ return { ...catalog, groups: catalog.groups.filter(group => !isFusionProvider(group.id)) }
62
+ }
63
+ const configuredProfile = async (): Promise<ResolvedProfile> => {
64
+ defaultProfile = undefined
65
+ const saved = store.readDocument('settings:profile')
66
+ if (!saved && (!config.profilePath || !isAbsolute(config.profilePath))) throw new Error(bi('请先在设置的 Fusion 页面选择 Lead 和 Worker', 'Open Settings → Fusion and choose a Lead and a Sidekick first'))
67
+ const raw = saved?.value as Record<string, unknown> | undefined ?? loadJsonObject(config.profilePath!)
68
+ const catalog = await physicalCatalog()
69
+ const pair = validatePairChoice(raw, catalog)
70
+ const next = saved ? modelProfileFromChoice(pair) : raw
71
+ defaultProfile = resolveProfile(next, next.interactionMode === 'model-like' ? loadModelPromptBundle() : loadPromptBundle(), { authorizedRoutes: catalog.groups.flatMap(group =>
72
+ group.models.map(model => ({ provider: group.id, model: model.id }))) })
73
+ return defaultProfile
74
+ }
75
+ const needsBudget = (profile: ResolvedProfile['profile']) => Boolean(config.authorizationPath) || profile.interactionMode !== 'model-like'
76
+ let runtime: FusionCoordinator | undefined
77
+ const effectiveRoutes = (binding: SessionBinding) => profileRoutes({ ...binding.profile,
78
+ lead: runtime?.modelControl.route(binding, 'lead') ?? binding.profile.lead,
79
+ worker: runtime?.modelControl.route(binding, 'worker') ?? binding.profile.worker })
80
+ let creating: Promise<FusionCoordinator> | undefined
81
+ const getRuntime = (frozen?: ResolvedProfile): Promise<FusionCoordinator> => {
82
+ if (runtime) return Promise.resolve(runtime)
83
+ if (creating) return creating
84
+ creating = (async () => {
85
+ const profile = frozen ?? await configuredProfile()
86
+ runtime = new FusionCoordinator(ctx, store, { profile, workerTools: config.workerTools ?? defaultWorkerTools,
87
+ resumeAuthorization: binding => needsBudget(binding.profile) ? budget.check(effectiveRoutes(binding)).authorizationId : 'native-account',
88
+ authorizeRequest: (_agent, binding) => {
89
+ if (!needsBudget(binding.profile)) return
90
+ try { budget.check(effectiveRoutes(binding)) }
91
+ catch (error) {
92
+ const state = store.load(binding.taskId)
93
+ if (state?.control.mode === 'running' && !state.control.budgetBlocked) runtime?.append(binding.taskId, 'budget/blocked', { reason: String(error) })
94
+ throw error
95
+ }
96
+ },
97
+ reserveRequest: (request, binding) => {
98
+ if (!needsBudget(binding.profile)) return
99
+ try { budget.reserve({ provider: request.provider, model: request.model }, request.maxTokens ?? 0) }
100
+ catch (error) {
101
+ const state = store.load(binding.taskId)
102
+ if (state?.control.mode === 'running' && !state.control.budgetBlocked) runtime?.append(binding.taskId, 'budget/blocked', { reason: String(error) })
103
+ throw error
104
+ }
105
+ } })
106
+ return runtime
107
+ })().finally(() => { creating = undefined })
108
+ return creating
109
+ }
110
+ // The catalog entry admits what the configured Lead admits: the Lead is the
111
+ // only role that reads user content, while the Worker receives text work orders.
112
+ const leadInputModalities = async (): Promise<readonly ModelModality[] | undefined> => {
113
+ try {
114
+ const saved = store.readDocument('settings:profile')?.value
115
+ const lead = savedLeadRoute(saved)
116
+ ?? (config.profilePath && isAbsolute(config.profilePath) ? savedLeadRoute(loadJsonObject(config.profilePath)) : undefined)
117
+ if (!lead) return undefined
118
+ return (await ctx.llm.resolveModelInfo(lead.provider, lead.model)).inputModalities
119
+ } catch {
120
+ // An unresolvable Lead route (unregistered provider, retired model, broken
121
+ // profile file) cannot verify image support; the catalog entry stays text-only.
122
+ return undefined
123
+ }
124
+ }
125
+ ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(leadInputModalities)))
126
+ ctx.effect(() => installNativeFusionSelection(ctx, {
127
+ coordinator: () => runtime, profile: () => defaultProfile,
128
+ selection: agent => {
129
+ const selection = agent.session.snapshotEvents().findLast(event => event.type === 'model/selection')
130
+ if (selection?.type === 'model/selection') return selection.data
131
+ if (bindings.read(agent.id)?.binding.selected) return { provider: FUSION_PROVIDER, model: FUSION_MODEL }
132
+ const header = agent.session.requestHeader()
133
+ return header ? { provider: header.config.provider, model: header.config.model } : ctx.agentDefaultModel.currentSelection()
134
+ },
135
+ }))
136
+ // Frozen bindings must be restored before another user request is allowed.
137
+ const restoreFailure = new Map<string, string>()
138
+ ctx.effect(() => ctx.on('agent/pre-step', async (payload, next) => {
139
+ const binding = bindings.read(payload.agent.id)?.binding
140
+ if (!binding?.selected) return next()
141
+ try { await getRuntime({ profile: binding.profile, prompts: binding.prompts }); if (restoreFailure.has(payload.agent.id)) restoreFailure.delete(payload.agent.id) }
142
+ catch (error) { restoreFailure.set(payload.agent.id, String(error)); return { kind: 'reject' } }
143
+ return next()
144
+ }, { prepend: true }))
145
+ const apiHandler: Parameters<Context['webServer']['register']>[0]['handler'] = async (req, res) => {
146
+ if (!trustedRequest(req)) return json(res, 403, { error: 'forbidden' })
147
+ try {
148
+ const url = new URL(req.url ?? '/', 'http://localhost')
149
+ if (req.method === 'GET') {
150
+ if (url.searchParams.get('view') === 'history') return json(res, 200, readHistory(store, url.searchParams.get('cursor') ?? ''))
151
+ if (url.searchParams.get('view') === 'activity') return json(res, 200, await readHistoricalFusionActivity(ctx, store,
152
+ url.searchParams.get('sessionId') ?? '', url.searchParams.get('callId') ?? '', Number(url.searchParams.get('after') ?? -1)))
153
+ if (url.searchParams.get('view') === 'status') {
154
+ const abort = new AbortController(), disconnected = () => abort.abort()
155
+ res.once('close', disconnected)
156
+ try {
157
+ const status = await readFusionStatusAsync(store, url.searchParams.get('sessionId') ?? '', abort.signal)
158
+ if (!res.destroyed) return json(res, 200, status)
159
+ return
160
+ } catch (error) { if (!abort.signal.aborted) throw error; return }
161
+ finally { res.off('close', disconnected) }
162
+ }
163
+ if (url.searchParams.get('view') === 'catalog') return json(res, 200, await ctx.sessionController.modelCatalog())
164
+ if (url.searchParams.get('view') === 'cache') {
165
+ let profile: ResolvedProfile | undefined
166
+ try { profile = await configuredProfile() } catch { profile = undefined }
167
+ return json(res, 200, { checked: CACHE_DEFAULTS_CHECKED, models: cacheView(cachePolicy,
168
+ profile ? { lead: profile.profile.lead, worker: profile.profile.worker } : undefined, profile?.profile.cacheKeepalive) })
169
+ }
170
+ if (url.searchParams.get('view') === 'settings') {
171
+ const saved = store.readDocument('settings:profile')
172
+ let profile: ResolvedProfile | undefined, reason: string | undefined, authorized = false
173
+ try {
174
+ profile = await configuredProfile()
175
+ if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile)); authorized = true
176
+ } catch (error) { reason = error instanceof Error ? error.message : String(error) }
177
+ return json(res, 200, { revision: saved?.revision ?? 0, catalog: await physicalCatalog(),
178
+ pair: profile ? choiceFromProfile(profile.profile) : null,
179
+ authorized, reason, managedAuthorization: Boolean(config.authorizationPath), policy: profile?.profile.interactionMode ?? 'legacy', actualBilledUsd: null, apiEquivalentUsd: null })
180
+ }
181
+ const sessionId = url.searchParams.get('sessionId') ?? ''
182
+ const binding = sessionId && bindings.read(sessionId)?.binding
183
+ let available = false, reason: string | undefined
184
+ try {
185
+ const profile = binding && binding.selected ? { profile: binding.profile, prompts: binding.prompts } : await configuredProfile()
186
+ const controller = await getRuntime(profile)
187
+ controller.transport.assertAvailable()
188
+ if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile))
189
+ available = true
190
+ } catch (error) { reason = error instanceof Error ? error.message : String(error) }
191
+ const state = binding && store.load(binding.taskId)
192
+ return json(res, 200, { selected: Boolean(binding && binding.selected), available, ...(reason ? { reason } : {}),
193
+ ...(state ? { task: { id: state.taskId, phase: state.phase, verification: state.verification, workerId: state.acceptedChild } } : {}) })
194
+ }
195
+ if (req.method === 'DELETE') {
196
+ const taskId = url.searchParams.get('taskId')
197
+ if (!taskId) return json(res, 400, { error: bi('缺少任务标识', 'Missing task id') })
198
+ try {
199
+ // The coordinator is the only authority that can see in-process Agents,
200
+ // tool calls and leases. It exists whenever this Host has served the plugin;
201
+ // without one, nothing in this process owns the task.
202
+ store.deleteTask(taskId, { liveReason: id => runtime?.liveWork(id) })
203
+ return json(res, 200, { ok: true, taskId })
204
+ } catch (error) {
205
+ const message = error instanceof Error ? error.message : String(error)
206
+ // Decide on the stored code, not on message text: a translated message would silently become a 500.
207
+ if (error instanceof FusionError && error.code === TASK_NOT_FOUND) return json(res, 404, { error: message })
208
+ if (error instanceof FusionError && error.code === TASK_BUSY) {
209
+ const blocker = error instanceof DeletionBlocked ? error.blocker : undefined
210
+ const detail = error instanceof DeletionBlocked ? error.detail : undefined
211
+ return json(res, 409, { error: message, ...(blocker ? { blocker } : {}), ...(detail ? { detail } : {}) })
212
+ }
213
+ return json(res, 500, { error: message })
214
+ }
215
+ }
216
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
217
+ const body = await readJson(req)
218
+ if (['continue', 'switch-role', 'schedule', 'cancel-schedule'].includes(String(body.action))) {
219
+ if (typeof body.sessionId !== 'string' || typeof body.taskId !== 'string' || !Number.isSafeInteger(body.revision)) throw new Error(bi('恢复参数无效', 'Invalid recovery parameters'))
220
+ const saved = bindings.read(body.sessionId)
221
+ if (!saved?.binding.selected || saved.binding.taskId !== body.taskId || saved.binding.profile.interactionMode !== 'model-like') throw new Error(bi('当前任务不支持此恢复操作', 'This task does not support that recovery action'))
222
+ const binding = saved.binding
223
+ const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
224
+ if (needsBudget(binding.profile)) budget.check(effectiveRoutes(binding))
225
+ if (body.action === 'cancel-schedule') {
226
+ const row = store.readDocument(`model-control:${body.sessionId}`)
227
+ if (row?.revision !== body.revision) throw new Error(bi('状态已变化,请刷新', 'The state changed; refresh'))
228
+ controller.modelControl.cancel(binding)
229
+ } else if (body.action === 'schedule') {
230
+ if (typeof body.dueAt !== 'string') throw new Error(bi('请选择继续时间', 'Choose when to continue'))
231
+ await controller.modelControl.schedule(body.sessionId, body.taskId, Number(body.revision), body.dueAt)
232
+ } else {
233
+ let change: { role: 'lead' | 'worker'; route: typeof binding.profile.lead } | undefined
234
+ if (body.action === 'switch-role') {
235
+ if (body.role !== 'lead' && body.role !== 'worker') throw new Error(bi('请选择要更换的角色', 'Choose the role to switch'))
236
+ const pair = validatePairChoice({ lead: body.route, worker: body.route }, await physicalCatalog())
237
+ change = { role: body.role, route: pair.lead }
238
+ if (needsBudget(binding.profile)) budget.check(profileRoutes({ ...binding.profile,
239
+ lead: controller.modelControl.route(binding, 'lead'), worker: controller.modelControl.route(binding, 'worker'), [change.role]: change.route }))
240
+ }
241
+ await controller.modelControl.continue(body.sessionId, body.taskId, Number(body.revision), change)
242
+ }
243
+ return json(res, 200, { ok: true })
244
+ }
245
+ if (body.action === 'cache-setting') {
246
+ if (typeof body.provider !== 'string' || typeof body.model !== 'string' || !body.provider || !body.model) throw new Error(bi('请选择模型', 'Choose a model'))
247
+ const setting = body.reset === true ? null : {
248
+ ...(body.mode === undefined || body.mode === null ? {} : { mode: body.mode as 'auto' | 'on' | 'off' }),
249
+ ...(body.intervalSeconds === undefined || body.intervalSeconds === null ? {} : { intervalSeconds: Number(body.intervalSeconds) }) }
250
+ cachePolicy.save(body.provider, body.model, setting)
251
+ return json(res, 200, { ok: true })
252
+ }
253
+ if (body.action === 'configure') {
254
+ if (!Number.isSafeInteger(body.revision) || Number(body.revision) < 0) throw new Error(bi('配置版本无效,请刷新后重试', 'Invalid settings revision; refresh and retry'))
255
+ const pair = validatePairChoice(body.pair, await physicalCatalog())
256
+ const revision = store.writeDocument('settings:profile', Number(body.revision), modelProfileFromChoice(pair))
257
+ const profile = await configuredProfile()
258
+ await getRuntime(profile)
259
+ return json(res, 200, { ok: true, revision })
260
+ }
261
+ if (body.action === 'authorize') {
262
+ if (config.authorizationPath) throw new Error(bi('运行额度由外部授权文件管理,不能在此覆盖', 'Run limits are managed by an external authorization file and cannot be overridden here'))
263
+ const saved = store.readDocument('settings:profile')
264
+ if (!saved || saved.revision !== body.revision) throw new Error(bi('模型组合已变化,请刷新后再启用额度', 'The pair changed; refresh before enabling limits'))
265
+ const profile = await configuredProfile()
266
+ if (store.readDocument('settings:profile')?.revision !== body.revision) throw new Error(bi('模型组合已变化,请刷新后再启用额度', 'The pair changed; refresh before enabling limits'))
267
+ const auth = authorizeConfiguredPair(store, profileRoutes(profile.profile), body.limits)
268
+ return json(res, 200, { ok: true, expiresAt: auth.expiresAt })
269
+ }
270
+ throw new Error(bi('请使用原模型菜单选择 Fusion 或切换普通模型', 'Use the model menu to select Fusion or another model'))
271
+ } catch (error) { return json(res, 409, { error: error instanceof Error ? error.message : String(error) }) }
272
+ }
273
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: FUSION_API_PATH, handler: apiHandler }))
274
+ // A tab still running the pre-rename bundle asks the old path until it is
275
+ // reloaded. Keep answering it from the same handler; nothing new uses it.
276
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: LEGACY_FUSION_API_PATH, handler: apiHandler }))
277
+ ctx.effect(() => ctx.commands.register({ name: 'fusion', description: bi('Fusion 状态、暂停、恢复与退出(不调用模型)', 'Fusion status, pause, resume and exit (no model call)'),
278
+ input: { hint: bi('status | pause | resume | recover [--effects-stopped] <检查记录> | off', 'status | pause | resume | recover [--effects-stopped] <inspection note> | off') },
279
+ handler: async invocation => {
280
+ const [action = 'status', ...note] = invocation.rawInput.trim().split(/\s+/)
281
+ try {
282
+ const binding = bindings.read(invocation.agent.id)?.binding
283
+ if (!binding?.selected) return { kind: 'success', text: bi('当前会话未启用 Fusion。请在模型选择器中选择 Fusion · 自动。', 'Fusion is not selected in this conversation. Choose Fusion · auto in the model menu.') }
284
+ if (action === 'status' || action === '') {
285
+ const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
286
+ const state = store.load(binding.taskId)!
287
+ const unfinishedEffects = controller.effects.pending(binding.taskId).map(({ record }) => ({ role: record.role, tool: record.toolName }))
288
+ const usage = store.listDocumentIds(`usage:${state.taskId}:`).map(id => store.readDocument(id)!.value as { purpose?: string })
289
+ return { kind: 'success', text: JSON.stringify({ taskId: state.taskId, phase: state.phase, verification: state.verification,
290
+ control: state.control, workerId: state.acceptedChild, modelCalls: usage.length,
291
+ cacheKeepaliveCalls: usage.filter(row => row.purpose === 'cache-keepalive').length,
292
+ compactionCalls: usage.filter(row => row.purpose === 'compaction').length,
293
+ unfinishedEffects, ...(unfinishedEffects.length ? { recoveryHint: bi('请核对中断的工具操作,确认对应命令及子进程已停止;Host 退出不代表这些操作已结束。', 'Check the interrupted tool operations and confirm their commands and child processes stopped; a Host exit does not end them.') } : {}),
294
+ actualBilledUsd: null, apiEquivalentUsd: null }, null, 2) }
295
+ }
296
+ const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
297
+ if (action === 'pause') { controller.modelControl.cancel(binding); await controller.pause(invocation.agent) }
298
+ else if (action === 'resume') {
299
+ const authorizationId = needsBudget(binding.profile) ? budget.check(effectiveRoutes(binding)).authorizationId : 'native-account'
300
+ if (binding.profile.interactionMode === 'model-like') await controller.modelControl.continue(binding.sessionId, binding.taskId,
301
+ store.readDocument(`model-control:${binding.sessionId}`)?.revision ?? 0)
302
+ else await controller.resume(invocation.agent, authorizationId)
303
+ } else if (action === 'recover') await controller.reconcile(invocation.agent, { commandId: invocation.commandId,
304
+ effectsStopped: note[0] === '--effects-stopped', note: (note[0] === '--effects-stopped' ? note.slice(1) : note).join(' ') })
305
+ else if (action === 'off') {
306
+ await controller.clear(invocation.agent)
307
+ await ctx.sessionController.selectModel({ sessionId: SessionId(invocation.agent.id), ...binding.profile.lead })
308
+ }
309
+ else throw new Error(bi('使用 /fusion status、pause、resume、recover <检查记录> 或 off', 'Use /fusion status, pause, resume, recover <inspection note> or off'))
310
+ return { kind: 'success', text: action === 'resume' ? binding.profile.interactionMode === 'model-like' ? bi('已通过原生收件箱提交一次继续操作。', 'One continuation was submitted through the native inbox.') : bi('Fusion 已恢复。发送下一条消息继续当前任务。', 'Fusion resumed. Send a message to continue the task.') : bi(`Fusion ${action} 已完成。`, `Fusion ${action} done.`) }
311
+ } catch (error) { return { kind: 'error', text: error instanceof Error ? error.message : String(error) } }
312
+ } }))
313
+ ctx.effect(() => async () => { if (creating) await creating.catch(() => undefined); if (runtime) await runtime.close(); store.close() })
314
+ // Warm only configuration/scopes; initialization performs no inference.
315
+ try { await getRuntime(await configuredProfile()) }
316
+ catch {
317
+ const selected = bindings.selected()[0]
318
+ if (selected) await getRuntime({ profile: selected.profile, prompts: selected.prompts })
319
+ }
320
+ ctx.logger.info('[my-plugins/dsh-fusion] loaded')
321
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,34 @@
1
+ export class FusionError extends Error {
2
+ constructor(
3
+ readonly code: string,
4
+ message: string,
5
+ ) {
6
+ super(message)
7
+ this.name = 'FusionError'
8
+ }
9
+ }
10
+
11
+ export const FUSION_CONTEXT_BUDGET = 'FUSION_CONTEXT_BUDGET'
12
+ export const REVISION_CONFLICT = 'REVISION_CONFLICT'
13
+ export const REVISION_STALE = 'REVISION_STALE'
14
+ export const EVENT_CONFLICT = 'EVENT_CONFLICT'
15
+ export const FLUSH_FAILED = 'FLUSH_FAILED'
16
+ export const CHECKSUM_MISMATCH = 'CHECKSUM_MISMATCH'
17
+ export const LEASE_BUSY = 'LEASE_BUSY'
18
+ export const LEASE_GENERATION = 'LEASE_GENERATION'
19
+ export const PROFILE_UNAVAILABLE = 'PROFILE_UNAVAILABLE'
20
+ export const SPEND_UNAUTHORIZED = 'SPEND_UNAUTHORIZED'
21
+ export const APPROVAL_SCOPE = 'APPROVAL_SCOPE'
22
+ export const OUTCOME_UNKNOWN = 'OUTCOME_UNKNOWN'
23
+ export const NEEDS_REPAIR = 'NEEDS_REPAIR'
24
+ export const USAGE_MERGE_CONFLICT = 'USAGE_MERGE_CONFLICT'
25
+ export const USAGE_LEDGER_REQUIRED = 'USAGE_LEDGER_REQUIRED'
26
+ export const CONTROL_BLOCKED = 'CONTROL_BLOCKED'
27
+ export const REVIEW_RESULT_CONFLICT = 'REVIEW_RESULT_CONFLICT'
28
+ export const REVIEW_TICKET_REQUIRED = 'REVIEW_TICKET_REQUIRED'
29
+ export const WORK_ORDER_CONFLICT = 'WORK_ORDER_CONFLICT'
30
+ export const STORE_MIGRATION_REQUIRED = 'STORE_MIGRATION_REQUIRED'
31
+ /** No task carries this id (or it was already deleted). */
32
+ export const TASK_NOT_FOUND = 'TASK_NOT_FOUND'
33
+ /** Deleting it would strand a live Worker, lease or unfinished handoff. */
34
+ export const TASK_BUSY = 'TASK_BUSY'
@@ -0,0 +1,290 @@
1
+ import type {
2
+ AcceptanceCriterion,
3
+ Digest,
4
+ SnapshotId,
5
+ TaskId,
6
+ ToolEvidence,
7
+ VerificationLevel,
8
+ WorkOrder,
9
+ WorkerReport,
10
+ } from '../contracts.js'
11
+ import { sha256Hex } from '../digest.js'
12
+ import {
13
+ artifactFingerprint,
14
+ assertPlannedCheck,
15
+ checkPlanDigest,
16
+ invocationHeader,
17
+ sameInvocationHeader,
18
+ type VerificationRegistry,
19
+ } from './receipts.js'
20
+
21
+ export interface EvidenceVerdict {
22
+ readonly verification: VerificationLevel
23
+ readonly reasons: readonly string[]
24
+ }
25
+
26
+ export type TrustedEvidenceRegistry =
27
+ | readonly ToolEvidence[]
28
+ | Readonly<Record<string, ToolEvidence>>
29
+ | ReadonlyMap<string, ToolEvidence>
30
+
31
+ export interface RegisteredVerifier {
32
+ readonly id: string
33
+ readonly kind: AcceptanceCriterion['verificationKind']
34
+ readonly argvDigests?: readonly Digest[]
35
+ }
36
+
37
+ export interface EvaluateReportOptions {
38
+ readonly verifiers?: readonly RegisteredVerifier[]
39
+ readonly registry?: VerificationRegistry
40
+ }
41
+
42
+ export const DEFAULT_TEST_VERIFIERS: readonly RegisteredVerifier[] = [
43
+ { id: 'pytest', kind: 'test', argvDigests: [sha256Hex('python -m pytest')] },
44
+ { id: 'npm-test', kind: 'test', argvDigests: [sha256Hex('npm test')] },
45
+ ]
46
+
47
+ interface EvidenceIndex {
48
+ readonly byId: ReadonlyMap<string, ToolEvidence>
49
+ readonly ambiguous: ReadonlySet<string>
50
+ }
51
+
52
+ function sameIdentity(left: ToolEvidence, right: ToolEvidence): boolean {
53
+ return left.operationId === right.operationId
54
+ && left.taskId === right.taskId
55
+ && left.nativeToolCallId === right.nativeToolCallId
56
+ }
57
+
58
+ function sameContent(left: ToolEvidence, right: ToolEvidence): boolean {
59
+ return sameIdentity(left, right)
60
+ && sameInvocationHeader(invocationHeader(left), invocationHeader(right))
61
+ && left.argvDigest === right.argvDigest
62
+ && left.cwdDigest === right.cwdDigest
63
+ && left.actor === right.actor
64
+ && left.state === right.state
65
+ && left.exitCode === right.exitCode
66
+ && left.inputSnapshot === right.inputSnapshot
67
+ && left.outputSnapshot === right.outputSnapshot
68
+ && left.endedAt === right.endedAt
69
+ && left.startedAt === right.startedAt
70
+ && artifactFingerprint(left.stdout) === artifactFingerprint(right.stdout)
71
+ && artifactFingerprint(left.stderr) === artifactFingerprint(right.stderr)
72
+ && (left.kind ?? 'tool') === (right.kind ?? 'tool')
73
+ }
74
+
75
+ function isLifecycleUpgrade(existing: ToolEvidence, incoming: ToolEvidence): boolean {
76
+ return sameIdentity(existing, incoming)
77
+ && sameInvocationHeader(invocationHeader(existing), invocationHeader(incoming))
78
+ && existing.state === 'started'
79
+ && incoming.state === 'completed'
80
+ }
81
+
82
+ function addAlias(byId: Map<string, ToolEvidence>, ambiguous: Set<string>, id: string, item: ToolEvidence): void {
83
+ const existing = byId.get(id)
84
+ if (!existing) {
85
+ byId.set(id, item)
86
+ return
87
+ }
88
+ if (sameContent(existing, item)) return
89
+ if (isLifecycleUpgrade(existing, item)) {
90
+ byId.set(id, item)
91
+ return
92
+ }
93
+ if (isLifecycleUpgrade(item, existing)) return
94
+ ambiguous.add(id)
95
+ }
96
+
97
+ function indexRegistry(registry: TrustedEvidenceRegistry): EvidenceIndex {
98
+ const byId = new Map<string, ToolEvidence>()
99
+ const ambiguous = new Set<string>()
100
+
101
+ const add = (item: ToolEvidence, explicitId?: string) => {
102
+ if (explicitId) addAlias(byId, ambiguous, explicitId, item)
103
+ addAlias(byId, ambiguous, item.operationId, item)
104
+ addAlias(byId, ambiguous, item.stdout.id, item)
105
+ addAlias(byId, ambiguous, item.stderr.id, item)
106
+ }
107
+
108
+ if (registry instanceof Map) {
109
+ for (const [id, item] of registry) add(item, id)
110
+ } else if (Array.isArray(registry)) {
111
+ for (const item of registry) add(item)
112
+ } else {
113
+ for (const [id, item] of Object.entries(registry)) add(item, id)
114
+ }
115
+
116
+ return { byId, ambiguous }
117
+ }
118
+
119
+ function eligibilityFailure(item: ToolEvidence, taskId: TaskId, currentSnapshot: SnapshotId): string | undefined {
120
+ if (item.taskId !== taskId) return `evidence ${item.operationId} belongs to task ${item.taskId}, not ${taskId}`
121
+ if (item.state !== 'completed') return `evidence ${item.operationId} is ${item.state}, not completed`
122
+ if (item.endedAt === null) return `evidence ${item.operationId} has no endedAt`
123
+ if (item.exitCode !== 0) return `evidence ${item.operationId} exited ${item.exitCode}`
124
+ if (item.inputSnapshot !== currentSnapshot) {
125
+ return `evidence ${item.operationId} input snapshot ${item.inputSnapshot} is not current ${currentSnapshot}`
126
+ }
127
+ if (item.outputSnapshot !== currentSnapshot) {
128
+ return `evidence ${item.operationId} output snapshot ${item.outputSnapshot ?? 'missing'} is not current ${currentSnapshot}`
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ function plannedCheckFailure(
134
+ workOrder: WorkOrder,
135
+ criterion: AcceptanceCriterion,
136
+ evidenceId: string,
137
+ registry: VerificationRegistry | undefined,
138
+ actualSnapshot: SnapshotId,
139
+ ): string | undefined {
140
+ if (criterion.verificationKind === 'human' || criterion.verificationKind === 'review') return undefined
141
+ if (!registry || !criterion.planId || !criterion.planDigest) {
142
+ return `criterion ${criterion.id} has no frozen CheckPlan; allowedPaths are not a verification cwd`
143
+ }
144
+ const plan = registry.getPlan(criterion.planId)
145
+ if (!plan) return `criterion ${criterion.id} cites unknown plan ${criterion.planId}`
146
+ if (criterion.planDigest !== checkPlanDigest(plan)) {
147
+ return `criterion ${criterion.id} plan digest does not match the frozen work-order plan`
148
+ }
149
+ const receipt = registry.getReceipt(evidenceId)
150
+ if (!receipt) {
151
+ return `criterion ${criterion.id} cites legacy evidence ${evidenceId} without a planned receipt`
152
+ }
153
+ try {
154
+ assertPlannedCheck(workOrder, criterion.id, plan, receipt, actualSnapshot)
155
+ return undefined
156
+ } catch (error) {
157
+ return `criterion ${criterion.id}: ${(error as Error).message}`
158
+ }
159
+ }
160
+
161
+ function relevanceFailure(
162
+ criterion: AcceptanceCriterion,
163
+ record: ToolEvidence,
164
+ verifiers: readonly RegisteredVerifier[],
165
+ ): string | undefined {
166
+ const kind = record.kind ?? 'tool'
167
+ if (criterion.verificationKind === 'human') {
168
+ if (kind !== 'human-decision') {
169
+ return `human criterion ${criterion.id} cannot be fulfilled by a tool exit`
170
+ }
171
+ return undefined
172
+ }
173
+ if (criterion.verificationKind === 'review') {
174
+ if (kind !== 'review-accept') {
175
+ return `review criterion ${criterion.id} needs an accepting review record`
176
+ }
177
+ return undefined
178
+ }
179
+ const allowed = verifiers.filter(verifier => {
180
+ if (verifier.kind !== criterion.verificationKind) return false
181
+ if (criterion.verifierId && verifier.id !== criterion.verifierId) return false
182
+ return true
183
+ })
184
+ if (!allowed.some(verifier => verifier.argvDigests?.includes(record.argvDigest))) {
185
+ return `criterion ${criterion.id} evidence argv is not a registered ${criterion.verificationKind} verifier`
186
+ }
187
+ return undefined
188
+ }
189
+
190
+ /**
191
+ * Score only the current work order's mandatory criteria and the evidence those
192
+ * claims actually cite. Historical failures stay in the registry for audit.
193
+ * Authenticity and relevance are independent checks.
194
+ */
195
+ export function evaluateReport(
196
+ workOrder: WorkOrder,
197
+ report: WorkerReport,
198
+ trustedRegistry: TrustedEvidenceRegistry,
199
+ currentSnapshot: SnapshotId,
200
+ options?: EvaluateReportOptions,
201
+ ): EvidenceVerdict {
202
+ const trust: string[] = []
203
+ if (report.workOrderId !== workOrder.id) {
204
+ trust.push(`report workOrderId ${report.workOrderId} does not match frozen work order ${workOrder.id}`)
205
+ }
206
+ if (report.revision !== workOrder.revision) {
207
+ trust.push(`report revision ${report.revision} does not match frozen work order revision ${workOrder.revision}`)
208
+ }
209
+ if (report.snapshot !== currentSnapshot) {
210
+ trust.push('snapshot drift: report snapshot is not the current workspace snapshot')
211
+ }
212
+
213
+ const acceptanceById = new Map(workOrder.acceptance.map(criterion => [criterion.id, criterion]))
214
+ const seenClaims = new Set<string>()
215
+ const index = indexRegistry(trustedRegistry)
216
+ const verifiers = options?.verifiers ?? DEFAULT_TEST_VERIFIERS
217
+ const satisfied = new Set<string>()
218
+
219
+ for (const claim of report.coverage) {
220
+ if (seenClaims.has(claim.criterionId)) {
221
+ trust.push(`duplicate criterion ${claim.criterionId}`)
222
+ continue
223
+ }
224
+ seenClaims.add(claim.criterionId)
225
+ if (!acceptanceById.has(claim.criterionId)) {
226
+ trust.push(`unknown criterion ${claim.criterionId}`)
227
+ continue
228
+ }
229
+ if (claim.state !== 'satisfied') continue
230
+ if (claim.evidenceIds.length === 0) {
231
+ trust.push(`criterion ${claim.criterionId} claimed satisfied without evidence`)
232
+ continue
233
+ }
234
+ const criterion = acceptanceById.get(claim.criterionId)!
235
+ const cited = new Set<string>()
236
+ let claimOk = true
237
+ for (const evidenceId of claim.evidenceIds) {
238
+ if (cited.has(evidenceId)) continue
239
+ cited.add(evidenceId)
240
+ if (index.ambiguous.has(evidenceId)) {
241
+ trust.push(`criterion ${claim.criterionId} cites ambiguous evidence id ${evidenceId}`)
242
+ claimOk = false
243
+ continue
244
+ }
245
+ if (criterion.verificationKind === 'test' || criterion.verificationKind === 'static-check') {
246
+ const planned = plannedCheckFailure(workOrder, criterion, evidenceId, options?.registry, currentSnapshot)
247
+ if (planned) {
248
+ trust.push(planned.startsWith('criterion ') ? planned : `criterion ${claim.criterionId}: ${planned}`)
249
+ claimOk = false
250
+ }
251
+ continue
252
+ }
253
+ const record = index.byId.get(evidenceId)
254
+ if (!record) {
255
+ trust.push(`criterion ${claim.criterionId} cites unknown evidence id ${evidenceId}`)
256
+ claimOk = false
257
+ continue
258
+ }
259
+ const why = eligibilityFailure(record, workOrder.taskId, currentSnapshot)
260
+ ?? relevanceFailure(criterion, record, verifiers)
261
+ if (why) {
262
+ trust.push(`criterion ${claim.criterionId}: ${why}`)
263
+ claimOk = false
264
+ }
265
+ }
266
+ if (claimOk) satisfied.add(claim.criterionId)
267
+ }
268
+
269
+ if (trust.length) {
270
+ return { verification: 'unverified', reasons: trust }
271
+ }
272
+
273
+ const coverage: string[] = []
274
+ if (report.status !== 'completed') {
275
+ coverage.push(`report status is ${report.status}`)
276
+ }
277
+ if (report.unresolved.length) {
278
+ coverage.push(`${report.unresolved.length} unresolved item(s) remain`)
279
+ }
280
+ for (const criterion of workOrder.acceptance) {
281
+ if (!criterion.mandatory) continue
282
+ if (!satisfied.has(criterion.id)) {
283
+ coverage.push(`mandatory criterion ${criterion.id} is not satisfied by current trusted evidence`)
284
+ }
285
+ }
286
+ if (coverage.length) {
287
+ return { verification: 'partial', reasons: coverage }
288
+ }
289
+ return { verification: 'verified', reasons: [] }
290
+ }