iterate-plugin 2.12.2 → 3.2.1

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 (41) hide show
  1. package/README.md +28 -12
  2. package/README.zh-CN.md +2 -1
  3. package/dist/approval-gate.js +16 -2
  4. package/dist/config-loader.js +5 -0
  5. package/dist/git-scope.js +61 -7
  6. package/dist/index.js +16 -6
  7. package/dist/session-hooks.js +36 -11
  8. package/dist/skill-prompt.js +3 -0
  9. package/dist/tools/decision-log.js +10 -1
  10. package/dist/tools/defense-events.js +260 -0
  11. package/dist/tools/defense-store.js +97 -0
  12. package/dist/tools/experience-bank.js +248 -0
  13. package/dist/tools/experience-store.js +132 -0
  14. package/dist/tools/quality-gate.js +180 -0
  15. package/dist/tools/quality-store.js +174 -0
  16. package/lib/client.js +662 -103
  17. package/lib/parse.js +93 -0
  18. package/package.json +7 -6
  19. package/src/approval-gate.ts +14 -2
  20. package/src/client/index.ts +542 -49
  21. package/src/config-loader.ts +5 -0
  22. package/src/git-scope.ts +48 -7
  23. package/src/index.ts +16 -6
  24. package/src/session-hooks.ts +33 -11
  25. package/src/skill-prompt.ts +3 -0
  26. package/src/tools/checkpoint.ts +1 -1
  27. package/src/tools/config.ts +1 -1
  28. package/src/tools/decision-log.ts +11 -2
  29. package/src/tools/defense-events.ts +295 -0
  30. package/src/tools/defense-store.ts +113 -0
  31. package/src/tools/experience-bank.ts +264 -0
  32. package/src/tools/experience-store.ts +160 -0
  33. package/src/tools/fix.ts +1 -1
  34. package/src/tools/history.ts +1 -1
  35. package/src/tools/prune.ts +1 -1
  36. package/src/tools/quality-gate.ts +193 -0
  37. package/src/tools/quality-store.ts +199 -0
  38. package/src/tools/review.ts +1 -1
  39. package/src/tools/transcript.ts +1 -1
  40. package/src/tools/triage.ts +1 -1
  41. package/src/types.ts +118 -0
@@ -73,6 +73,10 @@ import {
73
73
  countSessionImages,
74
74
  scanSessionForTranscript,
75
75
  normalizeTranscript,
76
+ filterLiveEntries,
77
+ filterTimelineEntries,
78
+ serializeObservatoryExport,
79
+ latestPhase,
76
80
  SEVERITY_LABEL,
77
81
  SEVERITY_COLOR,
78
82
  } from '../../lib/parse.js'
@@ -370,6 +374,11 @@ const ITERATE_CSS = `
370
374
  .iterate-filter-search { padding: 4px 8px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 11px; min-width: 140px; }
371
375
  .iterate-filter-search::placeholder { color: var(--dsw-alias-label-secondary); }
372
376
  .iterate-filter-count { margin-left: auto; white-space: nowrap; }
377
+ .iterate-filter-chip { padding: 3px 9px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 11px; cursor: pointer; }
378
+ .iterate-filter-chip:hover { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
379
+ .iterate-filter-chip[data-active] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); font-weight: 600; }
380
+ .iterate-filter-clear { padding: 3px 9px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 45%, transparent); color: var(--dsw-alias-state-warn-primary); background: transparent; font-size: 11px; cursor: pointer; }
381
+ .iterate-filter-clear:hover { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, transparent); }
373
382
  .iterate-batch { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); }
374
383
  .iterate-batch-label { margin-right: 2px; }
375
384
  .iterate-batch-btn { padding: 3px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 11px; cursor: pointer; }
@@ -422,6 +431,46 @@ const ITERATE_CSS = `
422
431
  /* Interruption / resume + attachment chips (dashboard) */
423
432
  .iterate-chip-resume { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 28%, transparent); }
424
433
  .iterate-chip-images { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 28%, transparent); }
434
+ /* Live workflow-phase chip (dashboard) */
435
+ .iterate-chip-phase { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-label-primary); background: color-mix(in srgb, var(--dsw-alias-label-secondary) 14%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-label-secondary) 26%, transparent); }
436
+ .iterate-chip-phase[data-live] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
437
+
438
+ /* v3.0: task_mode indicator chip */
439
+ .iterate-chip-taskmode { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; }
440
+ .iterate-chip-taskmode[data-mode="code"] { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 28%, transparent); }
441
+ .iterate-chip-taskmode[data-mode="iterate"] { color: #B45309; background: color-mix(in srgb, #B45309 12%, transparent); border: 1px solid color-mix(in srgb, #B45309 28%, transparent); }
442
+
443
+ /* v3.0: Quality gate status */
444
+ .iterate-gate { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; }
445
+ .iterate-gate[data-status="pass"] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
446
+ .iterate-gate[data-status="fail"] { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 28%, transparent); }
447
+ .iterate-gate[data-status="pending"] { color: var(--dsw-alias-label-secondary); background: color-mix(in srgb, var(--dsw-alias-label-secondary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-label-secondary) 28%, transparent); }
448
+
449
+ /* v3.0: Experience bank hit highlight */
450
+ .iterate-exp-hit { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); font-size: 11px; color: var(--dsw-alias-state-success-primary); }
451
+ .iterate-exp-hit b { font-weight: 600; }
452
+
453
+ /* v3.0: Defense event type badges */
454
+ .iterate-defense-badge { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; white-space: nowrap; }
455
+ .iterate-defense-badge[data-type="precondition_failed"] { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 28%, transparent); }
456
+ .iterate-defense-badge[data-type="rollback"] { color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 28%, transparent); }
457
+ .iterate-defense-badge[data-type="invariant_violated"] { color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 28%, transparent); }
458
+ .iterate-defense-badge[data-type="assumption_falsified"] { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 28%, transparent); }
459
+
460
+ /* v3.0: Command buttons */
461
+ .iterate-cmd { padding: 4px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 11px; cursor: pointer; font-weight: 500; }
462
+ .iterate-cmd:hover { background: var(--dsw-alias-bg-layer-2); }
463
+ .iterate-cmd[data-primary] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
464
+ .iterate-cmd[data-danger] { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); color: var(--dsw-alias-state-error-primary); }
465
+ .iterate-cmd:disabled { opacity: 0.5; cursor: not-allowed; }
466
+
467
+ /* v3.0: Dimension score bar */
468
+ .iterate-dim-score { display: flex; align-items: center; gap: 6px; font-size: 11px; }
469
+ .iterate-dim-score-bar { width: 60px; height: 6px; border-radius: 3px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
470
+ .iterate-dim-score-fill { height: 100%; border-radius: 3px; transition: width 300ms ease; }
471
+ .iterate-dim-score-fill[data-status="pass"] { background: var(--dsw-alias-state-success-primary); }
472
+ .iterate-dim-score-fill[data-status="warn"] { background: var(--dsw-alias-state-warn-primary); }
473
+ .iterate-dim-score-fill[data-status="fail"] { background: var(--dsw-alias-state-error-primary); }
425
474
 
426
475
  /* Accessibility-switch toggle */
427
476
  .iterate-switch { position: relative; width: 42px; height: 24px; border-radius: 999px; padding: 0; cursor: pointer; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); transition: background-color 160ms ease, border-color 160ms ease; }
@@ -563,6 +612,31 @@ function copyText(text: string): Promise<boolean> {
563
612
  return Promise.resolve(false)
564
613
  }
565
614
 
615
+ /**
616
+ * Trigger a browser download of `text` as a file. Resolves to whether the
617
+ * download was actually initiated (fails silently when the DOM/Blob APIs are
618
+ * unavailable, e.g. in a sandboxed renderer without scripting).
619
+ */
620
+ function downloadTextFile(filename: string, text: string): boolean {
621
+ if (typeof document === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined') return false
622
+ try {
623
+ const blob = new Blob([text], { type: 'application/json;charset=utf-8' })
624
+ const url = URL.createObjectURL(blob)
625
+ const a = document.createElement('a')
626
+ a.href = url
627
+ a.download = filename
628
+ a.rel = 'noopener'
629
+ document.body.appendChild(a)
630
+ a.click()
631
+ document.body.removeChild(a)
632
+ // Release the object URL on the next tick so the download has time to start.
633
+ setTimeout(() => URL.revokeObjectURL(url), 0)
634
+ return true
635
+ } catch {
636
+ return false
637
+ }
638
+ }
639
+
566
640
  /** Literal severity keys recognized by SEVERITY_LABEL / SEVERITY_COLOR. */
567
641
  const SEVERITY_KEYS = ['critical', 'high', 'medium', 'low'] as const
568
642
 
@@ -742,6 +816,35 @@ function ConvergenceDashboard(props: SlotProps) {
742
816
  }, `附件图片 ${String(imageCount)}`)
743
817
  : null
744
818
 
819
+ // Live run-state awareness: surface the transcript's current workflow phase
820
+ // (plan / review / fix / validate / report …) plus run liveness, so a
821
+ // long-running round never looks stale on the persistent dashboard.
822
+ const transcript = latestTranscript(session)
823
+ const phase = transcript ? latestPhase(transcript.phases) : ''
824
+ const liveChip = transcript && phase
825
+ ? React.createElement('span', {
826
+ className: 'iterate-chip-phase',
827
+ key: 'phase',
828
+ 'data-live': transcript.active === true ? '' : undefined,
829
+ title: transcript.active === true
830
+ ? `当前处于「${phase}」阶段(运行中)`
831
+ : `最近一次执行停留在「${phase}」阶段(已结束)`,
832
+ }, `${phase} · ${transcript.active === true ? '运行中' : '已结束'}`)
833
+ : null
834
+
835
+ // v3.0: task_mode indicator (code/iterate)
836
+ // task_mode is read from the session transcript if available
837
+ const taskModeTranscript = latestTranscript(session)
838
+ const taskMode = (taskModeTranscript as unknown as { taskMode?: string })?.taskMode
839
+ const taskModeChip = taskMode
840
+ ? React.createElement('span', {
841
+ className: 'iterate-chip-taskmode',
842
+ key: 'taskmode',
843
+ 'data-mode': taskMode,
844
+ title: taskMode === 'code' ? '代码模式:直接修改文件' : '迭代模式:审查-修复循环',
845
+ }, taskMode === 'code' ? '● code' : '● iterate')
846
+ : null
847
+
745
848
  const dimNames = Object.keys(dims)
746
849
  const dimBadges = dimNames.slice(0, 6).map((dim) =>
747
850
  React.createElement(
@@ -815,6 +918,8 @@ function ConvergenceDashboard(props: SlotProps) {
815
918
  fixBadge,
816
919
  resumeChip,
817
920
  imageChip,
921
+ liveChip,
922
+ taskModeChip,
818
923
  React.createElement(TrendChart, { points: trend.points }),
819
924
  ...dimBadges,
820
925
  )
@@ -920,6 +1025,8 @@ function TriagePanel(props: SlotProps) {
920
1025
  const [filter, setFilter] = React.useState<{ severities: string[]; dimensions: string[]; search: string }>({ severities: [], dimensions: [], search: '' })
921
1026
  const [selected, setSelected] = React.useState<number | null>(null)
922
1027
  const [selectAll, setSelectAll] = React.useState(false)
1028
+ // v3.0: Command mode for native action buttons
1029
+ const [cmdCopied, setCmdCopied] = React.useState<string | null>(null)
923
1030
 
924
1031
  /** Persist the verdicts to localStorage whenever they change. Kept OUT of
925
1032
  * the state updater (updaters must stay pure — React may double-invoke them
@@ -1005,6 +1112,37 @@ function TriagePanel(props: SlotProps) {
1005
1112
  const ignoredCount = ignored.length
1006
1113
  const counts = countVerdicts(verdicts)
1007
1114
 
1115
+ // v3.0: Command handlers for native action buttons
1116
+ const doApproveArchitecturalFix = () => {
1117
+ const cmd = '请调用 `iterate_fix` 批准架构修复(设置 is_architectural: true)'
1118
+ copyText(cmd).then((ok) => {
1119
+ if (ok) {
1120
+ setCmdCopied('approve-arch')
1121
+ setTimeout(() => setCmdCopied(null), 1600)
1122
+ }
1123
+ })
1124
+ }
1125
+
1126
+ const doTriggerNewRound = () => {
1127
+ const cmd = '请继续执行下一轮迭代审查'
1128
+ copyText(cmd).then((ok) => {
1129
+ if (ok) {
1130
+ setCmdCopied('new-round')
1131
+ setTimeout(() => setCmdCopied(null), 1600)
1132
+ }
1133
+ })
1134
+ }
1135
+
1136
+ const doRollbackToCheckpoint = () => {
1137
+ const cmd = '请调用 `iterate_checkpoint` 回滚到上一个检查点'
1138
+ copyText(cmd).then((ok) => {
1139
+ if (ok) {
1140
+ setCmdCopied('rollback')
1141
+ setTimeout(() => setCmdCopied(null), 1600)
1142
+ }
1143
+ })
1144
+ }
1145
+
1008
1146
  const doCopyYaml = () => {
1009
1147
  const yaml = toKnownIntentionalYaml(ignored)
1010
1148
  if (!yaml) return
@@ -1137,6 +1275,27 @@ function TriagePanel(props: SlotProps) {
1137
1275
  }, '生成应用指令'),
1138
1276
  ),
1139
1277
  ),
1278
+ // v3.0: Command buttons for native actions (§8)
1279
+ React.createElement('div', { className: 'iterate-triage-foot', style: { borderTop: '1px solid var(--dsw-alias-border-l1)' } },
1280
+ React.createElement('span', { style: { fontSize: 11, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, '指挥操作'),
1281
+ React.createElement('span', { style: { display: 'flex', gap: 6 } },
1282
+ React.createElement('button', {
1283
+ className: 'iterate-cmd', 'data-primary': '', 'data-copied': cmdCopied === 'approve-arch' ? '' : undefined,
1284
+ onClick: doApproveArchitecturalFix,
1285
+ title: '批准架构修复(复制指令文本)',
1286
+ }, cmdCopied === 'approve-arch' ? '已复制' : '批准架构修复'),
1287
+ React.createElement('button', {
1288
+ className: 'iterate-cmd', 'data-copied': cmdCopied === 'new-round' ? '' : undefined,
1289
+ onClick: doTriggerNewRound,
1290
+ title: '触发新一轮迭代审查',
1291
+ }, cmdCopied === 'new-round' ? '已复制' : '触发新一轮'),
1292
+ React.createElement('button', {
1293
+ className: 'iterate-cmd', 'data-danger': '', 'data-copied': cmdCopied === 'rollback' ? '' : undefined,
1294
+ onClick: doRollbackToCheckpoint,
1295
+ title: '回滚到上一个检查点',
1296
+ }, cmdCopied === 'rollback' ? '已复制' : '回滚检查点'),
1297
+ ),
1298
+ ),
1140
1299
  payload
1141
1300
  ? React.createElement('div', { className: 'iterate-payload' }, payload)
1142
1301
  : null,
@@ -1227,7 +1386,13 @@ function ProgressCapsule() {
1227
1386
  }
1228
1387
  }, [])
1229
1388
  if (!info) return null
1230
- return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-capsule', 'data-ok': info.ok ? '' : undefined }, info.text)
1389
+ return React.createElement('div', {
1390
+ 'data-iterate-root': '',
1391
+ className: 'iterate-capsule',
1392
+ 'data-ok': info.ok ? '' : undefined,
1393
+ role: 'status',
1394
+ 'aria-live': 'polite',
1395
+ }, info.text)
1231
1396
  }
1232
1397
 
1233
1398
  /** Settings page section (root scope; reads localStorage + latest known data). */
@@ -1398,6 +1563,10 @@ const OBS_TABS = [
1398
1563
  { key: 'f5', label: '断点恢复' },
1399
1564
  { key: 'f6', label: '运行控制台' },
1400
1565
  { key: 'f7', label: '决策时间线' },
1566
+ // v3.0: Quality Command Center tabs
1567
+ { key: 'f8', label: '质量门禁' },
1568
+ { key: 'f9', label: '经验银行' },
1569
+ { key: 'f10', label: '防御事件' },
1401
1570
  ] as const
1402
1571
 
1403
1572
  /** Live subagent activity badges: type → Chinese label + dsw token color accent. */
@@ -1473,6 +1642,8 @@ function ObservatoryPanel(props: SlotProps) {
1473
1642
  const [open, setOpen] = React.useState(false)
1474
1643
  const [tab, setTab] = React.useState('live')
1475
1644
  const [expandedThreads, setExpandedThreads] = React.useState<Set<string>>(new Set())
1645
+ // 'auto' follows the per-thread set; 'all'/'none' force every thread open/closed.
1646
+ const [threadMode, setThreadMode] = React.useState<'auto' | 'all' | 'none'>('auto')
1476
1647
  const [copiedKey, setCopiedKey] = React.useState<string | null>(null)
1477
1648
  // When a clipboard write is blocked (permissions/unsupported), reveal the
1478
1649
  // raw instruction text so the user can copy it manually instead of silently
@@ -1480,7 +1651,18 @@ function ObservatoryPanel(props: SlotProps) {
1480
1651
  const [copyFailText, setCopyFailText] = React.useState<string | null>(null)
1481
1652
  const [nudgeText, setNudgeText] = React.useState('')
1482
1653
  const [timelineType, setTimelineType] = React.useState('')
1654
+ const [timelineRound, setTimelineRound] = React.useState('')
1483
1655
  const [timelineSearch, setTimelineSearch] = React.useState('')
1656
+ const [liveType, setLiveType] = React.useState('')
1657
+ // F3 findings triage-light filter (severity / dimension / search).
1658
+ const [f3Filter, setF3Filter] = React.useState<{ severities: string[]; dimensions: string[]; search: string }>(
1659
+ { severities: [], dimensions: [], search: '' },
1660
+ )
1661
+ // v3.0: Experience bank search state
1662
+ const [expSearch, setExpSearch] = React.useState('')
1663
+ const [expResults, setExpResults] = React.useState<unknown[]>([])
1664
+ // v3.0: Defense events filter
1665
+ const [defenseFilter, setDefenseFilter] = React.useState('')
1484
1666
 
1485
1667
  // Single shared "copied" flash timer (reused across all copy buttons).
1486
1668
  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -1497,7 +1679,9 @@ function ObservatoryPanel(props: SlotProps) {
1497
1679
  })
1498
1680
  }
1499
1681
 
1682
+ /** Toggle one thread; a manual toggle exits any forced all/none mode. */
1500
1683
  const toggleThread = (key: string) => {
1684
+ setThreadMode('auto')
1501
1685
  setExpandedThreads((prev) => {
1502
1686
  const next = new Set(prev)
1503
1687
  if (next.has(key)) next.delete(key); else next.add(key)
@@ -1505,6 +1689,38 @@ function ObservatoryPanel(props: SlotProps) {
1505
1689
  })
1506
1690
  }
1507
1691
 
1692
+ /** Export the full observatory snapshot as JSON (download, then copy fallback). */
1693
+ const exportObservatory = () => {
1694
+ if (!manifest) return
1695
+ const json = serializeObservatoryExport(manifest, manifest.live || [])
1696
+ const filename = `iterate-observatory-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
1697
+ const flash = () => {
1698
+ setCopiedKey('export')
1699
+ if (copyTimer.current) clearTimeout(copyTimer.current)
1700
+ copyTimer.current = setTimeout(() => setCopiedKey((cur) => (cur === 'export' ? null : cur)), 1600)
1701
+ }
1702
+ if (downloadTextFile(filename, json)) { flash(); return }
1703
+ // Download unavailable: fall back to copying the payload to the clipboard,
1704
+ // and reveal the raw text if even the clipboard write is blocked.
1705
+ copyText(json).then((ok) => {
1706
+ if (!ok) { setCopyFailText(json); return }
1707
+ flash()
1708
+ })
1709
+ }
1710
+
1711
+ /** Force every thread open (or closed) and drop per-thread state. */
1712
+ const setAllThreads = (mode: 'all' | 'none') => {
1713
+ setThreadMode(mode)
1714
+ setExpandedThreads(new Set())
1715
+ }
1716
+
1717
+ /** Whether a thread should render expanded given the forced mode + per-thread set. */
1718
+ const isThreadExpanded = (key: string): boolean => {
1719
+ if (threadMode === 'all') return true
1720
+ if (threadMode === 'none') return false
1721
+ return expandedThreads.has(key)
1722
+ }
1723
+
1508
1724
  // No observable transcript yet: show a compact, collapsed header row.
1509
1725
  if (!manifest) {
1510
1726
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
@@ -1538,14 +1754,23 @@ function ObservatoryPanel(props: SlotProps) {
1538
1754
  if (rounds.length === 0) {
1539
1755
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无审查线程')
1540
1756
  }
1541
- return React.createElement('div', {}, ...rounds.map((r, ri) => {
1757
+ return React.createElement('div', {},
1758
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8, flexWrap: 'wrap' } },
1759
+ React.createElement('b', {}, '审查线程'),
1760
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setAllThreads('all'), title: '展开全部线程' }, '全部展开'),
1761
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setAllThreads('none'), title: '收起全部线程' }, '全部收起'),
1762
+ React.createElement('span', { className: 'iterate-filter-count' },
1763
+ `${threadMode === 'all' ? '全部展开' : threadMode === 'none' ? '全部收起' : '自由切换'} · ${rounds.length} 轮`,
1764
+ ),
1765
+ ),
1766
+ ...rounds.map((r, ri) => {
1542
1767
  const threads = r.threads || []
1543
1768
  const fCount = threads.reduce((sum, t) => sum + (t.findings ? t.findings.length : 0), 0)
1544
1769
  const threadBlocks = threads.length === 0
1545
1770
  ? [React.createElement('div', { key: 'none', className: 'iterate-obs-empty' }, '本轮无线程')]
1546
1771
  : threads.map((t, ti) => {
1547
1772
  const key = `${ri}-${ti}`
1548
- const expanded = expandedThreads.has(key)
1773
+ const expanded = isThreadExpanded(key)
1549
1774
  const dim = t.dimension || '未命名维度'
1550
1775
  const tFindings = t.findings || []
1551
1776
  const files = t.readFiles || []
@@ -1618,33 +1843,88 @@ function ObservatoryPanel(props: SlotProps) {
1618
1843
  if (findings.length === 0) {
1619
1844
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无发现')
1620
1845
  }
1621
- return React.createElement('div', {}, ...findings.map((f, i) => {
1846
+ const opts = buildFilterOptions(findings as unknown as Record<string, unknown>[])
1847
+ const { filtered } = filterFindingsWithIndices(findings as unknown as Record<string, unknown>[], f3Filter)
1848
+ const filterActive = f3Filter.severities.length > 0 || f3Filter.dimensions.length > 0 || f3Filter.search.trim() !== ''
1849
+ const toggleSeverity = (sev: string) => {
1850
+ setF3Filter((prev) => ({
1851
+ ...prev,
1852
+ severities: prev.severities.includes(sev)
1853
+ ? prev.severities.filter((s) => s !== sev)
1854
+ : [...prev.severities, sev],
1855
+ }))
1856
+ }
1857
+ const filterBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8, flexWrap: 'wrap' } },
1858
+ React.createElement('b', {}, '发现'),
1859
+ ...opts.severities.map((s) =>
1860
+ React.createElement('button', {
1861
+ key: `sev-${s.value}`,
1862
+ className: 'iterate-filter-chip',
1863
+ 'data-active': f3Filter.severities.includes(s.value) ? '' : undefined,
1864
+ title: `${s.value}(${s.count} 项)`,
1865
+ onClick: () => toggleSeverity(s.value),
1866
+ }, `${s.value} ${s.count}`),
1867
+ ),
1868
+ React.createElement('select', {
1869
+ className: 'iterate-filter-select',
1870
+ value: f3Filter.dimensions[0] || '',
1871
+ 'aria-label': '按维度筛选',
1872
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => {
1873
+ const v = e.target.value
1874
+ setF3Filter((prev) => ({ ...prev, dimensions: v ? [v] : [] }))
1875
+ },
1876
+ },
1877
+ React.createElement('option', { value: '' }, '全部维度'),
1878
+ ...opts.dimensions.map((d) =>
1879
+ React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
1880
+ ),
1881
+ ),
1882
+ React.createElement('input', {
1883
+ className: 'iterate-filter-search',
1884
+ type: 'search',
1885
+ placeholder: '搜索发现…',
1886
+ 'aria-label': '搜索发现',
1887
+ value: f3Filter.search,
1888
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
1889
+ setF3Filter((prev) => ({ ...prev, search: e.target.value })),
1890
+ }),
1891
+ filterActive
1892
+ ? React.createElement('button', {
1893
+ className: 'iterate-filter-clear',
1894
+ onClick: () => setF3Filter({ severities: [], dimensions: [], search: '' }),
1895
+ title: '清除全部筛选',
1896
+ }, '清除筛选')
1897
+ : null,
1898
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${findings.length}`),
1899
+ )
1900
+ const findingBlocks = filtered.map((f, i) => {
1622
1901
  const k = `f3-${i}`
1623
- const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
1902
+ const finding = f as unknown as ObsFinding
1903
+ const loc = `${String(finding.file || '?')}${typeof finding.line === 'number' && finding.line > 0 ? `:${finding.line}` : ''}`
1624
1904
  const entry = {
1625
- file: String(f.file || ''),
1626
- ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1627
- dimension: String(f.dimension || ''),
1628
- reason: String(f.summary || ''),
1905
+ file: String(finding.file || ''),
1906
+ ...(typeof finding.line === 'number' && finding.line > 0 ? { line: finding.line } : {}),
1907
+ dimension: String(finding.dimension || ''),
1908
+ reason: String(finding.summary || ''),
1629
1909
  }
1630
1910
  const knownInstruction = buildApplyInstruction([entry])
1631
- const fixInstruction = buildObsFixInstruction(f)
1911
+ const fixInstruction = buildObsFixInstruction(finding)
1632
1912
  return React.createElement('div', { key: k, className: 'iterate-obs-block' },
1633
1913
  React.createElement('div', { className: 'iterate-obs-block-head' },
1634
- React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(f.severity) } }),
1635
- React.createElement('span', {}, severityLabel(f.severity)),
1914
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(finding.severity) } }),
1915
+ React.createElement('span', {}, severityLabel(finding.severity)),
1636
1916
  React.createElement('span', { className: 'iterate-obs-file' }, loc),
1637
- React.createElement('span', {}, String(f.dimension || '')),
1638
- f.acknowledged === true
1917
+ React.createElement('span', {}, String(finding.dimension || '')),
1918
+ finding.acknowledged === true
1639
1919
  ? React.createElement('span', { className: 'iterate-obs-chip' }, '已确认')
1640
1920
  : null,
1641
1921
  ),
1642
1922
  React.createElement('div', { className: 'iterate-obs-block-body' },
1643
- React.createElement('div', { className: 'iterate-obs-msg' }, String(f.summary || '')),
1923
+ React.createElement('div', { className: 'iterate-obs-msg' }, String(finding.summary || '')),
1644
1924
  React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
1645
1925
  React.createElement('button', {
1646
1926
  className: 'iterate-btn', 'data-copied': copiedKey === `${k}-fix` ? '' : undefined,
1647
- onClick: () => copyInstruction(`${k}-fix`, String(f.suggested_fix || '')),
1927
+ onClick: () => copyInstruction(`${k}-fix`, String(finding.suggested_fix || '')),
1648
1928
  title: '复制该 finding 的建议修复文本',
1649
1929
  }, copiedKey === `${k}-fix` ? '已复制' : '复制 suggested_fix'),
1650
1930
  React.createElement('button', {
@@ -1660,7 +1940,14 @@ function ObservatoryPanel(props: SlotProps) {
1660
1940
  ),
1661
1941
  ),
1662
1942
  )
1663
- }))
1943
+ })
1944
+ if (filtered.length === 0) {
1945
+ return React.createElement('div', {},
1946
+ filterBar,
1947
+ React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的发现'),
1948
+ )
1949
+ }
1950
+ return React.createElement('div', {}, filterBar, ...findingBlocks)
1664
1951
  }
1665
1952
 
1666
1953
  // ── F4: fixes + rollback ───────────────────────────────────────────────
@@ -1786,23 +2073,29 @@ function ObservatoryPanel(props: SlotProps) {
1786
2073
  )
1787
2074
  }
1788
2075
 
1789
- // ── F7: decision timeline (type filter + search, newest first) ──────────
2076
+ // ── F7: decision timeline (type/round filter + search, newest first) ────
1790
2077
  const renderTimeline = () => {
1791
2078
  const entries = manifest.timeline || []
1792
2079
  if (entries.length === 0) {
1793
2080
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无时间线条目')
1794
2081
  }
1795
2082
  const types = Array.from(new Set(entries.map((t) => String(t.type || 'unknown')).filter(Boolean))).sort()
1796
- const q = timelineSearch.trim().toLowerCase()
1797
- const filtered = entries.filter((t) => {
1798
- if (timelineType && String(t.type || '') !== timelineType) return false
1799
- if (!q) return true
1800
- const hay = [String(t.type || ''), String(t.round ?? ''), JSON.stringify(t.data || {})].join(' ').toLowerCase()
1801
- return hay.indexOf(q) >= 0
2083
+ const rounds = Array.from(
2084
+ new Set(entries.map((t) => (typeof t.round === 'number' ? String(t.round) : '')).filter(Boolean)),
2085
+ ).sort((a, b) => Number(a) - Number(b))
2086
+ const filtered = filterTimelineEntries(entries, {
2087
+ type: timelineType,
2088
+ round: timelineRound,
2089
+ search: timelineSearch,
1802
2090
  })
1803
- // Newest first: entries are not guaranteed to be reverse ordered in the
1804
- // manifest, so sort by timestamp string descending for a stable timeline.
1805
- const sorted = filtered.slice().sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')))
2091
+ const filterActive = Boolean(timelineType || timelineRound || timelineSearch.trim())
2092
+ const clearTimelineFilter = () => {
2093
+ setTimelineType('')
2094
+ setTimelineRound('')
2095
+ setTimelineSearch('')
2096
+ }
2097
+ // filterTimelineEntries already returns entries newest-first.
2098
+ const sorted = filtered.slice()
1806
2099
  const rows = sorted.map((t, i) => {
1807
2100
  const k = `f7-${i}`
1808
2101
  const dataText = t.data && typeof t.data === 'object' ? JSON.stringify(t.data) : ''
@@ -1816,31 +2109,195 @@ function ObservatoryPanel(props: SlotProps) {
1816
2109
  dataText ? React.createElement('div', { className: 'iterate-obs-code' }, dataText) : null,
1817
2110
  )
1818
2111
  })
2112
+ const filterBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2113
+ React.createElement('b', {}, '时间线'),
2114
+ React.createElement('select', {
2115
+ className: 'iterate-filter-select',
2116
+ value: timelineType,
2117
+ 'aria-label': '按时间线类型筛选',
2118
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineType(e.target.value),
2119
+ },
2120
+ React.createElement('option', { value: '' }, '全部类型'),
2121
+ ...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
2122
+ ),
2123
+ React.createElement('select', {
2124
+ className: 'iterate-filter-select',
2125
+ value: timelineRound,
2126
+ 'aria-label': '按轮次筛选',
2127
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineRound(e.target.value),
2128
+ },
2129
+ React.createElement('option', { value: '' }, '全部轮次'),
2130
+ ...rounds.map((r) => React.createElement('option', { key: r, value: r }, `Round ${r}`)),
2131
+ ),
2132
+ React.createElement('input', {
2133
+ className: 'iterate-filter-search',
2134
+ type: 'search',
2135
+ placeholder: '搜索时间线…',
2136
+ 'aria-label': '搜索时间线',
2137
+ value: timelineSearch,
2138
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => setTimelineSearch(e.target.value),
2139
+ }),
2140
+ filterActive
2141
+ ? React.createElement('button', {
2142
+ className: 'iterate-filter-clear',
2143
+ onClick: clearTimelineFilter,
2144
+ title: '清除全部筛选',
2145
+ }, '清除')
2146
+ : null,
2147
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${entries.length}`),
2148
+ )
1819
2149
  if (rows.length === 0) {
1820
- return React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的时间线条目')
2150
+ return React.createElement('div', {},
2151
+ filterBar,
2152
+ React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的时间线条目'),
2153
+ )
1821
2154
  }
2155
+ return React.createElement('div', {}, filterBar, ...rows)
2156
+ }
2157
+
2158
+ // ── F8: Quality Gate ──────────────────────────────────────────────────────
2159
+ // v3.0: Quality command center - shows dimension convergence, verification pass rate, and overall PASS/FAIL
2160
+ const renderQualityGate = () => {
2161
+ // Quality gate data would come from manifest or be computed on-the-fly
2162
+ // For now, show a placeholder with instructions
2163
+ const qualityGateInstruction = '请调用 `iterate_quality_gate` 查询当前质量门禁状态'
1822
2164
  return React.createElement('div', {},
1823
2165
  React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1824
- React.createElement('select', {
1825
- className: 'iterate-filter-select',
1826
- value: timelineType,
1827
- 'aria-label': '按时间线类型筛选',
1828
- onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineType(e.target.value),
1829
- },
1830
- React.createElement('option', { value: '' }, '全部类型'),
1831
- ...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
2166
+ React.createElement('b', {}, '质量门禁'),
2167
+ React.createElement('button', {
2168
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'qgate' ? '' : undefined,
2169
+ onClick: () => copyInstruction('qgate', qualityGateInstruction),
2170
+ title: '复制 iterate_quality_gate 查询指令',
2171
+ }, copiedKey === 'qgate' ? '已复制' : '查询门禁'),
2172
+ ),
2173
+ React.createElement('div', { className: 'iterate-obs-block' },
2174
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2175
+ React.createElement('span', {}, '质量门禁视图'),
2176
+ React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.0'),
1832
2177
  ),
2178
+ React.createElement('div', { className: 'iterate-obs-block-body' },
2179
+ React.createElement('div', { className: 'iterate-obs-msg' }, '质量门禁显示各维度收敛度、验证通过率和整体 PASS/FAIL 状态。'),
2180
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
2181
+ React.createElement('button', {
2182
+ className: 'iterate-btn', 'data-copied': copiedKey === 'qgate-instr' ? '' : undefined,
2183
+ onClick: () => copyInstruction('qgate-instr', qualityGateInstruction),
2184
+ title: '复制查询指令',
2185
+ }, copiedKey === 'qgate-instr' ? '已复制' : '复制查询指令'),
2186
+ ),
2187
+ ),
2188
+ ),
2189
+ )
2190
+ }
2191
+
2192
+ // ── F9: Experience Bank ────────────────────────────────────────────────────
2193
+ // v3.0: Browse/search historical fixes and patterns
2194
+ const renderExperienceBank = () => {
2195
+ const expInstruction = expSearch
2196
+ ? `请调用 \`iterate_experience\` 搜索经验:\n\n\`\`\`json\n${JSON.stringify({ operation: 'search', query: expSearch }, null, 2)}\n\`\`\``
2197
+ : '请调用 `iterate_experience` 列出所有经验'
2198
+
2199
+ const resultRows = expResults.map((entry: unknown, i: number) => {
2200
+ const e = entry as { id?: string; pattern?: string; description?: string; hitCount?: number; dimension?: string }
2201
+ return React.createElement('div', { key: `exp-${i}`, className: 'iterate-obs-row' },
2202
+ React.createElement('span', { className: 'iterate-obs-chip' }, String(e.dimension || '')),
2203
+ React.createElement('span', { className: 'iterate-obs-msg' }, String(e.pattern || '')),
2204
+ React.createElement('span', { className: 'iterate-exp-hit' }, `命中 ${e.hitCount ?? 0} 次`),
2205
+ )
2206
+ })
2207
+
2208
+ return React.createElement('div', {},
2209
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2210
+ React.createElement('b', {}, '经验银行'),
2211
+ React.createElement('button', {
2212
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'exp-list' ? '' : undefined,
2213
+ onClick: () => copyInstruction('exp-list', '请调用 `iterate_experience` 列出所有经验'),
2214
+ title: '复制列出经验指令',
2215
+ }, copiedKey === 'exp-list' ? '已复制' : '列出经验'),
2216
+ ),
2217
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1833
2218
  React.createElement('input', {
1834
2219
  className: 'iterate-filter-search',
1835
2220
  type: 'search',
1836
- placeholder: '搜索时间线…',
1837
- 'aria-label': '搜索时间线',
1838
- value: timelineSearch,
1839
- onChange: (e: React.ChangeEvent<HTMLInputElement>) => setTimelineSearch(e.target.value),
2221
+ placeholder: '搜索经验模式…',
2222
+ 'aria-label': '搜索经验',
2223
+ value: expSearch,
2224
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => setExpSearch(e.target.value),
1840
2225
  }),
1841
- React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${entries.length}`),
2226
+ React.createElement('button', {
2227
+ className: 'iterate-btn', 'data-copied': copiedKey === 'exp-search' ? '' : undefined,
2228
+ disabled: !expSearch,
2229
+ onClick: () => copyInstruction('exp-search', expInstruction),
2230
+ title: '复制搜索指令',
2231
+ }, copiedKey === 'exp-search' ? '已复制' : '搜索'),
2232
+ ),
2233
+ resultRows.length > 0
2234
+ ? React.createElement('div', {}, ...resultRows)
2235
+ : React.createElement('div', { className: 'iterate-obs-empty' }, '输入关键词搜索历史经验,或点击「列出经验」查看全部'),
2236
+ )
2237
+ }
2238
+
2239
+ // ── F10: Defense Events ────────────────────────────────────────────────────
2240
+ // v3.0: Shows precondition failures, rollbacks, invariant violations, assumption falsifications
2241
+ const renderDefenseEvents = () => {
2242
+ const defenseInstruction = defenseFilter
2243
+ ? `请调用 \`iterate_defense_events\` 查询防御事件:\n\n\`\`\`json\n${JSON.stringify({ operation: 'list', type: defenseFilter || undefined }, null, 2)}\n\`\`\``
2244
+ : '请调用 `iterate_defense_events` 列出所有防御事件'
2245
+
2246
+ const typeOptions = [
2247
+ { value: '', label: '全部类型' },
2248
+ { value: 'precondition_failed', label: '前置校验失败' },
2249
+ { value: 'rollback', label: '回滚' },
2250
+ { value: 'invariant_violated', label: '不变量违反' },
2251
+ { value: 'assumption_falsified', label: '假设被证伪' },
2252
+ ]
2253
+
2254
+ return React.createElement('div', {},
2255
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2256
+ React.createElement('b', {}, '防御事件'),
2257
+ React.createElement('button', {
2258
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'defense-list' ? '' : undefined,
2259
+ onClick: () => copyInstruction('defense-list', '请调用 `iterate_defense_events` 列出所有防御事件'),
2260
+ title: '复制列出防御事件指令',
2261
+ }, copiedKey === 'defense-list' ? '已复制' : '列出事件'),
2262
+ React.createElement('button', {
2263
+ className: 'iterate-btn', 'data-copied': copiedKey === 'defense-counts' ? '' : undefined,
2264
+ onClick: () => copyInstruction('defense-counts', '请调用 `iterate_defense_events` 查询事件统计'),
2265
+ title: '复制统计指令',
2266
+ }, copiedKey === 'defense-counts' ? '已复制' : '统计'),
2267
+ ),
2268
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2269
+ React.createElement('select', {
2270
+ className: 'iterate-filter-select',
2271
+ value: defenseFilter,
2272
+ 'aria-label': '按事件类型筛选',
2273
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDefenseFilter(e.target.value),
2274
+ },
2275
+ ...typeOptions.map((opt) => React.createElement('option', { key: opt.value, value: opt.value }, opt.label)),
2276
+ ),
2277
+ defenseFilter
2278
+ ? React.createElement('button', {
2279
+ className: 'iterate-filter-clear',
2280
+ onClick: () => setDefenseFilter(''),
2281
+ title: '清除类型筛选',
2282
+ }, '清除')
2283
+ : null,
2284
+ ),
2285
+ React.createElement('div', { className: 'iterate-obs-block' },
2286
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2287
+ React.createElement('span', {}, '防御事件流'),
2288
+ React.createElement('span', { className: 'iterate-obs-badge' }, 'v3.0'),
2289
+ ),
2290
+ React.createElement('div', { className: 'iterate-obs-block-body' },
2291
+ React.createElement('div', { className: 'iterate-obs-msg' }, '防御事件包括:前置校验失败、回滚、不变量违反、假设被证伪。'),
2292
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
2293
+ React.createElement('button', {
2294
+ className: 'iterate-btn', 'data-copied': copiedKey === 'defense-instr' ? '' : undefined,
2295
+ onClick: () => copyInstruction('defense-instr', defenseInstruction),
2296
+ title: '复制查询指令',
2297
+ }, copiedKey === 'defense-instr' ? '已复制' : '复制查询指令'),
2298
+ ),
2299
+ ),
1842
2300
  ),
1843
- ...rows,
1844
2301
  )
1845
2302
  }
1846
2303
 
@@ -1864,12 +2321,14 @@ function ObservatoryPanel(props: SlotProps) {
1864
2321
  }
1865
2322
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无实时活动')
1866
2323
  }
1867
- const rows = liveEntries.map((e, i) => {
2324
+ const types = Array.from(new Set(liveEntries.map((e) => String(e.type || 'unknown')).filter(Boolean))).sort()
2325
+ const filtered = filterLiveEntries(liveEntries, liveType)
2326
+ const rows = filtered.map((e, i) => {
1868
2327
  const type = String(e.type || 'unknown')
1869
2328
  const meta = OBS_LIVE_META[type] || { label: String(e.type || '?'), color: OBS_LIVE_FALLBACK_COLOR }
1870
2329
  const tool = String(e.tool || '')
1871
2330
  const target = String(e.target || '')
1872
- const time = formatObsLiveTime(e.ts)
2331
+ const time = formatObsLiveTime(String(e.ts || ''))
1873
2332
  return React.createElement('div', { key: `live-${i}`, className: 'iterate-obs-row' },
1874
2333
  React.createElement('span', { className: 'iterate-obs-livebadge', style: { color: meta.color } }, meta.label),
1875
2334
  tool
@@ -1879,13 +2338,33 @@ function ObservatoryPanel(props: SlotProps) {
1879
2338
  React.createElement('span', { className: 'iterate-obs-head-meta' }, time),
1880
2339
  )
1881
2340
  })
1882
- return React.createElement('div', {},
1883
- React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1884
- React.createElement('b', {}, '实时活动'),
1885
- React.createElement('span', { className: 'iterate-obs-chip' }, `${liveEntries.length} 项`),
2341
+ const filterBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2342
+ React.createElement('b', {}, '实时活动'),
2343
+ React.createElement('select', {
2344
+ className: 'iterate-filter-select',
2345
+ value: liveType,
2346
+ 'aria-label': '按活动类型筛选',
2347
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setLiveType(e.target.value),
2348
+ },
2349
+ React.createElement('option', { value: '' }, '全部活动'),
2350
+ ...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
1886
2351
  ),
1887
- ...rows,
2352
+ liveType
2353
+ ? React.createElement('button', {
2354
+ className: 'iterate-filter-clear',
2355
+ onClick: () => setLiveType(''),
2356
+ title: '清除类型筛选',
2357
+ }, '清除')
2358
+ : null,
2359
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${liveEntries.length}`),
1888
2360
  )
2361
+ if (rows.length === 0) {
2362
+ return React.createElement('div', {},
2363
+ filterBar,
2364
+ React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的实时活动'),
2365
+ )
2366
+ }
2367
+ return React.createElement('div', {}, filterBar, ...rows)
1889
2368
  }
1890
2369
 
1891
2370
  const renderBody = () => {
@@ -1898,6 +2377,10 @@ function ObservatoryPanel(props: SlotProps) {
1898
2377
  case 'f5': return renderCheckpoint()
1899
2378
  case 'f6': return renderConsole()
1900
2379
  case 'f7': return renderTimeline()
2380
+ // v3.0: Quality Command Center tabs
2381
+ case 'f8': return renderQualityGate()
2382
+ case 'f9': return renderExperienceBank()
2383
+ case 'f10': return renderDefenseEvents()
1901
2384
  default: return null
1902
2385
  }
1903
2386
  }
@@ -1927,6 +2410,16 @@ function ObservatoryPanel(props: SlotProps) {
1927
2410
  React.createElement('div', { className: 'iterate-obs-code' }, copyFailText),
1928
2411
  )
1929
2412
  : null,
2413
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8, flexWrap: 'wrap' } },
2414
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
2415
+ `${manifest.mode || 'runtime'} · ${String(manifest.updatedAt || '')}`,
2416
+ ),
2417
+ React.createElement('button', {
2418
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'export' ? '' : undefined,
2419
+ onClick: exportObservatory,
2420
+ title: '将观测台全部数据导出为 JSON(优先下载,失败则复制)',
2421
+ }, copiedKey === 'export' ? '已导出' : '导出 JSON'),
2422
+ ),
1930
2423
  React.createElement('div', { className: 'iterate-obs-tabs' },
1931
2424
  ...OBS_TABS.map((t) =>
1932
2425
  React.createElement('button', {