iterate-plugin 2.10.0 → 2.12.0

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 (55) hide show
  1. package/README.md +42 -2
  2. package/README.zh-CN.md +40 -2
  3. package/dist/approval-gate.js +92 -0
  4. package/dist/config-loader.js +18 -3
  5. package/dist/config-write.js +7 -4
  6. package/dist/evidence.js +67 -1
  7. package/dist/git-scope.js +35 -6
  8. package/dist/index.js +15 -5
  9. package/dist/live.js +155 -0
  10. package/dist/meta-review.js +19 -5
  11. package/dist/method-scope.js +5 -1
  12. package/dist/paths.js +4 -0
  13. package/dist/review-scope.js +12 -8
  14. package/dist/review.js +76 -24
  15. package/dist/session-hooks.js +89 -0
  16. package/dist/skill-prompt.js +101 -19
  17. package/dist/tools/checkpoint.js +10 -3
  18. package/dist/tools/context.js +16 -4
  19. package/dist/tools/decision-log.js +29 -9
  20. package/dist/tools/fix.js +120 -3
  21. package/dist/tools/prune.js +16 -9
  22. package/dist/tools/review.js +4 -1
  23. package/dist/tools/transcript.js +324 -0
  24. package/dist/tools/triage.js +9 -6
  25. package/dist/tools/validate.js +5 -2
  26. package/dist/transcript.js +421 -0
  27. package/lib/client.js +966 -80
  28. package/lib/parse.js +302 -17
  29. package/package.json +1 -1
  30. package/src/approval-gate.ts +119 -0
  31. package/src/client/index.ts +807 -62
  32. package/src/config-loader.ts +16 -2
  33. package/src/config-write.ts +6 -4
  34. package/src/evidence.ts +69 -1
  35. package/src/git-scope.ts +34 -6
  36. package/src/index.ts +17 -6
  37. package/src/live.ts +185 -0
  38. package/src/meta-review.ts +24 -10
  39. package/src/method-scope.ts +5 -1
  40. package/src/paths.ts +5 -0
  41. package/src/review-scope.ts +11 -7
  42. package/src/review.ts +82 -25
  43. package/src/session-hooks.ts +90 -0
  44. package/src/skill-prompt.ts +101 -19
  45. package/src/tools/checkpoint.ts +10 -3
  46. package/src/tools/context.ts +14 -3
  47. package/src/tools/decision-log.ts +27 -10
  48. package/src/tools/fix.ts +114 -3
  49. package/src/tools/prune.ts +14 -11
  50. package/src/tools/review.ts +5 -2
  51. package/src/tools/transcript.ts +334 -0
  52. package/src/tools/triage.ts +9 -6
  53. package/src/tools/validate.ts +5 -2
  54. package/src/transcript.ts +475 -0
  55. package/src/types.ts +129 -0
@@ -17,13 +17,14 @@
17
17
  * - `inject`: services the shell must have up before `apply` runs.
18
18
  * - `apply(ctx)`: called by the client runtime with the ClientContext.
19
19
  *
20
- * Implements the §14.4 / §16 six-part UI layer:
20
+ * Implements the §14.4 / §16 UI layer:
21
21
  * 1. Convergence dashboard -> conversation.input.dock
22
- * 2. Findings triage panel -> conversation.chat.turnTail
23
- * 3. Iteration stats card -> conversation.chat.turnTail (same chain, empty-findings case)
24
- * 4. iterate theme skin -> theme.overrideTokens
25
- * 5. Progress event linkage -> ctx.on('slots/changed') + round-pulse + shell.overlay capsule
26
- * 6. Settings page section -> settings.section
22
+ * 2. Findings triage panel -> conversation.chat.turnTail
23
+ * 3. Iteration stats card -> conversation.chat.turnTail (same chain, empty-findings case)
24
+ * 4. iterate theme skin -> theme.overrideTokens
25
+ * 5. Progress event linkage -> ctx.on('slots/changed') + round-pulse + shell.overlay capsule
26
+ * 6. Settings page section -> settings.section
27
+ * 7. Runtime observatory -> conversation.input.dock (order 91, F1–F7 board)
27
28
  *
28
29
  * Design notes:
29
30
  * - All styles are injected via one <style> tag; colors use dsh token
@@ -70,6 +71,8 @@ import {
70
71
  buildRuntimeStatusGuide,
71
72
  scanSessionForResume,
72
73
  countSessionImages,
74
+ scanSessionForTranscript,
75
+ normalizeTranscript,
73
76
  SEVERITY_LABEL,
74
77
  SEVERITY_COLOR,
75
78
  } from '../../lib/parse.js'
@@ -142,6 +145,100 @@ interface ThemeService {
142
145
  overrideTokens(source: string, tokens: IterateTokenMap): () => void
143
146
  }
144
147
 
148
+ // ─── Runtime observatory (transcript) types ─────────────────────────────────
149
+ // Mirrors src/types.ts Transcript* series, but every field is defensive/optional
150
+ // so the panel never crashes on a malformed or partial manifest.
151
+
152
+ interface ObsCheckpoint {
153
+ mode?: string
154
+ round?: number
155
+ maxRounds?: number
156
+ fixedCount?: number
157
+ resumeCount?: number
158
+ updatedAt?: string
159
+ }
160
+
161
+ interface ObsNudge {
162
+ timestamp?: string
163
+ text?: string
164
+ }
165
+
166
+ interface ObsApproval {
167
+ active?: boolean
168
+ policy?: string
169
+ }
170
+
171
+ interface ObsThread {
172
+ dimension?: string
173
+ attempt?: number
174
+ messages?: string[]
175
+ readFiles?: string[]
176
+ findings?: ObsFinding[]
177
+ }
178
+
179
+ interface ObsRound {
180
+ round?: number
181
+ threads?: ObsThread[]
182
+ }
183
+
184
+ interface ObsFinding {
185
+ dimension?: string
186
+ file?: string
187
+ line?: number
188
+ severity?: string
189
+ summary?: string
190
+ failure_scenario?: string
191
+ suggested_fix?: string
192
+ is_atomic?: boolean
193
+ acknowledged?: boolean
194
+ }
195
+
196
+ interface ObsFix {
197
+ id?: string
198
+ timestamp?: string
199
+ round?: number
200
+ file?: string
201
+ summary?: string
202
+ linesAdded?: number
203
+ linesRemoved?: number
204
+ success?: boolean
205
+ }
206
+
207
+ interface ObsEntry {
208
+ timestamp?: string
209
+ round?: number
210
+ type?: string
211
+ data?: Record<string, unknown>
212
+ }
213
+
214
+ interface ObsLive {
215
+ ts?: string
216
+ type?: string
217
+ tool?: string
218
+ target?: string
219
+ }
220
+
221
+ interface ObsManifest {
222
+ version?: number
223
+ project?: string
224
+ updatedAt?: string
225
+ active?: boolean
226
+ mode?: string
227
+ goal?: string
228
+ phases?: string[]
229
+ round?: number
230
+ maxRounds?: number
231
+ rounds?: ObsRound[]
232
+ convergence?: number[]
233
+ findings?: ObsFinding[]
234
+ fixes?: ObsFix[]
235
+ live?: ObsLive[]
236
+ checkpoint?: ObsCheckpoint | null
237
+ timeline?: ObsEntry[]
238
+ nudge?: ObsNudge | null
239
+ approval?: ObsApproval
240
+ }
241
+
145
242
  /** Opaque slot props: heterogeneous per-slot payloads. */
146
243
  type SlotProps = Record<string, unknown>
147
244
 
@@ -333,6 +430,27 @@ const ITERATE_CSS = `
333
430
  .iterate-switch[data-on] { background: var(--dsw-alias-brand-primary); border-color: var(--dsw-alias-brand-primary); }
334
431
  .iterate-switch[data-on] .iterate-switch-knob { transform: translateX(18px); background: #FFFFFF; }
335
432
 
433
+ /* Shared keyboard focus ring for every iterate interactive control */
434
+ .iterate-btn:focus-visible, .iterate-vbtn:focus-visible, .iterate-batch-btn:focus-visible,
435
+ .iterate-filter-select:focus-visible, .iterate-filter-search:focus-visible,
436
+ .iterate-finding:focus-visible, .iterate-switch:focus-visible {
437
+ outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px;
438
+ }
439
+
440
+ /* Dashboard empty/onboarding state */
441
+ .iterate-dashboard-empty { opacity: 0.75; }
442
+ .iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
443
+
444
+ /* Convergence-completed progress fill */
445
+ .iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
446
+
447
+ /* Batch scope segmented control */
448
+ .iterate-batch-scope { opacity: 0.6; }
449
+ .iterate-batch-scope-on { opacity: 1; border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-label-primary); }
450
+
451
+ /* Overflow dimension chip */
452
+ .iterate-dim-more { opacity: 0.7; font-style: italic; }
453
+
336
454
  /* Button variants */
337
455
  .iterate-btn[data-ghost] { background: transparent; }
338
456
  .iterate-btn[data-danger] { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); color: var(--dsw-alias-state-error-primary); background: transparent; }
@@ -343,6 +461,38 @@ const ITERATE_CSS = `
343
461
  .iterate-guide { margin-top: 4px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; overflow: hidden; background: var(--dsw-alias-bg-layer-2); }
344
462
  .iterate-guide-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; background: var(--dsw-alias-bg-layer-2); border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 12px; color: var(--dsw-alias-label-secondary); }
345
463
  .iterate-guide-body { padding: 12px 14px; font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11.5px; line-height: 1.75; white-space: pre-wrap; color: var(--dsw-alias-label-primary); max-height: 260px; overflow: auto; }
464
+
465
+ /* ── Runtime observatory panel ──────────────────────────────────────────── */
466
+ .iterate-obs { margin: 6px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); overflow: hidden; font-size: 12px; color: var(--dsw-alias-label-primary); }
467
+ .iterate-obs-head { display: flex; align-items: center; gap: 10px; padding: 8px 14px; cursor: pointer; border-bottom: 1px solid var(--dsw-alias-border-l1); flex-wrap: wrap; }
468
+ .iterate-obs-head[data-closed] { border-bottom: none; }
469
+ .iterate-obs-title { font-weight: 650; }
470
+ .iterate-obs-badge { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); color: var(--dsw-alias-brand-primary); font-size: 11px; font-weight: 600; white-space: nowrap; }
471
+ .iterate-obs-badge[data-live] { color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); }
472
+ .iterate-obs-head-meta { margin-left: auto; font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
473
+ .iterate-obs-tabs { display: flex; gap: 4px; padding: 8px 14px 0; flex-wrap: wrap; border-bottom: 1px solid var(--dsw-alias-border-l1); }
474
+ .iterate-obs-tab { padding: 4px 10px; border-radius: 7px; 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; }
475
+ .iterate-obs-tab[data-active] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
476
+ .iterate-obs-body { padding: 12px 14px; max-height: 460px; overflow: auto; }
477
+ .iterate-obs-block { border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; overflow: hidden; margin-bottom: 8px; }
478
+ .iterate-obs-block:last-child { margin-bottom: 0; }
479
+ .iterate-obs-block-head { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--dsw-alias-bg-layer-2); border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-primary); flex-wrap: wrap; }
480
+ .iterate-obs-block-head[data-click] { cursor: pointer; }
481
+ .iterate-obs-block-body { padding: 8px 10px; }
482
+ .iterate-obs-chip { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 6px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
483
+ .iterate-obs-msg { padding: 2px 0; color: var(--dsw-alias-label-secondary); line-height: 1.5; font-size: 11px; word-break: break-word; }
484
+ .iterate-obs-file { font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 10.5px; color: var(--dsw-alias-label-secondary); word-break: break-all; }
485
+ .iterate-obs-bar { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); flex-wrap: wrap; }
486
+ .iterate-obs-bar b { color: var(--dsw-alias-label-primary); font-weight: 600; }
487
+ .iterate-obs-mono { font-family: var(--dsw-font-mono, ui-monospace, monospace); }
488
+ .iterate-obs-code { padding: 6px 8px; margin-top: 4px; border-radius: 6px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 10.5px; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; word-break: break-all; }
489
+ .iterate-obs-input { width: 100%; padding: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 11px; resize: vertical; }
490
+ .iterate-obs-input:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
491
+ .iterate-obs-input::placeholder { color: var(--dsw-alias-label-secondary); }
492
+ .iterate-obs-empty { font-size: 11px; color: var(--dsw-alias-label-secondary); padding: 8px 0; }
493
+ .iterate-obs-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); flex-wrap: wrap; }
494
+ .iterate-obs-row:last-child { border-bottom: none; }
495
+ .iterate-obs-livebadge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 7px; border-radius: 6px; border: 1px solid currentColor; background: color-mix(in srgb, currentColor 10%, transparent); font-size: 10.5px; font-weight: 600; white-space: nowrap; }
346
496
  `
347
497
 
348
498
  // ─── Small helpers ───────────────────────────────────────────────────────────
@@ -402,16 +552,15 @@ function removeStorageByPrefix(prefix: string): number {
402
552
  return removed
403
553
  }
404
554
 
405
- /** Copy text to the clipboard, returning whether it succeeded. */
406
- function copyText(text: string): boolean {
555
+ /** Copy text to the clipboard, resolving to whether it actually succeeded. */
556
+ function copyText(text: string): Promise<boolean> {
407
557
  if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
408
- navigator.clipboard.writeText(text).then(
558
+ return navigator.clipboard.writeText(text).then(
409
559
  () => true,
410
560
  () => false,
411
561
  )
412
- return true
413
562
  }
414
- return false
563
+ return Promise.resolve(false)
415
564
  }
416
565
 
417
566
  /** Literal severity keys recognized by SEVERITY_LABEL / SEVERITY_COLOR. */
@@ -523,7 +672,14 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
523
672
  style: { height: `${Math.max(4, Math.round((p.count / max) * 24))}px` },
524
673
  }),
525
674
  )
526
- return React.createElement('div', { className: 'iterate-trend', title: '各轮发现数量趋势' }, ...bars)
675
+ // Accessible summary: the per-round counts are otherwise invisible to
676
+ // assistive tech (mouse-only title divs).
677
+ const summary = points.map((p) => `Round ${p.round}: ${p.count}`).join(', ')
678
+ return React.createElement('div', {
679
+ className: 'iterate-trend',
680
+ role: 'img',
681
+ 'aria-label': `各轮发现数量趋势:${summary}`,
682
+ }, ...bars)
527
683
  }
528
684
 
529
685
  /** Dashboard: live convergence strip above the composer.
@@ -549,7 +705,16 @@ function ConvergenceDashboard(props: SlotProps) {
549
705
  setPulseKey((k) => k + 1)
550
706
  }, [report && hashReport(report) + ':' + getCurrentRound(report)])
551
707
 
552
- if (!report) return null
708
+ if (!report) {
709
+ // Empty/onboarding state: first-time users otherwise see nothing and have
710
+ // no idea the plugin exists or how to start.
711
+ return React.createElement(
712
+ 'div',
713
+ { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard iterate-dashboard-empty' },
714
+ React.createElement('span', { className: 'iterate-round-badge' }, 'iterate'),
715
+ React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。试试「review this project」或「/iterate review-only」'),
716
+ )
717
+ }
553
718
 
554
719
  const round = getCurrentRound(report)
555
720
  const total = getTotalRounds(report)
@@ -577,13 +742,25 @@ function ConvergenceDashboard(props: SlotProps) {
577
742
  }, `附件图片 ${String(imageCount)}`)
578
743
  : null
579
744
 
580
- const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
745
+ const dimNames = Object.keys(dims)
746
+ const dimBadges = dimNames.slice(0, 6).map((dim) =>
581
747
  React.createElement(
582
748
  'span',
583
749
  { key: dim, className: 'iterate-dim-badge' },
584
750
  `${dim} · ${(dims[dim]?.length ?? 0)}`,
585
751
  ),
586
752
  )
753
+ // Don't silently drop dimensions: surface the overflow as a +N chip.
754
+ const overflow = dimNames.length - 6
755
+ if (overflow > 0) {
756
+ dimBadges.push(
757
+ React.createElement(
758
+ 'span',
759
+ { key: '+more', className: 'iterate-dim-badge iterate-dim-more', title: dimNames.slice(6).join(', ') },
760
+ `+${overflow} 更多`,
761
+ ),
762
+ )
763
+ }
587
764
 
588
765
  // Fix-count badge: show a running "fixes applied" metric when the report
589
766
  // carries a number (normal mode only — threaded through `fixedCount`).
@@ -599,6 +776,23 @@ function ConvergenceDashboard(props: SlotProps) {
599
776
  }, `${String(fixCount)} fixes`)
600
777
  : null
601
778
 
779
+ // Convergence is the payoff of the review — make it visible on the
780
+ // persistent dashboard, not only in the transient 3.6s capsule.
781
+ const converged = report.convergence && report.convergence.converged === true
782
+ const convChip = converged
783
+ ? React.createElement('span', {
784
+ className: 'iterate-chip-resume',
785
+ key: 'converged',
786
+ title: '审查已收敛:最后一轮未发现新问题',
787
+ }, '✓ 已收敛')
788
+ : null
789
+
790
+ const sevMetric = (key: 'critical' | 'high' | 'medium' | 'low', label: string) =>
791
+ React.createElement('span', { className: 'iterate-metric', key, title: label },
792
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR[key] } }),
793
+ `${label} ${String(stats[key])}`,
794
+ )
795
+
602
796
  return React.createElement(
603
797
  'div',
604
798
  { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
@@ -608,20 +802,16 @@ function ConvergenceDashboard(props: SlotProps) {
608
802
  `Round ${round} / ${total}`,
609
803
  ),
610
804
  React.createElement('div', { className: 'iterate-progress' },
611
- React.createElement('div', { className: 'iterate-progress-fill', style: { width: `${progress}%` } }),
612
- ),
613
- React.createElement('span', { className: 'iterate-metric' },
614
- React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.critical } }),
615
- stats.critical,
616
- ),
617
- React.createElement('span', { className: 'iterate-metric' },
618
- React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.high } }),
619
- stats.high,
620
- ),
621
- React.createElement('span', { className: 'iterate-metric' },
622
- React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
623
- stats.medium,
805
+ React.createElement('div', {
806
+ className: converged ? 'iterate-progress-fill iterate-progress-fill-done' : 'iterate-progress-fill',
807
+ style: { width: `${progress}%` },
808
+ }),
624
809
  ),
810
+ convChip,
811
+ sevMetric('critical', 'CRIT'),
812
+ sevMetric('high', 'HIGH'),
813
+ sevMetric('medium', 'MED'),
814
+ sevMetric('low', 'LOW'),
625
815
  fixBadge,
626
816
  resumeChip,
627
817
  imageChip,
@@ -731,14 +921,15 @@ function TriagePanel(props: SlotProps) {
731
921
  const [selected, setSelected] = React.useState<number | null>(null)
732
922
  const [selectAll, setSelectAll] = React.useState(false)
733
923
 
734
- /** Persist + return the next verdicts state. */
735
- const persistVerdicts = (next: Record<string, 'keep' | 'skip' | 'ignore'>) => {
736
- if (storage) storage.set(storageKey, JSON.stringify(next))
737
- return next
738
- }
924
+ /** Persist the verdicts to localStorage whenever they change. Kept OUT of
925
+ * the state updater (updaters must stay pure React may double-invoke them
926
+ * under StrictMode, and a storage throw must not surface during render). */
927
+ React.useEffect(() => {
928
+ if (storage) storage.set(storageKey, JSON.stringify(verdicts))
929
+ }, [storageKey, verdicts])
739
930
 
740
931
  const setVerdict = (index: number, verdict: 'keep' | 'skip' | 'ignore') => {
741
- setVerdicts((prev) => persistVerdicts({ ...prev, [String(index)]: verdict }))
932
+ setVerdicts((prev) => ({ ...prev, [String(index)]: verdict }))
742
933
  }
743
934
 
744
935
  // ── Filtering (visible findings + their original indices) ───────────────
@@ -757,13 +948,10 @@ function TriagePanel(props: SlotProps) {
757
948
  const allIndices = allVerdictKeys(verdicts)
758
949
  const batchTarget = selectAll ? allIndices : indices
759
950
  const applyBatch = (verdict: 'keep' | 'skip' | 'ignore') => {
760
- setVerdicts((prev) => persistVerdicts(batchSetVerdict(prev, batchTarget, verdict)))
761
- }
762
- const applyBatchAll = (verdict: 'keep' | 'skip' | 'ignore') => {
763
- setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, verdict)))
951
+ setVerdicts((prev) => batchSetVerdict(prev, batchTarget, verdict))
764
952
  }
765
953
  const doResetVerdicts = () => {
766
- setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, 'keep')))
954
+ setVerdicts((prev) => setAllVerdicts(prev, 'keep'))
767
955
  setSelectAll(false)
768
956
  }
769
957
 
@@ -772,8 +960,13 @@ function TriagePanel(props: SlotProps) {
772
960
  const doc = typeof document !== 'undefined' ? document : null
773
961
  if (!doc) return
774
962
  const onKeyDown = (ev: KeyboardEvent) => {
963
+ // Never hijack modified shortcuts (Cmd/Ctrl/Alt combos like Cmd+A
964
+ // select-all) or keystrokes typed into an editable surface (the composer
965
+ // may be a contenteditable div, not a textarea).
966
+ if (ev.metaKey || ev.ctrlKey || ev.altKey) return
775
967
  const t = ev.target as HTMLElement | null
776
968
  if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
969
+ if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
777
970
  const verdict = keyToVerdict(ev.key)
778
971
  if (verdict && selected !== null && indices.includes(selected)) {
779
972
  ev.preventDefault()
@@ -807,10 +1000,16 @@ function TriagePanel(props: SlotProps) {
807
1000
  const doCopyYaml = () => {
808
1001
  const yaml = toKnownIntentionalYaml(ignored)
809
1002
  if (!yaml) return
810
- const ok = copyText(yaml)
811
- setCopied(true)
812
- setTimeout(() => setCopied(false), 1600)
813
- if (!ok) setPayload(yaml)
1003
+ copyText(yaml).then((ok) => {
1004
+ if (ok) {
1005
+ setCopied(true)
1006
+ setTimeout(() => setCopied(false), 1600)
1007
+ } else {
1008
+ // Copy failed (permissions/unsupported) — reveal the payload as a
1009
+ // manual-copy fallback instead of claiming success.
1010
+ setPayload(yaml)
1011
+ }
1012
+ })
814
1013
  }
815
1014
 
816
1015
  const doBuildInstruction = () => {
@@ -841,7 +1040,14 @@ function TriagePanel(props: SlotProps) {
841
1040
  key: String(index),
842
1041
  className: 'iterate-finding',
843
1042
  'data-selected': isSelected ? '' : undefined,
1043
+ role: 'option',
1044
+ 'aria-selected': isSelected,
1045
+ tabIndex: 0,
844
1046
  onClick: () => setSelected(index),
1047
+ onFocus: () => setSelected(index),
1048
+ onKeyDown: (e: React.KeyboardEvent) => {
1049
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelected(index) }
1050
+ },
845
1051
  },
846
1052
  React.createElement('div', { className: 'iterate-finding-meta' },
847
1053
  React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(severity) } }),
@@ -861,17 +1067,17 @@ function TriagePanel(props: SlotProps) {
861
1067
 
862
1068
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
863
1069
  React.createElement('div', { className: 'iterate-triage-head' },
864
- React.createElement('span', {}, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
1070
+ React.createElement('span', { role: 'heading', 'aria-level': 3 }, `Iterate · Findings 分诊 (${filtered.length}/${findings.length})`),
865
1071
  React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择'),
866
1072
  ),
867
1073
  React.createElement('div', { className: 'iterate-filter' },
868
- React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSeverityFilter(e.target.value), title: '按严重度筛选' },
1074
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.severities[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setSeverityFilter(e.target.value), 'aria-label': '按严重度筛选' },
869
1075
  React.createElement('option', { value: '' }, '全部严重度'),
870
1076
  ...options.severities.map((s) =>
871
1077
  React.createElement('option', { key: s.value, value: s.value }, `${severityLabel(s.value)} (${s.count})`),
872
1078
  ),
873
1079
  ),
874
- React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDimensionFilter(e.target.value), title: '按维度筛选' },
1080
+ React.createElement('select', { className: 'iterate-filter-select', value: filter.dimensions[0] || '', onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setDimensionFilter(e.target.value), 'aria-label': '按维度筛选' },
875
1081
  React.createElement('option', { value: '' }, '全部维度'),
876
1082
  ...options.dimensions.map((d) =>
877
1083
  React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
@@ -881,6 +1087,7 @@ function TriagePanel(props: SlotProps) {
881
1087
  className: 'iterate-filter-search',
882
1088
  type: 'search',
883
1089
  placeholder: '搜索文件 / 摘要…',
1090
+ 'aria-label': '搜索文件或摘要',
884
1091
  value: filter.search,
885
1092
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => setSearchFilter(e.target.value),
886
1093
  }),
@@ -891,26 +1098,35 @@ function TriagePanel(props: SlotProps) {
891
1098
  ),
892
1099
  ),
893
1100
  React.createElement('div', { className: 'iterate-batch' },
894
- React.createElement('span', { className: 'iterate-batch-label' }, '批量:'),
895
- React.createElement('label', { className: 'iterate-batch-check', title: '勾选后批量按钮作用于全部 findings,否则仅当前可见' },
896
- React.createElement('input', { type: 'checkbox', checked: selectAll, onChange: (e) => setSelectAll(e.target.checked) }),
897
- selectAll ? `全部 ${allIndices.length}` : '全选',
898
- ),
899
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, '全部 y'),
900
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, '全部 n'),
901
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, '全部 a'),
902
- React.createElement('span', { className: 'iterate-batch-label', style: { marginLeft: 8 } }, '全部:'),
903
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('keep') }, 'y'),
904
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('skip') }, 'n'),
905
- React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('ignore') }, 'a'),
1101
+ React.createElement('span', { className: 'iterate-batch-label' }, '批量作用于:'),
1102
+ React.createElement('button', {
1103
+ className: selectAll ? 'iterate-batch-btn iterate-batch-scope' : 'iterate-batch-btn iterate-batch-scope iterate-batch-scope-on',
1104
+ onClick: () => setSelectAll(false),
1105
+ title: '批量按钮仅作用于当前筛选可见的 findings',
1106
+ }, `可见 ${indices.length}`),
1107
+ React.createElement('button', {
1108
+ className: selectAll ? 'iterate-batch-btn iterate-batch-scope iterate-batch-scope-on' : 'iterate-batch-btn iterate-batch-scope',
1109
+ onClick: () => setSelectAll(true),
1110
+ title: '批量按钮作用于全部 findings',
1111
+ }, `全部 ${allIndices.length}`),
1112
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, 'y'),
1113
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, 'n'),
1114
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, 'a'),
906
1115
  React.createElement('button', { className: 'iterate-batch-btn', onClick: doResetVerdicts, title: '把所有判定恢复为默认 y(修复)' }, '重置'),
907
1116
  ),
908
1117
  ...rows,
909
1118
  React.createElement('div', { className: 'iterate-triage-foot' },
910
1119
  React.createElement('span', {}, `y ${counts.keep} · n ${counts.skip} · a ${counts.ignore} · 待写回 known_intentional:${ignoredCount} 条`),
911
1120
  React.createElement('span', { style: { display: 'flex', gap: 6 } },
912
- React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyYaml }, copied ? '已复制' : '复制 known_intentional'),
913
- React.createElement('button', { className: 'iterate-btn', onClick: doBuildInstruction }, '生成应用指令'),
1121
+ React.createElement('button', {
1122
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined,
1123
+ onClick: doCopyYaml, disabled: ignoredCount === 0,
1124
+ title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '复制 known_intentional YAML',
1125
+ }, copied ? '已复制' : `复制 known_intentional${ignoredCount > 0 ? `(${ignoredCount})` : ''}`),
1126
+ React.createElement('button', {
1127
+ className: 'iterate-btn', onClick: doBuildInstruction, disabled: ignoredCount === 0,
1128
+ title: ignoredCount === 0 ? '当前没有标记为「已知有意」的 finding' : '生成 iterate_triage 应用指令',
1129
+ }, '生成应用指令'),
914
1130
  ),
915
1131
  ),
916
1132
  payload
@@ -1031,10 +1247,11 @@ function SettingsPanel(_props: SlotProps) {
1031
1247
  setTimeout(() => setter(false), 1600)
1032
1248
  }
1033
1249
 
1034
- /** Copy a guide/status block; keep the copy button state in sync. */
1250
+ /** Copy a guide/status block; flash success only when it actually copied. */
1035
1251
  const doCopy = (text: string, slot: 'guide' | 'status') => {
1036
- copyText(text)
1037
- flashCopied(slot)
1252
+ copyText(text).then((ok) => {
1253
+ if (ok) flashCopied(slot)
1254
+ })
1038
1255
  }
1039
1256
 
1040
1257
  /** Two-step destroy guard: first click arms, second click clears. */
@@ -1160,6 +1377,525 @@ function SettingsPanel(_props: SlotProps) {
1160
1377
  )
1161
1378
  }
1162
1379
 
1380
+ // ─── Runtime observatory panel (F1–F7) ──────────────────────────────────────
1381
+
1382
+ /** Tab id → label for the observatory's eight panels. Live (real-time activity
1383
+ * stream) is first so it is the default view; F-panels follow. */
1384
+ const OBS_TABS = [
1385
+ { key: 'live', label: '实时活动流' },
1386
+ { key: 'f1', label: '审查线程' },
1387
+ { key: 'f2', label: '收敛趋势' },
1388
+ { key: 'f3', label: '发现定位' },
1389
+ { key: 'f4', label: '修复与回滚' },
1390
+ { key: 'f5', label: '断点恢复' },
1391
+ { key: 'f6', label: '运行控制台' },
1392
+ { key: 'f7', label: '决策时间线' },
1393
+ ] as const
1394
+
1395
+ /** Live subagent activity badges: type → Chinese label + dsw token color accent. */
1396
+ const OBS_LIVE_META: Record<string, { label: string; color: string }> = {
1397
+ read: { label: '阅读', color: 'var(--dsw-alias-brand-primary)' },
1398
+ fix: { label: '修复', color: 'var(--dsw-alias-state-success-primary)' },
1399
+ rollback: { label: '回滚', color: 'var(--dsw-alias-state-error-primary)' },
1400
+ diff: { label: '差异', color: 'var(--dsw-alias-state-warn-primary)' },
1401
+ review: { label: '审查', color: 'var(--dsw-alias-brand-primary)' },
1402
+ triage: { label: '分流', color: 'var(--dsw-alias-state-warn-primary)' },
1403
+ checkpoint: { label: '断点', color: 'var(--dsw-alias-brand-primary)' },
1404
+ validate: { label: '校验', color: 'var(--dsw-alias-state-success-primary)' },
1405
+ log: { label: '记录', color: 'var(--dsw-alias-label-secondary)' },
1406
+ prune: { label: '清理', color: 'var(--dsw-alias-state-warn-primary)' },
1407
+ info: { label: '信息', color: 'var(--dsw-alias-label-secondary)' },
1408
+ }
1409
+ const OBS_LIVE_FALLBACK_COLOR = 'var(--dsw-alias-label-secondary)'
1410
+
1411
+ /**
1412
+ * Format a live-event timestamp as DD/MM HH:mm:ss (0-padded). Accepts an epoch
1413
+ * ms number or an ISO-ish string; any unparseable value degrades to ''.
1414
+ */
1415
+ function formatObsLiveTime(ts?: string): string {
1416
+ if (!ts) return ''
1417
+ let date: Date | null = null
1418
+ const num = Number(ts)
1419
+ if (typeof ts === 'string' && ts !== '' && !Number.isNaN(num) && /^\d+$/.test(ts)) {
1420
+ date = new Date(num)
1421
+ } else {
1422
+ const d = new Date(ts)
1423
+ date = Number.isNaN(d.getTime()) ? null : d
1424
+ }
1425
+ if (!date) return String(ts)
1426
+ const p = (n: number) => String(n).padStart(2, '0')
1427
+ const h = p(date.getHours())
1428
+ const mi = p(date.getMinutes())
1429
+ const s = p(date.getSeconds())
1430
+ return `${p(date.getDate())}/${p(date.getMonth() + 1)} ${h}:${mi}:${s}`
1431
+ }
1432
+
1433
+ /** Find the latest runtime-observatory manifest inside a session snapshot. */
1434
+ function latestTranscript(session: unknown): ObsManifest | null {
1435
+ if (!session) return null
1436
+ const raw = scanSessionForTranscript(session)
1437
+ return raw ? normalizeTranscript(raw) as ObsManifest : null
1438
+ }
1439
+
1440
+ /** Build a human-pasteable `iterate_fix` instruction for one finding. */
1441
+ function buildObsFixInstruction(f: ObsFinding): string {
1442
+ const payload = JSON.stringify({
1443
+ file: String(f.file || ''),
1444
+ ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1445
+ dimension: String(f.dimension || ''),
1446
+ summary: String(f.summary || ''),
1447
+ ...(f.suggested_fix ? { suggested_fix: String(f.suggested_fix) } : {}),
1448
+ }, null, 2)
1449
+ return `请调用 \`iterate_fix\` 修复以下 finding:\n\n\`\`\`json\n${payload}\n\`\`\``
1450
+ }
1451
+
1452
+ /**
1453
+ * ObservatoryPanel: an 8-tab runtime observatory that renders the latest
1454
+ * iterate_transcript manifest found in the dsh session stream. The panel is
1455
+ * collapsed by default to a single entry row so it never re-occludes the main
1456
+ * convergence dashboard; clicking expands it into tabs. Every field read is
1457
+ * defensively guarded (optional chaining + fallbacks) so a partial/malformed
1458
+ * manifest never crashes the slot. The client cannot call harness tools
1459
+ * directly, so every action copies a paste-able instruction text instead.
1460
+ */
1461
+ function ObservatoryPanel(props: SlotProps) {
1462
+ const session = props && props.session ? props.session : null
1463
+ const manifest = latestTranscript(session)
1464
+
1465
+ const [open, setOpen] = React.useState(false)
1466
+ const [tab, setTab] = React.useState('live')
1467
+ const [expandedThreads, setExpandedThreads] = React.useState<Set<string>>(new Set())
1468
+ const [copiedKey, setCopiedKey] = React.useState<string | null>(null)
1469
+ const [nudgeText, setNudgeText] = React.useState('')
1470
+ const [timelineType, setTimelineType] = React.useState('')
1471
+ const [timelineSearch, setTimelineSearch] = React.useState('')
1472
+
1473
+ // Single shared "copied" flash timer (reused across all copy buttons).
1474
+ const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
1475
+ React.useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
1476
+
1477
+ /** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
1478
+ const copyInstruction = (key: string, text: string) => {
1479
+ if (!text) return
1480
+ copyText(text).then((ok) => {
1481
+ if (!ok) return
1482
+ setCopiedKey(key)
1483
+ if (copyTimer.current) clearTimeout(copyTimer.current)
1484
+ copyTimer.current = setTimeout(() => setCopiedKey((cur) => (cur === key ? null : cur)), 1600)
1485
+ })
1486
+ }
1487
+
1488
+ const toggleThread = (key: string) => {
1489
+ setExpandedThreads((prev) => {
1490
+ const next = new Set(prev)
1491
+ if (next.has(key)) next.delete(key); else next.add(key)
1492
+ return next
1493
+ })
1494
+ }
1495
+
1496
+ // No observable transcript yet: show a compact, collapsed header row.
1497
+ if (!manifest) {
1498
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
1499
+ React.createElement('div', { className: 'iterate-obs-head', 'data-closed': '' },
1500
+ React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
1501
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, '暂无运行时观测数据'),
1502
+ ),
1503
+ )
1504
+ }
1505
+
1506
+ const live = manifest.active === true
1507
+ const roundMeta = typeof manifest.round === 'number'
1508
+ ? (typeof manifest.maxRounds === 'number' ? `Round ${manifest.round}/${manifest.maxRounds}` : `Round ${manifest.round}`)
1509
+ : ''
1510
+ const headMeta = [manifest.mode || '', roundMeta, manifest.updatedAt || ''].filter(Boolean).join(' · ')
1511
+
1512
+ // ── F1: review threads ─────────────────────────────────────────────────
1513
+ const threadFindingPill = (f: ObsFinding, keyBase: string) => {
1514
+ const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
1515
+ return React.createElement('div', { key: keyBase, className: 'iterate-obs-row' },
1516
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(f.severity) } }),
1517
+ React.createElement('span', {}, severityLabel(f.severity)),
1518
+ React.createElement('span', { className: 'iterate-obs-file' }, loc),
1519
+ React.createElement('span', {}, String(f.dimension || '')),
1520
+ React.createElement('span', { className: 'iterate-obs-msg' }, String(f.summary || '')),
1521
+ )
1522
+ }
1523
+
1524
+ const renderThreads = () => {
1525
+ const rounds = manifest.rounds || []
1526
+ if (rounds.length === 0) {
1527
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无审查线程')
1528
+ }
1529
+ return React.createElement('div', {}, ...rounds.map((r, ri) => {
1530
+ const threads = r.threads || []
1531
+ const fCount = threads.reduce((sum, t) => sum + (t.findings ? t.findings.length : 0), 0)
1532
+ const threadBlocks = threads.length === 0
1533
+ ? [React.createElement('div', { key: 'none', className: 'iterate-obs-empty' }, '本轮无线程')]
1534
+ : threads.map((t, ti) => {
1535
+ const key = `${ri}-${ti}`
1536
+ const expanded = expandedThreads.has(key)
1537
+ const dim = t.dimension || '未命名维度'
1538
+ const tFindings = t.findings || []
1539
+ const files = t.readFiles || []
1540
+ const msgs = t.messages || []
1541
+ return React.createElement('div', { key, className: 'iterate-obs-block' },
1542
+ React.createElement('div', { className: 'iterate-obs-block-head', 'data-click': '', onClick: () => toggleThread(key), title: expanded ? '收起线程' : '展开线程' },
1543
+ React.createElement('span', {}, expanded ? '−' : '+'),
1544
+ React.createElement('b', {}, dim),
1545
+ React.createElement('span', { className: 'iterate-obs-chip' }, `attempt ${typeof t.attempt === 'number' ? t.attempt : '?'}`),
1546
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${tFindings.length} findings`),
1547
+ ),
1548
+ expanded
1549
+ ? React.createElement('div', { className: 'iterate-obs-block-body' },
1550
+ React.createElement('div', { className: 'iterate-obs-bar' },
1551
+ React.createElement('b', {}, '叙述'),
1552
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `${msgs.length} 条`),
1553
+ ),
1554
+ ...(msgs.length === 0
1555
+ ? [React.createElement('div', { key: 'msg-none', className: 'iterate-obs-empty' }, '无线索叙述')]
1556
+ : msgs.map((m, mi) => React.createElement('div', { key: mi, className: 'iterate-obs-msg' }, String(m)))),
1557
+ ...(files.length > 0
1558
+ ? [
1559
+ React.createElement('div', { key: 'files-h', className: 'iterate-obs-bar', style: { marginTop: 6 } },
1560
+ React.createElement('b', {}, '读取文件'),
1561
+ ),
1562
+ ...files.map((f, fi) =>
1563
+ React.createElement('div', { key: `f${fi}`, className: 'iterate-obs-file' }, String(f)),
1564
+ ),
1565
+ ]
1566
+ : []),
1567
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
1568
+ React.createElement('b', {}, '发现'),
1569
+ ),
1570
+ ...(tFindings.length === 0
1571
+ ? [React.createElement('div', { key: 'finding-none', className: 'iterate-obs-empty' }, '本线程无发现')]
1572
+ : tFindings.map((f, fi) => threadFindingPill(f, `tf${fi}`))),
1573
+ )
1574
+ : null,
1575
+ )
1576
+ })
1577
+ return React.createElement('div', { key: ri, className: 'iterate-obs-block' },
1578
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1579
+ React.createElement('b', {}, `Round ${typeof r.round === 'number' ? r.round : ri + 1}`),
1580
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${threads.length} 线程`),
1581
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `${fCount} findings`),
1582
+ ),
1583
+ React.createElement('div', { className: 'iterate-obs-block-body' }, ...threadBlocks),
1584
+ )
1585
+ }))
1586
+ }
1587
+
1588
+ // ── F2: convergence trend (inline bars, reusing TrendChart) ─────────────
1589
+ const renderTrend = () => {
1590
+ const conv = manifest.convergence || []
1591
+ if (conv.length === 0) {
1592
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无收敛数据')
1593
+ }
1594
+ const points = conv.map((n, i) => ({ round: i + 1, count: n }))
1595
+ return React.createElement('div', {},
1596
+ React.createElement(TrendChart, { points }),
1597
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
1598
+ `各轮发现数量:${conv.join(' → ')}${typeof manifest.round === 'number' ? ` · 当前 Round ${manifest.round}` : ''}`,
1599
+ ),
1600
+ )
1601
+ }
1602
+
1603
+ // ── F3: finding location + action copy buttons ─────────────────────────
1604
+ const renderFindings = () => {
1605
+ const findings = manifest.findings || []
1606
+ if (findings.length === 0) {
1607
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无发现')
1608
+ }
1609
+ return React.createElement('div', {}, ...findings.map((f, i) => {
1610
+ const k = `f3-${i}`
1611
+ const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
1612
+ const entry = {
1613
+ file: String(f.file || ''),
1614
+ ...(typeof f.line === 'number' && f.line > 0 ? { line: f.line } : {}),
1615
+ dimension: String(f.dimension || ''),
1616
+ reason: String(f.summary || ''),
1617
+ }
1618
+ const knownInstruction = buildApplyInstruction([entry])
1619
+ const fixInstruction = buildObsFixInstruction(f)
1620
+ return React.createElement('div', { key: k, className: 'iterate-obs-block' },
1621
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1622
+ React.createElement('span', { className: 'iterate-sev-dot', style: { background: severityColor(f.severity) } }),
1623
+ React.createElement('span', {}, severityLabel(f.severity)),
1624
+ React.createElement('span', { className: 'iterate-obs-file' }, loc),
1625
+ React.createElement('span', {}, String(f.dimension || '')),
1626
+ f.acknowledged === true
1627
+ ? React.createElement('span', { className: 'iterate-obs-chip' }, '已确认')
1628
+ : null,
1629
+ ),
1630
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1631
+ React.createElement('div', { className: 'iterate-obs-msg' }, String(f.summary || '')),
1632
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
1633
+ React.createElement('button', {
1634
+ className: 'iterate-btn', 'data-copied': copiedKey === `${k}-fix` ? '' : undefined,
1635
+ onClick: () => copyInstruction(`${k}-fix`, String(f.suggested_fix || '')),
1636
+ title: '复制该 finding 的建议修复文本',
1637
+ }, copiedKey === `${k}-fix` ? '已复制' : '复制 suggested_fix'),
1638
+ React.createElement('button', {
1639
+ className: 'iterate-btn', 'data-copied': copiedKey === `${k}-known` ? '' : undefined,
1640
+ onClick: () => copyInstruction(`${k}-known`, knownInstruction),
1641
+ title: '复制 iterate_triage 指令文本(标记为 known_intentional)',
1642
+ }, copiedKey === `${k}-known` ? '已复制' : 'known_intentional'),
1643
+ React.createElement('button', {
1644
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === `${k}-repair` ? '' : undefined,
1645
+ onClick: () => copyInstruction(`${k}-repair`, fixInstruction),
1646
+ title: '复制 iterate_fix 指令文本',
1647
+ }, copiedKey === `${k}-repair` ? '已复制' : '复制修复指令'),
1648
+ ),
1649
+ ),
1650
+ )
1651
+ }))
1652
+ }
1653
+
1654
+ // ── F4: fixes + rollback ───────────────────────────────────────────────
1655
+ const renderFixes = () => {
1656
+ const fixes = manifest.fixes || []
1657
+ if (fixes.length === 0) {
1658
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无修复记录')
1659
+ }
1660
+ return React.createElement('div', {}, ...fixes.map((f, i) => {
1661
+ const k = `f4-${i}`
1662
+ const id = String(f.id || `fix#${i + 1}`)
1663
+ const rollbackText = `请调用 \`iterate_rollback\` 回滚以下修复:\n\n\`\`\`json\n${JSON.stringify({ id }, null, 2)}\n\`\`\``
1664
+ const added = typeof f.linesAdded === 'number' ? f.linesAdded : 0
1665
+ const removed = typeof f.linesRemoved === 'number' ? f.linesRemoved : 0
1666
+ return React.createElement('div', { key: k, className: 'iterate-obs-block' },
1667
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1668
+ React.createElement('b', {}, id),
1669
+ React.createElement('span', { className: 'iterate-obs-file' }, String(f.file || '?')),
1670
+ React.createElement('span', { className: 'iterate-obs-chip' }, f.success === true ? '成功' : '失败'),
1671
+ typeof f.round === 'number'
1672
+ ? React.createElement('span', { className: 'iterate-obs-head-meta' }, `Round ${f.round}`)
1673
+ : null,
1674
+ ),
1675
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1676
+ React.createElement('div', { className: 'iterate-obs-msg' }, String(f.summary || '')),
1677
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 8 } },
1678
+ React.createElement('span', { className: 'iterate-obs-chip' }, `+${added}`),
1679
+ React.createElement('span', { className: 'iterate-obs-chip' }, `−${removed}`),
1680
+ React.createElement('button', {
1681
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === `${k}-rb` ? '' : undefined,
1682
+ onClick: () => copyInstruction(`${k}-rb`, rollbackText),
1683
+ title: '复制 iterate_rollback 指令文本',
1684
+ }, copiedKey === `${k}-rb` ? '已复制' : '回滚'),
1685
+ ),
1686
+ ),
1687
+ )
1688
+ }))
1689
+ }
1690
+
1691
+ // ── F5: checkpoint resume ──────────────────────────────────────────────
1692
+ const renderCheckpoint = () => {
1693
+ const cp = manifest.checkpoint
1694
+ if (!cp) {
1695
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无断点')
1696
+ }
1697
+ const resumeText = `请调用 \`iterate_checkpoint\` 从断点恢复迭代:\n\n\`\`\`json\n${JSON.stringify({
1698
+ operation: 'resume',
1699
+ mode: String(cp.mode || 'normal'),
1700
+ maxRounds: typeof cp.maxRounds === 'number' ? cp.maxRounds : null,
1701
+ }, null, 2)}\n\`\`\``
1702
+ const item = (label: string, value: unknown) =>
1703
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${label} ${String(value ?? '?')}`)
1704
+ return React.createElement('div', { className: 'iterate-obs-block' },
1705
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1706
+ React.createElement('b', {}, '断点'),
1707
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, String(cp.updatedAt || '')),
1708
+ ),
1709
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1710
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1711
+ item('mode', cp.mode || 'normal'),
1712
+ item('round', cp.round),
1713
+ item('maxRounds', cp.maxRounds),
1714
+ item('fixed', cp.fixedCount),
1715
+ item('resume', cp.resumeCount),
1716
+ ),
1717
+ React.createElement('button', {
1718
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
1719
+ onClick: () => copyInstruction('cp-resume', resumeText),
1720
+ title: '复制 iterate_checkpoint 综/恢复指令文本',
1721
+ }, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
1722
+ ),
1723
+ )
1724
+ }
1725
+
1726
+ // ── F6: run console (status / approval / nudge draft) ──────────────────
1727
+ const renderConsole = () => {
1728
+ const approval = manifest.approval || { policy: 'ask' }
1729
+ const policyLabelMap: Record<string, string> = { ask: '询问用户', deny: '拒绝', allow: '直接执行' }
1730
+ const policyLabel = policyLabelMap[String(approval.policy || 'ask')] || String(approval.policy || 'ask')
1731
+ const nudge = manifest.nudge || null
1732
+ const present = Boolean(nudge && nudge.text)
1733
+ const activeNudgeText = nudge && nudge.text ? nudge.text : ''
1734
+ const nudgeInstruction = `请调用 \`iterate_transcript\` 写入 nudge 指令:\n\n\`\`\`json\n${JSON.stringify({ operation: 'nudge', text: nudgeText }, null, 2)}\n\`\`\``
1735
+ return React.createElement('div', { className: 'iterate-obs-block' },
1736
+ React.createElement('div', { className: 'iterate-obs-block-head' },
1737
+ React.createElement('span', {}, '运行控制台'),
1738
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
1739
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, `批准策略:${policyLabel}`),
1740
+ ),
1741
+ React.createElement('div', { className: 'iterate-obs-block-body' },
1742
+ present
1743
+ ? React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 6 } },
1744
+ React.createElement('b', {}, '当前 nudge'),
1745
+ React.createElement('span', { className: 'iterate-obs-msg' }, activeNudgeText),
1746
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setNudgeText('') }, '清除'),
1747
+ )
1748
+ : null,
1749
+ React.createElement('textarea', {
1750
+ className: 'iterate-obs-input iterate-obs-mono',
1751
+ rows: 3,
1752
+ placeholder: '起草一条 nudge 方向指令…',
1753
+ value: nudgeText,
1754
+ 'aria-label': 'nudge 草稿',
1755
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setNudgeText(e.target.value),
1756
+ }),
1757
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
1758
+ React.createElement('button', {
1759
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'nudge' ? '' : undefined,
1760
+ disabled: !nudgeText,
1761
+ onClick: () => copyInstruction('nudge', nudgeInstruction),
1762
+ title: '复制 iterate_transcript nudge 指令文本',
1763
+ }, copiedKey === 'nudge' ? '已复制' : '复制 nudge 指令'),
1764
+ ),
1765
+ ),
1766
+ )
1767
+ }
1768
+
1769
+ // ── F7: decision timeline (type filter + search, newest first) ──────────
1770
+ const renderTimeline = () => {
1771
+ const entries = manifest.timeline || []
1772
+ if (entries.length === 0) {
1773
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无时间线条目')
1774
+ }
1775
+ const types = Array.from(new Set(entries.map((t) => String(t.type || 'unknown')).filter(Boolean))).sort()
1776
+ const q = timelineSearch.trim().toLowerCase()
1777
+ const filtered = entries.filter((t) => {
1778
+ if (timelineType && String(t.type || '') !== timelineType) return false
1779
+ if (!q) return true
1780
+ const hay = [String(t.type || ''), String(t.round ?? ''), JSON.stringify(t.data || {})].join(' ').toLowerCase()
1781
+ return hay.indexOf(q) >= 0
1782
+ })
1783
+ // Newest first: entries are not guaranteed to be reverse ordered in the
1784
+ // manifest, so sort by timestamp string descending for a stable timeline.
1785
+ const sorted = filtered.slice().sort((a, b) => String(b.timestamp || '').localeCompare(String(a.timestamp || '')))
1786
+ const rows = sorted.map((t, i) => {
1787
+ const k = `f7-${i}`
1788
+ const dataText = t.data && typeof t.data === 'object' ? JSON.stringify(t.data) : ''
1789
+ return React.createElement('div', { key: k, className: 'iterate-obs-row', style: { flexDirection: 'column', alignItems: 'flex-start' } },
1790
+ React.createElement('div', { className: 'iterate-obs-bar', style: { width: '100%' } },
1791
+ React.createElement('span', { className: 'iterate-obs-chip' }, String(t.type || '?')),
1792
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
1793
+ `${typeof t.round === 'number' ? `Round ${t.round} · ` : ''}${String(t.timestamp || '')}`,
1794
+ ),
1795
+ ),
1796
+ dataText ? React.createElement('div', { className: 'iterate-obs-code' }, dataText) : null,
1797
+ )
1798
+ })
1799
+ if (rows.length === 0) {
1800
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的时间线条目')
1801
+ }
1802
+ return React.createElement('div', {},
1803
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1804
+ React.createElement('select', {
1805
+ className: 'iterate-filter-select',
1806
+ value: timelineType,
1807
+ 'aria-label': '按时间线类型筛选',
1808
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineType(e.target.value),
1809
+ },
1810
+ React.createElement('option', { value: '' }, '全部类型'),
1811
+ ...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
1812
+ ),
1813
+ React.createElement('input', {
1814
+ className: 'iterate-filter-search',
1815
+ type: 'search',
1816
+ placeholder: '搜索时间线…',
1817
+ 'aria-label': '搜索时间线',
1818
+ value: timelineSearch,
1819
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => setTimelineSearch(e.target.value),
1820
+ }),
1821
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${entries.length}`),
1822
+ ),
1823
+ ...rows,
1824
+ )
1825
+ }
1826
+
1827
+ // ── Live: real-time secondary-subagent activity stream (newest first) ─────
1828
+ const renderLive = () => {
1829
+ const liveEntries = manifest.live || []
1830
+ if (liveEntries.length === 0) {
1831
+ return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无实时活动')
1832
+ }
1833
+ const rows = liveEntries.map((e, i) => {
1834
+ const type = String(e.type || 'unknown')
1835
+ const meta = OBS_LIVE_META[type] || { label: String(e.type || '?'), color: OBS_LIVE_FALLBACK_COLOR }
1836
+ const tool = String(e.tool || '')
1837
+ const target = String(e.target || '')
1838
+ const time = formatObsLiveTime(e.ts)
1839
+ return React.createElement('div', { key: `live-${i}`, className: 'iterate-obs-row' },
1840
+ React.createElement('span', { className: 'iterate-obs-livebadge', style: { color: meta.color } }, meta.label),
1841
+ tool
1842
+ ? React.createElement('span', { className: 'iterate-obs-chip' }, tool)
1843
+ : null,
1844
+ React.createElement('span', { className: 'iterate-obs-msg' }, target || '—'),
1845
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, time),
1846
+ )
1847
+ })
1848
+ return React.createElement('div', {},
1849
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1850
+ React.createElement('b', {}, '实时活动'),
1851
+ React.createElement('span', { className: 'iterate-obs-chip' }, `${liveEntries.length} 项`),
1852
+ ),
1853
+ ...rows,
1854
+ )
1855
+ }
1856
+
1857
+ const renderBody = () => {
1858
+ switch (tab) {
1859
+ case 'live': return renderLive()
1860
+ case 'f1': return renderThreads()
1861
+ case 'f2': return renderTrend()
1862
+ case 'f3': return renderFindings()
1863
+ case 'f4': return renderFixes()
1864
+ case 'f5': return renderCheckpoint()
1865
+ case 'f6': return renderConsole()
1866
+ case 'f7': return renderTimeline()
1867
+ default: return null
1868
+ }
1869
+ }
1870
+
1871
+ return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
1872
+ React.createElement('div', { className: 'iterate-obs-head', 'data-closed': open ? undefined : '', onClick: () => setOpen((v) => !v) },
1873
+ React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
1874
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
1875
+ React.createElement('span', { className: 'iterate-obs-head-meta' }, headMeta || 'runtime'),
1876
+ React.createElement('button', {
1877
+ className: 'iterate-btn', 'data-ghost': '',
1878
+ onClick: (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); setOpen((v) => !v) },
1879
+ }, open ? '收起' : '展开'),
1880
+ ),
1881
+ open
1882
+ ? React.createElement('div', {},
1883
+ React.createElement('div', { className: 'iterate-obs-tabs' },
1884
+ ...OBS_TABS.map((t) =>
1885
+ React.createElement('button', {
1886
+ key: t.key,
1887
+ className: 'iterate-obs-tab',
1888
+ 'data-active': tab === t.key ? '' : undefined,
1889
+ onClick: () => setTab(t.key),
1890
+ }, t.label),
1891
+ ),
1892
+ ),
1893
+ React.createElement('div', { className: 'iterate-obs-body' }, renderBody()),
1894
+ )
1895
+ : null,
1896
+ )
1897
+ }
1898
+
1163
1899
  // ─── Registration ────────────────────────────────────────────────────────────
1164
1900
 
1165
1901
  /**
@@ -1215,6 +1951,15 @@ export function apply(ctx: ClientContext): void {
1215
1951
  ),
1216
1952
  )
1217
1953
 
1954
+ // F1–F7: runtime observatory board (order 91, below the dashboard's 90 so
1955
+ // its collapsed header never occludes the main convergence dashboard).
1956
+ slotsSvc.inject('conversation.input.dock', () =>
1957
+ slotsSvc?.register(
1958
+ { name: 'conversation.input.dock', id: 'iterate-observatory', order: 91 },
1959
+ (props) => React.createElement(ObservatoryPanel, props),
1960
+ ),
1961
+ )
1962
+
1218
1963
  // 2 + 3: triage panel / stats card (turn-tail chain, session scope).
1219
1964
  slotsSvc.inject('conversation.chat.turnTail', () =>
1220
1965
  slotsSvc?.register(