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/README.md +2 -1
- package/README.zh-CN.md +2 -1
- package/dist/git-scope.js +61 -7
- package/lib/client.js +356 -100
- package/lib/parse.js +93 -0
- package/package.json +6 -5
- package/src/client/index.ts +265 -44
- package/src/git-scope.ts +48 -7
- package/src/tools/checkpoint.ts +1 -1
- package/src/tools/config.ts +1 -1
- package/src/tools/decision-log.ts +1 -1
- package/src/tools/fix.ts +1 -1
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +1 -1
- package/src/tools/review.ts +1 -1
- package/src/tools/transcript.ts +1 -1
- package/src/tools/triage.ts +1 -1
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.
|
|
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.
|
|
64
|
-
"@deepseek-ai/dsh-tools": "0.1.
|
|
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.
|
|
69
|
-
"@deepseek-ai/dsh-session": "0.1.
|
|
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",
|
package/src/client/index.ts
CHANGED
|
@@ -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', {},
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1883
|
-
React.createElement('
|
|
1884
|
-
|
|
1885
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -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-
|
|
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'
|