@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.6

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 (120) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +59 -20
  3. package/README.zh.md +59 -20
  4. package/lib/client.js +6208 -624
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +4774 -493
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +31 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/index.d.ts +23 -5
  26. package/lib/types/client/locales.d.ts +168 -1
  27. package/lib/types/client/native-panel.d.ts +6 -20
  28. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  29. package/lib/types/client/schedule-zone.d.ts +50 -0
  30. package/lib/types/client/seats.d.ts +18 -0
  31. package/lib/types/client/service.d.ts +32 -0
  32. package/lib/types/client/settings-form.d.ts +16 -0
  33. package/lib/types/core/controller.d.ts +46 -11
  34. package/lib/types/core/extension.d.ts +283 -0
  35. package/lib/types/core/handover.d.ts +13 -1
  36. package/lib/types/core/issue-form.d.ts +16 -0
  37. package/lib/types/core/poll-cadence.d.ts +31 -0
  38. package/lib/types/core/tasks.d.ts +228 -17
  39. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  40. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  41. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  42. package/lib/types/core/verification-budget.d.ts +52 -0
  43. package/lib/types/core/verification.d.ts +753 -0
  44. package/lib/types/core/workspace-target.d.ts +41 -0
  45. package/lib/types/host/agent-tools.d.ts +1 -1
  46. package/lib/types/host/extension-registry.d.ts +111 -0
  47. package/lib/types/host/llm-dispatch.d.ts +63 -0
  48. package/lib/types/host/verification-gate.d.ts +95 -0
  49. package/lib/types/host/verification-runner.d.ts +139 -0
  50. package/lib/types/host/workspace-evidence.d.ts +53 -0
  51. package/lib/types/host-ledger.d.ts +133 -14
  52. package/lib/types/host-runner.d.ts +55 -10
  53. package/lib/types/host-service.d.ts +205 -11
  54. package/lib/types/index.d.ts +134 -26
  55. package/lib/types/protocol.d.ts +92 -10
  56. package/lib/types/tool-surface.d.ts +71 -0
  57. package/package.json +28 -31
  58. package/src/client/PluginSettingsCard.tsx +9 -0
  59. package/src/client/TaskBoardSettingsCard.tsx +378 -66
  60. package/src/client/board/ClampedMarkdown.tsx +69 -0
  61. package/src/client/board/ConfirmDialog.tsx +7 -3
  62. package/src/client/board/EditTaskModal.tsx +5 -2
  63. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  64. package/src/client/board/NewTaskModal.tsx +168 -21
  65. package/src/client/board/TagManagerModal.tsx +235 -0
  66. package/src/client/board/TaskBoard.tsx +259 -103
  67. package/src/client/board/TaskCard.tsx +214 -81
  68. package/src/client/board/TaskDetail.tsx +289 -43
  69. package/src/client/board/TaskForm.tsx +13 -3
  70. package/src/client/board/VerificationReport.tsx +322 -0
  71. package/src/client/board/card-view.ts +220 -0
  72. package/src/client/board/icons.tsx +89 -0
  73. package/src/client/board/overlay.tsx +189 -0
  74. package/src/client/board/status-key.ts +30 -0
  75. package/src/client/board/task-markdown.module.css +137 -0
  76. package/src/client/board/task-markdown.tsx +106 -0
  77. package/src/client/board-settings.module.css +66 -0
  78. package/src/client/board.module.css +488 -63
  79. package/src/client/index.ts +82 -51
  80. package/src/client/locales.ts +343 -6
  81. package/src/client/native-panel.tsx +18 -2
  82. package/src/client/plugin-card-seat.ts +7 -0
  83. package/src/client/schedule-zone.ts +100 -2
  84. package/src/client/seats.tsx +58 -0
  85. package/src/client/service.ts +94 -0
  86. package/src/client/settings-card.module.css +31 -5
  87. package/src/client/settings-form.ts +27 -1
  88. package/src/core/controller.ts +105 -10
  89. package/src/core/extension.ts +376 -0
  90. package/src/core/freeze-snapshot.ts +2 -2
  91. package/src/core/handover.ts +16 -1
  92. package/src/core/issue-form.ts +19 -0
  93. package/src/core/poll-cadence.ts +41 -0
  94. package/src/core/store.ts +80 -15
  95. package/src/core/tasks.ts +293 -45
  96. package/src/core/use-cases/task-create.ts +30 -9
  97. package/src/core/use-cases/task-parent.ts +3 -2
  98. package/src/core/use-cases/task-schedule.ts +190 -25
  99. package/src/core/use-cases/task-tag.ts +110 -0
  100. package/src/core/use-cases/task-update.ts +13 -7
  101. package/src/core/verification-budget.ts +115 -0
  102. package/src/core/verification.ts +1594 -0
  103. package/src/core/workspace-target.ts +59 -0
  104. package/src/host/agent-tools.ts +125 -38
  105. package/src/host/extension-registry.ts +509 -0
  106. package/src/host/llm-dispatch.ts +87 -0
  107. package/src/host/verification-gate.ts +487 -0
  108. package/src/host/verification-runner.ts +702 -0
  109. package/src/host/workspace-evidence.ts +176 -0
  110. package/src/host-ai.ts +15 -9
  111. package/src/host-ledger.ts +468 -71
  112. package/src/host-routes.ts +15 -2
  113. package/src/host-runner.ts +106 -24
  114. package/src/host-service.ts +546 -40
  115. package/src/index.ts +305 -21
  116. package/src/protocol.ts +226 -28
  117. package/src/tool-surface.ts +95 -0
  118. package/lib/invariant.js +0 -6
  119. package/lib/types/invariant.d.ts +0 -3
  120. package/src/invariant.ts +0 -4
@@ -0,0 +1,702 @@
1
+ /**
2
+ * Goal acceptance runner: evidence assembly and the judge calls of one
3
+ * acceptance attempt.
4
+ *
5
+ * Lives in the host half because it needs the live session event log and the
6
+ * official `llm` service; every rule it applies (criteria, scale, tags,
7
+ * thresholds, budgets, caps) comes from `../core/verification.ts`, which mirrors
8
+ * the installed dsh-llm-verifier 0.8.4 default final acceptance.
9
+ */
10
+ import { createUserMessage, type FinishReason, type LlmRuntime } from '@deepseek-ai/dsh-llm'
11
+ import { openOneShotStream } from './llm-dispatch.ts'
12
+ import { createHash } from 'node:crypto'
13
+ import { DEFAULT_VERIFICATION_BUDGET_MS } from '../core/verification-budget.ts'
14
+ import {
15
+ CODING_CRITERIA,
16
+ EMPTY_WORK_BASELINE,
17
+ workSlotOfRound,
18
+ VERIFICATION_DETAIL_MAX_FINDINGS,
19
+ VERIFICATION_DETAIL_MAX_PROBLEM_CHARS,
20
+ VERIFICATION_DETAIL_MAX_QUOTE_CHARS,
21
+ assessAcceptanceValidity,
22
+ type AcceptanceEvidenceText,
23
+ type VerificationCriterionFinding,
24
+ type VerificationInvalidReason,
25
+ VERIFICATION_ROUNDS,
26
+ VERIFICATION_THRESHOLD,
27
+ acceptancePassed,
28
+ buildAcceptancePrompt,
29
+ extractScore,
30
+ type VerificationAttempt,
31
+ type CatalogGroup,
32
+ type CatalogModel,
33
+ type CatalogReasoningEffort,
34
+ type ModelCatalogView,
35
+ type VerificationContract,
36
+ type VerificationCriterion,
37
+ type VerificationCriterionScore,
38
+ type VerificationEvidenceSummary,
39
+ type VerificationRoute,
40
+ type VerificationSettings,
41
+ type VerificationUsage,
42
+ } from '../core/verification.ts'
43
+
44
+ /** Largest trace handed to the judge, mirroring the verifier's default cap. */
45
+ export const VERIFICATION_MAX_TRACE_CHARS = 80_000
46
+ /** Largest single trace entry; one huge tool result must not eat the budget. */
47
+ export const VERIFICATION_MAX_ENTRY_CHARS = 4_000
48
+ /** Largest per-finding text kept from one judge answer. */
49
+ export const VERIFICATION_MAX_FINDING_CHARS = 400
50
+ /** Largest verbatim citation kept from one finding. */
51
+ export const VERIFICATION_MAX_QUOTE_CHARS = VERIFICATION_DETAIL_MAX_QUOTE_CHARS
52
+ /** Largest requirement/observation/gap statement kept from one finding. */
53
+ export const VERIFICATION_MAX_PROBLEM_CHARS = VERIFICATION_DETAIL_MAX_PROBLEM_CHARS
54
+ /** Structured findings kept per judge answer and per acceptance. */
55
+ export const VERIFICATION_MAX_FINDINGS_PER_CALL = 3
56
+ export const VERIFICATION_MAX_FINDINGS = VERIFICATION_DETAIL_MAX_FINDINGS
57
+ /**
58
+ * Default ceiling of ONE judge request. The effective ceiling is a row setting
59
+ * (`goalVerificationCallTimeoutSeconds`), read live by the gate, because 120s was
60
+ * tight for a reasoning model reading an 80,000 character trace with a 16,384
61
+ * token output cap: every timeout it produced was charged as an acceptance
62
+ * anomaly against a budget that then failed the card.
63
+ */
64
+ export const VERIFICATION_CALL_TIMEOUT_MS = 150_000
65
+ export const VERIFICATION_MAX_TOKENS = 16_384
66
+ export const VERIFICATION_TEMPERATURE = 0.2
67
+ export const VERIFICATION_REQUEST_ATTEMPTS = 3
68
+
69
+ /** Redaction patterns, mirrored from the verifier's defaults. */
70
+ export const VERIFICATION_REDACT_PATTERNS: readonly string[] = [
71
+ "Bearer\\s+[A-Za-z0-9._~+\\/=-]+",
72
+ '(?:api[_-]?key|token|password|secret)\\s*[=:]\\s*["\']?[^\\s,"\';}]+',
73
+ ]
74
+
75
+ /** Why one acceptance could not produce a verdict. */
76
+ export type VerificationErrorKind = 'route-unavailable' | 'timeout' | 'auth' | 'parse' | 'request' | 'aborted' | 'budget'
77
+
78
+ /**
79
+ * The time one acceptance attempt may still spend.
80
+ *
81
+ * `deadline` is the instant the whole attempt runs out; `callTimeoutMs` is the
82
+ * ceiling of a single judge call. The pair is what ties the retry budget to the
83
+ * acceptance budget: a call is not started unless its own ceiling still fits
84
+ * inside what is left, because starting one the outer budget will abort anyway
85
+ * only burns wall time and turns a slow machine into an anomaly.
86
+ */
87
+ export interface AcceptanceBudget {
88
+ /** Absolute instant (ms epoch) the acceptance attempt runs out at. */
89
+ deadline: number
90
+ /** Ceiling of one judge call in milliseconds. */
91
+ callTimeoutMs: number
92
+ /** Clock the caller runs on, injected so tests drive it deterministically. */
93
+ now: () => number
94
+ }
95
+
96
+ /** An acceptance anomaly: never a quality verdict, always recorded as one. */
97
+ export class VerificationError extends Error {
98
+ constructor(
99
+ readonly kind: VerificationErrorKind,
100
+ message: string,
101
+ readonly usage: VerificationUsage = emptyUsage(),
102
+ options?: { cause?: unknown },
103
+ ) {
104
+ super(message, options)
105
+ this.name = 'VerificationError'
106
+ }
107
+ }
108
+
109
+ /** Fresh zeroed usage counters. */
110
+ export function emptyUsage(): VerificationUsage {
111
+ return { calls: 0, inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }
112
+ }
113
+
114
+ /** Whether a thrown error is worth one more request attempt. */
115
+ function retryable(error: unknown): boolean {
116
+ const message = error instanceof Error ? error.message : String(error)
117
+ return /rate|quota|timeout|timed out|temporar|network|fetch|socket|5\d\d|overload/i.test(message)
118
+ }
119
+
120
+ /** Redact one text in place, failing closed on an unusable pattern. */
121
+ function redact(text: string, patterns: readonly string[]): string {
122
+ let result = text
123
+ for (const pattern of patterns) {
124
+ if (pattern.length === 0 || pattern.length > 500) continue
125
+ // Reject nested/unbounded quantifiers that can backtrack catastrophically.
126
+ if (/\([^)]*[+*][^)]*\)[+*{]|\.\*[+*{]|\.\+[+*{]/u.test(pattern)) continue
127
+ try {
128
+ result = result.replace(new RegExp(pattern, 'giu'), '[REDACTED]')
129
+ } catch {
130
+ // An unusable pattern is skipped rather than failing the acceptance.
131
+ }
132
+ }
133
+ return result
134
+ }
135
+
136
+ /** Bound one text, keeping its tail: the newest output is what the judge grades. */
137
+ function boundTail(text: string, maxChars: number, notice: string): { text: string; omitted: number } {
138
+ if (text.length <= maxChars) return { text, omitted: 0 }
139
+ const omitted = text.length - maxChars
140
+ const prefix = notice.replace('%d', String(omitted))
141
+ if (prefix.length >= maxChars) return { text: text.slice(-maxChars), omitted }
142
+ return { text: prefix + text.slice(-(maxChars - prefix.length)), omitted }
143
+ }
144
+
145
+ /** One rendered content block of a tool result or assistant message. */
146
+ function blockText(block: unknown): string {
147
+ if (typeof block !== 'object' || block === null) return ''
148
+ const row = block as Record<string, unknown>
149
+ const type = row.type
150
+ if (type === 'text' && typeof row.text === 'string') return row.text
151
+ if (type === 'reasoning' && typeof row.text === 'string') return '[Reasoning] ' + row.text
152
+ if (type === 'tool-call') return '[Tool Call] ' + String(row.name ?? '') + ' ' + String(row.arguments ?? '')
153
+ if (type === 'tool-result') {
154
+ const nested = Array.isArray(row.content) ? row.content : []
155
+ return '[Tool Result] ' + nested.map(blockText).filter(part => part !== '').join('\n')
156
+ }
157
+ if (type === 'file') return '[File] ' + String(row.path ?? row.filename ?? row.title ?? 'attachment')
158
+ return ''
159
+ }
160
+
161
+ /** Render one message's blocks as plain text. */
162
+ function messageText(content: unknown): string {
163
+ if (!Array.isArray(content)) return ''
164
+ return content.map(blockText).filter(part => part !== '').join('\n')
165
+ }
166
+
167
+ /** Structural view of one session event, as the host log carries it. */
168
+ interface RawEvent {
169
+ type?: unknown
170
+ seq?: unknown
171
+ time?: unknown
172
+ data?: unknown
173
+ }
174
+
175
+ /** Read one session's events across the shapes the SDK cohort exposes. */
176
+ export function sessionEvents(session: unknown): readonly RawEvent[] {
177
+ const candidate = session as { snapshotEvents?: () => readonly RawEvent[]; events?: readonly RawEvent[] } | undefined
178
+ if (typeof candidate?.snapshotEvents === 'function') return candidate.snapshotEvents()
179
+ return candidate?.events ?? []
180
+ }
181
+
182
+ /** Evidence assembled for one acceptance attempt. */
183
+ export interface AcceptanceEvidence {
184
+ problem: string
185
+ trace: string
186
+ entries: number
187
+ fromSeq?: number
188
+ toSeq?: number
189
+ omittedCharacters: number
190
+ hash: string
191
+ }
192
+
193
+ /**
194
+ * Assemble the evidence one acceptance judges.
195
+ *
196
+ * The window starts at the execution's own `startedAt`, never at the session's
197
+ * first event: a reused session carries the history of earlier executions, and
198
+ * those runs must not be judged as this one's work. Only observed output is
199
+ * rendered — tool calls with their arguments and their results, assistant
200
+ * prose, goal round markers and team messages — and the result is redacted and
201
+ * capped before it ever reaches a judge.
202
+ * @param session - the live session of the execution.
203
+ * @param problem - the execution's composed prompt, shown as the task statement.
204
+ * @param startedAt - the instant the execution opened (ms epoch).
205
+ * @returns the bounded, redacted evidence.
206
+ */
207
+ /**
208
+ * The session events inside one execution's own window.
209
+ *
210
+ * A reused session carries the history of earlier executions; only the events
211
+ * at or after this execution's start belong to the work being judged.
212
+ * @param session - the live session.
213
+ * @param startedAt - the execution's start instant (ms epoch).
214
+ * @returns the events of this execution, oldest first.
215
+ */
216
+ export function evidenceEvents(session: unknown, startedAt: number): readonly RawEvent[] {
217
+ return sessionEvents(session).filter(event => typeof event.time !== 'number' || startedAt <= 0 || event.time >= startedAt)
218
+ }
219
+
220
+ export function collectEvidence(session: unknown, problem: string, startedAt: number): AcceptanceEvidence {
221
+ const events = evidenceEvents(session, startedAt)
222
+ .slice()
223
+ .sort((a, b) => (typeof a.seq === 'number' && typeof b.seq === 'number' ? a.seq - b.seq : 0))
224
+ const entries: string[] = []
225
+ for (const event of events) {
226
+ const data = (typeof event.data === 'object' && event.data !== null ? event.data : {}) as Record<string, unknown>
227
+ const seq = typeof event.seq === 'number' ? event.seq : 0
228
+ if (event.type === 'user/message') {
229
+ const source = (typeof data.source === 'object' && data.source !== null ? data.source : {}) as Record<string, unknown>
230
+ const kind = String(source.kind ?? '')
231
+ if (kind !== 'user' && kind !== 'team-message' && kind !== 'goal') continue
232
+ const label = kind === 'goal' ? 'Goal Round' : kind === 'team-message' ? 'Team Message' : 'User'
233
+ entries.push('--- ' + label + ' seq ' + seq + ' ---\n' + messageText(data.content))
234
+ } else if (event.type === 'assistant/message') {
235
+ const message = (typeof data.message === 'object' && data.message !== null ? data.message : {}) as Record<string, unknown>
236
+ entries.push('--- Assistant turn ' + String(data.turn ?? '?') + ' step ' + String(data.step ?? '?') + ' ---\n' + messageText(message.content))
237
+ } else if (event.type === 'tool/call') {
238
+ entries.push('--- Tool Call turn ' + String(data.turn ?? '?') + ' step ' + String(data.step ?? '?') + ' ---\n[Command] ' + String(data.name ?? '') + ' ' + String(data.arguments ?? ''))
239
+ } else if (event.type === 'tool/result') {
240
+ const message = (typeof data.message === 'object' && data.message !== null ? data.message : {}) as Record<string, unknown>
241
+ const failed = message.isError === true ? ' [Error]' : ''
242
+ entries.push('--- Tool Result' + failed + ' turn ' + String(data.turn ?? '?') + ' step ' + String(data.step ?? '?') + ' ---\n[Output] ' + messageText(message.content))
243
+ } else if (event.type === 'tool/ptc-dispatch' || event.type === 'tool/code-dispatch') {
244
+ const label = event.type === 'tool/ptc-dispatch' ? 'PTC Dispatch' : 'Code Dispatch'
245
+ const status = data.isError === true ? ' [Error]' : ''
246
+ const args = data.arguments === undefined ? '' : ' ' + (typeof data.arguments === 'string' ? data.arguments : JSON.stringify(data.arguments))
247
+ entries.push('--- ' + label + ' ' + String(data.name ?? '') + status + ' seq ' + seq + ' ---\n[Command] ' + String(data.name ?? '') + args + '\n[Output] ' + messageText(data.content))
248
+ } else if (event.type === 'team/message/queued') {
249
+ const message = (typeof data.message === 'object' && data.message !== null ? data.message : {}) as Record<string, unknown>
250
+ entries.push('--- Team Message Queued from ' + String(message.senderName ?? 'Teammate') + ' seq ' + seq + ' ---\n' + messageText(message.content))
251
+ }
252
+ }
253
+ const bounded = entries
254
+ .map(entry => entry.length <= VERIFICATION_MAX_ENTRY_CHARS ? entry : entry.slice(0, VERIFICATION_MAX_ENTRY_CHARS) + '\n[Entry truncated]')
255
+ .filter(entry => entry.trim() !== '')
256
+ const joined = redact(bounded.join('\n\n'), VERIFICATION_REDACT_PATTERNS)
257
+ const window = boundTail(joined, VERIFICATION_MAX_TRACE_CHARS, '[Earlier trace truncated: %d characters omitted]\n')
258
+ const problemText = redact(problem, VERIFICATION_REDACT_PATTERNS).trim()
259
+ const hash = createHash('sha256').update(problemText).update('\u0000').update(window.text).digest('hex')
260
+ const seqs = events.map(event => (typeof event.seq === 'number' ? event.seq : undefined)).filter((seq): seq is number => seq !== undefined)
261
+ return {
262
+ problem: problemText,
263
+ trace: window.text,
264
+ entries: bounded.length,
265
+ ...(seqs.length === 0 ? {} : { fromSeq: Math.min(...seqs), toSeq: Math.max(...seqs) }),
266
+ omittedCharacters: window.omitted,
267
+ hash,
268
+ }
269
+ }
270
+
271
+ /** One judge answer's parsed verdict. */
272
+ interface JudgeAnswer {
273
+ scoreWork: number
274
+ scoreBaseline: number
275
+ findings: string[]
276
+ usage: VerificationUsage
277
+ }
278
+
279
+ /**
280
+ * One finding the judge reported, before its citation is resolved against the
281
+ * evidence the judge actually saw.
282
+ */
283
+ interface RawFinding {
284
+ /** The criterion id this finding is attached to (always the caller's id). */
285
+ criterionId: string
286
+ /** The criterion name as the prompt offered it. */
287
+ criterionName: string
288
+ /** Which evidence block the citation claims to come from. */
289
+ location: VerificationCriterionFinding['location']
290
+ quote: string
291
+ requirement: string
292
+ observation: string
293
+ gap: string
294
+ body: string
295
+ action?: string
296
+ }
297
+
298
+ /** How one cited slot maps onto the caller's own evidence under a swapped round. */
299
+ function locationOfSlot(slot: 'A' | 'B', round: number): VerificationCriterionFinding['location'] {
300
+ // The odd round swaps the two slots: citing the slot the work occupies is a
301
+ // citation of the work's own trajectory, and citing the other one is a
302
+ // citation of the empty-work baseline, which can never veto the work.
303
+ return slot === workSlotOfRound(round) ? 'trajectory' : 'baseline'
304
+ }
305
+
306
+ /** Resolve one `location` (or legacy `evidence`) attribute into an evidence location. */
307
+ function readLocation(raw: string, round: number): VerificationCriterionFinding['location'] | undefined {
308
+ const value = raw.trim().toUpperCase()
309
+ if (value === 'TASK') return 'task'
310
+ if (value === 'CONTEXT' || value === 'WORKSPACE') return 'workspace'
311
+ if (value === 'TRAJECTORY_A' || value === 'A') return locationOfSlot('A', round)
312
+ if (value === 'TRAJECTORY_B' || value === 'B') return locationOfSlot('B', round)
313
+ return undefined
314
+ }
315
+
316
+ /**
317
+ * Parse the `<finding ...>` elements out of one judge answer.
318
+ *
319
+ * A finding must name the criterion this prompt offered and cite an evidence
320
+ * location the prompt actually rendered; a finding for another criterion, a
321
+ * citation of a location that was never offered, and an empty body are dropped
322
+ * here rather than echoed back as if the judge had located something. The
323
+ * quote is carried verbatim: whether it really occurs in the evidence it names
324
+ * is decided by the caller, which is the only side holding the evidence text.
325
+ */
326
+ function parseFindings(text: string, criterion: VerificationCriterion, round: number): RawFinding[] {
327
+ const pattern = /<finding\s+([^>]*)>([\s\S]*?)<\/finding>/giu
328
+ const attributes = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/gu
329
+ const found: RawFinding[] = []
330
+ const seen = new Set<string>()
331
+ for (let match = pattern.exec(text); match !== null && found.length < VERIFICATION_MAX_FINDINGS_PER_CALL; match = pattern.exec(text)) {
332
+ const attrs = new Map<string, string>()
333
+ for (let attribute = attributes.exec(match[1] ?? ''); attribute !== null; attribute = attributes.exec(match[1] ?? '')) {
334
+ if (['criterion', 'evidence', 'location', 'requirement', 'observation', 'gap', 'quote', 'action'].includes(attribute[1] ?? '')) {
335
+ attrs.set(attribute[1] ?? '', attribute[2] ?? '')
336
+ }
337
+ }
338
+ const named = attrs.get('criterion') ?? ''
339
+ // A finding must name the criterion this prompt offered; anything else is
340
+ // dropped, never echoed.
341
+ if (named !== '' && named.toLowerCase() !== criterion.name.toLowerCase() && named.toLowerCase() !== criterion.id.toLowerCase()) continue
342
+ const location = readLocation(attrs.get('location') ?? attrs.get('evidence') ?? '', round)
343
+ if (location === undefined) continue
344
+ const quote = (attrs.get('quote') ?? '').trim().slice(0, VERIFICATION_MAX_QUOTE_CHARS)
345
+ const requirement = (attrs.get('requirement') ?? '').trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS)
346
+ const observation = (attrs.get('observation') ?? '').trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS)
347
+ const gap = (attrs.get('gap') ?? '').trim().slice(0, VERIFICATION_MAX_PROBLEM_CHARS)
348
+ const body = (match[2] ?? '').trim().slice(0, VERIFICATION_MAX_FINDING_CHARS)
349
+ if (body === '' && quote === '') continue
350
+ const action = (attrs.get('action') ?? '').trim().slice(0, 300)
351
+ const key = criterion.id + '|' + location + '|' + quote + '|' + body
352
+ if (seen.has(key)) continue
353
+ seen.add(key)
354
+ found.push({
355
+ criterionId: criterion.id,
356
+ criterionName: criterion.name,
357
+ location,
358
+ quote,
359
+ requirement,
360
+ observation,
361
+ gap,
362
+ body,
363
+ ...(action === '' ? {} : { action }),
364
+ })
365
+ }
366
+ return found
367
+ }
368
+
369
+ /** Render one finding as the single feedback line the agent reads. */
370
+ function renderFinding(finding: RawFinding): string {
371
+ const locator = '[' + finding.criterionName + ' ' + finding.location + ']'
372
+ const action = finding.action === undefined ? '' : ' → 建议核实:' + finding.action
373
+ const detail = finding.body !== '' ? finding.body : finding.quote
374
+ return locator + ' ' + detail + action
375
+ }
376
+
377
+ /**
378
+ * One judge request's outcome.
379
+ *
380
+ * Every attempt is admitted against the acceptance budget first: a call whose
381
+ * own ceiling no longer fits in the time left is refused before it is opened,
382
+ * so a slow route cannot spend the whole acceptance budget on calls the outer
383
+ * abort is going to kill anyway.
384
+ */
385
+ async function judgeOnce(
386
+ llm: LlmRuntime,
387
+ route: VerificationRoute,
388
+ prompt: string,
389
+ signal: AbortSignal,
390
+ budget: AcceptanceBudget,
391
+ ): Promise<{ text: string; usage: VerificationUsage }> {
392
+ const usage = emptyUsage()
393
+ let lastError: unknown
394
+ /** The outer abort is the acceptance budget running out, not a cancellation. */
395
+ const stopped = (): VerificationError => budget.now() >= budget.deadline
396
+ ? new VerificationError('budget', 'the acceptance budget ran out before the judge answered', usage)
397
+ : new VerificationError('aborted', 'the acceptance was cancelled', usage)
398
+ for (let attempt = 0; attempt < VERIFICATION_REQUEST_ATTEMPTS; attempt++) {
399
+ if (signal.aborted) throw stopped()
400
+ if (budget.deadline - budget.now() <= budget.callTimeoutMs) {
401
+ throw new VerificationError('budget', 'the acceptance budget cannot fit another judge call', usage)
402
+ }
403
+ const timeout = new AbortController()
404
+ const timer = setTimeout(() => { timeout.abort(new Error('task-board verification: the judge request timed out')) }, budget.callTimeoutMs)
405
+ const forward = (): void => { timeout.abort(signal.reason) }
406
+ signal.addEventListener('abort', forward, { once: true })
407
+ try {
408
+ // The judge dispatches through the registration-bound prepared call, the
409
+ // same path the official agent loop uses: the public `stream()` is a
410
+ // mutable instance property a third-party plugin may replace, and a
411
+ // replacement with the `llm/stream` listener signature broke every
412
+ // acceptance with "next(...) is not a function or its return value is not
413
+ // async iterable".
414
+ const stream = await openOneShotStream(
415
+ llm,
416
+ {
417
+ provider: route.provider,
418
+ model: route.model,
419
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
420
+ temperature: VERIFICATION_TEMPERATURE,
421
+ maxTokens: VERIFICATION_MAX_TOKENS,
422
+ },
423
+ {
424
+ messages: [createUserMessage({ content: [{ type: 'text' as const, text: prompt }], source: { kind: 'user' as const } })],
425
+ signal: timeout.signal,
426
+ },
427
+ )
428
+ let text = ''
429
+ usage.calls += 1
430
+ let finishReason: FinishReason | undefined
431
+ for await (const chunk of stream) {
432
+ const row = chunk as unknown as Record<string, unknown>
433
+ if (row.type === 'text-delta' && typeof row.text === 'string') text += row.text
434
+ else if (row.type === 'usage') {
435
+ const reported = (typeof row.usage === 'object' && row.usage !== null ? row.usage : {}) as Record<string, unknown>
436
+ usage.inputTokens += typeof reported.inputTokens === 'number' ? reported.inputTokens : 0
437
+ usage.outputTokens += typeof reported.outputTokens === 'number' ? reported.outputTokens : 0
438
+ usage.reasoningTokens += typeof reported.reasoningTokens === 'number' ? reported.reasoningTokens : 0
439
+ } else if (row.type === 'finish') {
440
+ finishReason = chunk.type === 'finish' ? chunk.reason : undefined
441
+ }
442
+ }
443
+ if (signal.aborted) throw stopped()
444
+ if (timeout.signal.aborted) throw new VerificationError('timeout', 'the judge request timed out', usage)
445
+ if (finishReason?.kind === 'error' || finishReason?.kind === 'aborted') {
446
+ const failure = finishReason.failure
447
+ const message = redact([failure.code, failure.status, failure.message].filter(value => value !== undefined).join(': '), VERIFICATION_REDACT_PATTERNS).slice(0, 2_000)
448
+ const kind = finishReason.kind === 'aborted' ? 'aborted'
449
+ : failure.status === 401 || failure.status === 403 ? 'auth' : 'request'
450
+ throw new VerificationError(kind, message, usage)
451
+ }
452
+ if (finishReason?.kind === 'max-tokens') {
453
+ throw new VerificationError('parse', 'the judge answer hit the output ceiling (' + VERIFICATION_MAX_TOKENS + ' tokens) and carries no usable verdict', usage)
454
+ }
455
+ if (text.trim() === '') {
456
+ throw new VerificationError('parse', 'the judge returned an empty answer', usage)
457
+ }
458
+ try {
459
+ extractScore(text, 'score_A')
460
+ extractScore(text, 'score_B')
461
+ } catch {
462
+ throw new VerificationError('parse', 'the judge answer did not contain valid score_A and score_B A-T scores', usage)
463
+ }
464
+ return { text, usage }
465
+ } catch (error) {
466
+ lastError = error
467
+ if (error instanceof VerificationError && (error.kind === 'aborted' || error.kind === 'auth')) throw error
468
+ if (signal.aborted) throw stopped()
469
+ if (timeout.signal.aborted) {
470
+ lastError = new VerificationError('timeout', 'the judge request timed out after ' + Math.round(budget.callTimeoutMs / 1000) + 's', usage)
471
+ }
472
+ const parseFailure = lastError instanceof VerificationError && lastError.kind === 'parse'
473
+ if (attempt + 1 >= VERIFICATION_REQUEST_ATTEMPTS || (!parseFailure && !retryable(lastError))) break
474
+ if (!parseFailure) usage.usageIncomplete = true
475
+ await new Promise(resolve => { setTimeout(resolve, 500 * (attempt + 1)) })
476
+ } finally {
477
+ clearTimeout(timer)
478
+ signal.removeEventListener('abort', forward)
479
+ }
480
+ }
481
+ const failure = lastError
482
+ if (failure instanceof VerificationError) throw new VerificationError(failure.kind, failure.message, usage)
483
+ const message = failure instanceof Error ? failure.message : String(failure)
484
+ const kind: VerificationErrorKind = /401|403|unauthor|api key|forbidden|credential/i.test(message) ? 'auth' : 'request'
485
+ throw new VerificationError(kind, message, usage)
486
+ }
487
+
488
+ /** The verdict of one acceptance attempt. */
489
+ export interface AcceptanceResult {
490
+ attempt: VerificationAttempt
491
+ }
492
+
493
+ /**
494
+ * Run one acceptance: every criterion is judged twice, the odd round with the
495
+ * A/B slots swapped, and the per-round scores are mapped back and averaged per
496
+ * criterion.
497
+ * @param input - the judge route, evidence, threshold, time budget and cancellation.
498
+ * @returns the recorded attempt (quality verdict, anomaly, or budget stop).
499
+ */
500
+ export async function runAcceptance(input: {
501
+ llm: LlmRuntime
502
+ route: VerificationRoute
503
+ evidence: AcceptanceEvidence
504
+ threshold: number
505
+ index: number
506
+ signal: AbortSignal
507
+ /** Instant (ms epoch) the attempt runs out at; defaults to now plus the default budget. */
508
+ deadline?: number
509
+ /** Ceiling of one judge call; defaults to {@link VERIFICATION_CALL_TIMEOUT_MS}. */
510
+ callTimeoutMs?: number
511
+ /** Host-observed workspace changes, rendered as the prompt's reference context. */
512
+ context?: string
513
+ /** How many changed files that context shows. */
514
+ workspaceFiles?: number
515
+ now?: () => number
516
+ }): Promise<AcceptanceResult> {
517
+ const now = input.now ?? Date.now
518
+ const startedAt = now()
519
+ const budget: AcceptanceBudget = {
520
+ deadline: input.deadline ?? startedAt + DEFAULT_VERIFICATION_BUDGET_MS,
521
+ callTimeoutMs: input.callTimeoutMs ?? VERIFICATION_CALL_TIMEOUT_MS,
522
+ now,
523
+ }
524
+ const usage = emptyUsage()
525
+ const evidenceSummary: VerificationEvidenceSummary = {
526
+ chars: input.evidence.trace.length,
527
+ omittedCharacters: input.evidence.omittedCharacters,
528
+ entries: input.evidence.entries,
529
+ hash: input.evidence.hash,
530
+ ...(input.evidence.fromSeq === undefined ? {} : { fromSeq: input.evidence.fromSeq }),
531
+ ...(input.evidence.toSeq === undefined ? {} : { toSeq: input.evidence.toSeq }),
532
+ ...(input.workspaceFiles === undefined || input.workspaceFiles <= 0 ? {} : { workspaceFiles: input.workspaceFiles }),
533
+ }
534
+ const findings: string[] = []
535
+ const seenFindings = new Set<string>()
536
+ const structured: VerificationCriterionFinding[] = []
537
+ const seenStructured = new Set<string>()
538
+ // The exact evidence texts this acceptance handed to the judge, retained so
539
+ // the validity check can verify every citation against what was really shown.
540
+ const evidenceText: AcceptanceEvidenceText = {
541
+ task: input.evidence.problem,
542
+ trajectory: input.evidence.trace,
543
+ workspace: input.context ?? '',
544
+ }
545
+ try {
546
+ const perCriterion = new Map<string, { criterion: VerificationCriterion; work: number[]; baseline: number[] }>()
547
+ for (const criterion of CODING_CRITERIA) perCriterion.set(criterion.id, { criterion, work: [], baseline: [] })
548
+ for (const criterion of CODING_CRITERIA) {
549
+ for (let repeat = 0; repeat < VERIFICATION_ROUNDS; repeat++) {
550
+ const swapped = repeat % 2 === 1
551
+ const candidateA = swapped ? EMPTY_WORK_BASELINE : input.evidence.trace
552
+ const candidateB = swapped ? input.evidence.trace : EMPTY_WORK_BASELINE
553
+ const prompt = buildAcceptancePrompt(input.evidence.problem, candidateA, candidateB, criterion, undefined, input.context, workSlotOfRound(repeat))
554
+ const answer = await judgeOnce(input.llm, input.route, prompt, input.signal, budget)
555
+ const scoreA = extractScore(answer.text, 'score_A')
556
+ const scoreB = extractScore(answer.text, 'score_B')
557
+ if (answer.usage.usageIncomplete === true) usage.usageIncomplete = true
558
+ usage.calls += answer.usage.calls
559
+ usage.inputTokens += answer.usage.inputTokens
560
+ usage.outputTokens += answer.usage.outputTokens
561
+ usage.reasoningTokens += answer.usage.reasoningTokens
562
+ const bucket = perCriterion.get(criterion.id)!
563
+ bucket.work.push(swapped ? scoreB : scoreA)
564
+ bucket.baseline.push(swapped ? scoreA : scoreB)
565
+ for (const finding of parseFindings(answer.text, criterion, repeat)) {
566
+ if (structured.length < VERIFICATION_MAX_FINDINGS) {
567
+ const key = finding.criterionId + '|' + finding.location + '|' + finding.quote + '|' + finding.body
568
+ if (!seenStructured.has(key)) {
569
+ seenStructured.add(key)
570
+ structured.push({
571
+ criterionId: finding.criterionId,
572
+ requirement: finding.requirement !== '' ? finding.requirement : finding.criterionName,
573
+ observation: finding.observation !== '' ? finding.observation : finding.body,
574
+ gap: finding.gap !== '' ? finding.gap : finding.body,
575
+ quote: finding.quote,
576
+ location: finding.location,
577
+ ...(finding.action === undefined ? {} : { action: finding.action }),
578
+ })
579
+ }
580
+ }
581
+ const line = renderFinding(finding)
582
+ if (seenFindings.has(line)) continue
583
+ seenFindings.add(line)
584
+ if (findings.length < VERIFICATION_MAX_FINDINGS) findings.push(line)
585
+ }
586
+ }
587
+ }
588
+ const criteria: VerificationCriterionScore[] = CODING_CRITERIA.map(criterion => {
589
+ const bucket = perCriterion.get(criterion.id)!
590
+ const score = average(bucket.work)
591
+ const baseline = average(bucket.baseline)
592
+ return { id: criterion.id, name: criterion.name, score, baseline, threshold: input.threshold, passed: score >= input.threshold }
593
+ })
594
+ const score = average(criteria.map(criterion => criterion.score))
595
+ const baseline = average(criteria.map(criterion => criterion.baseline))
596
+ const passed = acceptancePassed(score, baseline, criteria, input.threshold)
597
+ // A veto is only bookable when its evidence holds up; a rejecting attempt
598
+ // that cannot substantiate the rejection is recorded as INVALID so it can
599
+ // never consume the quality budget or fail the card.
600
+ const validity = passed
601
+ ? { valid: true as const }
602
+ : assessAcceptanceValidity({
603
+ criteria,
604
+ findings: structured,
605
+ evidence: evidenceText,
606
+ omittedCharacters: input.evidence.omittedCharacters,
607
+ })
608
+ return {
609
+ attempt: {
610
+ index: input.index,
611
+ at: startedAt,
612
+ stage: validity.valid ? 'quality' : 'invalid',
613
+ passed: validity.valid ? passed : false,
614
+ score,
615
+ baseline,
616
+ criteria,
617
+ findings,
618
+ usage,
619
+ evidence: evidenceSummary,
620
+ route: input.route,
621
+ channel: 'explicit-tag',
622
+ rounds: VERIFICATION_ROUNDS,
623
+ ...(structured.length === 0 ? {} : { criterionFindings: structured }),
624
+ ...(validity.valid ? {} : {
625
+ invalidReason: (validity as { reason: VerificationInvalidReason }).reason,
626
+ error: classifyInvalid(validity as { reason: VerificationInvalidReason; detail: string }),
627
+ }),
628
+ },
629
+ }
630
+ } catch (error) {
631
+ const failure = error instanceof Error ? error : new Error(String(error))
632
+ const kind = failure instanceof VerificationError ? failure.kind : 'request'
633
+ const merged = mergeUsage(usage, failure instanceof VerificationError ? failure.usage : emptyUsage())
634
+ return {
635
+ attempt: {
636
+ index: input.index,
637
+ at: startedAt,
638
+ // A budget stop is its own stage: the time ran out before any verdict,
639
+ // which is neither a judgement of the work nor an anomaly of the
640
+ // judge route, so it is charged to no budget.
641
+ stage: kind === 'budget' ? 'budget' : 'exception',
642
+ passed: false,
643
+ score: 0,
644
+ baseline: 0,
645
+ criteria: [],
646
+ findings,
647
+ usage: merged,
648
+ evidence: evidenceSummary,
649
+ route: input.route,
650
+ channel: 'explicit-tag',
651
+ rounds: VERIFICATION_ROUNDS,
652
+ error: classifyMessage(kind, failure.message),
653
+ },
654
+ }
655
+ }
656
+ }
657
+
658
+ /** Average of a list, 0 when empty. */
659
+ function average(values: readonly number[]): number {
660
+ if (values.length === 0) return 0
661
+ return values.reduce((total, value) => total + value, 0) / values.length
662
+ }
663
+
664
+ /** Fold one usage record into another. */
665
+ function mergeUsage(target: VerificationUsage, source: VerificationUsage): VerificationUsage {
666
+ return {
667
+ calls: target.calls + source.calls,
668
+ inputTokens: target.inputTokens + source.inputTokens,
669
+ outputTokens: target.outputTokens + source.outputTokens,
670
+ reasoningTokens: target.reasoningTokens + source.reasoningTokens,
671
+ ...(target.usageIncomplete === true || source.usageIncomplete === true ? { usageIncomplete: true } : {}),
672
+ }
673
+ }
674
+
675
+ /** Prefix one invalid-acceptance message with its class so the report stays actionable. */
676
+ function classifyInvalid(validity: { reason: VerificationInvalidReason; detail: string }): string {
677
+ const labels: Record<VerificationInvalidReason, string> = {
678
+ 'missing-finding': '验收无效(未达标判据没有对应的结构化问题反馈)',
679
+ 'unlocatable-quote': '验收无效(问题反馈引用不存在于本次裁判所见的证据中)',
680
+ 'baseline-finding': '验收无效(问题反馈指向空工作基线而非本次工作)',
681
+ 'vacuous-finding': '验收无效(问题反馈空泛,未说明实际观测与差异)',
682
+ 'insufficient-evidence': '验收无效(裁判所见证据不足,无法支撑否决)',
683
+ }
684
+ return labels[validity.reason] + ': ' + validity.detail
685
+ }
686
+
687
+ /** Prefix one anomaly message with its class so the report stays actionable. */
688
+ function classifyMessage(kind: VerificationErrorKind, message: string): string {
689
+ const labels: Record<VerificationErrorKind, string> = {
690
+ 'route-unavailable': '验收异常(裁判路由不可用)',
691
+ timeout: '验收异常(裁判超时)',
692
+ auth: '验收异常(鉴权失败)',
693
+ parse: '验收异常(裁判回答无法解析)',
694
+ request: '验收异常(裁判请求失败)',
695
+ aborted: '验收异常(验收被取消)',
696
+ budget: '验收未完成(时间预算耗尽,未产生任何判定,不计入异常额度)',
697
+ }
698
+ return labels[kind] + ': ' + message
699
+ }
700
+
701
+
702
+