@linxin666/dsh-client-ui-task-board 0.4.5 → 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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +20 -12
  3. package/README.zh.md +20 -12
  4. package/lib/client.js +1327 -229
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1909 -348
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
  8. package/lib/types/client/index.d.ts +4 -5
  9. package/lib/types/client/locales.d.ts +65 -0
  10. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  11. package/lib/types/client/schedule-zone.d.ts +50 -0
  12. package/lib/types/core/controller.d.ts +10 -11
  13. package/lib/types/core/poll-cadence.d.ts +31 -0
  14. package/lib/types/core/tasks.d.ts +144 -14
  15. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  16. package/lib/types/core/verification-budget.d.ts +52 -0
  17. package/lib/types/core/verification.d.ts +376 -9
  18. package/lib/types/host/verification-gate.d.ts +24 -0
  19. package/lib/types/host/verification-runner.d.ts +38 -7
  20. package/lib/types/host-ledger.d.ts +82 -7
  21. package/lib/types/host-runner.d.ts +32 -9
  22. package/lib/types/host-service.d.ts +117 -9
  23. package/lib/types/index.d.ts +54 -0
  24. package/lib/types/protocol.d.ts +50 -12
  25. package/package.json +2 -6
  26. package/src/client/TaskBoardSettingsCard.tsx +74 -1
  27. package/src/client/board/NewTaskModal.tsx +151 -20
  28. package/src/client/board/TaskCard.tsx +3 -3
  29. package/src/client/board/TaskDetail.tsx +219 -18
  30. package/src/client/board/VerificationReport.tsx +132 -13
  31. package/src/client/board/status-key.ts +3 -0
  32. package/src/client/index.ts +43 -50
  33. package/src/client/locales.ts +137 -4
  34. package/src/client/plugin-card-seat.ts +7 -0
  35. package/src/client/schedule-zone.ts +100 -2
  36. package/src/core/controller.ts +12 -8
  37. package/src/core/poll-cadence.ts +41 -0
  38. package/src/core/store.ts +64 -15
  39. package/src/core/tasks.ts +193 -39
  40. package/src/core/use-cases/task-create.ts +30 -9
  41. package/src/core/use-cases/task-schedule.ts +190 -25
  42. package/src/core/use-cases/task-update.ts +3 -3
  43. package/src/core/verification-budget.ts +115 -0
  44. package/src/core/verification.ts +785 -17
  45. package/src/host/agent-tools.ts +106 -21
  46. package/src/host/verification-gate.ts +172 -16
  47. package/src/host/verification-runner.ts +207 -40
  48. package/src/host-ledger.ts +328 -42
  49. package/src/host-runner.ts +52 -19
  50. package/src/host-service.ts +299 -36
  51. package/src/index.ts +101 -5
  52. package/src/protocol.ts +132 -28
  53. package/lib/invariant.js +0 -6
  54. package/lib/types/client/board-card-seat.d.ts +0 -34
  55. package/lib/types/invariant.d.ts +0 -3
  56. package/src/client/board-card-seat.ts +0 -102
  57. package/src/invariant.ts +0 -4
@@ -9,13 +9,13 @@ import type { BoardController, ControllerSnapshot } from '../../core/controller.
9
9
  import type { TaskBoardExtensionActionRequest, TaskBoardExtensionDispatch } from '../../core/extension.ts'
10
10
  import { useTaskBoardSeats } from '../seats.tsx'
11
11
  import { isValidCron } from '../../core/schedule.ts'
12
- import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
12
+ import { MANUAL_STATUSES, TASK_PERMISSIONS, canMoveTask, hasOpenExecution, tagTone, type ExecutionRecord, type ScheduleMode, type TaskPermission, type TaskRecord } from '../../core/tasks.ts'
13
13
  import { canEditTaskContent } from '../../core/use-cases/task-update.ts'
14
14
  import { requiresPermissionConfirmation } from '../../core/handover.ts'
15
15
  import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../../core/subtask.ts'
16
16
  import { t, type TaskBoardKey } from '../locales.ts'
17
17
  import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
18
- import { nextRunLabel, zoneChoices } from '../schedule-zone.ts'
18
+ import { budgetLabel, defaultZonedInputValue, endedLabel, nextRunLabel, parseZonedInput, runCapOption, skippedLabel, zonedInputValue, zoneChoices } from '../schedule-zone.ts'
19
19
  import css from '../board.module.css'
20
20
  import { ConfirmDialog } from './ConfirmDialog.tsx'
21
21
  import { EditTaskModal, EditTagsModal } from './EditTaskModal.tsx'
@@ -39,8 +39,12 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
39
39
  /** One execution-history row. */
40
40
  function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
41
41
  const result = execution.result
42
+ // An external record is one the Host never observed running: no session, no
43
+ // spinner, and a named outside agent instead of the initiating session. The
44
+ // data attribute keeps the distinction machine-readable too.
45
+ const external = execution.external === true
42
46
  return (
43
- <li className={css.executionRow} data-result={result}>
47
+ <li className={css.executionRow} data-result={result} data-external={external ? 'true' : undefined}>
44
48
  <span className={css.executionBadge} data-result={result}>
45
49
  {result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
46
50
  </span>
@@ -48,7 +52,12 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
48
52
  {t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
49
53
  {execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
50
54
  </span>
51
- {execution.initiatedBy !== undefined && (
55
+ {external && execution.initiatedBy !== undefined && (
56
+ <span className={css.executionTimes} title={execution.initiatedBy}>
57
+ {t('detail.execution.external', { agent: execution.initiatedBy })}
58
+ </span>
59
+ )}
60
+ {!external && execution.initiatedBy !== undefined && (
52
61
  <span className={css.executionTimes} title={execution.initiatedBy}>
53
62
  {t('detail.execution.initiator', { session: execution.initiatedBy })}
54
63
  </span>
@@ -73,7 +82,7 @@ function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRec
73
82
  }
74
83
 
75
84
  /** The execution-target editor: workspace / mode / permission pickers. */
76
- function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable }: {
85
+ function ExecutionSettingsSection({ controller, task, pending, executionOptions, teamRunAvailable, goalRunEnabled }: {
77
86
  controller: BoardController
78
87
  task: TaskRecord
79
88
  pending: boolean
@@ -81,6 +90,12 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
81
90
  executionOptions: ControllerSnapshot['executionOptions']
82
91
  /** Whether this deployment serves the Agent Teams service. */
83
92
  teamRunAvailable: boolean
93
+ /**
94
+ * Live GLOBAL native-/goal switch. It only decides whether the card's own
95
+ * preference takes effect: the option stays operable while it is off, the
96
+ * stored value is never rewritten by it, and the explanation below says so.
97
+ */
98
+ goalRunEnabled: boolean
84
99
  }) {
85
100
  const options = executionOptions
86
101
  const workspaceId = task.workspaceId ?? ''
@@ -173,13 +188,17 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
173
188
  <label className={css.scheduleToggle}>
174
189
  <input
175
190
  type="checkbox"
176
- checked={task.goalRun !== false}
191
+ checked={task.goalRun === true}
192
+ // Always operable (only a pending execution locks it): the global
193
+ // switch decides whether the preference takes effect, never whether
194
+ // the user may state it, and it never rewrites what is stored here.
177
195
  disabled={pending}
178
196
  onChange={event => { controller.updateTask(task.id, { goalRun: event.target.checked }) }}
179
197
  />
180
198
  <span>{t('exec.goalRun')}</span>
181
199
  </label>
182
200
  <p className={css.detailText}>{t('exec.goalRunHint')}</p>
201
+ {!goalRunEnabled && <p className={css.detailText}>{t('settings.goalRunGlobalDisabledTaskOption')}</p>}
183
202
  <label className={css.scheduleToggle}>
184
203
  <input
185
204
  type="checkbox"
@@ -204,31 +223,49 @@ function ExecutionSettingsSection({ controller, task, pending, executionOptions,
204
223
  )
205
224
  }
206
225
 
207
- /** The scheduled-runs editor: enable toggle, cron input + presets, zone, next-run info. */
226
+ /**
227
+ * The scheduled-runs editor. A one-shot has one planned instant; a recurring
228
+ * rule has a cron expression, an optional run cap, and a zone. The editor
229
+ * shows the rule's own state (next target, run counter, stop/skip record) and
230
+ * can cancel a future plan.
231
+ */
208
232
  function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
209
233
  const schedule = task.schedule
234
+ const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
235
+ // The rule's own zone; '' means "follow the Host zone" (no stored zone).
236
+ const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
237
+ const [mode, setMode] = useState<ScheduleMode>(schedule?.mode ?? 'cron')
210
238
  const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
239
+ const [atText, setAtText] = useState<string>(() => schedule?.at === undefined
240
+ ? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
241
+ : zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
242
+ const [maxRunsChoice, setMaxRunsChoice] = useState<string>(() => runCapOption(schedule?.maxRuns))
243
+ const [maxRunsText, setMaxRunsText] = useState<string>(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
211
244
  const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
212
245
  const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
213
246
  const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
214
247
  const [error, setError] = useState<string | undefined>(undefined)
215
- const hostTimeZone = controller.getSnapshot().host?.scheduler.timeZone
216
- // The rule's own zone; '' means "follow the Host zone" (no stored zone).
217
- const [zone, setZone] = useState<string>(schedule?.timeZone ?? '')
218
248
  // One clock reading per render pass, so the absolute and relative halves of
219
249
  // the label always describe the same instant.
220
250
  const now = Date.now()
221
251
 
222
252
  // Keep the editor in sync when the task record changes underneath (the
223
- // schedule rolls forward as runs trigger).
253
+ // schedule rolls forward as runs trigger, ends at its budget, or is edited
254
+ // elsewhere).
224
255
  useEffect(() => {
256
+ setMode(schedule?.mode ?? 'cron')
225
257
  setCron(schedule?.cron ?? '0 9 * * *')
258
+ setZone(schedule?.timeZone ?? '')
259
+ setAtText(schedule?.at === undefined
260
+ ? defaultZonedInputValue(Date.now(), schedule?.timeZone ?? hostTimeZone)
261
+ : zonedInputValue(schedule.at, schedule?.timeZone ?? hostTimeZone))
262
+ setMaxRunsChoice(runCapOption(schedule?.maxRuns))
263
+ setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
226
264
  setEnabled(schedule?.enabled ?? false)
227
265
  setNextRunAt(schedule?.nextRunAt)
228
266
  setLastTriggeredAt(schedule?.lastTriggeredAt)
229
- setZone(schedule?.timeZone ?? '')
230
267
  setError(undefined)
231
- }, [task.id, schedule?.enabled, schedule?.cron, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt])
268
+ }, [task.id, hostTimeZone, schedule?.mode, schedule?.enabled, schedule?.cron, schedule?.at, schedule?.timeZone, schedule?.nextRunAt, schedule?.lastTriggeredAt, schedule?.maxRuns])
232
269
 
233
270
  /** Validate + persist the current cron text (Enter or blur). */
234
271
  const saveCron = (value: string): void => {
@@ -239,21 +276,101 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
239
276
  return
240
277
  }
241
278
  setError(undefined)
242
- controller.setSchedule(task.id, { cron: trimmed })
279
+ controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
280
+ }
281
+
282
+ /** Validate + persist the one-shot's planned instant. */
283
+ const saveAt = (value: string): void => {
284
+ setAtText(value)
285
+ const at = parseZonedInput(value, effectiveZone)
286
+ if (at === undefined || at <= now) {
287
+ setError(t('detail.schedule.atInvalid'))
288
+ return
289
+ }
290
+ setError(undefined)
291
+ controller.setSchedule(task.id, { mode: 'once', at })
292
+ }
293
+
294
+ /** Switch the kind of plan, persisting a usable target for the new kind. */
295
+ const changeMode = (next: ScheduleMode): void => {
296
+ if (next === 'once') {
297
+ const at = parseZonedInput(atText, effectiveZone)
298
+ if (at === undefined || at <= now) {
299
+ setError(t('detail.schedule.atInvalid'))
300
+ return
301
+ }
302
+ setMode('once')
303
+ setError(undefined)
304
+ controller.setSchedule(task.id, { mode: 'once', at })
305
+ return
306
+ }
307
+ const trimmed = cron.trim()
308
+ if (trimmed === '' || !isValidCron(trimmed)) {
309
+ setError(t('detail.schedule.invalid'))
310
+ return
311
+ }
312
+ setMode('cron')
313
+ setError(undefined)
314
+ controller.setSchedule(task.id, { mode: 'cron', cron: trimmed })
243
315
  }
244
316
 
245
317
  /**
246
318
  * Change the rule's zone. `''` clears the stored zone, which is how a user
247
- * returns a rule to following the Host zone.
319
+ * returns a rule to following the Host zone. A one-shot's instant is
320
+ * absolute, so only its rendered wall clock follows the new zone; a cron
321
+ * rule's wall clock itself moves with the zone.
248
322
  */
249
323
  const changeZone = (value: string): void => {
250
324
  setZone(value)
251
325
  setError(undefined)
326
+ if (mode === 'once' && schedule?.at !== undefined) {
327
+ setAtText(zonedInputValue(schedule.at, value === '' ? hostTimeZone : value))
328
+ controller.setSchedule(task.id, { mode: 'once', timeZone: value === '' ? null : value })
329
+ return
330
+ }
252
331
  controller.setSchedule(task.id, { timeZone: value === '' ? null : value })
253
332
  }
254
333
 
255
- /** Arm/disarm the schedule (arming first persists the edited cron). */
334
+ /** Change the recurring rule's run cap (unlimited, a preset, or a custom value). */
335
+ const applyMaxRuns = (choice: string): void => {
336
+ setMaxRunsChoice(choice)
337
+ setError(undefined)
338
+ if (choice === 'unlimited') {
339
+ controller.setSchedule(task.id, { maxRuns: null })
340
+ return
341
+ }
342
+ if (choice === 'custom') {
343
+ setMaxRunsText(schedule?.maxRuns === undefined ? '' : String(schedule.maxRuns))
344
+ return
345
+ }
346
+ controller.setSchedule(task.id, { maxRuns: Number(choice) })
347
+ }
348
+
349
+ /** Persist a custom run cap; a non-positive or fractional value is refused. */
350
+ const saveCustomMaxRuns = (value: string): void => {
351
+ setMaxRunsText(value)
352
+ const parsed = Number(value.trim())
353
+ if (value.trim() === '' || !Number.isInteger(parsed) || parsed < 1) {
354
+ setError(t('detail.schedule.maxRunsInvalid'))
355
+ return
356
+ }
357
+ setError(undefined)
358
+ controller.setSchedule(task.id, { maxRuns: parsed })
359
+ }
360
+
361
+ /** Arm/disarm the schedule (arming first persists the edited plan). */
256
362
  const toggleEnabled = (next: boolean): void => {
363
+ if (next && mode === 'once') {
364
+ const at = parseZonedInput(atText, effectiveZone)
365
+ if (at === undefined || at <= now) {
366
+ setError(t('detail.schedule.atInvalid'))
367
+ return
368
+ }
369
+ setError(undefined)
370
+ const submitted = controller.setSchedule(task.id, { mode: 'once', at, enabled: true })
371
+ if (submitted && !controller.isHostBacked()) setEnabled(true)
372
+ return
373
+ }
257
374
  const trimmed = cron.trim()
258
375
  if (next && (trimmed === '' || !isValidCron(trimmed))) {
259
376
  setError(t('detail.schedule.invalid'))
@@ -267,11 +384,19 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
267
384
  if (submitted && !controller.isHostBacked()) setEnabled(next)
268
385
  }
269
386
 
387
+ /** Drop every future trigger without touching the card's execution history. */
388
+ const cancelSchedule = (): void => {
389
+ setError(undefined)
390
+ const submitted = controller.setSchedule(task.id, { enabled: false })
391
+ if (submitted && !controller.isHostBacked()) setEnabled(false)
392
+ }
393
+
270
394
  const applyPreset = (preset: string): void => {
271
395
  if (preset === '') return
272
396
  setCron(preset)
397
+ setMode('cron')
273
398
  setError(undefined)
274
- controller.setSchedule(task.id, { cron: preset })
399
+ controller.setSchedule(task.id, { mode: 'cron', cron: preset })
275
400
  }
276
401
 
277
402
  // The zone the rule's wall clock is actually read in: its own when set,
@@ -286,6 +411,11 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
286
411
  // The rule's own stored zone rides the list even when this runtime's
287
412
  // inventory does not carry it, so opening the editor cannot rewrite it.
288
413
  const zones = zoneChoices(hostTimeZone, schedule?.timeZone)
414
+ // The ledger's own view of the budget and stop record: derived here rather
415
+ // than tracked in local state, so the display cannot drift from the Host.
416
+ const budget = schedule === undefined ? undefined : budgetLabel(schedule)
417
+ const ended = schedule === undefined ? undefined : endedLabel(schedule)
418
+ const skipped = schedule === undefined ? undefined : skippedLabel(schedule)
289
419
 
290
420
  return (
291
421
  <section className={css.detailSection}>
@@ -299,6 +429,35 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
299
429
  />
300
430
  <span>{t('detail.schedule.enable')}</span>
301
431
  </label>
432
+ <label className={css.scheduleZone}>
433
+ <span>{t('detail.schedule.mode')}</span>
434
+ <select
435
+ className={css.schedulePreset}
436
+ value={mode}
437
+ disabled={pending}
438
+ aria-label={t('detail.schedule.mode')}
439
+ onChange={event => { changeMode(event.target.value as ScheduleMode) }}
440
+ >
441
+ <option value="cron">{t('detail.schedule.mode.cron')}</option>
442
+ <option value="once">{t('detail.schedule.mode.once')}</option>
443
+ </select>
444
+ </label>
445
+ {mode === 'once' ? (
446
+ <label className={css.scheduleZone}>
447
+ <span>{t('detail.schedule.at')}</span>
448
+ <input
449
+ className={css.input}
450
+ type="datetime-local"
451
+ value={atText}
452
+ disabled={pending}
453
+ aria-label={t('detail.schedule.at')}
454
+ title={t('detail.schedule.atHint')}
455
+ onChange={event => { setAtText(event.target.value); setError(undefined) }}
456
+ onBlur={() => { saveAt(atText) }}
457
+ />
458
+ </label>
459
+ ) : (
460
+ <>
302
461
  <div className={css.scheduleRow}>
303
462
  <input
304
463
  className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
@@ -324,6 +483,39 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
324
483
  ))}
325
484
  </select>
326
485
  </div>
486
+ <label className={css.scheduleZone}>
487
+ <span>{t('detail.schedule.maxRuns')}</span>
488
+ <select
489
+ className={css.schedulePreset}
490
+ value={maxRunsChoice}
491
+ disabled={pending}
492
+ aria-label={t('detail.schedule.maxRuns')}
493
+ onChange={event => { applyMaxRuns(event.target.value) }}
494
+ >
495
+ <option value="unlimited">{t('detail.schedule.maxRunsUnlimited')}</option>
496
+ <option value="1">{t('detail.schedule.maxRunsValue', { count: '1' })}</option>
497
+ <option value="2">{t('detail.schedule.maxRunsValue', { count: '2' })}</option>
498
+ <option value="custom">{t('detail.schedule.maxRunsCustom')}</option>
499
+ </select>
500
+ </label>
501
+ {maxRunsChoice === 'custom' && (
502
+ <label className={css.scheduleZone}>
503
+ <span>{t('detail.schedule.maxRuns')}</span>
504
+ <input
505
+ className={css.input}
506
+ type="number"
507
+ min={1}
508
+ step={1}
509
+ value={maxRunsText}
510
+ disabled={pending}
511
+ aria-label={t('detail.schedule.maxRuns')}
512
+ onChange={event => { setMaxRunsText(event.target.value) }}
513
+ onBlur={() => { saveCustomMaxRuns(maxRunsText) }}
514
+ />
515
+ </label>
516
+ )}
517
+ </>
518
+ )}
327
519
  {/* A list is used instead of a free-text field so only zones the Host can
328
520
  also resolve are offered; the Host zone entry clears the stored value. */}
329
521
  <label className={css.scheduleZone}>
@@ -333,7 +525,7 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
333
525
  value={zone}
334
526
  disabled={pending}
335
527
  aria-label={t('detail.schedule.timeZone')}
336
- title={t('detail.schedule.timeZoneHint')}
528
+ title={mode === 'once' ? t('detail.schedule.zoneOnceHint') : t('detail.schedule.timeZoneHint')}
337
529
  onChange={event => { changeZone(event.target.value) }}
338
530
  >
339
531
  {zones.map(choice => (
@@ -346,6 +538,14 @@ function ScheduleSection({ controller, task, pending }: { controller: BoardContr
346
538
  {t('detail.schedule.nextRun')} {nextLabel}
347
539
  {' · '}{t('detail.schedule.lastTriggered')} {lastLabel}
348
540
  </p>
541
+ {budget !== undefined && <p className={css.scheduleMeta}>{budget}</p>}
542
+ {ended !== undefined && <p className={css.scheduleMeta}>{ended}</p>}
543
+ {ended === undefined && skipped !== undefined && <p className={css.scheduleMeta}>{skipped}</p>}
544
+ {enabled && (
545
+ <button type="button" className={css.ghostButton} disabled={pending} onClick={cancelSchedule}>
546
+ {t('detail.schedule.cancel')}
547
+ </button>
548
+ )}
349
549
  </section>
350
550
  )
351
551
  }
@@ -611,6 +811,7 @@ export function TaskDetail({ controller, task, phase = 'open' }: { controller: B
611
811
  pending={pending}
612
812
  executionOptions={snapshot.executionOptions}
613
813
  teamRunAvailable={snapshot.host?.teamRunAvailable === true}
814
+ goalRunEnabled={snapshot.host?.goalRunEnabled === true}
614
815
  />
615
816
  <ScheduleSection controller={controller} task={current} pending={pending} />
616
817
  </>
@@ -13,13 +13,18 @@
13
13
  * execution row's existing markup instead of extending that enum.
14
14
  */
15
15
  import {
16
+ MAX_INVALID_ATTEMPTS,
16
17
  MAX_QUALITY_ATTEMPTS,
17
18
  exceptionAttempts,
19
+ invalidAttempts,
18
20
  qualityAttempts,
19
21
  verificationPhase,
20
22
  verificationTotals,
23
+ type AcceptanceAuditRecord,
24
+ type AcceptanceCleanupRecord,
21
25
  type ExecutionVerification,
22
26
  type VerificationAttempt,
27
+ type VerificationCriterionFinding,
23
28
  } from '../../core/verification.ts'
24
29
  import { t } from '../locales.ts'
25
30
  import type { TaskBoardKey } from '../locales.ts'
@@ -46,24 +51,67 @@ const PHASE_STATUS_KEY: Record<ReturnType<typeof verificationPhase>, TaskBoardKe
46
51
  repairing: 'verify.status.failed',
47
52
  passed: 'verify.status.passed',
48
53
  failed: 'verify.status.failed',
54
+ invalid: 'verify.status.invalidHeld',
49
55
  }
50
56
 
51
- /** One recorded attempt: a quality verdict or an acceptance anomaly. */
52
- function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
53
- const exception = attempt.stage === 'exception'
57
+ /** Evidence location → locale key, for one structured finding's citation. */
58
+ const LOCATION_KEY: Record<VerificationCriterionFinding['location'], TaskBoardKey> = {
59
+ task: 'verify.location.task',
60
+ trajectory: 'verify.location.trajectory',
61
+ workspace: 'verify.location.workspace',
62
+ baseline: 'verify.location.baseline',
63
+ unknown: 'verify.location.unknown',
64
+ }
65
+
66
+ /** Invalid reason → locale key. */
67
+ function invalidReasonKey(attempt: VerificationAttempt): TaskBoardKey {
68
+ switch (attempt.invalidReason) {
69
+ case 'missing-finding': return 'verify.invalidReason.missing-finding'
70
+ case 'unlocatable-quote': return 'verify.invalidReason.unlocatable-quote'
71
+ case 'baseline-finding': return 'verify.invalidReason.baseline-finding'
72
+ case 'vacuous-finding': return 'verify.invalidReason.vacuous-finding'
73
+ case 'insufficient-evidence': return 'verify.invalidReason.insufficient-evidence'
74
+ default: return 'verify.invalidReason.unknown'
75
+ }
76
+ }
77
+
78
+ /** Audit stage → locale key (the lightweight credential after cleanup). */
79
+ const AUDIT_STAGE_KEY: Record<AcceptanceAuditRecord['stage'], TaskBoardKey> = {
80
+ quality: 'verify.cleanup.stage.quality',
81
+ invalid: 'verify.cleanup.stage.invalid',
82
+ exception: 'verify.cleanup.stage.exception',
83
+ budget: 'verify.cleanup.stage.budget',
84
+ }
85
+
86
+ /** One recorded attempt: a quality verdict, an invalid veto, an anomaly, or a budget stop. */
87
+ function AttemptRow({ attempt, detailRemoved }: { attempt: VerificationAttempt, detailRemoved: boolean }) {
88
+ // Everything that is not a quality verdict renders as an anomaly row: a
89
+ // budget stop carries no score either, and an invalid acceptance carries a
90
+ // score the board REFUSED to book, so showing either as a scored failure
91
+ // would read as a judgement the board never made. The invalid row gets its
92
+ // own badge and reason line, because it is a distinct outcome.
93
+ const invalid = attempt.stage === 'invalid'
94
+ const exception = attempt.stage !== 'quality' && !invalid
54
95
  const result = exception ? 'failed' : attempt.passed ? 'succeeded' : 'failed'
55
96
  return (
56
97
  <li className={css.executionRow} data-result={result}>
57
98
  <span className={css.executionBadge} data-result={result}>
58
- {exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
99
+ {invalid ? t('verify.status.invalid') : exception ? t('verify.status.exception') : attempt.passed ? t('verify.status.passed') : t('verify.status.failed')}
59
100
  </span>
60
101
  <span className={css.executionTimes}>
61
- {exception
62
- ? t('verify.attemptException', { index: String(attempt.index) })
63
- : t('verify.attempt', { index: String(attempt.index) })}
102
+ {invalid
103
+ ? t('verify.attemptInvalid', { index: String(attempt.index) })
104
+ : exception
105
+ ? t('verify.attemptException', { index: String(attempt.index) })
106
+ : t('verify.attempt', { index: String(attempt.index) })}
64
107
  {' · '}{routeLabel(attempt)}
65
- {!exception && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
108
+ {!exception && !invalid && ' · ' + t('verify.rounds', { rounds: String(attempt.rounds) })}
66
109
  </span>
110
+ {invalid && (
111
+ <span className={css.executionTimes}>
112
+ {t('verify.invalidDetail', { reason: t(invalidReasonKey(attempt)) })}
113
+ </span>
114
+ )}
67
115
  {attempt.error !== undefined && <span className={css.executionTimes}>{attempt.error}</span>}
68
116
  {!exception && (
69
117
  <>
@@ -88,6 +136,25 @@ function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
88
136
  </ul>
89
137
  </>
90
138
  )}
139
+ {(attempt.criterionFindings ?? []).length > 0 && (
140
+ <>
141
+ <span className={css.executionTimes}>{t('verify.criterionFindings')}</span>
142
+ <ul className={css.executionList}>
143
+ {(attempt.criterionFindings ?? []).map((finding, index) => (
144
+ <li key={findingKey(attempt, index)} className={css.executionTimes}>
145
+ {t('verify.criterionFindingFull', {
146
+ criterion: finding.criterionId,
147
+ location: t(LOCATION_KEY[finding.location]),
148
+ quote: finding.quote,
149
+ requirement: finding.requirement,
150
+ observation: finding.observation,
151
+ gap: finding.gap,
152
+ })}
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ </>
157
+ )}
91
158
  {attempt.findings.length > 0 && (
92
159
  <>
93
160
  <span className={css.executionTimes}>{t('verify.findings')}</span>
@@ -98,8 +165,12 @@ function AttemptRow({ attempt }: { attempt: VerificationAttempt }) {
98
165
  </ul>
99
166
  </>
100
167
  )}
101
- {!exception && attempt.findings.length === 0 && (
102
- <span className={css.executionTimes}>{t('verify.noFindings')}</span>
168
+ {!exception && !invalid && attempt.findings.length === 0 && (
169
+ // A cleaned attempt has no findings because the acceptance removed them,
170
+ // not because the judge gave none: saying "the judge reported no
171
+ // finding" here would invite exactly the misreading the cleanup rule
172
+ // exists to avoid.
173
+ <span className={css.executionTimes}>{t(detailRemoved ? 'verify.detailRemoved' : 'verify.noFindings')}</span>
103
174
  )}
104
175
  <span className={css.executionTimes}>
105
176
  {t('verify.evidence', {
@@ -145,16 +216,20 @@ export function VerificationReport({ verification }: { verification: ExecutionVe
145
216
  ? 'verify.applicability.skipped'
146
217
  : verification.applicability === 'goal-unavailable'
147
218
  ? 'verify.applicability.goalUnavailable'
148
- : verification.applicability === 'team-member' ? 'verify.applicability.teamMember' : undefined
219
+ : verification.applicability === 'goal-disabled'
220
+ ? 'verify.applicability.goalDisabled'
221
+ : verification.applicability === 'team-member' ? 'verify.applicability.teamMember' : undefined
149
222
  return (
150
223
  <div className={css.executionTimes}>
151
224
  <span className={css.executionBadge} data-result={phase === 'passed' ? 'succeeded' : phase === 'failed' ? 'failed' : undefined}>
152
225
  {t('verify.title')} · {t(PHASE_STATUS_KEY[phase])}
153
226
  </span>
154
227
  <span className={css.executionTimes}>
155
- {t('verify.counts', {
228
+ {t('verify.invalidCounts', {
156
229
  quality: String(qualityAttempts(verification).length),
157
230
  max: String(MAX_QUALITY_ATTEMPTS),
231
+ invalid: String(invalidAttempts(verification).length),
232
+ maxInvalid: String(MAX_INVALID_ATTEMPTS),
158
233
  exceptions: String(exceptionAttempts(verification).length),
159
234
  })}
160
235
  </span>
@@ -194,10 +269,54 @@ export function VerificationReport({ verification }: { verification: ExecutionVe
194
269
  {verification.attempts.length > 0 && (
195
270
  <ul className={css.executionList}>
196
271
  {[...verification.attempts].reverse().map(attempt => (
197
- <AttemptRow key={attempt.at.toString(36) + '-' + attempt.stage + '-' + String(attempt.index)} attempt={attempt} />
272
+ <AttemptRow
273
+ key={attempt.at.toString(36) + '-' + attempt.stage + '-' + String(attempt.index)}
274
+ attempt={attempt}
275
+ detailRemoved={verification.cleanup?.state === 'cleaned'}
276
+ />
198
277
  ))}
199
278
  </ul>
200
279
  )}
280
+ {verification.cleanup !== undefined && <CleanupNotice cleanup={verification.cleanup} />}
201
281
  </div>
202
282
  )
203
283
  }
284
+
285
+ /**
286
+ * Retention notice of one execution's acceptance detail.
287
+ *
288
+ * It exists so a reader never mistakes a cleaned-up report for a judge that
289
+ * produced no evidence: the notice says the detail WAS produced and was removed
290
+ * by the automatic cleanup, and lists the lightweight credential that survives.
291
+ * A pending or failed cleanup is reported the same way — never as a missing
292
+ * verdict and never as a revoked pass.
293
+ */
294
+ function CleanupNotice({ cleanup }: { cleanup: AcceptanceCleanupRecord }) {
295
+ const key: TaskBoardKey = cleanup.state === 'cleaned'
296
+ ? 'verify.cleanup.cleaned'
297
+ : cleanup.state === 'failed' ? 'verify.cleanup.failed' : 'verify.cleanup.pending'
298
+ return (
299
+ <>
300
+ <span className={css.executionTimes}>
301
+ {cleanup.state === 'failed'
302
+ ? t(key, { attempts: String(cleanup.attempts), error: cleanup.lastError ?? '' })
303
+ : t(key)}
304
+ </span>
305
+ {cleanup.audit.length > 0 && (
306
+ <ul className={css.executionList}>
307
+ {cleanup.audit.map((record, index) => (
308
+ <li key={record.at.toString(36) + '-' + record.stage + '-' + String(index)} className={css.executionTimes}>
309
+ {t('verify.cleanup.audit', {
310
+ index: String(index + 1),
311
+ stage: t(AUDIT_STAGE_KEY[record.stage]),
312
+ score: percent(record.score),
313
+ hash: record.evidenceHash.slice(0, 12),
314
+ })}
315
+ {record.findingSummary === undefined ? '' : ' · ' + record.findingSummary}
316
+ </li>
317
+ ))}
318
+ </ul>
319
+ )}
320
+ </>
321
+ )
322
+ }
@@ -23,6 +23,9 @@ export const VERIFICATION_PHASE_KEY: Record<VerificationPhase, TaskBoardKey> = {
23
23
  repairing: 'running.repairing',
24
24
  passed: 'running.verificationPassed',
25
25
  failed: 'running.verificationFailed',
26
+ // Held for a human: the judge produced no usable veto, so this is NOT the
27
+ // quality-failure label.
28
+ invalid: 'running.verificationInvalid',
26
29
  }
27
30
 
28
31
  /**