@linxin666/dsh-client-ui-task-board 0.4.5 → 0.4.7

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 (83) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -18
  3. package/README.zh.md +34 -18
  4. package/lib/client.js +2804 -1024
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2576 -416
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +61 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/index.d.ts +4 -5
  15. package/lib/types/client/locales.d.ts +81 -0
  16. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  17. package/lib/types/client/schedule-zone.d.ts +50 -0
  18. package/lib/types/core/card-defaults.d.ts +78 -0
  19. package/lib/types/core/controller.d.ts +41 -17
  20. package/lib/types/core/launch-width.d.ts +31 -0
  21. package/lib/types/core/poll-cadence.d.ts +31 -0
  22. package/lib/types/core/reasoning-effort.d.ts +57 -0
  23. package/lib/types/core/subtask.d.ts +30 -8
  24. package/lib/types/core/tasks.d.ts +163 -14
  25. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  26. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  27. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  28. package/lib/types/core/verification-budget.d.ts +52 -0
  29. package/lib/types/core/verification.d.ts +376 -9
  30. package/lib/types/host/launch-admission.d.ts +72 -0
  31. package/lib/types/host/verification-gate.d.ts +24 -0
  32. package/lib/types/host/verification-runner.d.ts +38 -7
  33. package/lib/types/host-ledger.d.ts +91 -11
  34. package/lib/types/host-runner.d.ts +111 -13
  35. package/lib/types/host-service.d.ts +199 -8
  36. package/lib/types/index.d.ts +141 -0
  37. package/lib/types/protocol.d.ts +63 -12
  38. package/package.json +29 -33
  39. package/src/client/CardDefaultPresetField.tsx +101 -0
  40. package/src/client/TaskBoardSettingsCard.tsx +288 -17
  41. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  42. package/src/client/board/NewTaskModal.tsx +237 -60
  43. package/src/client/board/ReasoningEffortField.tsx +50 -0
  44. package/src/client/board/TaskCard.tsx +3 -3
  45. package/src/client/board/TaskDetail.tsx +261 -24
  46. package/src/client/board/VerificationReport.tsx +132 -13
  47. package/src/client/board/model-options.ts +178 -0
  48. package/src/client/board/preset-label.ts +32 -0
  49. package/src/client/board/preset-roster.ts +58 -0
  50. package/src/client/board/status-key.ts +3 -0
  51. package/src/client/board/team-run-promotion.ts +39 -0
  52. package/src/client/index.ts +105 -108
  53. package/src/client/locales.ts +171 -6
  54. package/src/client/plugin-card-seat.ts +7 -0
  55. package/src/client/schedule-zone.ts +100 -2
  56. package/src/client/settings-entry-form.ts +0 -1
  57. package/src/core/card-defaults.ts +132 -0
  58. package/src/core/controller.ts +55 -16
  59. package/src/core/launch-width.ts +38 -0
  60. package/src/core/poll-cadence.ts +41 -0
  61. package/src/core/reasoning-effort.ts +92 -0
  62. package/src/core/store.ts +69 -15
  63. package/src/core/subtask.ts +48 -13
  64. package/src/core/tasks.ts +213 -39
  65. package/src/core/use-cases/task-create.ts +57 -25
  66. package/src/core/use-cases/task-schedule.ts +190 -25
  67. package/src/core/use-cases/task-update.ts +9 -4
  68. package/src/core/verification-budget.ts +115 -0
  69. package/src/core/verification.ts +785 -17
  70. package/src/host/agent-tools.ts +127 -29
  71. package/src/host/launch-admission.ts +140 -0
  72. package/src/host/verification-gate.ts +172 -16
  73. package/src/host/verification-runner.ts +207 -40
  74. package/src/host-ledger.ts +346 -48
  75. package/src/host-runner.ts +234 -35
  76. package/src/host-service.ts +539 -52
  77. package/src/index.ts +250 -7
  78. package/src/protocol.ts +160 -32
  79. package/lib/invariant.js +0 -6
  80. package/lib/types/client/board-card-seat.d.ts +0 -34
  81. package/lib/types/invariant.d.ts +0 -3
  82. package/src/client/board-card-seat.ts +0 -102
  83. package/src/invariant.ts +0 -4
@@ -10,9 +10,18 @@
10
10
  import { createUserMessage, type FinishReason, type LlmRuntime } from '@deepseek-ai/dsh-llm'
11
11
  import { openOneShotStream } from './llm-dispatch.ts'
12
12
  import { createHash } from 'node:crypto'
13
+ import { DEFAULT_VERIFICATION_BUDGET_MS } from '../core/verification-budget.ts'
13
14
  import {
14
15
  CODING_CRITERIA,
15
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,
16
25
  VERIFICATION_ROUNDS,
17
26
  VERIFICATION_THRESHOLD,
18
27
  acceptancePassed,
@@ -38,11 +47,21 @@ export const VERIFICATION_MAX_TRACE_CHARS = 80_000
38
47
  export const VERIFICATION_MAX_ENTRY_CHARS = 4_000
39
48
  /** Largest per-finding text kept from one judge answer. */
40
49
  export const VERIFICATION_MAX_FINDING_CHARS = 400
41
- /** Findings kept per judge answer and per acceptance. */
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. */
42
55
  export const VERIFICATION_MAX_FINDINGS_PER_CALL = 3
43
- export const VERIFICATION_MAX_FINDINGS = 6
44
- /** One judge request's ceiling and its retry budget. */
45
- export const VERIFICATION_CALL_TIMEOUT_MS = 120_000
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
46
65
  export const VERIFICATION_MAX_TOKENS = 16_384
47
66
  export const VERIFICATION_TEMPERATURE = 0.2
48
67
  export const VERIFICATION_REQUEST_ATTEMPTS = 3
@@ -54,7 +73,25 @@ export const VERIFICATION_REDACT_PATTERNS: readonly string[] = [
54
73
  ]
55
74
 
56
75
  /** Why one acceptance could not produce a verdict. */
57
- export type VerificationErrorKind = 'route-unavailable' | 'timeout' | 'auth' | 'parse' | 'request' | 'aborted'
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
+ }
58
95
 
59
96
  /** An acceptance anomaly: never a quality verdict, always recorded as one. */
60
97
  export class VerificationError extends Error {
@@ -239,16 +276,54 @@ interface JudgeAnswer {
239
276
  usage: VerificationUsage
240
277
  }
241
278
 
242
- /** One finding the judge located, before it is rendered. */
279
+ /**
280
+ * One finding the judge reported, before its citation is resolved against the
281
+ * evidence the judge actually saw.
282
+ */
243
283
  interface RawFinding {
244
- criterion?: string
245
- evidence: string
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
246
294
  body: string
247
295
  action?: string
248
296
  }
249
297
 
250
- /** Parse the optional `<finding ...>` lines out of one judge answer. */
251
- function parseFindings(text: string, criterion: VerificationCriterion): RawFinding[] {
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[] {
252
327
  const pattern = /<finding\s+([^>]*)>([\s\S]*?)<\/finding>/giu
253
328
  const attributes = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/gu
254
329
  const found: RawFinding[] = []
@@ -256,45 +331,77 @@ function parseFindings(text: string, criterion: VerificationCriterion): RawFindi
256
331
  for (let match = pattern.exec(text); match !== null && found.length < VERIFICATION_MAX_FINDINGS_PER_CALL; match = pattern.exec(text)) {
257
332
  const attrs = new Map<string, string>()
258
333
  for (let attribute = attributes.exec(match[1] ?? ''); attribute !== null; attribute = attributes.exec(match[1] ?? '')) {
259
- if (['criterion', 'evidence', 'action'].includes(attribute[1] ?? '')) attrs.set(attribute[1] ?? '', attribute[2] ?? '')
334
+ if (['criterion', 'evidence', 'location', 'requirement', 'observation', 'gap', 'quote', 'action'].includes(attribute[1] ?? '')) {
335
+ attrs.set(attribute[1] ?? '', attribute[2] ?? '')
336
+ }
260
337
  }
261
338
  const named = attrs.get('criterion') ?? ''
262
- // A finding must name the criterion this prompt offered and cite an evidence
263
- // token the prompt actually rendered; anything else is dropped, never echoed.
339
+ // A finding must name the criterion this prompt offered; anything else is
340
+ // dropped, never echoed.
264
341
  if (named !== '' && named.toLowerCase() !== criterion.name.toLowerCase() && named.toLowerCase() !== criterion.id.toLowerCase()) continue
265
- const evidence = (attrs.get('evidence') ?? '').trim().toUpperCase()
266
- if (!['TASK', 'A', 'B'].includes(evidence)) 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)
267
348
  const body = (match[2] ?? '').trim().slice(0, VERIFICATION_MAX_FINDING_CHARS)
268
- if (body === '') continue
349
+ if (body === '' && quote === '') continue
269
350
  const action = (attrs.get('action') ?? '').trim().slice(0, 300)
270
- const key = named + '|' + evidence + '|' + body
351
+ const key = criterion.id + '|' + location + '|' + quote + '|' + body
271
352
  if (seen.has(key)) continue
272
353
  seen.add(key)
273
- found.push({ criterion: criterion.name, evidence, body, ...(action === '' ? {} : { action }) })
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
+ })
274
365
  }
275
366
  return found
276
367
  }
277
368
 
278
369
  /** Render one finding as the single feedback line the agent reads. */
279
370
  function renderFinding(finding: RawFinding): string {
280
- const locator = '[' + (finding.criterion ?? 'finding') + ' evidence=' + finding.evidence + ']'
371
+ const locator = '[' + finding.criterionName + ' ' + finding.location + ']'
281
372
  const action = finding.action === undefined ? '' : ' → 建议核实:' + finding.action
282
- return locator + ' ' + finding.body + action
373
+ const detail = finding.body !== '' ? finding.body : finding.quote
374
+ return locator + ' ' + detail + action
283
375
  }
284
376
 
285
- /** One judge request's outcome. */
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
+ */
286
385
  async function judgeOnce(
287
386
  llm: LlmRuntime,
288
387
  route: VerificationRoute,
289
388
  prompt: string,
290
389
  signal: AbortSignal,
390
+ budget: AcceptanceBudget,
291
391
  ): Promise<{ text: string; usage: VerificationUsage }> {
292
392
  const usage = emptyUsage()
293
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)
294
398
  for (let attempt = 0; attempt < VERIFICATION_REQUEST_ATTEMPTS; attempt++) {
295
- if (signal.aborted) throw new VerificationError('aborted', 'the acceptance was cancelled before the judge answer', usage)
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
+ }
296
403
  const timeout = new AbortController()
297
- const timer = setTimeout(() => { timeout.abort(new Error('task-board verification: the judge request timed out')) }, VERIFICATION_CALL_TIMEOUT_MS)
404
+ const timer = setTimeout(() => { timeout.abort(new Error('task-board verification: the judge request timed out')) }, budget.callTimeoutMs)
298
405
  const forward = (): void => { timeout.abort(signal.reason) }
299
406
  signal.addEventListener('abort', forward, { once: true })
300
407
  try {
@@ -333,7 +440,7 @@ async function judgeOnce(
333
440
  finishReason = chunk.type === 'finish' ? chunk.reason : undefined
334
441
  }
335
442
  }
336
- if (signal.aborted) throw new VerificationError('aborted', 'the acceptance was cancelled', usage)
443
+ if (signal.aborted) throw stopped()
337
444
  if (timeout.signal.aborted) throw new VerificationError('timeout', 'the judge request timed out', usage)
338
445
  if (finishReason?.kind === 'error' || finishReason?.kind === 'aborted') {
339
446
  const failure = finishReason.failure
@@ -358,9 +465,9 @@ async function judgeOnce(
358
465
  } catch (error) {
359
466
  lastError = error
360
467
  if (error instanceof VerificationError && (error.kind === 'aborted' || error.kind === 'auth')) throw error
361
- if (signal.aborted) throw new VerificationError('aborted', 'the acceptance was cancelled', usage)
468
+ if (signal.aborted) throw stopped()
362
469
  if (timeout.signal.aborted) {
363
- lastError = new VerificationError('timeout', 'the judge request timed out after ' + Math.round(VERIFICATION_CALL_TIMEOUT_MS / 1000) + 's', usage)
470
+ lastError = new VerificationError('timeout', 'the judge request timed out after ' + Math.round(budget.callTimeoutMs / 1000) + 's', usage)
364
471
  }
365
472
  const parseFailure = lastError instanceof VerificationError && lastError.kind === 'parse'
366
473
  if (attempt + 1 >= VERIFICATION_REQUEST_ATTEMPTS || (!parseFailure && !retryable(lastError))) break
@@ -387,8 +494,8 @@ export interface AcceptanceResult {
387
494
  * Run one acceptance: every criterion is judged twice, the odd round with the
388
495
  * A/B slots swapped, and the per-round scores are mapped back and averaged per
389
496
  * criterion.
390
- * @param input - the judge route, evidence, threshold and cancellation.
391
- * @returns the recorded attempt (quality verdict or anomaly).
497
+ * @param input - the judge route, evidence, threshold, time budget and cancellation.
498
+ * @returns the recorded attempt (quality verdict, anomaly, or budget stop).
392
499
  */
393
500
  export async function runAcceptance(input: {
394
501
  llm: LlmRuntime
@@ -397,6 +504,10 @@ export async function runAcceptance(input: {
397
504
  threshold: number
398
505
  index: number
399
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
400
511
  /** Host-observed workspace changes, rendered as the prompt's reference context. */
401
512
  context?: string
402
513
  /** How many changed files that context shows. */
@@ -405,6 +516,11 @@ export async function runAcceptance(input: {
405
516
  }): Promise<AcceptanceResult> {
406
517
  const now = input.now ?? Date.now
407
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
+ }
408
524
  const usage = emptyUsage()
409
525
  const evidenceSummary: VerificationEvidenceSummary = {
410
526
  chars: input.evidence.trace.length,
@@ -417,6 +533,15 @@ export async function runAcceptance(input: {
417
533
  }
418
534
  const findings: string[] = []
419
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
+ }
420
545
  try {
421
546
  const perCriterion = new Map<string, { criterion: VerificationCriterion; work: number[]; baseline: number[] }>()
422
547
  for (const criterion of CODING_CRITERIA) perCriterion.set(criterion.id, { criterion, work: [], baseline: [] })
@@ -425,8 +550,8 @@ export async function runAcceptance(input: {
425
550
  const swapped = repeat % 2 === 1
426
551
  const candidateA = swapped ? EMPTY_WORK_BASELINE : input.evidence.trace
427
552
  const candidateB = swapped ? input.evidence.trace : EMPTY_WORK_BASELINE
428
- const prompt = buildAcceptancePrompt(input.evidence.problem, candidateA, candidateB, criterion, undefined, input.context)
429
- const answer = await judgeOnce(input.llm, input.route, prompt, input.signal)
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)
430
555
  const scoreA = extractScore(answer.text, 'score_A')
431
556
  const scoreB = extractScore(answer.text, 'score_B')
432
557
  if (answer.usage.usageIncomplete === true) usage.usageIncomplete = true
@@ -437,13 +562,23 @@ export async function runAcceptance(input: {
437
562
  const bucket = perCriterion.get(criterion.id)!
438
563
  bucket.work.push(swapped ? scoreB : scoreA)
439
564
  bucket.baseline.push(swapped ? scoreA : scoreB)
440
- for (const finding of parseFindings(answer.text, criterion)) {
441
- // The prompt rendered the swapped slots; the finding must cite the
442
- // caller's slots, or it would point the agent at the wrong object.
443
- const located: RawFinding = swapped && (finding.evidence === 'A' || finding.evidence === 'B')
444
- ? { ...finding, evidence: finding.evidence === 'A' ? 'B' : 'A' }
445
- : finding
446
- const line = renderFinding(located)
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)
447
582
  if (seenFindings.has(line)) continue
448
583
  seenFindings.add(line)
449
584
  if (findings.length < VERIFICATION_MAX_FINDINGS) findings.push(line)
@@ -459,12 +594,23 @@ export async function runAcceptance(input: {
459
594
  const score = average(criteria.map(criterion => criterion.score))
460
595
  const baseline = average(criteria.map(criterion => criterion.baseline))
461
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
+ })
462
608
  return {
463
609
  attempt: {
464
610
  index: input.index,
465
611
  at: startedAt,
466
- stage: 'quality',
467
- passed,
612
+ stage: validity.valid ? 'quality' : 'invalid',
613
+ passed: validity.valid ? passed : false,
468
614
  score,
469
615
  baseline,
470
616
  criteria,
@@ -474,6 +620,11 @@ export async function runAcceptance(input: {
474
620
  route: input.route,
475
621
  channel: 'explicit-tag',
476
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
+ }),
477
628
  },
478
629
  }
479
630
  } catch (error) {
@@ -484,7 +635,10 @@ export async function runAcceptance(input: {
484
635
  attempt: {
485
636
  index: input.index,
486
637
  at: startedAt,
487
- stage: 'exception',
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',
488
642
  passed: false,
489
643
  score: 0,
490
644
  baseline: 0,
@@ -518,6 +672,18 @@ function mergeUsage(target: VerificationUsage, source: VerificationUsage): Verif
518
672
  }
519
673
  }
520
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
+
521
687
  /** Prefix one anomaly message with its class so the report stays actionable. */
522
688
  function classifyMessage(kind: VerificationErrorKind, message: string): string {
523
689
  const labels: Record<VerificationErrorKind, string> = {
@@ -527,6 +693,7 @@ function classifyMessage(kind: VerificationErrorKind, message: string): string {
527
693
  parse: '验收异常(裁判回答无法解析)',
528
694
  request: '验收异常(裁判请求失败)',
529
695
  aborted: '验收异常(验收被取消)',
696
+ budget: '验收未完成(时间预算耗尽,未产生任何判定,不计入异常额度)',
530
697
  }
531
698
  return labels[kind] + ': ' + message
532
699
  }