iterate-plugin 2.12.2 → 2.12.3

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.
package/lib/parse.js CHANGED
@@ -1427,4 +1427,97 @@ export function buildRuntimeStatusGuide() {
1427
1427
  '清理状态:让模型调用 iterate_prune(默认 dry-run,只报告不删除,显式 dryRun:false 才真正清理)。',
1428
1428
  ]
1429
1429
  return lines.join('\n')
1430
+ }
1431
+
1432
+ // ─── Runtime-observatory UI pure helpers ─────────────────────────────────────
1433
+
1434
+ /**
1435
+ * Filter the live reviewer-activity feed by activity type. An empty/unknown
1436
+ * `type` matches everything; entries are returned in their original (newest
1437
+ * first) order. Purely defensive: non-array input yields [].
1438
+ *
1439
+ * @param {unknown} entries
1440
+ * @param {unknown} type
1441
+ * @returns {Array<Record<string, unknown>>}
1442
+ */
1443
+ export function filterLiveEntries(entries, type) {
1444
+ const list = Array.isArray(entries) ? entries : []
1445
+ const t = typeof type === 'string' ? type.trim() : ''
1446
+ if (!t) return list.slice()
1447
+ return list.filter((e) => e && typeof e === 'object' && String(e.type ?? '') === t)
1448
+ }
1449
+
1450
+ /**
1451
+ * Filter decision-timeline entries by type / round / free-text search, then
1452
+ * sort newest first by timestamp string (timeline entries are not guaranteed
1453
+ * to be reverse-ordered in the manifest).
1454
+ *
1455
+ * - `type`: exact `entry.type` match when non-empty.
1456
+ * - `round`: exact `entry.round` string match when non-empty.
1457
+ * - `search`: case-insensitive substring over type + round + JSON data.
1458
+ *
1459
+ * @param {unknown} entries
1460
+ * @param {{ type?: unknown, round?: unknown, search?: unknown } | null | undefined} opts
1461
+ * @returns {Array<Record<string, unknown>>}
1462
+ */
1463
+ export function filterTimelineEntries(entries, opts) {
1464
+ const list = Array.isArray(entries) ? entries : []
1465
+ const o = opts && typeof opts === 'object' ? opts : {}
1466
+ const type = typeof o.type === 'string' ? o.type : ''
1467
+ const round = typeof o.round === 'string' ? o.round : ''
1468
+ const q = typeof o.search === 'string' ? o.search.trim().toLowerCase() : ''
1469
+ const filtered = list.filter((t) => {
1470
+ if (!t || typeof t !== 'object') return false
1471
+ if (type && String(t.type ?? '') !== type) return false
1472
+ if (round && String(t.round ?? '') !== round) return false
1473
+ if (q) {
1474
+ const hay = [String(t.type ?? ''), String(t.round ?? ''), JSON.stringify(t.data ?? {})].join(' ').toLowerCase()
1475
+ if (hay.indexOf(q) < 0) return false
1476
+ }
1477
+ return true
1478
+ })
1479
+ return filtered
1480
+ .slice()
1481
+ .sort((a, b) => String(b.timestamp ?? '').localeCompare(String(a.timestamp ?? '')))
1482
+ }
1483
+
1484
+ /**
1485
+ * Serialize the full observatory state (manifest + live feed) into a JSON
1486
+ * string the client can copy/export. Always includes an `exportedAt` stamp and
1487
+ * guards against non-serializable / oversized payloads by falling back to the
1488
+ * manifest only.
1489
+ *
1490
+ * @param {unknown} manifest
1491
+ * @param {unknown} live
1492
+ * @returns {string}
1493
+ */
1494
+ export function serializeObservatoryExport(manifest, live) {
1495
+ const payload = {
1496
+ exportedAt: new Date().toISOString(),
1497
+ manifest: manifest && typeof manifest === 'object' ? manifest : null,
1498
+ live: Array.isArray(live) ? live : [],
1499
+ }
1500
+ try {
1501
+ return JSON.stringify(payload, null, 2)
1502
+ } catch {
1503
+ // A cyclic / non-serializable manifest must not crash the copy action.
1504
+ return JSON.stringify({ exportedAt: payload.exportedAt, manifest: null, live: [] }, null, 2)
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * Latest non-empty workflow phase name (plan / review / fix / validate /
1510
+ * report …) from the transcript manifest's phase list. The last recorded
1511
+ * phase is the one the run is currently in (or last finished).
1512
+ *
1513
+ * @param {unknown} phases
1514
+ * @returns {string}
1515
+ */
1516
+ export function latestPhase(phases) {
1517
+ const list = Array.isArray(phases) ? phases : []
1518
+ let latest = ''
1519
+ for (const p of list) {
1520
+ if (typeof p === 'string' && p.trim()) latest = p.trim()
1521
+ }
1522
+ return latest
1430
1523
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.12.2",
3
+ "version": "2.12.3",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,13 +60,14 @@
60
60
  "test:validate": "tsx --test test/validate.test.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@deepseek-ai/cordis": "4.0.1",
64
- "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
63
+ "@deepseek-ai/cordis": "4.0.2",
64
+ "@deepseek-ai/dsh-tools": "0.1.2-alpha.3",
65
+ "@deepseek-ai/dsh-util-values": "0.1.2-alpha.3",
65
66
  "js-yaml": "4.3.1"
66
67
  },
67
68
  "devDependencies": {
68
- "@deepseek-ai/dsh-jobs": "^0.1.1-rc.2",
69
- "@deepseek-ai/dsh-session": "0.1.1-rc.2",
69
+ "@deepseek-ai/dsh-jobs": "^0.1.2-alpha.3",
70
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.3",
70
71
  "@types/js-yaml": "4.0.9",
71
72
  "@types/node": "22.15.0",
72
73
  "@types/react": "19.2.2",
@@ -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,9 @@ 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); }
425
437
 
426
438
  /* Accessibility-switch toggle */
427
439
  .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 +575,31 @@ function copyText(text: string): Promise<boolean> {
563
575
  return Promise.resolve(false)
564
576
  }
565
577
 
578
+ /**
579
+ * Trigger a browser download of `text` as a file. Resolves to whether the
580
+ * download was actually initiated (fails silently when the DOM/Blob APIs are
581
+ * unavailable, e.g. in a sandboxed renderer without scripting).
582
+ */
583
+ function downloadTextFile(filename: string, text: string): boolean {
584
+ if (typeof document === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined') return false
585
+ try {
586
+ const blob = new Blob([text], { type: 'application/json;charset=utf-8' })
587
+ const url = URL.createObjectURL(blob)
588
+ const a = document.createElement('a')
589
+ a.href = url
590
+ a.download = filename
591
+ a.rel = 'noopener'
592
+ document.body.appendChild(a)
593
+ a.click()
594
+ document.body.removeChild(a)
595
+ // Release the object URL on the next tick so the download has time to start.
596
+ setTimeout(() => URL.revokeObjectURL(url), 0)
597
+ return true
598
+ } catch {
599
+ return false
600
+ }
601
+ }
602
+
566
603
  /** Literal severity keys recognized by SEVERITY_LABEL / SEVERITY_COLOR. */
567
604
  const SEVERITY_KEYS = ['critical', 'high', 'medium', 'low'] as const
568
605
 
@@ -742,6 +779,22 @@ function ConvergenceDashboard(props: SlotProps) {
742
779
  }, `附件图片 ${String(imageCount)}`)
743
780
  : null
744
781
 
782
+ // Live run-state awareness: surface the transcript's current workflow phase
783
+ // (plan / review / fix / validate / report …) plus run liveness, so a
784
+ // long-running round never looks stale on the persistent dashboard.
785
+ const transcript = latestTranscript(session)
786
+ const phase = transcript ? latestPhase(transcript.phases) : ''
787
+ const liveChip = transcript && phase
788
+ ? React.createElement('span', {
789
+ className: 'iterate-chip-phase',
790
+ key: 'phase',
791
+ 'data-live': transcript.active === true ? '' : undefined,
792
+ title: transcript.active === true
793
+ ? `当前处于「${phase}」阶段(运行中)`
794
+ : `最近一次执行停留在「${phase}」阶段(已结束)`,
795
+ }, `${phase} · ${transcript.active === true ? '运行中' : '已结束'}`)
796
+ : null
797
+
745
798
  const dimNames = Object.keys(dims)
746
799
  const dimBadges = dimNames.slice(0, 6).map((dim) =>
747
800
  React.createElement(
@@ -815,6 +868,7 @@ function ConvergenceDashboard(props: SlotProps) {
815
868
  fixBadge,
816
869
  resumeChip,
817
870
  imageChip,
871
+ liveChip,
818
872
  React.createElement(TrendChart, { points: trend.points }),
819
873
  ...dimBadges,
820
874
  )
@@ -1473,6 +1527,8 @@ function ObservatoryPanel(props: SlotProps) {
1473
1527
  const [open, setOpen] = React.useState(false)
1474
1528
  const [tab, setTab] = React.useState('live')
1475
1529
  const [expandedThreads, setExpandedThreads] = React.useState<Set<string>>(new Set())
1530
+ // 'auto' follows the per-thread set; 'all'/'none' force every thread open/closed.
1531
+ const [threadMode, setThreadMode] = React.useState<'auto' | 'all' | 'none'>('auto')
1476
1532
  const [copiedKey, setCopiedKey] = React.useState<string | null>(null)
1477
1533
  // When a clipboard write is blocked (permissions/unsupported), reveal the
1478
1534
  // raw instruction text so the user can copy it manually instead of silently
@@ -1480,7 +1536,13 @@ function ObservatoryPanel(props: SlotProps) {
1480
1536
  const [copyFailText, setCopyFailText] = React.useState<string | null>(null)
1481
1537
  const [nudgeText, setNudgeText] = React.useState('')
1482
1538
  const [timelineType, setTimelineType] = React.useState('')
1539
+ const [timelineRound, setTimelineRound] = React.useState('')
1483
1540
  const [timelineSearch, setTimelineSearch] = React.useState('')
1541
+ const [liveType, setLiveType] = React.useState('')
1542
+ // F3 findings triage-light filter (severity / dimension / search).
1543
+ const [f3Filter, setF3Filter] = React.useState<{ severities: string[]; dimensions: string[]; search: string }>(
1544
+ { severities: [], dimensions: [], search: '' },
1545
+ )
1484
1546
 
1485
1547
  // Single shared "copied" flash timer (reused across all copy buttons).
1486
1548
  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -1497,7 +1559,9 @@ function ObservatoryPanel(props: SlotProps) {
1497
1559
  })
1498
1560
  }
1499
1561
 
1562
+ /** Toggle one thread; a manual toggle exits any forced all/none mode. */
1500
1563
  const toggleThread = (key: string) => {
1564
+ setThreadMode('auto')
1501
1565
  setExpandedThreads((prev) => {
1502
1566
  const next = new Set(prev)
1503
1567
  if (next.has(key)) next.delete(key); else next.add(key)
@@ -1505,6 +1569,37 @@ function ObservatoryPanel(props: SlotProps) {
1505
1569
  })
1506
1570
  }
1507
1571
 
1572
+ /** Export the full observatory snapshot as JSON (download, then copy fallback). */
1573
+ const exportObservatory = () => {
1574
+ const json = serializeObservatoryExport(manifest, manifest.live || [])
1575
+ const filename = `iterate-observatory-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
1576
+ const flash = () => {
1577
+ setCopiedKey('export')
1578
+ if (copyTimer.current) clearTimeout(copyTimer.current)
1579
+ copyTimer.current = setTimeout(() => setCopiedKey((cur) => (cur === 'export' ? null : cur)), 1600)
1580
+ }
1581
+ if (downloadTextFile(filename, json)) { flash(); return }
1582
+ // Download unavailable: fall back to copying the payload to the clipboard,
1583
+ // and reveal the raw text if even the clipboard write is blocked.
1584
+ copyText(json).then((ok) => {
1585
+ if (!ok) { setCopyFailText(json); return }
1586
+ flash()
1587
+ })
1588
+ }
1589
+
1590
+ /** Force every thread open (or closed) and drop per-thread state. */
1591
+ const setAllThreads = (mode: 'all' | 'none') => {
1592
+ setThreadMode(mode)
1593
+ setExpandedThreads(new Set())
1594
+ }
1595
+
1596
+ /** Whether a thread should render expanded given the forced mode + per-thread set. */
1597
+ const isThreadExpanded = (key: string): boolean => {
1598
+ if (threadMode === 'all') return true
1599
+ if (threadMode === 'none') return false
1600
+ return expandedThreads.has(key)
1601
+ }
1602
+
1508
1603
  // No observable transcript yet: show a compact, collapsed header row.
1509
1604
  if (!manifest) {
1510
1605
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
@@ -1538,14 +1633,23 @@ function ObservatoryPanel(props: SlotProps) {
1538
1633
  if (rounds.length === 0) {
1539
1634
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无审查线程')
1540
1635
  }
1541
- return React.createElement('div', {}, ...rounds.map((r, ri) => {
1636
+ return React.createElement('div', {},
1637
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8, flexWrap: 'wrap' } },
1638
+ React.createElement('b', {}, '审查线程'),
1639
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setAllThreads('all'), title: '展开全部线程' }, '全部展开'),
1640
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setAllThreads('none'), title: '收起全部线程' }, '全部收起'),
1641
+ React.createElement('span', { className: 'iterate-filter-count' },
1642
+ `${threadMode === 'all' ? '全部展开' : threadMode === 'none' ? '全部收起' : '自由切换'} · ${rounds.length} 轮`,
1643
+ ),
1644
+ ),
1645
+ ...rounds.map((r, ri) => {
1542
1646
  const threads = r.threads || []
1543
1647
  const fCount = threads.reduce((sum, t) => sum + (t.findings ? t.findings.length : 0), 0)
1544
1648
  const threadBlocks = threads.length === 0
1545
1649
  ? [React.createElement('div', { key: 'none', className: 'iterate-obs-empty' }, '本轮无线程')]
1546
1650
  : threads.map((t, ti) => {
1547
1651
  const key = `${ri}-${ti}`
1548
- const expanded = expandedThreads.has(key)
1652
+ const expanded = isThreadExpanded(key)
1549
1653
  const dim = t.dimension || '未命名维度'
1550
1654
  const tFindings = t.findings || []
1551
1655
  const files = t.readFiles || []
@@ -1618,7 +1722,61 @@ function ObservatoryPanel(props: SlotProps) {
1618
1722
  if (findings.length === 0) {
1619
1723
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无发现')
1620
1724
  }
1621
- return React.createElement('div', {}, ...findings.map((f, i) => {
1725
+ const opts = buildFilterOptions(findings)
1726
+ const { filtered } = filterFindingsWithIndices(findings, f3Filter)
1727
+ const filterActive = f3Filter.severities.length > 0 || f3Filter.dimensions.length > 0 || f3Filter.search.trim() !== ''
1728
+ const toggleSeverity = (sev: string) => {
1729
+ setF3Filter((prev) => ({
1730
+ ...prev,
1731
+ severities: prev.severities.includes(sev)
1732
+ ? prev.severities.filter((s) => s !== sev)
1733
+ : [...prev.severities, sev],
1734
+ }))
1735
+ }
1736
+ const filterBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8, flexWrap: 'wrap' } },
1737
+ React.createElement('b', {}, '发现'),
1738
+ ...opts.severities.map((s) =>
1739
+ React.createElement('button', {
1740
+ key: `sev-${s.value}`,
1741
+ className: 'iterate-filter-chip',
1742
+ 'data-active': f3Filter.severities.includes(s.value) ? '' : undefined,
1743
+ title: `${s.value}(${s.count} 项)`,
1744
+ onClick: () => toggleSeverity(s.value),
1745
+ }, `${s.value} ${s.count}`),
1746
+ ),
1747
+ React.createElement('select', {
1748
+ className: 'iterate-filter-select',
1749
+ value: f3Filter.dimensions[0] || '',
1750
+ 'aria-label': '按维度筛选',
1751
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => {
1752
+ const v = e.target.value
1753
+ setF3Filter((prev) => ({ ...prev, dimensions: v ? [v] : [] }))
1754
+ },
1755
+ },
1756
+ React.createElement('option', { value: '' }, '全部维度'),
1757
+ ...opts.dimensions.map((d) =>
1758
+ React.createElement('option', { key: d.value, value: d.value }, `${d.value} (${d.count})`),
1759
+ ),
1760
+ ),
1761
+ React.createElement('input', {
1762
+ className: 'iterate-filter-search',
1763
+ type: 'search',
1764
+ placeholder: '搜索发现…',
1765
+ 'aria-label': '搜索发现',
1766
+ value: f3Filter.search,
1767
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
1768
+ setF3Filter((prev) => ({ ...prev, search: e.target.value })),
1769
+ }),
1770
+ filterActive
1771
+ ? React.createElement('button', {
1772
+ className: 'iterate-filter-clear',
1773
+ onClick: () => setF3Filter({ severities: [], dimensions: [], search: '' }),
1774
+ title: '清除全部筛选',
1775
+ }, '清除筛选')
1776
+ : null,
1777
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${findings.length}`),
1778
+ )
1779
+ const findingBlocks = filtered.map((f, i) => {
1622
1780
  const k = `f3-${i}`
1623
1781
  const loc = `${String(f.file || '?')}${typeof f.line === 'number' && f.line > 0 ? `:${f.line}` : ''}`
1624
1782
  const entry = {
@@ -1660,7 +1818,14 @@ function ObservatoryPanel(props: SlotProps) {
1660
1818
  ),
1661
1819
  ),
1662
1820
  )
1663
- }))
1821
+ })
1822
+ if (filtered.length === 0) {
1823
+ return React.createElement('div', {},
1824
+ filterBar,
1825
+ React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的发现'),
1826
+ )
1827
+ }
1828
+ return React.createElement('div', {}, filterBar, ...findingBlocks)
1664
1829
  }
1665
1830
 
1666
1831
  // ── F4: fixes + rollback ───────────────────────────────────────────────
@@ -1786,23 +1951,29 @@ function ObservatoryPanel(props: SlotProps) {
1786
1951
  )
1787
1952
  }
1788
1953
 
1789
- // ── F7: decision timeline (type filter + search, newest first) ──────────
1954
+ // ── F7: decision timeline (type/round filter + search, newest first) ────
1790
1955
  const renderTimeline = () => {
1791
1956
  const entries = manifest.timeline || []
1792
1957
  if (entries.length === 0) {
1793
1958
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无时间线条目')
1794
1959
  }
1795
1960
  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
1961
+ const rounds = Array.from(
1962
+ new Set(entries.map((t) => (typeof t.round === 'number' ? String(t.round) : '')).filter(Boolean)),
1963
+ ).sort((a, b) => Number(a) - Number(b))
1964
+ const filtered = filterTimelineEntries(entries, {
1965
+ type: timelineType,
1966
+ round: timelineRound,
1967
+ search: timelineSearch,
1802
1968
  })
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 || '')))
1969
+ const filterActive = Boolean(timelineType || timelineRound || timelineSearch.trim())
1970
+ const clearTimelineFilter = () => {
1971
+ setTimelineType('')
1972
+ setTimelineRound('')
1973
+ setTimelineSearch('')
1974
+ }
1975
+ // filterTimelineEntries already returns entries newest-first.
1976
+ const sorted = filtered.slice()
1806
1977
  const rows = sorted.map((t, i) => {
1807
1978
  const k = `f7-${i}`
1808
1979
  const dataText = t.data && typeof t.data === 'object' ? JSON.stringify(t.data) : ''
@@ -1816,32 +1987,50 @@ function ObservatoryPanel(props: SlotProps) {
1816
1987
  dataText ? React.createElement('div', { className: 'iterate-obs-code' }, dataText) : null,
1817
1988
  )
1818
1989
  })
1819
- if (rows.length === 0) {
1820
- return React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的时间线条目')
1821
- }
1822
- return React.createElement('div', {},
1823
- 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)),
1832
- ),
1833
- React.createElement('input', {
1834
- className: 'iterate-filter-search',
1835
- type: 'search',
1836
- placeholder: '搜索时间线…',
1837
- 'aria-label': '搜索时间线',
1838
- value: timelineSearch,
1839
- onChange: (e: React.ChangeEvent<HTMLInputElement>) => setTimelineSearch(e.target.value),
1840
- }),
1841
- React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${entries.length}`),
1990
+ const filterBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
1991
+ React.createElement('b', {}, '时间线'),
1992
+ React.createElement('select', {
1993
+ className: 'iterate-filter-select',
1994
+ value: timelineType,
1995
+ 'aria-label': '按时间线类型筛选',
1996
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineType(e.target.value),
1997
+ },
1998
+ React.createElement('option', { value: '' }, '全部类型'),
1999
+ ...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
1842
2000
  ),
1843
- ...rows,
2001
+ React.createElement('select', {
2002
+ className: 'iterate-filter-select',
2003
+ value: timelineRound,
2004
+ 'aria-label': '按轮次筛选',
2005
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setTimelineRound(e.target.value),
2006
+ },
2007
+ React.createElement('option', { value: '' }, '全部轮次'),
2008
+ ...rounds.map((r) => React.createElement('option', { key: r, value: r }, `Round ${r}`)),
2009
+ ),
2010
+ React.createElement('input', {
2011
+ className: 'iterate-filter-search',
2012
+ type: 'search',
2013
+ placeholder: '搜索时间线…',
2014
+ 'aria-label': '搜索时间线',
2015
+ value: timelineSearch,
2016
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => setTimelineSearch(e.target.value),
2017
+ }),
2018
+ filterActive
2019
+ ? React.createElement('button', {
2020
+ className: 'iterate-filter-clear',
2021
+ onClick: clearTimelineFilter,
2022
+ title: '清除全部筛选',
2023
+ }, '清除')
2024
+ : null,
2025
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${entries.length}`),
1844
2026
  )
2027
+ if (rows.length === 0) {
2028
+ return React.createElement('div', {},
2029
+ filterBar,
2030
+ React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的时间线条目'),
2031
+ )
2032
+ }
2033
+ return React.createElement('div', {}, filterBar, ...rows)
1845
2034
  }
1846
2035
 
1847
2036
  // ── Live: real-time secondary-subagent activity stream (newest first) ─────
@@ -1864,7 +2053,9 @@ function ObservatoryPanel(props: SlotProps) {
1864
2053
  }
1865
2054
  return React.createElement('div', { className: 'iterate-obs-empty' }, '暂无实时活动')
1866
2055
  }
1867
- const rows = liveEntries.map((e, i) => {
2056
+ const types = Array.from(new Set(liveEntries.map((e) => String(e.type || 'unknown')).filter(Boolean))).sort()
2057
+ const filtered = filterLiveEntries(liveEntries, liveType)
2058
+ const rows = filtered.map((e, i) => {
1868
2059
  const type = String(e.type || 'unknown')
1869
2060
  const meta = OBS_LIVE_META[type] || { label: String(e.type || '?'), color: OBS_LIVE_FALLBACK_COLOR }
1870
2061
  const tool = String(e.tool || '')
@@ -1879,13 +2070,33 @@ function ObservatoryPanel(props: SlotProps) {
1879
2070
  React.createElement('span', { className: 'iterate-obs-head-meta' }, time),
1880
2071
  )
1881
2072
  })
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} 项`),
2073
+ const filterBar = React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8 } },
2074
+ React.createElement('b', {}, '实时活动'),
2075
+ React.createElement('select', {
2076
+ className: 'iterate-filter-select',
2077
+ value: liveType,
2078
+ 'aria-label': '按活动类型筛选',
2079
+ onChange: (e: React.ChangeEvent<HTMLSelectElement>) => setLiveType(e.target.value),
2080
+ },
2081
+ React.createElement('option', { value: '' }, '全部活动'),
2082
+ ...types.map((t) => React.createElement('option', { key: t, value: t }, t)),
1886
2083
  ),
1887
- ...rows,
2084
+ liveType
2085
+ ? React.createElement('button', {
2086
+ className: 'iterate-filter-clear',
2087
+ onClick: () => setLiveType(''),
2088
+ title: '清除类型筛选',
2089
+ }, '清除')
2090
+ : null,
2091
+ React.createElement('span', { className: 'iterate-filter-count' }, `${filtered.length}/${liveEntries.length}`),
1888
2092
  )
2093
+ if (rows.length === 0) {
2094
+ return React.createElement('div', {},
2095
+ filterBar,
2096
+ React.createElement('div', { className: 'iterate-obs-empty' }, '无匹配的实时活动'),
2097
+ )
2098
+ }
2099
+ return React.createElement('div', {}, filterBar, ...rows)
1889
2100
  }
1890
2101
 
1891
2102
  const renderBody = () => {
@@ -1927,6 +2138,16 @@ function ObservatoryPanel(props: SlotProps) {
1927
2138
  React.createElement('div', { className: 'iterate-obs-code' }, copyFailText),
1928
2139
  )
1929
2140
  : null,
2141
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 8, flexWrap: 'wrap' } },
2142
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
2143
+ `${manifest.mode || 'runtime'} · ${String(manifest.updatedAt || '')}`,
2144
+ ),
2145
+ React.createElement('button', {
2146
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'export' ? '' : undefined,
2147
+ onClick: exportObservatory,
2148
+ title: '将观测台全部数据导出为 JSON(优先下载,失败则复制)',
2149
+ }, copiedKey === 'export' ? '已导出' : '导出 JSON'),
2150
+ ),
1930
2151
  React.createElement('div', { className: 'iterate-obs-tabs' },
1931
2152
  ...OBS_TABS.map((t) =>
1932
2153
  React.createElement('button', {
package/src/git-scope.ts CHANGED
@@ -43,6 +43,51 @@ export interface GitScopeResult {
43
43
  * NUL is present (callers that did not pass -z) fall back to newline-split
44
44
  * with C-style quote/escape unescaping for core.quotePath output.
45
45
  */
46
+ /**
47
+ * Decode the quoted body of a git core.quotePath output line into the real
48
+ * filename bytes, then interpret them as UTF-8.
49
+ *
50
+ * Single-pass and escape-atomic: each `\` consumes exactly one escape (\" \\
51
+ * \t \n or a 3-digit octal for a raw byte), so a literal `\\303` in a filename
52
+ * (escaped backslash + literal "303") is decoded as the byte `\` followed by
53
+ * ASCII "303" rather than as the single byte 0xC3. Ordinary characters in the
54
+ * quoted body are ASCII (git always octal-escapes non-ASCII bytes), so they map
55
+ * 1:1 to bytes.
56
+ */
57
+ function decodeQuotedPath(content: string): string {
58
+ const bytes: number[] = []
59
+ let i = 0
60
+ while (i < content.length) {
61
+ const ch = content[i]!
62
+ if (ch !== '\\') {
63
+ bytes.push(ch.charCodeAt(0))
64
+ i++
65
+ continue
66
+ }
67
+ const next = content[i + 1]
68
+ if (next === '"') { bytes.push(0x22); i += 2 }
69
+ else if (next === '\\') { bytes.push(0x5c); i += 2 }
70
+ else if (next === 't') { bytes.push(0x09); i += 2 }
71
+ else if (next === 'n') { bytes.push(0x0a); i += 2 }
72
+ else if (next !== undefined && next >= '0' && next <= '7') {
73
+ const oct = content.slice(i + 1, i + 4)
74
+ if (oct.length === 3 && /^[0-7]{3}$/.test(oct)) {
75
+ bytes.push(parseInt(oct, 8))
76
+ i += 4
77
+ } else {
78
+ // Malformed octal — keep the backslash literally.
79
+ bytes.push(0x5c)
80
+ i++
81
+ }
82
+ } else {
83
+ // Unknown escape — keep the backslash literally.
84
+ bytes.push(0x5c)
85
+ i++
86
+ }
87
+ }
88
+ return Buffer.from(bytes).toString('utf-8')
89
+ }
90
+
46
91
  export function parseChangedFiles(stdout: string): string[] {
47
92
  if (stdout.includes('\0')) {
48
93
  return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0)
@@ -52,15 +97,11 @@ export function parseChangedFiles(stdout: string): string[] {
52
97
  .map((line) => {
53
98
  const trimmed = line.trim()
54
99
  // git core.quotePath wraps paths with special characters in "..."; the
55
- // content uses C-style escapes (\" \\ \t \n and \ooo octal for non-ASCII).
100
+ // content uses C-style escapes (\" \\ \t \n) and \ooo octal escapes for
101
+ // non-ASCII bytes (which are raw UTF-8 BYTES, not Latin-1 code points).
56
102
  const quoted = trimmed.match(/^"(.*)"$/)
57
103
  if (!quoted) return trimmed
58
- return quoted[1]!
59
- .replace(/\\"/g, '"')
60
- .replace(/\\\\/g, '\\')
61
- .replace(/\\t/g, '\t')
62
- .replace(/\\n/g, '\n')
63
- .replace(/\\([0-7]{3})/g, (_m, oct: string) => String.fromCharCode(parseInt(oct, 8)))
104
+ return decodeQuotedPath(quoted[1]!)
64
105
  })
65
106
  .filter((line) => line.length > 0)
66
107
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
- import type { JsonValue } from '@deepseek-ai/dsh-session'
14
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
15
15
  import { resolveProjectRootForExec } from '../config-loader.ts'
16
16
  import { checkpointPath, iterateDir } from '../paths.ts'
17
17
  import { readRegistry } from './fix.ts'