@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,131 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import type { Digest, PairProfile, PhysicalRoute, RoleContextPolicy } from '../contracts.js'
3
+ import { digestOf, sha256Hex } from '../digest.js'
4
+ import { FusionError, PROFILE_UNAVAILABLE } from '../errors.js'
5
+ import { keepaliveChoice } from './keepalive.js'
6
+ import { compactorChoice, profileRoutes } from './compactor.js'
7
+
8
+ const PLACEHOLDERS = new Set(['__AUTHORIZED_PROVIDER__', '__EXACT_MODEL_ID__', '__EXACT_WORKER_ID__', '__EXISTING_PROJECT_POLICY__'])
9
+
10
+ export interface PromptBundle {
11
+ lead: string
12
+ worker: string
13
+ compact: string
14
+ }
15
+
16
+ export interface ResolvedProfile {
17
+ readonly profile: PairProfile
18
+ readonly prompts: PromptBundle
19
+ }
20
+
21
+ export interface ProfileCatalog {
22
+ readonly authorizedRoutes: readonly PhysicalRoute[]
23
+ }
24
+
25
+ function isPlaceholder(route: PhysicalRoute): boolean {
26
+ return PLACEHOLDERS.has(route.provider) || PLACEHOLDERS.has(route.model)
27
+ }
28
+
29
+ function asPolicy(raw: RoleContextPolicy | undefined, fallback: RoleContextPolicy): RoleContextPolicy {
30
+ const policy = {
31
+ targetInputTokens: raw?.targetInputTokens ?? fallback.targetInputTokens,
32
+ reserveOutputTokens: raw?.reserveOutputTokens ?? fallback.reserveOutputTokens,
33
+ minSafetyTokens: raw?.minSafetyTokens ?? fallback.minSafetyTokens,
34
+ safetyFraction: raw?.safetyFraction ?? fallback.safetyFraction,
35
+ compactToFraction: raw?.compactToFraction ?? fallback.compactToFraction,
36
+ evidenceReadTokens: raw?.evidenceReadTokens ?? fallback.evidenceReadTokens,
37
+ maxToolVisibleTokens: raw?.maxToolVisibleTokens ?? fallback.maxToolVisibleTokens,
38
+ }
39
+ for (const key of ['targetInputTokens', 'reserveOutputTokens', 'evidenceReadTokens', 'maxToolVisibleTokens'] as const) {
40
+ if (!Number.isSafeInteger(policy[key]) || policy[key] <= 0) throw new FusionError(PROFILE_UNAVAILABLE, `Invalid context policy: ${key}`)
41
+ }
42
+ if (!Number.isSafeInteger(policy.minSafetyTokens) || policy.minSafetyTokens < 0
43
+ || !Number.isFinite(policy.safetyFraction) || policy.safetyFraction < 0 || policy.safetyFraction >= 1
44
+ || !Number.isFinite(policy.compactToFraction) || policy.compactToFraction <= 0 || policy.compactToFraction >= 1) {
45
+ throw new FusionError(PROFILE_UNAVAILABLE, 'Invalid context safety or compaction policy')
46
+ }
47
+ return policy
48
+ }
49
+
50
+ const LEAD_DEFAULT: RoleContextPolicy = {
51
+ // The final guard always intersects this soft target with the registered
52
+ // physical model window, output reservation and safety margin. A fixed 32k
53
+ // default can reject the real Host's system prompt/tools before its first
54
+ // request; do not impose an undocumented smaller window by default.
55
+ targetInputTokens: Number.MAX_SAFE_INTEGER,
56
+ reserveOutputTokens: 8_000,
57
+ minSafetyTokens: 2_048,
58
+ safetyFraction: 0.05,
59
+ compactToFraction: 0.5,
60
+ evidenceReadTokens: 4_000,
61
+ maxToolVisibleTokens: 6_000,
62
+ }
63
+
64
+ const WORKER_DEFAULT: RoleContextPolicy = {
65
+ ...LEAD_DEFAULT,
66
+ compactToFraction: 0.55,
67
+ evidenceReadTokens: 8_000,
68
+ maxToolVisibleTokens: 8_000,
69
+ }
70
+
71
+ export function promptDigests(prompts: PromptBundle): PairProfile['promptDigests'] {
72
+ return {
73
+ lead: sha256Hex(prompts.lead),
74
+ worker: sha256Hex(prompts.worker),
75
+ compact: sha256Hex(prompts.compact),
76
+ }
77
+ }
78
+
79
+ export function completeProfile(raw: Record<string, unknown>, prompts: PromptBundle): PairProfile {
80
+ const lead = raw.lead as PhysicalRoute
81
+ const worker = raw.worker as PhysicalRoute
82
+ const context = (raw.context ?? {}) as Partial<PairProfile['context']>
83
+ const promptHash = promptDigests(prompts)
84
+ const cacheKeepalive = keepaliveChoice(raw.cacheKeepalive)
85
+ const compactor = compactorChoice(raw.compactor)
86
+ if (raw.interactionMode !== undefined && raw.interactionMode !== 'model-like') throw new Error('Unknown Fusion interaction mode')
87
+ if (raw.workflowPolicy !== undefined && (!['enforced-v1', 'enforced-v2', 'enforced-v3'].includes(String(raw.workflowPolicy)) || raw.interactionMode !== 'model-like')) throw new Error('Unknown Fusion workflow policy')
88
+ const profile: PairProfile = {
89
+ schemaVersion: 1,
90
+ id: String(raw.id),
91
+ version: String(raw.version),
92
+ digest: '' as Digest,
93
+ enabled: raw.enabled === true,
94
+ quality: (raw.quality as PairProfile['quality']) ?? 'experimental',
95
+ ...(raw.interactionMode === 'model-like' ? { interactionMode: 'model-like' as const } : {}),
96
+ ...(raw.workflowPolicy === 'enforced-v1' || raw.workflowPolicy === 'enforced-v2' || raw.workflowPolicy === 'enforced-v3' ? { workflowPolicy: raw.workflowPolicy } : {}),
97
+ lead,
98
+ worker,
99
+ workerUpgradePath: (raw.workerUpgradePath as PhysicalRoute[]) ?? [],
100
+ promptDigests: promptHash,
101
+ context: {
102
+ lead: asPolicy(context.lead, LEAD_DEFAULT),
103
+ worker: asPolicy(context.worker, WORKER_DEFAULT),
104
+ },
105
+ dataPolicyId: String(raw.dataPolicyId ?? ''),
106
+ evidenceCampaignIds: (raw.evidenceCampaignIds as string[]) ?? [],
107
+ ...(cacheKeepalive === undefined ? {} : { cacheKeepalive }),
108
+ ...(compactor === undefined ? {} : { compactor }),
109
+ }
110
+ return { ...profile, digest: digestOf(profile) }
111
+ }
112
+
113
+ export function resolveProfile(raw: Record<string, unknown>, prompts: PromptBundle, catalog: ProfileCatalog): ResolvedProfile {
114
+ const profile = completeProfile(raw, prompts)
115
+ if (!profile.enabled) {
116
+ throw new FusionError(PROFILE_UNAVAILABLE, `profile ${profile.id} is disabled`)
117
+ }
118
+ if (profileRoutes(profile).some(isPlaceholder) || PLACEHOLDERS.has(profile.dataPolicyId)) {
119
+ throw new FusionError(PROFILE_UNAVAILABLE, `profile ${profile.id} still has placeholder routes`)
120
+ }
121
+ const authorized = (route: PhysicalRoute) =>
122
+ catalog.authorizedRoutes.some(item => item.provider === route.provider && item.model === route.model)
123
+ if (!profileRoutes(profile).every(authorized)) {
124
+ throw new FusionError(PROFILE_UNAVAILABLE, `profile ${profile.id} routes are not authorized on this Host`)
125
+ }
126
+ return { profile, prompts }
127
+ }
128
+
129
+ export function loadJsonObject(path: string): Record<string, unknown> {
130
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
131
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import type { PromptBundle } from './profile/resolve.js'
5
+ import { promptDigests } from './profile/resolve.js'
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url))
8
+
9
+ export function defaultPromptDir(): string {
10
+ return join(here, '..', 'prompts')
11
+ }
12
+
13
+ export function loadPromptBundle(dir = defaultPromptDir()): PromptBundle {
14
+ return {
15
+ lead: readFileSync(join(dir, 'lead.md'), 'utf8'),
16
+ worker: readFileSync(join(dir, 'worker.md'), 'utf8'),
17
+ compact: readFileSync(join(dir, 'compact.md'), 'utf8'),
18
+ }
19
+ }
20
+
21
+ export function loadModelPromptBundle(dir = defaultPromptDir()): PromptBundle {
22
+ return {
23
+ lead: readFileSync(join(dir, 'model-lead.md'), 'utf8'),
24
+ worker: readFileSync(join(dir, 'model-worker.md'), 'utf8'),
25
+ compact: readFileSync(join(dir, 'compact.md'), 'utf8'),
26
+ }
27
+ }
28
+
29
+ export function promptManifest(dir = defaultPromptDir()) {
30
+ const prompts = loadPromptBundle(dir)
31
+ return { prompts, digests: promptDigests(prompts) }
32
+ }
@@ -0,0 +1,38 @@
1
+ import type { OutboxRow, SessionId } from '../contracts.js'
2
+ import { NEEDS_REPAIR, OUTCOME_UNKNOWN } from '../errors.js'
3
+
4
+ export type ChildPersistence = 'present' | 'missing' | 'corrupt'
5
+
6
+ export interface RecoveryScan {
7
+ readonly outbox: OutboxRow
8
+ readonly childPersistence: ChildPersistence
9
+ readonly effectKnown: boolean | null
10
+ readonly reportAlreadyRecorded: boolean
11
+ readonly parentDelivered: boolean
12
+ }
13
+
14
+ export type RecoveryDecision =
15
+ | { action: 'retry-prepare'; child: SessionId }
16
+ | { action: 'resume-child'; child: SessionId }
17
+ | { action: 'return-stored-result' }
18
+ | { action: 'needs-repair'; code: typeof NEEDS_REPAIR }
19
+ | { action: 'outcome-unknown'; code: typeof OUTCOME_UNKNOWN }
20
+ | { action: 'idle' }
21
+
22
+ export function reconcile(scan: RecoveryScan): RecoveryDecision {
23
+ if (scan.reportAlreadyRecorded && !scan.parentDelivered) return { action: 'return-stored-result' }
24
+ if (scan.reportAlreadyRecorded && scan.parentDelivered) return { action: 'idle' }
25
+ if (scan.effectKnown === null && (scan.outbox.state === 'dispatched' || scan.outbox.state === 'claimed')) {
26
+ return { action: 'outcome-unknown', code: OUTCOME_UNKNOWN }
27
+ }
28
+ if (scan.outbox.state === 'prepared' && scan.childPersistence === 'missing' && scan.outbox.reservedChild) {
29
+ return { action: 'retry-prepare', child: scan.outbox.reservedChild }
30
+ }
31
+ if (scan.outbox.state === 'accepted' && scan.childPersistence !== 'present') {
32
+ return { action: 'needs-repair', code: NEEDS_REPAIR }
33
+ }
34
+ if ((scan.outbox.state === 'accepted' || scan.outbox.state === 'claimed') && scan.outbox.reservedChild) {
35
+ return { action: 'resume-child', child: scan.outbox.reservedChild }
36
+ }
37
+ return { action: 'idle' }
38
+ }
@@ -0,0 +1,48 @@
1
+ import type { FusionEvent, ReviewTicket, TaskId } from '../contracts.js'
2
+ import { EVENT_CONFLICT, FusionError } from '../errors.js'
3
+ import type { ReviewClassification } from './protocol.js'
4
+ import { captureTicket, createReviewTicket, resultFromCapturedTicket, reviewOutboxRow, reviewRequestedEvent } from './ticket.js'
5
+ import type { FusionStore } from '../task/store.js'
6
+
7
+ /**
8
+ * Offline producer: persist the captured ticket and outbox row before any adapter send.
9
+ * The model callback may read the latest TaskState only for seq/CAS.
10
+ */
11
+ export function persistReviewRequest(
12
+ store: FusionStore,
13
+ taskId: TaskId,
14
+ ids: { ticketId: string; requestId: string },
15
+ payloadRef: string,
16
+ ): Readonly<ReviewTicket> {
17
+ const current = store.load(taskId)
18
+ if (!current) throw new FusionError(EVENT_CONFLICT, `unknown task ${taskId}`)
19
+ const ticket = captureTicket(createReviewTicket(current, ids))
20
+ const requested = reviewRequestedEvent(current, current.seq + 1, ids)
21
+ store.transact(taskId, current.seq, persisted => ({
22
+ events: [requested],
23
+ outbox: [...persisted.outbox, reviewOutboxRow(ticket, payloadRef)],
24
+ }))
25
+ return ticket
26
+ }
27
+
28
+ export function loadReviewOutboxByRequestId(store: FusionStore, taskId: TaskId, requestId: string) {
29
+ const row = store.outbox(taskId).find(item => item.requestId === requestId || item.ticket?.requestId === requestId)
30
+ if (!row?.ticket) throw new FusionError(EVENT_CONFLICT, `review outbox missing for ${requestId}`)
31
+ return { row, ticket: captureTicket(row.ticket) }
32
+ }
33
+
34
+ export function appendCapturedReviewResult(
35
+ store: FusionStore,
36
+ taskId: TaskId,
37
+ ticket: Readonly<ReviewTicket>,
38
+ outcome: { requestId: string; terminalEvidenceRef: string; classification: ReviewClassification },
39
+ ): ReturnType<FusionStore['append']> {
40
+ const current = store.load(taskId)
41
+ if (!current) throw new FusionError(EVENT_CONFLICT, `unknown task ${taskId}`)
42
+ const event: FusionEvent = resultFromCapturedTicket(ticket, outcome, {
43
+ eventId: `review-completed-${ticket.requestId}-${current.seq + 1}`,
44
+ seq: current.seq + 1,
45
+ createdAt: new Date().toISOString(),
46
+ })
47
+ return store.append(taskId, current.revision, [event])
48
+ }
@@ -0,0 +1,212 @@
1
+ export type ReviewStatus = 'complete' | 'incomplete' | 'not_required' | 'failed'
2
+
3
+ export type ReviewDecision = 'approve' | 'rework' | 'needs-decision' | 'REVIEW_INCOMPLETE'
4
+
5
+ export type ExecutionPath = 'direct' | 'forced_fusion' | 'fusion_auto'
6
+
7
+ export type ReviewTermination = 'success' | 'truncated' | 'aborted' | 'error' | 'filtered' | 'unknown'
8
+
9
+ export interface ReviewClassification {
10
+ reviewStatus: ReviewStatus
11
+ decision: ReviewDecision | 'not_required'
12
+ protocolComplete: boolean
13
+ /** True only after a substitute artifact was actually delivered. */
14
+ fallbackUsed: boolean
15
+ /** True when a fallback or retry is still required. */
16
+ fallbackRequired: boolean
17
+ termination: ReviewTermination
18
+ reasons: readonly string[]
19
+ }
20
+
21
+ export interface ReviewAttemptFields {
22
+ artifactPass: boolean
23
+ executionPath: ExecutionPath
24
+ leadReviewStatus: ReviewStatus
25
+ finishReason: string | null
26
+ protocolComplete: boolean
27
+ fallbackUsed: boolean
28
+ deliveredArtifactSource: string | null
29
+ }
30
+
31
+ const VALID_DECISIONS = new Set(['approve', 'accept', 'rework', 'needs-decision'])
32
+
33
+ const SUCCESS_REASONS = new Set(['stop', 'end_turn', 'end-turn', 'tool_calls', 'tool_call', 'completed', 'success'])
34
+ const TRUNCATED_REASONS = new Set(['length', 'max_tokens', 'max_token'])
35
+ const ABORTED_REASONS = new Set(['aborted', 'abort', 'cancelled', 'canceled', 'cancel'])
36
+ const ERROR_REASONS = new Set(['error', 'internal_error', 'server_error'])
37
+ const FILTERED_REASONS = new Set(['content_filter', 'content-filter', 'filtered', 'contentfilter'])
38
+
39
+ function normalizeFinishReason(finishReason: string | null | undefined): string | null {
40
+ if (finishReason === null || finishReason === undefined) return null
41
+ const trimmed = finishReason.trim().toLowerCase()
42
+ return trimmed.length ? trimmed : null
43
+ }
44
+
45
+ function terminationFromFinishReason(finishReason: string | null): ReviewTermination | null {
46
+ if (finishReason === null) return null
47
+ if (SUCCESS_REASONS.has(finishReason)) return 'success'
48
+ if (TRUNCATED_REASONS.has(finishReason)) return 'truncated'
49
+ if (ABORTED_REASONS.has(finishReason)) return 'aborted'
50
+ if (ERROR_REASONS.has(finishReason)) return 'error'
51
+ if (FILTERED_REASONS.has(finishReason)) return 'filtered'
52
+ return 'unknown'
53
+ }
54
+
55
+ export function normalizeTermination(input: {
56
+ finishReason?: string | null
57
+ termination?: ReviewTermination
58
+ completeEvidence?: boolean
59
+ }): ReviewTermination {
60
+ const finishReason = normalizeFinishReason(input.finishReason)
61
+ const fromFinish = terminationFromFinishReason(finishReason)
62
+ if (input.termination && fromFinish && input.termination !== fromFinish) {
63
+ return 'unknown'
64
+ }
65
+ if (input.termination) return input.termination
66
+ if (fromFinish) return fromFinish
67
+ return input.completeEvidence === true ? 'success' : 'unknown'
68
+ }
69
+
70
+ function parsedDecision(parsed: unknown): string | undefined {
71
+ if (!parsed || typeof parsed !== 'object') return undefined
72
+ const decision = (parsed as { decision?: unknown }).decision
73
+ return typeof decision === 'string' ? decision.trim().toLowerCase() : undefined
74
+ }
75
+
76
+ function incomplete(
77
+ reasons: readonly string[],
78
+ termination: ReviewTermination,
79
+ reviewStatus: ReviewStatus = 'incomplete',
80
+ ): ReviewClassification {
81
+ return {
82
+ reviewStatus,
83
+ decision: 'REVIEW_INCOMPLETE',
84
+ protocolComplete: false,
85
+ fallbackUsed: false,
86
+ fallbackRequired: true,
87
+ termination,
88
+ reasons,
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Lead review is fail-closed. Only an explicit success termination plus a
94
+ * versioned accept/rework/needs-decision structure can complete the protocol.
95
+ * DIRECT skips review.
96
+ */
97
+ export function classifyReview(input: {
98
+ executionPath: ExecutionPath
99
+ finishReason?: string | null
100
+ termination?: ReviewTermination
101
+ completeEvidence?: boolean
102
+ rawText?: string | null
103
+ parsed?: unknown
104
+ }): ReviewClassification {
105
+ if (input.executionPath === 'direct') {
106
+ return {
107
+ reviewStatus: 'not_required',
108
+ decision: 'not_required',
109
+ protocolComplete: true,
110
+ fallbackUsed: false,
111
+ fallbackRequired: false,
112
+ termination: 'success',
113
+ reasons: ['direct path does not require Lead review'],
114
+ }
115
+ }
116
+
117
+ const reasons: string[] = []
118
+ const termination = normalizeTermination(input)
119
+ if (termination === 'truncated') reasons.push(`finish_reason=${normalizeFinishReason(input.finishReason) ?? 'truncated'}`)
120
+ if (termination === 'aborted') reasons.push('termination=aborted')
121
+ if (termination === 'error') reasons.push('termination=error')
122
+ if (termination === 'filtered') reasons.push('termination=filtered')
123
+ if (termination === 'unknown') reasons.push('termination=unknown; parsed text is not success evidence')
124
+
125
+ const raw = input.rawText ?? ''
126
+ if (!raw.trim()) reasons.push('empty review output')
127
+
128
+ const decision = parsedDecision(input.parsed)
129
+ if (input.parsed !== undefined && input.parsed !== null && !decision) {
130
+ reasons.push('illegal review structure')
131
+ } else if (decision && !VALID_DECISIONS.has(decision)) {
132
+ reasons.push(`illegal review decision ${decision}`)
133
+ }
134
+
135
+ if (termination === 'truncated' || termination === 'unknown') {
136
+ return incomplete(reasons.length ? reasons : [`termination=${termination}`], termination)
137
+ }
138
+ if (termination === 'aborted' || termination === 'error' || termination === 'filtered') {
139
+ return incomplete(reasons, termination, 'failed')
140
+ }
141
+
142
+ if (reasons.length) {
143
+ return incomplete(reasons, termination)
144
+ }
145
+
146
+ if (decision === 'rework') {
147
+ return {
148
+ reviewStatus: 'complete',
149
+ decision: 'rework',
150
+ protocolComplete: true,
151
+ fallbackUsed: false,
152
+ fallbackRequired: false,
153
+ termination,
154
+ reasons: [],
155
+ }
156
+ }
157
+ if (decision === 'needs-decision') {
158
+ return {
159
+ reviewStatus: 'complete',
160
+ decision: 'needs-decision',
161
+ protocolComplete: true,
162
+ fallbackUsed: false,
163
+ fallbackRequired: false,
164
+ termination,
165
+ reasons: [],
166
+ }
167
+ }
168
+ if (decision === 'approve' || decision === 'accept') {
169
+ return {
170
+ reviewStatus: 'complete',
171
+ decision: 'approve',
172
+ protocolComplete: true,
173
+ fallbackUsed: false,
174
+ fallbackRequired: false,
175
+ termination,
176
+ reasons: [],
177
+ }
178
+ }
179
+
180
+ return incomplete(['review decision missing'], termination)
181
+ }
182
+
183
+ /** Map classifier output onto the reducer event decision. */
184
+ export function toReducerReviewDecision(
185
+ classification: ReviewClassification,
186
+ ): 'accept' | 'rework' | 'needs-decision' | 'REVIEW_INCOMPLETE' {
187
+ if (classification.decision === 'approve') return 'accept'
188
+ if (classification.decision === 'rework') return 'rework'
189
+ if (classification.decision === 'needs-decision') return 'needs-decision'
190
+ return 'REVIEW_INCOMPLETE'
191
+ }
192
+
193
+ export function reviewAttemptFields(input: {
194
+ artifactPass: boolean
195
+ executionPath: ExecutionPath
196
+ classification: ReviewClassification
197
+ finishReason?: string | null
198
+ deliveredArtifactSource?: string | null
199
+ }): ReviewAttemptFields {
200
+ const deliveredArtifactSource = input.deliveredArtifactSource ?? null
201
+ return {
202
+ artifactPass: input.artifactPass,
203
+ executionPath: input.executionPath,
204
+ leadReviewStatus: input.classification.reviewStatus,
205
+ finishReason: input.finishReason ?? null,
206
+ protocolComplete: input.classification.protocolComplete,
207
+ fallbackUsed: Boolean(deliveredArtifactSource) && (
208
+ input.classification.fallbackRequired || input.classification.fallbackUsed
209
+ ),
210
+ deliveredArtifactSource,
211
+ }
212
+ }
@@ -0,0 +1,166 @@
1
+ import type {
2
+ FusionEvent,
3
+ OutboxRow,
4
+ ReportSubject,
5
+ ReviewReadinessReceipt,
6
+ ReviewResultV2,
7
+ ReviewTicket,
8
+ TaskId,
9
+ WorkerReport,
10
+ } from '../contracts.js'
11
+ import { OperationId } from '../contracts.js'
12
+ import { digestOf } from '../digest.js'
13
+ import { EVENT_CONFLICT, FusionError } from '../errors.js'
14
+ import type { ReviewClassification } from './protocol.js'
15
+ import { toReducerReviewDecision } from './protocol.js'
16
+ import type { TaskState } from '../task/state.js'
17
+
18
+ function requireThat(value: unknown, code: string): asserts value {
19
+ if (!value) throw new FusionError(EVENT_CONFLICT, code)
20
+ }
21
+
22
+ function freeze<T>(value: T): Readonly<T> {
23
+ if (value && typeof value === 'object') {
24
+ for (const child of Object.values(value as Record<string, unknown>)) freeze(child)
25
+ Object.freeze(value)
26
+ }
27
+ return value as Readonly<T>
28
+ }
29
+
30
+ export function reportSubject(taskId: TaskId, report: WorkerReport): ReportSubject {
31
+ const reportDigest = digestOf(report)
32
+ return {
33
+ taskId,
34
+ workOrderId: report.workOrderId,
35
+ revision: report.revision,
36
+ reportId: `report:${reportDigest}`,
37
+ reportDigest,
38
+ snapshot: report.snapshot,
39
+ }
40
+ }
41
+
42
+ export function sameSubject(left: ReportSubject, right: ReportSubject): boolean {
43
+ return digestOf(left) === digestOf(right)
44
+ }
45
+
46
+ export function reviewReadinessReceipt(subject: ReportSubject): ReviewReadinessReceipt {
47
+ const receipt = {
48
+ subject,
49
+ receiptId: `ready:${subject.reportDigest}`,
50
+ verdict: 'review-ready' as const,
51
+ receiptDigest: '' as ReviewReadinessReceipt['receiptDigest'],
52
+ }
53
+ return { ...receipt, receiptDigest: digestOf({ subject, receiptId: receipt.receiptId, verdict: receipt.verdict }) }
54
+ }
55
+
56
+ export function captureTicket(ticket: ReviewTicket): Readonly<ReviewTicket> {
57
+ requireThat(ticket.id && ticket.requestId && Number.isSafeInteger(ticket.generation), 'INVALID_TICKET')
58
+ requireThat(ticket.workOrderDigest, 'WORK_ORDER_DIGEST_REQUIRED')
59
+ return freeze(structuredClone(ticket))
60
+ }
61
+
62
+ export function createReviewTicket(
63
+ state: TaskState,
64
+ ids: { ticketId: string; requestId: string },
65
+ ): ReviewTicket {
66
+ const receipt = state.validatedReceipt
67
+ const workOrder = state.currentWorkOrder
68
+ if (!receipt) throw new FusionError(EVENT_CONFLICT, 'review ticket requires a validated report')
69
+ if (!workOrder) throw new FusionError(EVENT_CONFLICT, 'review ticket requires a current work order')
70
+ return captureTicket({
71
+ id: ids.ticketId,
72
+ requestId: ids.requestId,
73
+ generation: state.reviewGeneration + 1,
74
+ subject: receipt.subject,
75
+ validationDigest: digestOf(receipt),
76
+ workOrderDigest: digestOf(workOrder),
77
+ })
78
+ }
79
+
80
+ export function reviewRequestedEvent(
81
+ state: TaskState,
82
+ seq: number,
83
+ ids: { ticketId: string; requestId: string },
84
+ extras: Partial<Pick<FusionEvent, 'id' | 'revision' | 'createdAt' | 'causeId'>> = {},
85
+ ): Extract<FusionEvent, { type: 'review/requested' }> {
86
+ return {
87
+ schemaVersion: 1,
88
+ id: extras.id ?? `review-requested-${ids.requestId}`,
89
+ taskId: state.taskId,
90
+ seq,
91
+ revision: extras.revision ?? state.revision,
92
+ type: 'review/requested',
93
+ createdAt: extras.createdAt ?? new Date().toISOString(),
94
+ causeId: extras.causeId ?? ids.requestId,
95
+ payload: { ticket: createReviewTicket(state, ids) },
96
+ }
97
+ }
98
+
99
+ export function reviewOutboxRow(ticket: Readonly<ReviewTicket>, payloadRef: string): OutboxRow {
100
+ requireThat(payloadRef.length > 0, 'REVIEW_PAYLOAD_REF_REQUIRED')
101
+ return {
102
+ operationId: OperationId(`review:${ticket.requestId}`),
103
+ taskId: ticket.subject.taskId,
104
+ kind: 'review-request',
105
+ state: 'prepared',
106
+ payloadRef,
107
+ requestId: ticket.requestId,
108
+ ticket: structuredClone(ticket),
109
+ }
110
+ }
111
+
112
+ /** Callback constructor. No TaskState argument: the producer cannot rebind to the latest task. */
113
+ export function resultFromCapturedTicket(
114
+ ticket: Readonly<ReviewTicket>,
115
+ outcome: {
116
+ requestId: string
117
+ terminalEvidenceRef: string
118
+ classification: ReviewClassification
119
+ },
120
+ envelope: { eventId: string; seq: number; createdAt: string },
121
+ ): Extract<FusionEvent, { type: 'review/completed' }> {
122
+ requireThat(outcome.requestId === ticket.requestId, 'REVIEW_REQUEST_MISMATCH')
123
+ requireThat(outcome.terminalEvidenceRef.length > 0, 'TERMINAL_EVIDENCE_REQUIRED')
124
+ requireThat(envelope.eventId && Number.isSafeInteger(envelope.seq) && envelope.seq > 0, 'INVALID_EVENT_ENVELOPE')
125
+ const classification = outcome.classification
126
+ requireThat(classification.decision !== 'not_required', 'DIRECT_IS_NOT_A_REVIEW_RESULT')
127
+ const protocolValid = classification.reviewStatus === 'complete'
128
+ && classification.protocolComplete
129
+ && classification.termination === 'success'
130
+ const decision = protocolValid ? toReducerReviewDecision(classification) : 'REVIEW_INCOMPLETE'
131
+ const binding: ReviewResultV2 = {
132
+ ticketId: ticket.id,
133
+ requestId: ticket.requestId,
134
+ generation: ticket.generation,
135
+ subject: structuredClone(ticket.subject),
136
+ terminalEvidenceRef: outcome.terminalEvidenceRef,
137
+ decision,
138
+ protocolValid,
139
+ }
140
+ return {
141
+ schemaVersion: 1,
142
+ id: envelope.eventId,
143
+ taskId: ticket.subject.taskId,
144
+ seq: envelope.seq,
145
+ revision: ticket.subject.revision,
146
+ type: 'review/completed',
147
+ createdAt: envelope.createdAt,
148
+ causeId: ticket.requestId,
149
+ payload: { decision, binding },
150
+ }
151
+ }
152
+
153
+ /** Call at review dispatch AND immediately before committing verified completion. */
154
+ export function assertCurrentSubject(state: TaskState, ticket: ReviewTicket, actualSnapshot: ReportSubject['snapshot']): void {
155
+ const workOrder = state.currentWorkOrder
156
+ requireThat(workOrder, 'CURRENT_WORK_ORDER_REQUIRED')
157
+ requireThat(workOrder.taskId === state.taskId && workOrder.id === ticket.subject.workOrderId, 'WORK_ORDER_CHANGED')
158
+ requireThat(state.revision === workOrder.revision && workOrder.revision === ticket.subject.revision, 'REVISION_CHANGED')
159
+ requireThat(digestOf(workOrder) === ticket.workOrderDigest, 'WORK_ORDER_CHANGED')
160
+ requireThat(state.candidateReport && state.validatedReport && state.validatedReceipt, 'VALIDATED_CANDIDATE_REQUIRED')
161
+ requireThat(digestOf(reportSubject(state.taskId, state.candidateReport)) === digestOf(ticket.subject), 'CANDIDATE_CHANGED')
162
+ requireThat(digestOf(reportSubject(state.taskId, state.validatedReport)) === digestOf(ticket.subject), 'VALIDATED_REPORT_CHANGED')
163
+ requireThat(digestOf(state.validatedReceipt.subject) === digestOf(ticket.subject), 'READINESS_SUBJECT_CHANGED')
164
+ requireThat(digestOf(state.validatedReceipt) === ticket.validationDigest, 'READINESS_CHANGED')
165
+ requireThat(actualSnapshot === ticket.subject.snapshot, 'WORKSPACE_CHANGED')
166
+ }
package/src/status.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { ControlView } from './host/native-model-control.js'
2
+ import type { PhysicalRoute, Role, TokenMeasurement, VerificationLevel } from './contracts.js'
3
+
4
+ /** Bounded, read-only UI projection. No prompts, tool arguments or credentials. */
5
+ export interface FusionStatus {
6
+ schemaVersion: 1
7
+ sessionId: string
8
+ selected: boolean
9
+ observedAt: string
10
+ task?: {
11
+ id: string
12
+ revision: number
13
+ stage: string
14
+ detail: string | null
15
+ /** English copies for an English DSH; optional so older records still read. */
16
+ stageEn?: string
17
+ detailEn?: string | null
18
+ attention: boolean
19
+ verification: VerificationLevel
20
+ workerId: string | null
21
+ modelControl?: ControlView
22
+ models: { lead: PhysicalRoute; worker: PhysicalRoute; compactor: PhysicalRoute | null }
23
+ usage: {
24
+ calls: number
25
+ finalCalls: number
26
+ provisionalCalls: number
27
+ unreportedCalls: number
28
+ compactionCalls: number
29
+ keepaliveCalls: number
30
+ /** Known subtotals only; missing request fields never become zero totals. */
31
+ input: UsageSubtotal
32
+ cacheRead: UsageSubtotal
33
+ output: UsageSubtotal
34
+ actualBilledUsd: null
35
+ upstreamHttpCalls: null
36
+ /** Where the tokens went: the point of Fusion is that most land on the cheaper Worker. */
37
+ byRole: Record<Role, RoleUsage>
38
+ }
39
+ /** Checks frozen on the current work order; 0 means the Lead review was the only verification. */
40
+ automatedChecks: number | null
41
+ requests: { id: string; role: Role; purpose: string; provider: string; model: string;
42
+ outcome: string; authority: 'none' | 'provisional' | 'final'; startedAt: string }[]
43
+ tools: { id: string; role: Role; name: string; state: string; failed: boolean; startedAt: string }[]
44
+ contexts: { role: Role; purpose: string; inputTokens: number; budget: number;
45
+ quality: TokenMeasurement['quality']; admitted: boolean; checkedAt: string }[]
46
+ pendingApprovals: number
47
+ unsettledTools: number
48
+ /** True while a human inspection is required before continue, model switch, or leaving Fusion. */
49
+ recovering?: boolean
50
+ outcomeUnknown?: boolean
51
+ }
52
+ }
53
+
54
+ export interface RoleUsage { calls: number; input: UsageSubtotal; cacheRead: UsageSubtotal; output: UsageSubtotal }
55
+ export interface UsageSubtotal { knownTokens: number; reportedRequests: number; totalRequests: number }