iterate-plugin 3.15.0 → 3.17.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.
package/lib/parse.js CHANGED
@@ -1967,6 +1967,198 @@ export function buildQualityGateClearInstruction() {
1967
1967
  )
1968
1968
  }
1969
1969
 
1970
+ /**
1971
+ * `iterate_quality_gate` compute payload — the one-click "recompute the gate
1972
+ * from THIS session" builder.
1973
+ *
1974
+ * The gate's `compute` operation needs a hand-shaped payload
1975
+ * (`dimensions` / `findings` / `validationResults` / `findingsByRound` /
1976
+ * `fixedByDimension`) that a human cannot reliably type by hand — especially
1977
+ * the per-dimension per-round convergence series. This builder turns the
1978
+ * observatory's manifest (already normalized into the session stream) into
1979
+ * that payload, so the model applies the exact data the user is looking at.
1980
+ *
1981
+ * Every field is optional and only included when non-empty (an empty findings
1982
+ * list is still a legal `compute` — it yields a `pending` certificate, never
1983
+ * a fabricated FAIL — so a caller may still emit a bare `{ operation }`).
1984
+ * The returned text is a paste-able natural-language instruction, mirroring
1985
+ * the rest of the panel's copy-to-command payloads.
1986
+ *
1987
+ * @param {{
1988
+ * dimensions?: string[],
1989
+ * findings?: Array<{ dimension: string, severity: string, file: string, line?: number }>,
1990
+ * validationResults?: Array<{ command: string, exitCode: number }>,
1991
+ * findingsByRound?: Record<string, number[]>,
1992
+ * fixedByDimension?: Record<string, number>,
1993
+ * } | null | undefined} input
1994
+ * @returns {string}
1995
+ */
1996
+ export function buildQualityGateComputeInstruction(input) {
1997
+ const src = input && typeof input === 'object' ? input : {}
1998
+ const cleanStr = (v) => typeof v === 'string' && v.length > 0
1999
+ const dimensions = Array.isArray(src.dimensions)
2000
+ ? src.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
2001
+ : []
2002
+ const findings = Array.isArray(src.findings)
2003
+ ? src.findings.filter((f) => f && typeof f === 'object' && cleanStr(f.dimension) && cleanStr(f.file))
2004
+ .map((f) => ({
2005
+ dimension: f.dimension,
2006
+ severity: typeof f.severity === 'string' && f.severity ? f.severity : 'medium',
2007
+ file: f.file,
2008
+ ...(typeof f.line === 'number' && Number.isFinite(f.line) && f.line > 0 ? { line: f.line } : {}),
2009
+ }))
2010
+ : []
2011
+ const validationResults = Array.isArray(src.validationResults)
2012
+ ? src.validationResults
2013
+ .filter((v) => v && typeof v === 'object' && cleanStr(v.command) && typeof v.exitCode === 'number' && Number.isFinite(v.exitCode))
2014
+ .map((v) => ({ command: v.command, exitCode: v.exitCode }))
2015
+ : []
2016
+ const findingsByRound = {}
2017
+ if (src.findingsByRound && typeof src.findingsByRound === 'object' && !Array.isArray(src.findingsByRound)) {
2018
+ for (const [dim, series] of Object.entries(src.findingsByRound)) {
2019
+ const clean = Array.isArray(series)
2020
+ ? series.filter((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0)
2021
+ : []
2022
+ if (clean.length > 0) findingsByRound[dim] = clean
2023
+ }
2024
+ }
2025
+ const fixedByDimension = {}
2026
+ if (src.fixedByDimension && typeof src.fixedByDimension === 'object' && !Array.isArray(src.fixedByDimension)) {
2027
+ for (const [dim, n] of Object.entries(src.fixedByDimension)) {
2028
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) fixedByDimension[dim] = n
2029
+ }
2030
+ }
2031
+ const payload = { operation: 'compute' }
2032
+ if (dimensions.length > 0) payload.dimensions = dimensions
2033
+ if (findings.length > 0) payload.findings = findings
2034
+ if (validationResults.length > 0) payload.validationResults = validationResults
2035
+ if (Object.keys(findingsByRound).length > 0) payload.findingsByRound = findingsByRound
2036
+ if (Object.keys(fixedByDimension).length > 0) payload.fixedByDimension = fixedByDimension
2037
+ return (
2038
+ '请调用 `iterate_quality_gate` 重算质量门禁证书(从当前会话推导维度/发现/验证结果并持久化):\n\n' +
2039
+ '```json\n' +
2040
+ `${JSON.stringify(payload, null, 2)}\n` +
2041
+ '```'
2042
+ )
2043
+ }
2044
+
2045
+ /**
2046
+ * Reduce a normalized transcript manifest (+ the session-scanned quality gate)
2047
+ * into the `iterate_quality_gate compute` input. Pure: reads only the fields
2048
+ * the observatory already renders, so the one-click recompute can never
2049
+ * disagree with what the user is looking at.
2050
+ *
2051
+ * @param {Record<string, unknown> | null | undefined} manifest normalized transcript
2052
+ * @param {{ dimensions?: Array<{ dimension?: string }> } | null | undefined} qualityGate
2053
+ * @returns {{
2054
+ * dimensions: string[],
2055
+ * findings: Array<{ dimension: string, severity: string, file: string, line?: number }>,
2056
+ * validationResults: Array<{ command: string, exitCode: number }>,
2057
+ * findingsByRound: Record<string, number[]>,
2058
+ * fixedByDimension: Record<string, number>,
2059
+ * }}
2060
+ */
2061
+ export function deriveQualityGateComputeInput(manifest, qualityGate) {
2062
+ const m = manifest && typeof manifest === 'object' ? manifest : {}
2063
+ const asArray = (v) => (Array.isArray(v) ? v : [])
2064
+
2065
+ // dimensions: union of every thread dimension across rounds, falling back
2066
+ // to the already-computed gate's dimension names (a dry-run with no threads
2067
+ // but a previous certificate still recomputes with the same gated set).
2068
+ const dims = []
2069
+ for (const r of asArray(m.rounds)) {
2070
+ for (const t of asArray(r && r.threads)) {
2071
+ const d = t && typeof t.dimension === 'string' ? t.dimension : ''
2072
+ if (d && !dims.includes(d)) dims.push(d)
2073
+ }
2074
+ }
2075
+ if (dims.length === 0 && qualityGate && Array.isArray(qualityGate.dimensions)) {
2076
+ for (const d of qualityGate.dimensions) {
2077
+ const name = d && typeof d.dimension === 'string' ? d.dimension : ''
2078
+ if (name && !dims.includes(name)) dims.push(name)
2079
+ }
2080
+ }
2081
+
2082
+ const findings = []
2083
+ for (const f of asArray(m.findings)) {
2084
+ if (!f || typeof f !== 'object') continue
2085
+ if (typeof f.dimension !== 'string' || !f.dimension) continue
2086
+ if (typeof f.file !== 'string' || !f.file) continue
2087
+ findings.push({
2088
+ dimension: f.dimension,
2089
+ severity: typeof f.severity === 'string' && f.severity ? f.severity : 'medium',
2090
+ file: f.file,
2091
+ ...(typeof f.line === 'number' && Number.isFinite(f.line) && f.line > 0 ? { line: f.line } : {}),
2092
+ })
2093
+ }
2094
+
2095
+ const validationResults = []
2096
+ for (const v of asArray(m.validations)) {
2097
+ if (!v || typeof v !== 'object') continue
2098
+ if (typeof v.command !== 'string' || !v.command) continue
2099
+ if (typeof v.exitCode !== 'number' || !Number.isFinite(v.exitCode)) continue
2100
+ validationResults.push({ command: v.command, exitCode: v.exitCode })
2101
+ }
2102
+
2103
+ // findingsByRound: per dimension, per review round, the number of findings
2104
+ // raised that round — a true convergence series (latest last), padded to the
2105
+ // round count so the gate's convergenceRate measures the actual first→last
2106
+ // shrink even when a dimension is absent from a round's threads (0).
2107
+ const rounds = asArray(m.rounds)
2108
+ const allDims = dims.slice()
2109
+ for (const r of rounds) {
2110
+ for (const t of asArray(r && r.threads)) {
2111
+ const d = t && typeof t.dimension === 'string' ? t.dimension : ''
2112
+ if (d && !allDims.includes(d)) allDims.push(d)
2113
+ }
2114
+ }
2115
+ const byRound = {}
2116
+ for (const d of allDims) {
2117
+ byRound[d] = []
2118
+ }
2119
+ for (const r of rounds) {
2120
+ for (const d of allDims) {
2121
+ let n = 0
2122
+ for (const t of asArray(r && r.threads)) {
2123
+ if (!t || t.dimension !== d) continue
2124
+ for (const f of asArray(t && t.findings)) {
2125
+ if (f && typeof f === 'object' && typeof f.dimension === 'string' && f.dimension === d) n += 1
2126
+ }
2127
+ }
2128
+ byRound[d].push(n)
2129
+ }
2130
+ }
2131
+
2132
+ // fixedByDimension: attribute successful fixes to a dimension by matching
2133
+ // the fixed file (and line, when both sides carry one) to a finding. A fix
2134
+ // with no match is left unattributed — the field is optional and a gate
2135
+ // recompute stays correct without it.
2136
+ const fixedByDimension = {}
2137
+ const findingsIndex = findings
2138
+ for (const fx of asArray(m.fixes)) {
2139
+ if (!fx || typeof fx !== 'object' || fx.success !== true) continue
2140
+ const file = typeof fx.file === 'string' ? fx.file : ''
2141
+ if (!file) continue
2142
+ let dim = ''
2143
+ for (const f of findingsIndex) {
2144
+ if (f.file !== file) continue
2145
+ if (typeof f.line === 'number' && typeof fx.line === 'number' && f.line !== fx.line) continue
2146
+ dim = f.dimension
2147
+ break
2148
+ }
2149
+ if (!dim) continue
2150
+ fixedByDimension[dim] = (fixedByDimension[dim] || 0) + 1
2151
+ }
2152
+
2153
+ return {
2154
+ dimensions: dims,
2155
+ findings,
2156
+ validationResults,
2157
+ findingsByRound: byRound,
2158
+ fixedByDimension,
2159
+ }
2160
+ }
2161
+
1970
2162
  /**
1971
2163
  * `iterate_preflight` readiness-check payload (runs the pre-run checklist).
1972
2164
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.15.0",
3
+ "version": "3.17.0",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -296,8 +296,30 @@ export const EN: Record<string, string> = {
296
296
  '各轮发现数量:{a0}{a1}': 'Findings per round: {a0}{a1}',
297
297
  ' · 当前 Round {a0}': ' · current round {a0}',
298
298
  '跨轮对比(本会话)': 'Cross-round comparison (this session)',
299
- '跨会话对比:本表只覆盖当前会话;点「拉取磁盘快照」让模型回显 iterate_history,再用「导出 JSON」并排比较两次运行':
300
- 'Cross-session comparison: this table only covers the current session — click "Pull disk snapshot" to have the model echo iterate_history, then use "Export JSON" to compare two runs side by side',
299
+ '跨会话对比:本表只覆盖当前会话;要跨两次迭代对比,用下方「存为对比基准」保存本次运行,再与下一次迭代对比。':
300
+ 'Cross-session comparison: this table only covers the current session — to compare two iterations, save this run with \"Save as comparison baseline\" below, then compare the next iteration against it.',
301
+ 跨会话对比: 'Cross-session comparison',
302
+ '基准已保存:{a0}': 'Baseline saved: {a0}',
303
+ '先保存当前运行为基准,再跑下一次迭代后一键对比':
304
+ 'Save the current run as the baseline, then one-click compare the next iteration against it',
305
+ '把当前运行摘要保存为对比基准(含轮次/发现/修复/验证/门禁)':
306
+ 'Save the current run summary as the comparison baseline (rounds / findings / fixes / validation / gate)',
307
+ '当前没有可保存的运行数据': 'No run data to save yet',
308
+ 已存为基准: 'Saved as baseline',
309
+ 存为对比基准: 'Save as comparison baseline',
310
+ '显示或隐藏与已保存基准的对比表': 'Show or hide the comparison against the saved baseline',
311
+ 收起对比: 'Hide comparison',
312
+ 与基准对比: 'Compare with baseline',
313
+ '清除已保存的对比基准': 'Clear the saved comparison baseline',
314
+ 清除基准: 'Clear baseline',
315
+ '当前会话没有运行数据可对比': 'This session has no run data to compare yet',
316
+ 指标: 'Metric',
317
+ 基准: 'Baseline',
318
+ 当前: 'Current',
319
+ 变化: 'Delta',
320
+ 复制对比表: 'Copy comparison table',
321
+ '复制跨会话对比表为 Markdown(可粘贴进 PR / issue / CI 注释)':
322
+ 'Copy the cross-session comparison as a Markdown table (paste into a PR / issue / CI comment)',
301
323
 
302
324
  // ── F3 finding locations ─────────────────────────────────────────────────
303
325
  暂无发现: 'No findings',
@@ -379,6 +401,11 @@ export const EN: Record<string, string> = {
379
401
  '复制 iterate_quality_gate clear 指令文本(重置陈旧的 FAIL 证书)':
380
402
  'Copy the iterate_quality_gate clear instruction (resets a stale FAIL certificate)',
381
403
  清除门禁: 'Clear gate',
404
+ 重算门禁: 'Recompute gate',
405
+ '复制 iterate_quality_gate compute 指令:从当前会话推导维度/发现/验证结果,一键重算并持久化门禁证书':
406
+ 'Copy the iterate_quality_gate compute instruction: derive dimensions / findings / validation results from this session and recompute + persist the gate certificate',
407
+ '当前会话没有可重算的门禁数据(需先有审查/验证结果)':
408
+ 'No gate data in this session to recompute (need review / validation results first)',
382
409
  '复制拉取磁盘快照指令:让模型回显 .iterate/ 下的门禁、经验、防御事件与 transcript':
383
410
  'Copy the disk-snapshot instruction: have the model echo the gate, experience, defense events and transcript artifacts under .iterate/',
384
411
  质量门禁视图: 'Quality gate view',
@@ -101,6 +101,8 @@ import {
101
101
  buildRollbackInstruction, // #10: shared rollback payload
102
102
  buildQualityGateQueryInstruction, // #10: JSON-carrying query instruction
103
103
  buildQualityGateClearInstruction, // #10: JSON-carrying clear instruction
104
+ buildQualityGateComputeInstruction, // v3.17: one-click recompute payload from THIS session
105
+ deriveQualityGateComputeInput, // v3.17: manifest → compute input reducer
104
106
  buildTriageReadbackInstruction, // #7: close the write-back loop
105
107
  buildExperienceListInstruction, // #3/#10: JSON list payload for F9
106
108
  buildDefenseEventsListInstruction, // #3/#10: JSON list payload for F10
@@ -146,6 +148,18 @@ import {
146
148
  registerIterateShortcuts,
147
149
  type ShortcutsService,
148
150
  } from './shortcuts.ts'
151
+ // v3.16: cross-session run comparison — the F2 tab compares two iterations of
152
+ // the same project (save the current run as a baseline, then compare any later
153
+ // run against it), instead of the old export-two-JSONs-and-diff-by-hand chore.
154
+ import {
155
+ buildRunBaseline,
156
+ parseRunBaseline,
157
+ compareRuns,
158
+ buildRunCompareMarkdown,
159
+ RUN_BASELINE_STORAGE_KEY,
160
+ type RunBaseline,
161
+ type RunCompareMetric,
162
+ } from './run-compare.ts'
149
163
 
150
164
  // ─── Module contract ─────────────────────────────────────────────────────────
151
165
 
@@ -2318,6 +2332,17 @@ const OBS_LIVE_META: Record<string, { label: { zh: string; en: string }; color:
2318
2332
  }
2319
2333
  const OBS_LIVE_FALLBACK_COLOR = 'var(--dsw-alias-label-secondary)'
2320
2334
 
2335
+ /** v3.16: cross-session comparison metric labels (inline {zh,en} pair, like OBS_TABS). */
2336
+ const RUN_COMPARE_METRIC_LABELS: Record<string, { zh: string; en: string }> = {
2337
+ rounds: { zh: '轮次', en: 'Rounds' },
2338
+ findings: { zh: '发现', en: 'Findings' },
2339
+ criticalHigh: { zh: '严重(critical+high)', en: 'Critical + high' },
2340
+ fixed: { zh: '修复成功', en: 'Fixes applied' },
2341
+ failedFixes: { zh: '修复失败', en: 'Failed fixes' },
2342
+ validationPassRate: { zh: '验证通过率', en: 'Validation pass rate' },
2343
+ qualityGateScore: { zh: '门禁得分', en: 'Quality gate score' },
2344
+ }
2345
+
2321
2346
  /**
2322
2347
  * Format a live-event timestamp as DD/MM HH:mm:ss (0-padded). Accepts an epoch
2323
2348
  * ms number or an ISO-ish string; any unparseable value degrades to ''.
@@ -2428,6 +2453,20 @@ function ObservatoryPanel(props: SlotProps) {
2428
2453
  // v3.0: Defense events filter
2429
2454
  const [defenseFilter, setDefenseFilter] = React.useState('')
2430
2455
 
2456
+ // v3.16: cross-session comparison — the baseline is read from localStorage
2457
+ // once on mount (a corrupt/stale payload reads as "no baseline", never as a
2458
+ // phantom run); `showCompare` toggles the side-by-side table, `baselineFlash`
2459
+ // confirms the save action.
2460
+ const [baseline, setBaseline] = React.useState<RunBaseline | null>(() => {
2461
+ try {
2462
+ return parseRunBaseline(storage ? storage.get(RUN_BASELINE_STORAGE_KEY) : null)
2463
+ } catch {
2464
+ return null
2465
+ }
2466
+ })
2467
+ const [showCompare, setShowCompare] = React.useState(false)
2468
+ const [baselineFlash, setBaselineFlash] = React.useState(false)
2469
+
2431
2470
  // Single shared "copied" flash timer (reused across all copy buttons).
2432
2471
  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
2433
2472
  React.useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
@@ -2691,18 +2730,62 @@ function ObservatoryPanel(props: SlotProps) {
2691
2730
  }))
2692
2731
  }
2693
2732
 
2733
+ // ── v3.16: cross-session run comparison handlers ────────────────────────
2734
+ // The summary is built from the SAME manifest the F2 tab renders, so a saved
2735
+ // baseline can never disagree with what the user is looking at.
2736
+ const currentRunSummary = (): RunBaseline | null => {
2737
+ const hasRun =
2738
+ (manifest.convergence || []).length > 0 ||
2739
+ (manifest.findings || []).length > 0 ||
2740
+ (manifest.fixes || []).length > 0 ||
2741
+ (typeof manifest.round === 'number' && manifest.round > 0)
2742
+ if (!hasRun) return null
2743
+ return buildRunBaseline(
2744
+ {
2745
+ project: manifest.project || undefined,
2746
+ mode: manifest.mode || undefined,
2747
+ convergence: manifest.convergence || [],
2748
+ findings: (manifest.findings || []) as Array<{ severity?: string }>,
2749
+ fixes: (manifest.fixes || []) as Array<{ success?: boolean }>,
2750
+ validations: (manifest.validations || []) as Array<{ allowed?: boolean }>,
2751
+ qualityGateStatus: (qualityGate && qualityGate.overallStatus) || null,
2752
+ qualityGateScore: qualityGate && typeof qualityGate.overallScore === 'number' ? qualityGate.overallScore : null,
2753
+ stoppedReason: manifest.stoppedReason || null,
2754
+ },
2755
+ new Date().toISOString(),
2756
+ )
2757
+ }
2758
+ const saveBaseline = () => {
2759
+ const summary = currentRunSummary()
2760
+ if (!summary || !storage) return
2761
+ try {
2762
+ storage.set(RUN_BASELINE_STORAGE_KEY, JSON.stringify(summary))
2763
+ setBaseline(summary)
2764
+ setShowCompare(true)
2765
+ setBaselineFlash(true)
2766
+ if (copyTimer.current) clearTimeout(copyTimer.current)
2767
+ copyTimer.current = setTimeout(() => setBaselineFlash(false), 1600)
2768
+ } catch (err) {
2769
+ log('failed to persist run baseline', err)
2770
+ }
2771
+ }
2772
+ const clearBaseline = () => {
2773
+ if (storage) {
2774
+ try { storage.remove(RUN_BASELINE_STORAGE_KEY) } catch (err) { log('failed to clear run baseline', err) }
2775
+ }
2776
+ setBaseline(null)
2777
+ setShowCompare(false)
2778
+ }
2779
+
2694
2780
  // ── F2: convergence trend (inline bars, reusing TrendChart) ─────────────
2695
2781
  const renderTrend = () => {
2696
2782
  const conv = manifest.convergence || []
2697
- if (conv.length === 0) {
2698
- return React.createElement('div', { className: 'iterate-obs-empty' }, t('暂无收敛数据'))
2699
- }
2700
2783
  const points = conv.map((n, i) => ({ round: i + 1, count: n }))
2701
2784
  // Gap #13 (step ①): single-session cross-round comparison. One row per
2702
2785
  // round — findings raised / fixed / severity mix / validation outcome.
2703
- // Cross-SESSION comparison rides on the #4 pull instruction instead (the
2704
- // client has no filesystem access, so `iterate_history` aggregates have to
2705
- // be echoed back into the session first).
2786
+ // v3.16: the cross-SESSION half is now the `renderCrossSessionCompare`
2787
+ // block below (save the current run as a baseline, then compare any later
2788
+ // run against it) instead of the old export-two-JSONs-and-diff-by-hand chore.
2706
2789
  const report = latestReport(session)
2707
2790
  const comparison = buildRoundComparison(report, manifest.validations || [], manifest.fixes || [])
2708
2791
  const comparisonTable = comparison.length === 0
@@ -2742,22 +2825,132 @@ function ObservatoryPanel(props: SlotProps) {
2742
2825
  }),
2743
2826
  ),
2744
2827
  )
2745
- return React.createElement('div', {},
2746
- React.createElement(TrendChart, { points }),
2747
- React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
2748
- tf(`各轮发现数量:{a0}{a1}`, { a0: conv.join(' → '), a1: typeof manifest.round === 'number' ? tf(` · 当前 Round {a0}`, { a0: manifest.round }) : '' }),
2749
- ),
2750
- comparisonTable
2751
- ? React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2752
- React.createElement('div', { className: 'iterate-obs-block-head' },
2753
- React.createElement('span', {}, t('跨轮对比(本会话)')),
2754
- React.createElement('span', { className: 'iterate-obs-head-meta' },
2755
- t('跨会话对比:本表只覆盖当前会话;点「拉取磁盘快照」让模型回显 iterate_history,再用「导出 JSON」并排比较两次运行'),
2756
- ),
2828
+ const trendBody = conv.length === 0
2829
+ ? React.createElement('div', { className: 'iterate-obs-empty' }, t('暂无收敛数据'))
2830
+ : React.createElement('div', {},
2831
+ React.createElement(TrendChart, { points }),
2832
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
2833
+ tf(`各轮发现数量:{a0}{a1}`, { a0: conv.join(' → '), a1: typeof manifest.round === 'number' ? tf(` · 当前 Round {a0}`, { a0: manifest.round }) : '' }),
2834
+ ),
2835
+ comparisonTable
2836
+ ? React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2837
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2838
+ React.createElement('span', {}, t('跨轮对比(本会话)')),
2839
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
2840
+ t('跨会话对比:本表只覆盖当前会话;要跨两次迭代对比,用下方「存为对比基准」保存本次运行,再与下一次迭代对比。'),
2841
+ ),
2842
+ ),
2843
+ React.createElement('div', { className: 'iterate-obs-block-body' }, comparisonTable),
2844
+ )
2845
+ : null,
2846
+ )
2847
+ return React.createElement('div', {}, trendBody, renderCrossSessionCompare())
2848
+ }
2849
+
2850
+ // ── v3.16: cross-session run comparison (F2) ─────────────────────────────
2851
+ // Side-by-side comparison of the CURRENT run against a previously saved
2852
+ // baseline, persisted in localStorage. This replaces the manual
2853
+ // export-JSON-and-diff chore the old cross-session hint pointed at: save the
2854
+ // current run once, then compare any later run against it in one click.
2855
+ const renderCrossSessionCompare = () => {
2856
+ const current = currentRunSummary()
2857
+ const hasRun = current !== null
2858
+ const metricLabel = (key: string): string => {
2859
+ const m = RUN_COMPARE_METRIC_LABELS[key]
2860
+ return m ? m[getLang()] : key
2861
+ }
2862
+ const fmtCell = (v: number | null, key: string): string => {
2863
+ if (v === null) return '—'
2864
+ return key === 'validationPassRate' ? `${v}%` : String(v)
2865
+ }
2866
+ const deltaText = (row: RunCompareMetric): string => {
2867
+ if (row.baseline === null || row.current === null) return '—'
2868
+ const d = row.current - row.baseline
2869
+ return d === 0 ? '0' : `${d > 0 ? '+' : ''}${d}`
2870
+ }
2871
+ const deltaTint = (row: RunCompareMetric): { color: string; fontWeight: number } | undefined => {
2872
+ if (row.baseline === null || row.current === null || row.higherIsBetter === null) return undefined
2873
+ const d = row.current - row.baseline
2874
+ if (d === 0) return undefined
2875
+ const improved = row.higherIsBetter ? d > 0 : d < 0
2876
+ return improved
2877
+ ? { color: 'var(--dsw-alias-state-success-primary)', fontWeight: 600 }
2878
+ : { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 600 }
2879
+ }
2880
+ let compareBody: React.ReactNode = null
2881
+ if (baseline && current && showCompare) {
2882
+ const rows = compareRuns(baseline, current)
2883
+ compareBody = React.createElement('table', { className: 'iterate-obs-table' },
2884
+ React.createElement('thead', {},
2885
+ React.createElement('tr', {},
2886
+ React.createElement('th', {}, t('指标')),
2887
+ React.createElement('th', {}, t('基准')),
2888
+ React.createElement('th', {}, t('当前')),
2889
+ React.createElement('th', {}, t('变化')),
2890
+ ),
2891
+ ),
2892
+ React.createElement('tbody', {},
2893
+ ...rows.map((row) =>
2894
+ React.createElement('tr', { key: `rc-${row.key}` },
2895
+ React.createElement('td', {}, metricLabel(row.key)),
2896
+ React.createElement('td', {}, fmtCell(row.baseline, row.key)),
2897
+ React.createElement('td', {}, fmtCell(row.current, row.key)),
2898
+ React.createElement('td', { style: deltaTint(row) }, deltaText(row)),
2757
2899
  ),
2758
- React.createElement('div', { className: 'iterate-obs-block-body' }, comparisonTable),
2759
- )
2760
- : null,
2900
+ ),
2901
+ ),
2902
+ )
2903
+ }
2904
+ return React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2905
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2906
+ React.createElement('span', {}, t('跨会话对比')),
2907
+ baseline
2908
+ ? React.createElement('span', { className: 'iterate-obs-head-meta' }, tf(`基准已保存:{a0}`, { a0: baseline.savedAt }))
2909
+ : React.createElement('span', { className: 'iterate-obs-head-meta' }, t('先保存当前运行为基准,再跑下一次迭代后一键对比')),
2910
+ ),
2911
+ React.createElement('div', { className: 'iterate-obs-block-body' },
2912
+ React.createElement('div', { className: 'iterate-obs-bar', style: { flexWrap: 'wrap' } },
2913
+ React.createElement('button', {
2914
+ className: 'iterate-btn', 'data-primary': '', disabled: !hasRun,
2915
+ title: hasRun ? t('把当前运行摘要保存为对比基准(含轮次/发现/修复/验证/门禁)') : t('当前没有可保存的运行数据'),
2916
+ onClick: saveBaseline,
2917
+ }, baselineFlash ? t('已存为基准') : t('存为对比基准')),
2918
+ baseline
2919
+ ? React.createElement('button', {
2920
+ className: 'iterate-btn', 'data-copied': showCompare ? '' : undefined,
2921
+ disabled: !hasRun,
2922
+ title: t('显示或隐藏与已保存基准的对比表'),
2923
+ onClick: () => setShowCompare((v) => !v),
2924
+ }, showCompare ? t('收起对比') : t('与基准对比'))
2925
+ : null,
2926
+ baseline && current && showCompare
2927
+ ? React.createElement('button', {
2928
+ className: 'iterate-btn', 'data-copied': copiedKey === 'rc-copy' ? '' : undefined,
2929
+ onClick: () => copyInstruction('rc-copy', buildRunCompareMarkdown(compareRuns(baseline, current), {
2930
+ 指标: t('指标'),
2931
+ 基准: t('基准'),
2932
+ 当前: t('当前'),
2933
+ 变化: t('变化'),
2934
+ ...Object.fromEntries(
2935
+ Object.entries(RUN_COMPARE_METRIC_LABELS).map(([k, v]) => [k, v[getLang()]]),
2936
+ ),
2937
+ })),
2938
+ title: t('复制跨会话对比表为 Markdown(可粘贴进 PR / issue / CI 注释)'),
2939
+ }, copiedKey === 'rc-copy' ? t('已复制') : t('复制对比表'))
2940
+ : null,
2941
+ baseline
2942
+ ? React.createElement('button', {
2943
+ className: 'iterate-btn', 'data-ghost': '',
2944
+ title: t('清除已保存的对比基准'),
2945
+ onClick: clearBaseline,
2946
+ }, t('清除基准'))
2947
+ : null,
2948
+ ),
2949
+ showCompare && baseline && !hasRun
2950
+ ? React.createElement('div', { className: 'iterate-obs-empty', style: { marginTop: 8 } }, t('当前会话没有运行数据可对比'))
2951
+ : null,
2952
+ compareBody,
2953
+ ),
2761
2954
  )
2762
2955
  }
2763
2956
 
@@ -3152,6 +3345,19 @@ function ObservatoryPanel(props: SlotProps) {
3152
3345
  // Reset a stale FAIL certificate before a fresh iteration — the tool's
3153
3346
  // `clear` operation existed since v3.5.1 but had no client entry point.
3154
3347
  const gateClearInstruction = buildQualityGateClearInstruction()
3348
+ // v3.17: one-click recompute. The gate's `compute` payload needs a
3349
+ // hand-shaped `findingsByRound` convergence series that a human cannot
3350
+ // type by hand — this derives it from the session manifest the panel is
3351
+ // already rendering, so the recompute can never disagree with the view.
3352
+ const computeInput = deriveQualityGateComputeInput(
3353
+ manifest as unknown as Record<string, unknown> | null | undefined,
3354
+ qualityGate as unknown as Record<string, unknown> | null | undefined,
3355
+ )
3356
+ const computeInstruction = buildQualityGateComputeInstruction(computeInput)
3357
+ const hasComputeData =
3358
+ computeInput.dimensions.length > 0 ||
3359
+ computeInput.findings.length > 0 ||
3360
+ computeInput.validationResults.length > 0
3155
3361
  const gate = qualityGate
3156
3362
  const dims = gate && gate.dimensions ? gate.dimensions : []
3157
3363
  const hasGate = gate !== null && Boolean(gate.overallStatus || gate.overallScore != null || dims.length > 0)
@@ -3167,6 +3373,14 @@ function ObservatoryPanel(props: SlotProps) {
3167
3373
  onClick: () => copyInstruction('qgate', gateInstruction),
3168
3374
  title: t('复制 iterate_quality_gate 查询指令(含 JSON 参数)'),
3169
3375
  }, copiedKey === 'qgate' ? t('已复制') : t('查询门禁')),
3376
+ React.createElement('button', {
3377
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'qgate-compute' ? '' : undefined,
3378
+ disabled: !hasComputeData,
3379
+ onClick: () => copyInstruction('qgate-compute', computeInstruction),
3380
+ title: hasComputeData
3381
+ ? t('复制 iterate_quality_gate compute 指令:从当前会话推导维度/发现/验证结果,一键重算并持久化门禁证书')
3382
+ : t('当前会话没有可重算的门禁数据(需先有审查/验证结果)'),
3383
+ }, copiedKey === 'qgate-compute' ? t('已复制') : t('重算门禁')),
3170
3384
  React.createElement('button', {
3171
3385
  className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'qgate-clear' ? '' : undefined,
3172
3386
  onClick: () => copyInstruction('qgate-clear', gateClearInstruction),