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/README.md +4 -2
- package/README.zh-CN.md +3 -1
- package/dist/index.js +5 -4
- package/dist/tools/decision-log.js +21 -2
- package/lib/client.js +443 -7
- package/lib/parse.js +192 -0
- package/package.json +1 -1
- package/src/client/i18n.ts +29 -2
- package/src/client/index.ts +235 -21
- package/src/client/run-compare.ts +239 -0
- package/src/index.ts +5 -4
- package/src/tools/decision-log.ts +20 -2
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.
|
|
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",
|
package/src/client/i18n.ts
CHANGED
|
@@ -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
|
-
'
|
|
300
|
-
'Cross-session comparison: this table only covers the current session —
|
|
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',
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
2704
|
-
//
|
|
2705
|
-
//
|
|
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
|
-
|
|
2746
|
-
React.createElement(
|
|
2747
|
-
React.createElement('div', {
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
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
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
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),
|