@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,112 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import type { Agent } from '@deepseek-ai/dsh-agent'
5
+ import type { StreamChunk } from '@deepseek-ai/dsh-llm'
6
+ import type { PhysicalRoute, Role } from '../contracts.js'
7
+ import { digestOf } from '../digest.js'
8
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
9
+ import type { SessionBinding } from './bindings.js'
10
+ import { nativeRequestAgent } from './native-request.js'
11
+
12
+ /** Explicit bounded authorization when the native provider exposes no reliable money meter. */
13
+ export interface NativeSpendingAuthorization {
14
+ schemaVersion: 1
15
+ kind: 'native-fusion-requests'
16
+ approved: true
17
+ authorizationId: string
18
+ approvedBy: string
19
+ expiresAt: string
20
+ routes: readonly PhysicalRoute[]
21
+ maxNativeRequests: number
22
+ maxReservedOutputTokens: number
23
+ /** This cannot be represented as a hard dollar cap. */
24
+ costPolicy: 'unknown-cost-acknowledged'
25
+ }
26
+
27
+ export function nativeAuthorization(raw: unknown): NativeSpendingAuthorization {
28
+ const keys = new Set(['schemaVersion', 'kind', 'approved', 'authorizationId', 'approvedBy', 'expiresAt', 'routes',
29
+ 'maxNativeRequests', 'maxReservedOutputTokens', 'costPolicy'])
30
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).some(key => !keys.has(key))) {
31
+ throw new Error('Unknown authorization fields; this authorization cannot enforce a money limit')
32
+ }
33
+ const value = raw as NativeSpendingAuthorization
34
+ if (!value || value.schemaVersion !== 1 || value.kind !== 'native-fusion-requests' || value.approved !== true
35
+ || !value.authorizationId?.trim() || !value.approvedBy?.trim()
36
+ || !Number.isFinite(Date.parse(value.expiresAt)) || Date.parse(value.expiresAt) <= Date.now()
37
+ || !Number.isSafeInteger(value.maxNativeRequests) || value.maxNativeRequests <= 0
38
+ || !Number.isSafeInteger(value.maxReservedOutputTokens) || value.maxReservedOutputTokens <= 0
39
+ || value.costPolicy !== 'unknown-cost-acknowledged' || !Array.isArray(value.routes) || !value.routes.length
40
+ || value.routes.some(route => !route.provider?.trim() || !route.model?.trim())) {
41
+ throw new Error('An unexpired, explicit native request spending authorization is required')
42
+ }
43
+ return structuredClone(value)
44
+ }
45
+
46
+ const UNLIMITED_HOURS = 24 * 365 * 100
47
+
48
+ /** Called only by explicit human settings actions (save or limits), never by a model tool. */
49
+ export function authorizeConfiguredPair(store: SqliteFusionStore, routes: readonly PhysicalRoute[], limits: unknown): NativeSpendingAuthorization {
50
+ const input = limits as Record<string, unknown> | undefined
51
+ if (!input || input.acknowledgeAccountUsage !== true || input.acknowledgeUnknownCost !== true) throw new Error('请确认使用模型账号额度,以及次数限制并非金额上限')
52
+ // Saving a pair is the user's consent to use those accounts; caps are optional.
53
+ // An omitted cap means none, so ordinary use never stalls on a limit the user did not choose.
54
+ const integer = (key: string, max: number, fallback: number): number => {
55
+ const value = input[key]
56
+ if (value === undefined || value === null) return fallback
57
+ if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > max) throw new Error(`${key} 超出允许范围`)
58
+ return Number(value)
59
+ }
60
+ const auth = nativeAuthorization({ schemaVersion: 1, kind: 'native-fusion-requests', approved: true,
61
+ authorizationId: randomUUID(), approvedBy: 'local-user-settings',
62
+ expiresAt: new Date(Date.now() + integer('validHours', UNLIMITED_HOURS, UNLIMITED_HOURS) * 3600_000).toISOString(), routes,
63
+ maxNativeRequests: integer('maxNativeRequests', Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER),
64
+ maxReservedOutputTokens: integer('maxReservedOutputTokens', Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER),
65
+ costPolicy: 'unknown-cost-acknowledged' })
66
+ const prior = store.readDocument('settings:authorization')
67
+ store.writeDocument('settings:authorization', prior?.revision ?? 0, auth)
68
+ return auth
69
+ }
70
+
71
+ export class NativeRequestBudget {
72
+ constructor(readonly store: SqliteFusionStore, readonly authorizationFile?: string) {}
73
+
74
+ check(routes: readonly PhysicalRoute[]): NativeSpendingAuthorization {
75
+ const raw = this.authorizationFile ? JSON.parse(readFileSync(this.authorizationFile, 'utf8'))
76
+ : this.store.readDocument('settings:authorization')?.value
77
+ if (!raw) throw new Error('请前往设置 → Fusion 启用调用额度')
78
+ const auth = nativeAuthorization(raw)
79
+ if (routes.some(route => !auth.routes.some(allowed => allowed.provider === route.provider && allowed.model === route.model))) {
80
+ throw new Error('The frozen model configuration is outside this spending authorization')
81
+ }
82
+ const prior = this.store.readDocument(`budget:${auth.authorizationId}`)?.value as { digest?: string; requests?: number; reservedOutputTokens?: number } | undefined
83
+ if (prior && prior.digest !== digestOf(auth)) throw new Error('Authorization changed under an existing id; record a new human authorization')
84
+ if (Number(prior?.requests ?? 0) >= auth.maxNativeRequests || Number(prior?.reservedOutputTokens ?? 0) >= auth.maxReservedOutputTokens) throw new Error('Approved native request budget exhausted')
85
+ return auth
86
+ }
87
+
88
+ install(ctx: Context, owner: (agent: Agent) => { binding: SessionBinding; role: Role } | undefined,
89
+ onBlocked?: (binding: SessionBinding, error: unknown) => void): () => void {
90
+ const budget = this
91
+ return ctx.on('llm/stream', async function* (request, next): AsyncIterable<StreamChunk> {
92
+ const agent = nativeRequestAgent(ctx, request), binding = agent && owner(agent)
93
+ if (binding) {
94
+ try { budget.reserve({ provider: request.provider, model: request.model }, request.maxTokens ?? 0) }
95
+ catch (error) { onBlocked?.(binding.binding, error); throw error }
96
+ }
97
+ yield* next()
98
+ }, { prepend: true })
99
+ }
100
+
101
+ reserve(route: PhysicalRoute, maxOutputTokens: number): void {
102
+ const auth = this.check([route])
103
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) throw new Error('A bounded output reservation is required')
104
+ const key = `budget:${auth.authorizationId}`, prior = this.store.readDocument(key)
105
+ const row = prior?.value as { requests: number; reservedOutputTokens: number } | undefined
106
+ const requests = (row?.requests ?? 0) + 1, reservedOutputTokens = (row?.reservedOutputTokens ?? 0) + maxOutputTokens
107
+ if (requests > auth.maxNativeRequests || reservedOutputTokens > auth.maxReservedOutputTokens) throw new Error('This request exceeds the approved request/output budget')
108
+ // Unknown billing retains the reservation, including aborted requests.
109
+ this.store.writeDocument(key, prior?.revision ?? 0, { schemaVersion: 1, digest: digestOf(auth), requests, reservedOutputTokens,
110
+ lastReservationId: randomUUID(), actualBilledUsd: null, apiEquivalentUsd: null, maximumDollarCost: null })
111
+ }
112
+ }
@@ -0,0 +1,474 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { promisify } from 'node:util'
4
+ import { accessSync, constants, lstatSync, readdirSync, readFileSync } from 'node:fs'
5
+ import { homedir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import { ToolCallId } from '@deepseek-ai/dsh-llm'
9
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
10
+ import { ArtifactId, OperationId, SessionId } from '../contracts.js'
11
+ import type { ToolEvidence, WorkOrder, WorkerReport } from '../contracts.js'
12
+ import { digestOf, sha256Hex } from '../digest.js'
13
+ import { evaluateReport } from '../evidence/gate.js'
14
+ import type { CheckPlan, PlannedReceipt } from '../evidence/receipts.js'
15
+ import { assertPlannedCheck, memoryVerificationRegistry } from '../evidence/receipts.js'
16
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
17
+ import { snapshotWorkspace, workspacePath } from './workspace.js'
18
+ import { nativeShellTool } from './shell.js'
19
+
20
+ export interface CheckDefinition {
21
+ id: string
22
+ description: string
23
+ command: string
24
+ kind: 'test' | 'static-check'
25
+ parser: TestParser | 'exit-code'
26
+ /** Existing acceptance test files. Their bytes may not change during execution. May be empty for a new project. */
27
+ definitionPaths: readonly string[]
28
+ /** Per-check limit; defaults to the work-order policy. */
29
+ timeoutSeconds?: number
30
+ /**
31
+ * `no-new-failures`: a broad regression suite measured against the untouched workspace. The Host runs it
32
+ * once before the Worker starts and freezes the failing test ids; the candidate passes when every failure
33
+ * it shows was already failing there. Default: every test must pass.
34
+ */
35
+ baseline?: 'no-new-failures'
36
+ }
37
+ /** Parsers that name each failing test, which baseline-relative checks need. Mocha's default report spreads a name over several lines. */
38
+ export const BASELINE_PARSERS = ['unittest', 'pytest', 'vitest', 'jest', 'tap', 'go', 'cargo'] as const
39
+ export type BaselineParser = typeof BASELINE_PARSERS[number]
40
+ export const TEST_PARSERS = ['unittest', 'pytest', 'vitest', 'jest', 'mocha', 'tap', 'go', 'cargo'] as const
41
+ export type TestParser = typeof TEST_PARSERS[number]
42
+ export const MAX_CHECK_SECONDS = 3_600
43
+ export interface FrozenCheck { definition: CheckDefinition; plan: CheckPlan }
44
+
45
+ /** Shell setup failures need a corrected check, rather than an implementation repair. */
46
+ export function checkCommandUnavailable(exitCode: number | null, stderr: string, platform: NodeJS.Platform = process.platform): boolean {
47
+ if (exitCode === null || exitCode === 0) return false
48
+ if (exitCode === 126 || exitCode === 127) return true
49
+ if (platform !== 'win32') return false
50
+ // PowerShell normally returns 1 for CommandNotFoundException. Its concise
51
+ // error view omits the exception id, so also recognize its specific message.
52
+ // A generic exit 1 or a missing data file must remain a normal check failure.
53
+ const text = stderr.replace(/\x1b\[[0-9;]*m/g, '').replace(/\s+/g, ' ')
54
+ return /\bCommandNotFoundException\b/.test(text)
55
+ || /\bThe term ['"][^'"]+['"] is not recognized as (?:the |a )?name of a cmdlet, function, script file, or (?:executable|operable) program\b/i.test(text)
56
+ }
57
+
58
+ function definitionDigest(root: string, definition: CheckDefinition) {
59
+ return digestOf({ definition, files: definition.definitionPaths.map(path => ({ path, digest: sha256Hex(readFileSync(workspacePath(root, path))) })) })
60
+ }
61
+
62
+ export function freezeChecks(order: WorkOrder, root: string, definitions: readonly CheckDefinition[]): FrozenCheck[] {
63
+ // Zero checks is a review-only order: honest, and cheaper than forcing the Lead to do untestable work itself.
64
+ if (definitions.length > 12) throw new Error('Delegate accepts at most 12 acceptance checks')
65
+ const ids = new Set<string>()
66
+ return definitions.map(definition => {
67
+ if (!/^[a-zA-Z0-9._-]{1,80}$/.test(definition.id) || ids.has(definition.id)) throw new Error('Acceptance check ids must be unique')
68
+ ids.add(definition.id)
69
+ if (!definition.description.trim() || !definition.command.trim() || definition.command.length > 8000) throw new Error('Invalid acceptance command')
70
+ if (definition.kind === 'test' && !(TEST_PARSERS as readonly string[]).includes(definition.parser)) {
71
+ throw new Error(`Test checks need a count parser (${TEST_PARSERS.join(', ')}); a runner without one is a static-check with the exit-code parser`)
72
+ }
73
+ if (definition.timeoutSeconds !== undefined && (!Number.isSafeInteger(definition.timeoutSeconds)
74
+ || definition.timeoutSeconds < 1 || definition.timeoutSeconds > MAX_CHECK_SECONDS)) throw new Error(`timeoutSeconds must be 1–${MAX_CHECK_SECONDS}`)
75
+ if (definition.kind === 'static-check' && definition.parser !== 'exit-code') throw new Error('Static checks use the exit-code parser')
76
+ if (definition.baseline !== undefined && (definition.baseline !== 'no-new-failures' || definition.kind !== 'test'
77
+ || !(BASELINE_PARSERS as readonly string[]).includes(definition.parser))) {
78
+ throw new Error(`Acceptance check ${definition.id}: baseline "no-new-failures" needs kind "test" with a parser that names each failing test (${BASELINE_PARSERS.join(', ')}); `
79
+ + `this check has kind "${definition.kind}" and parser "${definition.parser}". Retry fusion_delegate with `
80
+ + (definition.parser === 'mocha' ? 'the mocha command run with --reporter tap and parser "tap", ' : 'one of those parsers for this suite, ')
81
+ + 'or without baseline so every test must pass.')
82
+ }
83
+ for (const path of definition.definitionPaths) {
84
+ if (!lstatSync(workspacePath(root, path), { throwIfNoEntry: false })?.isFile()) {
85
+ throw new Error(`Acceptance check ${definition.id}: definitionPaths must name existing regular files whose bytes stay unchanged; ${JSON.stringify(path)} is not one. Keep existing acceptance files here; new regression tests belong in allowedPaths and can still run through the acceptance command. Retry fusion_delegate with corrected definitionPaths; do not create placeholders or finish the task to release a lease.`)
86
+ }
87
+ }
88
+ return { definition: structuredClone(definition), plan: {
89
+ id: `check:${order.id}:${definition.id}`, version: 2, taskId: order.taskId,
90
+ workOrderId: order.id, revision: order.revision, criterionId: definition.id, kind: definition.kind,
91
+ snapshot: order.baseSnapshot, snapshotBinding: 'candidate', cwdDigest: digestOf(root), argvDigest: digestOf(definition.command),
92
+ definitionDigest: definitionDigest(root, definition), policyDigest: digestOf(order.policy),
93
+ ...(definition.kind === 'test' ? { minExecutedTests: 1 } : {}),
94
+ } }
95
+ })
96
+ }
97
+
98
+ /**
99
+ * Counts passed tests from a runner's summary. A suite passes when nothing failed or errored and at
100
+ * least one test ran; skipped, deselected, pending, ignored and expected-failure tests were not
101
+ * executed and do not veto the result (real projects routinely skip optional-dependency tests; study
102
+ * 2026-09-26: "799 passed, 86 skipped" blocked an otherwise verified acceptance).
103
+ */
104
+ export function parseTestCounts(parser: CheckDefinition['parser'], text: string): PlannedReceipt['counts'] {
105
+ const clean = text.replace(/\x1b\[[0-9;]*m/g, '')
106
+ let count: number | undefined
107
+ if (parser === 'unittest') {
108
+ // "OK", "OK (skipped=3)", "OK (skipped=1, expected failures=2)"; unexpected successes fail the run.
109
+ const ok = [...clean.matchAll(/(?:^|\n)OK(?: \(([^)\n]*)\))?\s*(?:\n|$)/g)].at(-1)
110
+ if (ok && !/unexpected successes=/.test(ok[1] ?? '')) count = Number([...clean.matchAll(/Ran (\d+) tests? in /g)].at(-1)?.[1])
111
+ - Number(ok[1]?.match(/skipped=(\d+)/)?.[1] ?? 0) || undefined
112
+ } else if (parser === 'pytest') {
113
+ // The final summary line, e.g. "== 799 passed, 86 skipped, 3 warnings in 12.3s ==".
114
+ const summary = [...clean.matchAll(/(?:^|\n)[=\s]*((?:\d+ \w+(?: \w+)?(?:, )?)+) in [\d.]+s/g)].at(-1)?.[1] ?? ''
115
+ if (!/\b\d+ (?:failed|errors?)\b/.test(summary)) count = Number(summary.match(/\b(\d+) passed\b/)?.[1])
116
+ } else if (parser === 'vitest') {
117
+ // "Tests 12 passed (12)" or "Tests 12 passed | 2 skipped (14)"; any failure word disqualifies.
118
+ const last = [...clean.matchAll(/(?:^|\n)\s*Tests\s+([^\n]*?)\s*\((\d+)\)/g)].at(-1)
119
+ if (last && !/\bfailed\b/.test(last[1]!)) count = Number(last[1]!.match(/(\d+) passed/)?.[1])
120
+ } else if (parser === 'jest') {
121
+ // "Tests: 12 passed, 12 total" or "Tests: 2 skipped, 1 todo, 12 passed, 15 total".
122
+ const last = [...clean.matchAll(/(?:^|\n)\s*Tests:\s+([^\n]*)/g)].at(-1)?.[1]
123
+ if (last && !/\bfailed\b/.test(last)) count = Number(last.match(/(\d+) passed/)?.[1])
124
+ } else if (parser === 'mocha' && !/\b\d+ failing\b/.test(clean)) {
125
+ count = Number([...clean.matchAll(/(?:^|\n)\s*(\d+) passing\b/g)].at(-1)?.[1])
126
+ } else if (parser === 'go' && !/(?:^|\n)\s*--- FAIL:|(?:^|\n)FAIL\b/.test(clean)) {
127
+ // Needs `go test -v`: only verbose output names each passed test.
128
+ const passed = [...clean.matchAll(/(?:^|\n)\s*--- PASS: /g)].length
129
+ if (passed) count = passed
130
+ } else if (parser === 'cargo') {
131
+ const results = [...clean.matchAll(/test result: (\w+)\. (\d+) passed; (\d+) failed;/g)]
132
+ if (results.length && results.every(match => match[1] === 'ok' && match[3] === '0')) {
133
+ count = results.reduce((sum, match) => sum + Number(match[2]), 0)
134
+ }
135
+ } else if (parser === 'tap' && !/(?:^|\n)not ok\b(?![^\n]*#\s*SKIP)/i.test(clean)) {
136
+ // "ok 3 # SKIP reason" is a skip, not a pass; "not ok ... # TODO" is still a failure here.
137
+ const plan = [...clean.matchAll(/(?:^|\n)1\.\.(\d+)\s*(?:\n|$)/g)].at(-1)
138
+ const ok = [...clean.matchAll(/(?:^|\n)ok\s+\d+\b(?![^\n]*#\s*SKIP)/gi)].length
139
+ const skipped = [...clean.matchAll(/(?:^|\n)(?:not )?ok\s+\d+\b[^\n]*#\s*SKIP/gi)].length
140
+ if (plan && Number(plan[1]) === ok + skipped) count = ok
141
+ }
142
+ return count && Number.isSafeInteger(count) && count > 0 ? { executed: count, passed: count, failed: 0 } : undefined
143
+ }
144
+
145
+ /** What a baseline run needs from each parser; shown when a baseline run cannot be read. */
146
+ const BASELINE_OUTPUT: Record<BaselineParser, string> = {
147
+ pytest: 'the FAILED/ERROR/SUBFAILED ids of its short summary',
148
+ unittest: 'a FAIL:/ERROR: header for each failure its FAILED (...) summary counts',
149
+ vitest: 'a "FAIL file > test" line for each failure its Tests summary counts; a test file that fails to load names no tests',
150
+ jest: 'a "● test" header for each failure its Tests: summary counts; a suite that fails to run names no tests',
151
+ tap: 'a described top-level "not ok" line for each failure, matching the 1..N plan',
152
+ go: 'go test -v output: --- FAIL lines and a final ok/FAIL line for each package',
153
+ cargo: 'a "test name ... FAILED" line for each failure its test result lines count, with names unique across test binaries',
154
+ }
155
+
156
+ /**
157
+ * Passed count and the ids of failing tests, only when the output names every failure the summary counts:
158
+ * a failure without a name, or two failures under one name, would let a candidate trade a frozen failure for
159
+ * a new one. Ids must stay the same across runs, so positions (TAP numbers, durations) are not part of them.
160
+ */
161
+ export function parseTestFailures(parser: CheckDefinition['parser'], text: string): { passed: number; failedIds: string[] } | undefined {
162
+ const clean = text.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r\n/g, '\n')
163
+ const lines = clean.split('\n')
164
+ const matches = (pattern: RegExp) => lines.flatMap(line => { const match = line.match(pattern); return match ? [match] : [] })
165
+ let found: { passed: number; failed: number; ids: string[] } | undefined
166
+ if (parser === 'pytest') {
167
+ const summary = [...clean.matchAll(/(?:^|\n)[=\s]*((?:\d+ \w+(?: \w+)?(?:, )?)+) in [\d.]+s/g)].at(-1)?.[1]
168
+ if (!summary) return undefined
169
+ const count = (word: RegExp) => Number(summary.match(word)?.[1] ?? 0)
170
+ found = { passed: count(/\b(\d+) passed\b/), failed: count(/\b(\d+) failed\b/) + count(/\b(\d+) errors?\b/),
171
+ // A failed subtest is counted as a failure and listed as "SUBFAILED(i=1) <id>" (pytest 9) or "SUBFAIL <id>".
172
+ ids: matches(/^(?:FAILED|ERROR|SUBFAIL(?:ED)?(\([^)]*\))?) (\S+)/).map(match => match[1] ? `${match[2]} ${match[1]}` : match[2]!) }
173
+ } else if (parser === 'unittest') {
174
+ const ran = [...clean.matchAll(/Ran (\d+) tests? in /g)].at(-1)
175
+ const status = [...clean.matchAll(/(?:^|\n)(?:OK|FAILED)(?: \(([^)\n]*)\))?[ \t]*(?=\n|$)/g)].at(-1)
176
+ if (!ran || !status) return undefined
177
+ const count = (key: string) => Number(status[1]?.match(new RegExp(`\\b${key}=(\\d+)`))?.[1] ?? 0)
178
+ // An unexpected success fails the run without a FAIL: header to name it.
179
+ if (count('unexpected successes')) return undefined
180
+ const failed = count('failures') + count('errors')
181
+ found = { passed: Math.max(0, Number(ran[1]) - failed - count('skipped') - count('expected failures')), failed,
182
+ ids: matches(/^(?:FAIL|ERROR): (.+?)\s*$/).map(match => match[1]!) }
183
+ } else if (parser === 'vitest' || parser === 'jest') {
184
+ const summary = parser === 'vitest' ? [...clean.matchAll(/(?:^|\n)\s*Tests\s+([^\n]*?)\s*\(\d+\)/g)].at(-1)?.[1]
185
+ : [...clean.matchAll(/(?:^|\n)\s*Tests:\s+([^\n]*)/g)].at(-1)?.[1]
186
+ if (summary === undefined) return undefined
187
+ const count = (word: RegExp) => Number(summary.match(word)?.[1] ?? 0)
188
+ let ids: string[]
189
+ if (parser === 'vitest') {
190
+ // "FAIL file > suite > test"; a bare "FAIL file" is a suite that failed to load, whose tests are not counted.
191
+ const failures = matches(/^\s*FAIL\s+(.+?)\s*$/).map(match => match[1]!)
192
+ if (failures.some(id => !id.includes(' > '))) return undefined
193
+ ids = failures
194
+ } else {
195
+ if (/(?:^|\n)\s*● Test suite failed to run/.test(clean)) return undefined
196
+ // "● suite › test" headers follow the "FAIL file" line of their test file; the file keeps equal titles apart.
197
+ let file = ''
198
+ ids = lines.flatMap(line => {
199
+ file = line.match(/^\s*FAIL\s+(\S+)/)?.[1] ?? file
200
+ const title = line.match(/^\s*● (.+?)\s*$/)?.[1]
201
+ return title ? [file ? `${file} › ${title}` : title] : []
202
+ })
203
+ }
204
+ found = { passed: count(/(\d+) passed/), failed: count(/(\d+) failed/), ids }
205
+ } else if (parser === 'tap') {
206
+ const plan = [...clean.matchAll(/(?:^|\n)1\.\.(\d+)\s*(?=\n|$)/g)].at(-1)
207
+ const points = matches(/^(not )?ok\s+\d+\b(.*)$/)
208
+ const skipped = (point: RegExpMatchArray) => /#\s*SKIP/i.test(point[2]!)
209
+ if (!plan || Number(plan[1]) !== points.length) return undefined
210
+ const failures = points.filter(point => point[1] && !skipped(point))
211
+ .map(point => point[2]!.replace(/\s#\s*(?:TODO|SKIP)\b.*$/i, '').replace(/^\s*-?\s*/, '').trim())
212
+ if (failures.some(id => !id)) return undefined
213
+ found = { passed: points.filter(point => !point[1] && !skipped(point)).length, failed: failures.length, ids: failures }
214
+ } else if (parser === 'go') {
215
+ // Verbose output lists each package's results, then its "ok"/"FAIL" line. A failed package with no
216
+ // failing test (build failure, a panic outside a test) is itself the failure.
217
+ const ids: string[] = []
218
+ let pending: string[] = [], packages = 0
219
+ for (const line of lines) {
220
+ const test = line.match(/^\s*--- FAIL: (\S+)/)?.[1]
221
+ if (test) { pending.push(test); continue }
222
+ const result = line.match(/^(ok|FAIL)\s+(\S+)/)
223
+ if (!result) continue
224
+ packages++
225
+ if (result[1] === 'FAIL') ids.push(...pending.length ? pending.map(name => `${result[2]} ${name}`) : [result[2]!])
226
+ else if (pending.length) return undefined
227
+ pending = []
228
+ }
229
+ if (!packages || pending.length) return undefined
230
+ found = { passed: matches(/^\s*--- PASS: /).length, failed: ids.length, ids }
231
+ } else if (parser === 'cargo') {
232
+ const results = [...clean.matchAll(/test result: \w+\. (\d+) passed; (\d+) failed;/g)]
233
+ if (!results.length) return undefined
234
+ found = { passed: results.reduce((sum, match) => sum + Number(match[1]), 0), failed: results.reduce((sum, match) => sum + Number(match[2]), 0),
235
+ ids: matches(/^test (.+) \.\.\. FAILED$/).map(match => match[1]!) }
236
+ }
237
+ if (!found) return undefined
238
+ const ids = [...new Set(found.ids)].sort()
239
+ // A report may repeat a failure (jest and vitest list it again in their summaries); distinct names must match the count.
240
+ if (ids.length !== found.failed) return undefined
241
+ return { passed: found.passed, failedIds: ids }
242
+ }
243
+
244
+ function record(value: unknown): Record<string, unknown> | undefined {
245
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
246
+ }
247
+
248
+ const execFileAsync = promisify(execFile)
249
+ const WRAPPERS = new Set(['env', 'exec', 'time', 'nohup', 'command', 'nice'])
250
+ const WRAPPER_VALUE_OPTIONS: Record<string, string[]> = { env: ['-u', '--unset', '-C', '--chdir', '-S', '--split-string'],
251
+ nice: ['-n', '--adjustment'], time: ['-f', '--format', '-o', '--output'] }
252
+ // PowerShell language statements are not programs: Get-Command cannot resolve
253
+ // them, even though the shell can execute the statement (for example exit 127).
254
+ const PWSH_KEYWORDS = new Set(['exit', 'return', 'throw', 'break', 'continue', 'if', 'else', 'elseif', 'switch',
255
+ 'for', 'foreach', 'while', 'do', 'until', 'try', 'catch', 'finally', 'trap', 'function', 'filter',
256
+ 'param', 'begin', 'process', 'end', 'dynamicparam', 'class', 'enum', 'data', 'using'])
257
+
258
+ /**
259
+ * Bare program names each simple command segment starts with. Paths are left
260
+ * out because an earlier `cd` in the same command changes where they resolve;
261
+ * anything this cannot parse is simply not probed.
262
+ */
263
+ export function checkPrograms(command: string, platform: NodeJS.Platform = process.platform): string[] {
264
+ const programs = new Set<string>()
265
+ // Quoted text is an argument (for example `python3 -c "...; sys.exit(1)"`), never a command boundary.
266
+ // Heredoc bodies are data (hash lists, scripts fed to stdin), not commands.
267
+ // Both become NUL, which no program name contains: a quoted word in program position (`"$PY" -m pytest`)
268
+ // is left unprobed, and a quoted assignment value (`FOO="a b" pytest`) stays one word.
269
+ const noHeredocs = command.replace(/<<-?\s*(['"]?)([A-Za-z_]\w*)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, ' \0 ')
270
+ const unquoted = noHeredocs.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '\0')
271
+ // A command that sets its own PATH resolves programs somewhere this probe cannot see; its recorded result decides.
272
+ if (/(^|[\s;&|(])(export\s+)?PATH=/.test(unquoted)) return []
273
+ for (const segment of unquoted.split(/&&|\|\||[;|\n&()]/)) {
274
+ const words = segment.trim().split(/\s+/).filter(Boolean)
275
+ let index = 0, wrapper: string | undefined
276
+ while (index < words.length) {
277
+ const word = words[index]!
278
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) index++
279
+ else if (WRAPPERS.has(word)) { wrapper = word; index++ }
280
+ else if (wrapper && word.startsWith('-')) {
281
+ // A wrapper option that takes a separate value (`env -u NAME`, `nice -n 5`) consumes the next word too.
282
+ index += WRAPPER_VALUE_OPTIONS[wrapper]?.includes(word) ? 2 : 1
283
+ } else break
284
+ }
285
+ const word = words[index]
286
+ // A program name has a letter and is not a long hex digest or a bare number.
287
+ if (word && /^[A-Za-z0-9_][A-Za-z0-9_.+-]*$/.test(word) && /[A-Za-z]/.test(word) && !/^[0-9a-f]{16,}$/i.test(word)
288
+ && !(platform === 'win32' && PWSH_KEYWORDS.has(word.toLowerCase()))) programs.add(word)
289
+ }
290
+ return [...programs]
291
+ }
292
+
293
+ /**
294
+ * Resolves program names the way the native bash tool will: `bash -c` with the
295
+ * Host's own PATH (its subprocess scrub removes only credential-shaped and
296
+ * DSH_* names). Running here instead of through the tool keeps this read-only
297
+ * probe out of the user's approval flow. Returns undefined when inconclusive,
298
+ * for example without bash; the recorded check result is then the fallback.
299
+ */
300
+ export async function probeMissingPrograms(root: string, programs: readonly string[]): Promise<{ missing: string[]; alternatives: Record<string, string[]>; locations: Record<string, string[]> } | undefined> {
301
+ const variants = (name: string) => [`${name}3`, ...(name === 'python' || name === 'python3' ? ['py'] : [])].filter(item => item !== name)
302
+ const names = [...new Set(programs.flatMap(name => [name, ...variants(name)]))]
303
+ const script = process.platform === 'win32'
304
+ ? `foreach ($p in @(${names.map(name => `'${name}'`).join(',')})) { if (Get-Command $p -ErrorAction SilentlyContinue) { Write-Output "FOUND $p" } }; Write-Output 'PROBE-DONE'`
305
+ : `for p in ${names.map(name => `'${name}'`).join(' ')}; do command -v -- "$p" >/dev/null 2>&1 && printf 'FOUND %s\\n' "$p"; done; printf 'PROBE-DONE\\n'`
306
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined
307
+ && !/KEY|PASSWORD|SECRET|TOKEN/i.test(key) && !key.toUpperCase().startsWith('DSH_'))) as Record<string, string>
308
+ const [shell, args] = process.platform === 'win32' ? ['pwsh', ['-NoProfile', '-NonInteractive', '-Command', script]] : ['bash', ['-c', script]]
309
+ let text: string
310
+ try { text = (await execFileAsync(shell, args, { cwd: root, env, timeout: process.platform === 'win32' ? 15_000 : 5_000 })).stdout }
311
+ catch { return undefined }
312
+ if (!text.includes('PROBE-DONE')) return undefined
313
+ const found = new Set([...text.matchAll(/^FOUND (\S+)$/gm)].map(match => match[1]!))
314
+ const missing = programs.filter(name => !found.has(name))
315
+ return { missing, alternatives: Object.fromEntries(missing.map(name => [name, variants(name).filter(item => found.has(item))])),
316
+ locations: Object.fromEntries(missing.map(name => [name, programLocations(name)])) }
317
+ }
318
+
319
+ /**
320
+ * Where a program missing from the Host PATH is installed anyway. A desktop app started from the Dock gets only
321
+ * the system PATH, so Homebrew, version-manager and per-user tools are invisible to bash there; naming the
322
+ * absolute path lets the Lead retry once instead of searching.
323
+ */
324
+ export function programLocations(name: string, home = homedir()): string[] {
325
+ const windows = process.platform === 'win32'
326
+ const dirs = windows
327
+ ? [join(home, '.bun/bin'), join(home, '.deno/bin'), join(home, '.cargo/bin'),
328
+ join(home, 'scoop/shims'), join(home, 'AppData/Roaming/npm'), join(home, 'AppData/Local/Programs'),
329
+ join(home, 'AppData/Local/Microsoft/WinGet/Links')]
330
+ : ['/opt/homebrew/bin', '/usr/local/bin', join(home, '.local/bin'), join(home, '.cargo/bin'), join(home, '.bun/bin'),
331
+ join(home, '.deno/bin'), join(home, '.volta/bin'), join(home, '.local/share/mise/shims'), join(home, '.asdf/shims')]
332
+ try {
333
+ const nvm = join(home, '.nvm/versions/node')
334
+ dirs.push(...readdirSync(nvm).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })).map(version => join(nvm, version, 'bin')))
335
+ } catch { /* no nvm */ }
336
+ // Windows resolves executables through PATHEXT — an extensionless file is not runnable; POSIX checks the exec bit.
337
+ const candidates = windows
338
+ ? (name.includes('.') ? [name] : [])
339
+ .concat((process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD;.PS1').split(';').map(ext => `${name}${ext.toLowerCase()}`))
340
+ : [name]
341
+ return dirs.flatMap(dir => candidates.map(file => join(dir, file))).filter(path => {
342
+ try { accessSync(path, windows ? constants.F_OK : constants.X_OK); return true } catch { return false }
343
+ }).slice(0, 3)
344
+ }
345
+
346
+ /** Trusted producer: commands run through native dispatch/permission and retain the enclosing tool token. */
347
+ interface CheckInput {
348
+ ctx: Context; store: SqliteFusionStore; exec: ToolRunContext; root: string; order: WorkOrder;
349
+ nativeTimeout?: boolean; authorizeNested(callId: string): () => void; uncertain?(evidence: ToolEvidence): void
350
+ }
351
+
352
+ /** One frozen command through the native bash tool, recorded as invocation evidence. */
353
+ async function invokeCheck(input: CheckInput, check: FrozenCheck) {
354
+ const { ctx, store, exec, root, order } = input
355
+ if (definitionDigest(root, check.definition) !== check.plan.definitionDigest) throw new Error(`Frozen acceptance definition changed: ${check.definition.id}`)
356
+ const before = snapshotWorkspace(root)
357
+ const startedAt = new Date().toISOString()
358
+ const callId = ToolCallId(`fusion-check-${randomUUID()}`)
359
+ const operationId = OperationId(callId)
360
+ const empty = store.putArtifact(order.taskId, Buffer.from(''), 'text/plain')
361
+ const started: ToolEvidence = { schemaVersion: 1, taskId: order.taskId, operationId,
362
+ actor: SessionId(exec.agent!.id), nativeToolCallId: callId,
363
+ argvDigest: check.plan.argvDigest, cwdDigest: check.plan.cwdDigest,
364
+ inputSnapshot: before.id, exitCode: null, startedAt, endedAt: null, stdout: empty, stderr: empty, state: 'started' }
365
+ const key = `check-invocation:${order.taskId}:${callId}`
366
+ store.writeDocument(key, 0, { check, evidence: started })
367
+ const remove = input.authorizeNested(callId)
368
+ let result
369
+ try {
370
+ result = await ctx.tools.execute({ callId, rootCallId: exec.rootCallId, parent: exec.token,
371
+ name: nativeShellTool, arguments: { command: check.definition.command, description: check.definition.description,
372
+ workdir: root, ...(input.nativeTimeout && check.definition.timeoutSeconds === undefined ? {} : { timeoutMs: (check.definition.timeoutSeconds ?? order.policy.commandMaxSeconds) * 1000 }), run_in_background: false },
373
+ agent: exec.agent, signal: exec.signal })
374
+ } catch (error) {
375
+ const evidence: ToolEvidence = { ...started, state: 'outcome-unknown', endedAt: new Date().toISOString(),
376
+ stderr: store.putArtifact(order.taskId, Buffer.from(String(error)), 'text/plain') }
377
+ store.writeDocument(key, 1, { check, evidence })
378
+ input.uncertain?.(evidence)
379
+ throw error
380
+ } finally { remove() }
381
+ for (const context of result.additionalContexts ?? []) exec.deferContext(context)
382
+ const raw = record(result.value)
383
+ const stdout = record(raw?.stdout), stderr = record(raw?.stderr)
384
+ const nativeForeground = !result.isError && raw?.kind === 'foreground'
385
+ const completed = nativeForeground && raw.aborted === false && raw.timedOut === false
386
+ && raw.signal === null && typeof raw.exitCode === 'number'
387
+ const outText = typeof stdout?.text === 'string' ? stdout.text : ''
388
+ const errText = typeof stderr?.text === 'string' ? stderr.text : JSON.stringify(result.content)
389
+ const after = snapshotWorkspace(root)
390
+ const evidence: ToolEvidence = { ...started, outputSnapshot: after.id,
391
+ exitCode: completed ? Number(raw.exitCode) : null, endedAt: new Date().toISOString(),
392
+ stdout: store.putArtifact(order.taskId, Buffer.from(outText), 'text/plain'),
393
+ stderr: store.putArtifact(order.taskId, Buffer.from(errText), 'text/plain'),
394
+ state: completed ? 'completed' : exec.signal.aborted ? 'cancelled' : 'outcome-unknown' }
395
+ return { key, evidence, text: `${outText}\n${errText}`, truncated: Boolean(stdout?.truncated || stderr?.truncated), before, after }
396
+ }
397
+
398
+ /** Counts for a receipt; a baseline-relative check discounts failures frozen from the untouched workspace. */
399
+ function receiptCounts(check: FrozenCheck, text: string): PlannedReceipt['counts'] {
400
+ const allowed = check.plan.allowedFailures
401
+ if (!allowed) return parseTestCounts(check.definition.parser, text)
402
+ const parsed = parseTestFailures(check.definition.parser, text)
403
+ if (!parsed) return undefined
404
+ const known = parsed.failedIds.filter(id => allowed.includes(id))
405
+ const fresh = parsed.failedIds.filter(id => !allowed.includes(id))
406
+ return { executed: parsed.passed + fresh.length, passed: parsed.passed, failed: fresh.length,
407
+ ...(known.length ? { knownFailures: known } : {}), ...(fresh.length ? { newFailures: fresh } : {}) }
408
+ }
409
+
410
+ /**
411
+ * Runs every baseline-relative check on the untouched workspace before the Worker starts and freezes the
412
+ * failing test ids into its plan. An unreadable baseline refuses the delegation: guessing would let a
413
+ * candidate hide new failures.
414
+ */
415
+ export async function runBaselineChecks(input: CheckInput & { checks: readonly FrozenCheck[] }): Promise<FrozenCheck[]> {
416
+ const out: FrozenCheck[] = []
417
+ for (const check of input.checks) {
418
+ if (check.definition.baseline !== 'no-new-failures') { out.push(check); continue }
419
+ input.exec.signal.throwIfAborted()
420
+ const run = await invokeCheck(input, check)
421
+ input.store.writeDocument(run.key, 1, { check, evidence: run.evidence, baseline: true })
422
+ if (run.evidence.state === 'outcome-unknown') {
423
+ input.uncertain?.(run.evidence)
424
+ throw new Error('Baseline command outcome is unknown; inspect the recorded effects before delegating')
425
+ }
426
+ if (run.before.id !== run.after.id) throw new Error(`Baseline run of ${check.definition.id} changed the source snapshot; use a command that does not write source files`)
427
+ const parsed = run.evidence.state === 'completed' && !run.truncated ? parseTestFailures(check.definition.parser, run.text) : undefined
428
+ if (!parsed) {
429
+ throw new Error(`Baseline run of ${check.definition.id} could not be read (exit ${run.evidence.exitCode}, state ${run.evidence.state}). `
430
+ + `It needs a completed run with ${BASELINE_OUTPUT[check.definition.parser as BaselineParser]}. Retry fusion_delegate with a runnable command, or drop baseline to require every test to pass. `
431
+ + `Output tail: ${run.text.slice(-600)}`)
432
+ }
433
+ out.push({ definition: check.definition, plan: { ...check.plan, allowedFailures: parsed.failedIds } })
434
+ }
435
+ return out
436
+ }
437
+
438
+ export async function runNativeChecks(input: CheckInput & {
439
+ report: WorkerReport; checks: readonly FrozenCheck[];
440
+ }): Promise<{ report: WorkerReport; receipts: PlannedReceipt[]; verdict: ReturnType<typeof evaluateReport> }> {
441
+ const { store, exec, root, order, checks } = input
442
+ if (order.mode === 'text' && checks.length) throw new Error('Text assignments cannot run workspace checks')
443
+ const receipts: PlannedReceipt[] = []
444
+ for (const check of checks) {
445
+ exec.signal.throwIfAborted()
446
+ const run = await invokeCheck(input, check)
447
+ const counts = run.truncated ? undefined : receiptCounts(check, run.text)
448
+ const receipt: PlannedReceipt = { id: `receipt:${run.evidence.nativeToolCallId}`, planDigest: digestOf(check.plan), evidence: run.evidence, ...(counts ? { counts } : {}) }
449
+ store.writeDocument(run.key, 1, { check, evidence: run.evidence, receipt })
450
+ if (run.evidence.state === 'outcome-unknown') {
451
+ input.uncertain?.(run.evidence)
452
+ throw new Error('Acceptance command outcome is unknown; inspect the recorded effects before continuing')
453
+ }
454
+ receipts.push(receipt)
455
+ if (run.before.id !== run.after.id) throw new Error('Acceptance command changed the source snapshot; rework is required')
456
+ }
457
+ const report: WorkerReport = { ...input.report,
458
+ verification: receipts.flatMap(receipt => [receipt.evidence.stdout, receipt.evidence.stderr]),
459
+ coverage: checks.map((check, index) => {
460
+ const receipt = receipts[index]!
461
+ let state: 'satisfied' | 'not-satisfied' | 'not-verified' = 'satisfied'
462
+ let explanation = 'The frozen acceptance check passed on this exact candidate.'
463
+ try { assertPlannedCheck(order, check.definition.id, check.plan, receipt, input.report.snapshot) }
464
+ catch (error) {
465
+ state = receipt.evidence.state === 'completed' && receipt.evidence.exitCode !== 0 ? 'not-satisfied' : 'not-verified'
466
+ explanation = `Native check does not prove this criterion: ${String(error)}`
467
+ }
468
+ return { criterionId: check.definition.id, state, evidenceIds: [ArtifactId(receipt.id)], explanation }
469
+ }) }
470
+ const registry = memoryVerificationRegistry(checks.map(check => check.plan), receipts)
471
+ if (order.mode === 'text' && checks.length) throw new Error('Text work cannot run filesystem checks')
472
+ const verdict = evaluateReport(order, report, [], order.mode === 'text' ? report.snapshot : snapshotWorkspace(root).id, { registry })
473
+ return { report, receipts, verdict }
474
+ }
@@ -0,0 +1,34 @@
1
+ import { isAgentLoopRequest, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
2
+ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
3
+ import type { PairProfile, PhysicalRoute } from '../contracts.js'
4
+
5
+ /**
6
+ * Public llm/stream routing for the Host's mutable, one-shot summary envelope.
7
+ * The Host still owns the compaction transaction and records this exact target.
8
+ * Never replace a prepared AgentLoop request or start a nested model call.
9
+ */
10
+ export function routeNativeCompaction(request: GenerateOptions, profile: PairProfile, effectiveRoute?: PhysicalRoute): void {
11
+ const choice = profile.compactor ?? (effectiveRoute && profile.interactionMode === 'model-like' ? { route: effectiveRoute, maxOutputTokens: request.maxTokens } : undefined)
12
+ if (request.purpose !== 'compaction' || !choice) return
13
+ if (isAgentLoopRequest(request)) {
14
+ throw new Error('DSH 压缩请求已冻结,无法通过公开接口选择压缩模型')
15
+ }
16
+ if (Object.isFrozen(request)) {
17
+ // A compactor can pin its envelope after resolving the public session route
18
+ // and planning against that exact model. Validate; never enlarge its cap.
19
+ if (request.provider !== choice.route.provider || request.model !== choice.route.model
20
+ || request.reasoningEffort !== choice.route.reasoningEffort
21
+ || !Number.isSafeInteger(request.maxTokens) || Number(request.maxTokens) <= 0
22
+ || choice.maxOutputTokens !== undefined && request.maxTokens! > choice.maxOutputTokens)
23
+ throw new Error('压缩请求已冻结,且与已规划的模型或输出上限不一致')
24
+ return
25
+ }
26
+ request.provider = choice.route.provider
27
+ request.model = choice.route.model
28
+ if (choice.maxOutputTokens !== undefined) request.maxTokens = choice.maxOutputTokens
29
+ if (choice.route.reasoningEffort === undefined) delete request.reasoningEffort
30
+ else request.reasoningEffort = ReasoningEffortId(choice.route.reasoningEffort)
31
+ // Pin the admitted target/cap through later middleware. Public llm.stream
32
+ // accepts frozen envelopes and resolves adapter defaults on its own copy.
33
+ Object.freeze(request)
34
+ }