iterate-plugin 3.14.0 → 3.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.14.0",
3
+ "version": "3.16.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",
@@ -56,7 +56,8 @@
56
56
  "0.2.1-rc.4": "compatible",
57
57
  "0.2.1-rc.5": "compatible",
58
58
  "0.2.2-alpha.2": "compatible",
59
- "0.2.2-alpha.3": "compatible"
59
+ "0.2.2-alpha.3": "compatible",
60
+ "0.2.2-rc.1": "compatible"
60
61
  }
61
62
  },
62
63
  "client": {
@@ -93,14 +94,14 @@
93
94
  "test:validate": "tsx --test test/validate.test.ts"
94
95
  },
95
96
  "dependencies": {
96
- "iterate-kernel": "0.1.9",
97
+ "iterate-kernel": "0.1.10",
97
98
  "js-yaml": "4.3.2"
98
99
  },
99
100
  "peerDependencies": {
100
101
  "@deepseek-ai/cordis": "^4.0.5-alpha.2 || ~4.0.6-alpha.1 || ~4.0.6-alpha.2",
101
- "@deepseek-ai/dsh-session": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3",
102
- "@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3",
103
- "@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3"
102
+ "@deepseek-ai/dsh-session": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3 || 0.2.2-rc.1",
103
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3 || 0.2.2-rc.1",
104
+ "@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3 || 0.2.2-rc.1"
104
105
  },
105
106
  "peerDependenciesMeta": {
106
107
  "@deepseek-ai/cordis": {
@@ -117,20 +118,20 @@
117
118
  }
118
119
  },
119
120
  "devDependencies": {
120
- "@deepseek-ai/cordis": "4.0.6-alpha.2",
121
- "@deepseek-ai/dsh-agent": "0.2.2-alpha.3",
122
- "@deepseek-ai/dsh-jobs": "0.2.2-alpha.3",
123
- "@deepseek-ai/dsh-llm": "0.2.2-alpha.3",
124
- "@deepseek-ai/dsh-ptc-runtime": "0.2.2-alpha.3",
125
- "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.3",
126
- "@deepseek-ai/dsh-sandbox-policy": "0.2.2-alpha.3",
127
- "@deepseek-ai/dsh-scope": "0.2.2-alpha.3",
128
- "@deepseek-ai/dsh-session": "0.2.2-alpha.3",
129
- "@deepseek-ai/dsh-system-prompt": "0.2.2-alpha.3",
130
- "@deepseek-ai/dsh-tools": "0.2.2-alpha.3",
131
- "@deepseek-ai/dsh-user-approval": "0.2.2-alpha.3",
132
- "@deepseek-ai/dsh-util-values": "0.2.2-alpha.3",
133
- "@deepseek-ai/dsh-working-directory": "0.2.2-alpha.3",
121
+ "@deepseek-ai/cordis": "4.1.0",
122
+ "@deepseek-ai/dsh-agent": "0.2.2-rc.1",
123
+ "@deepseek-ai/dsh-jobs": "0.2.2-rc.1",
124
+ "@deepseek-ai/dsh-llm": "0.2.2-rc.1",
125
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.2-rc.1",
126
+ "@deepseek-ai/dsh-sandbox": "0.2.2-rc.1",
127
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.2-rc.1",
128
+ "@deepseek-ai/dsh-scope": "0.2.2-rc.1",
129
+ "@deepseek-ai/dsh-session": "0.2.2-rc.1",
130
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-rc.1",
131
+ "@deepseek-ai/dsh-tools": "0.2.2-rc.1",
132
+ "@deepseek-ai/dsh-user-approval": "0.2.2-rc.1",
133
+ "@deepseek-ai/dsh-util-values": "0.2.2-rc.1",
134
+ "@deepseek-ai/dsh-working-directory": "0.2.2-rc.1",
134
135
  "@types/js-yaml": "4.0.9",
135
136
  "@types/node": "22.15.0",
136
137
  "@types/react": "19.2.2",
@@ -144,5 +145,8 @@
144
145
  "engines": {
145
146
  "node": ">=20",
146
147
  "dsh": ">=0.1.1-rc.1"
148
+ },
149
+ "overrides": {
150
+ "zod": "4.6.7"
147
151
  }
148
152
  }
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { getLang, type Lang } from './i18n.ts'
43
+ import { OBSERVATORY_SHORTCUT_ID } from './shortcuts.ts'
43
44
 
44
45
  // ─── Structural service types (defensive; mirrors SlotsService) ──────────────
45
46
  //
@@ -63,6 +64,8 @@ export interface IterateMenuAction {
63
64
  separatorBefore?: boolean
64
65
  /** Localized operation + recovery guidance when `run` rejects. */
65
66
  failureLabel?: string
67
+ /** Live-shortcut display for an action backed by a registered command. */
68
+ commandId?: string
66
69
  run?: () => void | Promise<void>
67
70
  }
68
71
 
@@ -168,7 +171,7 @@ export function buildSessionMenuEntries(lang: Lang, opts?: { hasCheckpoint?: boo
168
171
  export function buildFileMenuEntries(lang: Lang): MenuEntry[] {
169
172
  return [
170
173
  { id: 'iterate.review-file', label: pick('iterate.review-file', lang) },
171
- { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
174
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
172
175
  ]
173
176
  }
174
177
 
@@ -177,7 +180,7 @@ export function buildDirectoryMenuEntries(lang: Lang): MenuEntry[] {
177
180
  return [
178
181
  { id: 'iterate.review-dir', label: pick('iterate.review-dir', lang) },
179
182
  { id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
180
- { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang) },
183
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
181
184
  ]
182
185
  }
183
186
 
@@ -203,6 +206,12 @@ function toAction(entry: MenuEntry, deps: IterateMenuDeps, lang: Lang): IterateM
203
206
  }
204
207
  if (entry.disabled) action.disabled = true
205
208
  if (entry.openTab) {
209
+ // The "open command center" action is backed by the registered
210
+ // `iterate.open-observatory` keyboard command, so the context menu shows
211
+ // its live keycap (Cmd/Ctrl+Shift+O) — the shortcut is discoverable in the
212
+ // same place the user would look for it. Display only: selection still
213
+ // runs this action's `run`.
214
+ action.commandId = OBSERVATORY_SHORTCUT_ID
206
215
  action.run = () => {
207
216
  deps.openPanel({ tab: entry.openTab })
208
217
  deps.notify(lang === 'zh' ? '已打开 iterate 观测台。' : 'Iterate observatory opened.', true)
@@ -296,8 +296,27 @@ 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',
301
320
 
302
321
  // ── F3 finding locations ─────────────────────────────────────────────────
303
322
  暂无发现: 'No findings',
@@ -24,7 +24,7 @@
24
24
  * 4. iterate theme skin -> theme.overrideTokens
25
25
  * 5. Progress event linkage -> ctx.on('slots/changed') + round-pulse + shell.overlay capsule
26
26
  * 6. Settings page section -> settings.section
27
- * 7. Runtime observatory -> conversation.input.dock (order 91, F1–F7 board)
27
+ * 7. Runtime observatory -> conversation.input.dock (order 91, F1–F11 board)
28
28
  *
29
29
  * Design notes:
30
30
  * - All styles are injected via one <style> tag; colors use dsh token
@@ -141,6 +141,22 @@ import {
141
141
  type ContextMenusService,
142
142
  type ObsOpenDetail,
143
143
  } from './context-menu.ts'
144
+ // v3.15: keyboard-command contribution (dsh 0.2.2-rc.1 unified shortcuts).
145
+ import {
146
+ registerIterateShortcuts,
147
+ type ShortcutsService,
148
+ } from './shortcuts.ts'
149
+ // v3.16: cross-session run comparison — the F2 tab compares two iterations of
150
+ // the same project (save the current run as a baseline, then compare any later
151
+ // run against it), instead of the old export-two-JSONs-and-diff-by-hand chore.
152
+ import {
153
+ buildRunBaseline,
154
+ parseRunBaseline,
155
+ compareRuns,
156
+ RUN_BASELINE_STORAGE_KEY,
157
+ type RunBaseline,
158
+ type RunCompareMetric,
159
+ } from './run-compare.ts'
144
160
 
145
161
  // ─── Module contract ─────────────────────────────────────────────────────────
146
162
 
@@ -932,6 +948,21 @@ function readContextMenus(ctx: ClientContext): ContextMenusService | undefined {
932
948
  return svc as unknown as ContextMenusService
933
949
  }
934
950
 
951
+ /**
952
+ * Resolve the dsh 0.2.2-rc.1 `shortcuts` service. Like contextMenus it is NOT
953
+ * in the typed `ClientContext`, so it is probed through `ctx.get` and
954
+ * structurally validated. A host that predates the capability returns
955
+ * `undefined` and the plugin simply has no keyboard command.
956
+ */
957
+ function readShortcuts(ctx: ClientContext): ShortcutsService | undefined {
958
+ if (!ctx || typeof ctx.get !== 'function') return undefined
959
+ const raw = ctx.get('shortcuts', false)
960
+ if (!raw || typeof raw !== 'object') return undefined
961
+ const svc = raw as { register?: unknown }
962
+ if (typeof svc.register !== 'function') return undefined
963
+ return svc as unknown as ShortcutsService
964
+ }
965
+
935
966
  /**
936
967
  * Extract a session identifier from an opaque host-shaped session object.
937
968
  * The slot `props.session` shape is host-defined; this probes the common
@@ -967,6 +998,8 @@ let themeListener: (() => void) | null = null
967
998
  let themeEnabled = true
968
999
  /** v3.14: right-click menu contribution disposer (re-apply() never stacks). */
969
1000
  let menuDisposer: (() => void) | null = null
1001
+ /** v3.15: keyboard-command contribution disposer (re-apply() never stacks). */
1002
+ let shortcutDisposer: (() => void) | null = null
970
1003
  const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
971
1004
 
972
1005
  /**
@@ -2232,7 +2265,7 @@ function SettingsPanel(props: SlotProps) {
2232
2265
  )
2233
2266
  }
2234
2267
 
2235
- // ─── Runtime observatory panel (F1–F7) ──────────────────────────────────────
2268
+ // ─── Runtime observatory panel (F1–F11) ─────────────────────────────────────
2236
2269
 
2237
2270
  /** Tab id → label for the observatory's eleven panels. Live (real-time
2238
2271
  * activity stream) is first so it is the default view; F-panels follow.
@@ -2296,6 +2329,17 @@ const OBS_LIVE_META: Record<string, { label: { zh: string; en: string }; color:
2296
2329
  }
2297
2330
  const OBS_LIVE_FALLBACK_COLOR = 'var(--dsw-alias-label-secondary)'
2298
2331
 
2332
+ /** v3.16: cross-session comparison metric labels (inline {zh,en} pair, like OBS_TABS). */
2333
+ const RUN_COMPARE_METRIC_LABELS: Record<string, { zh: string; en: string }> = {
2334
+ rounds: { zh: '轮次', en: 'Rounds' },
2335
+ findings: { zh: '发现', en: 'Findings' },
2336
+ criticalHigh: { zh: '严重(critical+high)', en: 'Critical + high' },
2337
+ fixed: { zh: '修复成功', en: 'Fixes applied' },
2338
+ failedFixes: { zh: '修复失败', en: 'Failed fixes' },
2339
+ validationPassRate: { zh: '验证通过率', en: 'Validation pass rate' },
2340
+ qualityGateScore: { zh: '门禁得分', en: 'Quality gate score' },
2341
+ }
2342
+
2299
2343
  /**
2300
2344
  * Format a live-event timestamp as DD/MM HH:mm:ss (0-padded). Accepts an epoch
2301
2345
  * ms number or an ISO-ish string; any unparseable value degrades to ''.
@@ -2359,7 +2403,7 @@ function buildObsFixInstruction(f: ObsFinding): string {
2359
2403
  }
2360
2404
 
2361
2405
  /**
2362
- * ObservatoryPanel: an 8-tab runtime observatory that renders the latest
2406
+ * ObservatoryPanel: a 12-tab runtime observatory that renders the latest
2363
2407
  * iterate_transcript manifest found in the dsh session stream. The panel is
2364
2408
  * collapsed by default to a single entry row so it never re-occludes the main
2365
2409
  * convergence dashboard; clicking expands it into tabs. Every field read is
@@ -2406,6 +2450,20 @@ function ObservatoryPanel(props: SlotProps) {
2406
2450
  // v3.0: Defense events filter
2407
2451
  const [defenseFilter, setDefenseFilter] = React.useState('')
2408
2452
 
2453
+ // v3.16: cross-session comparison — the baseline is read from localStorage
2454
+ // once on mount (a corrupt/stale payload reads as "no baseline", never as a
2455
+ // phantom run); `showCompare` toggles the side-by-side table, `baselineFlash`
2456
+ // confirms the save action.
2457
+ const [baseline, setBaseline] = React.useState<RunBaseline | null>(() => {
2458
+ try {
2459
+ return parseRunBaseline(storage ? storage.get(RUN_BASELINE_STORAGE_KEY) : null)
2460
+ } catch {
2461
+ return null
2462
+ }
2463
+ })
2464
+ const [showCompare, setShowCompare] = React.useState(false)
2465
+ const [baselineFlash, setBaselineFlash] = React.useState(false)
2466
+
2409
2467
  // Single shared "copied" flash timer (reused across all copy buttons).
2410
2468
  const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
2411
2469
  React.useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current) }, [])
@@ -2669,18 +2727,62 @@ function ObservatoryPanel(props: SlotProps) {
2669
2727
  }))
2670
2728
  }
2671
2729
 
2730
+ // ── v3.16: cross-session run comparison handlers ────────────────────────
2731
+ // The summary is built from the SAME manifest the F2 tab renders, so a saved
2732
+ // baseline can never disagree with what the user is looking at.
2733
+ const currentRunSummary = (): RunBaseline | null => {
2734
+ const hasRun =
2735
+ (manifest.convergence || []).length > 0 ||
2736
+ (manifest.findings || []).length > 0 ||
2737
+ (manifest.fixes || []).length > 0 ||
2738
+ (typeof manifest.round === 'number' && manifest.round > 0)
2739
+ if (!hasRun) return null
2740
+ return buildRunBaseline(
2741
+ {
2742
+ project: manifest.project || undefined,
2743
+ mode: manifest.mode || undefined,
2744
+ convergence: manifest.convergence || [],
2745
+ findings: (manifest.findings || []) as Array<{ severity?: string }>,
2746
+ fixes: (manifest.fixes || []) as Array<{ success?: boolean }>,
2747
+ validations: (manifest.validations || []) as Array<{ allowed?: boolean }>,
2748
+ qualityGateStatus: (qualityGate && qualityGate.overallStatus) || null,
2749
+ qualityGateScore: qualityGate && typeof qualityGate.overallScore === 'number' ? qualityGate.overallScore : null,
2750
+ stoppedReason: manifest.stoppedReason || null,
2751
+ },
2752
+ new Date().toISOString(),
2753
+ )
2754
+ }
2755
+ const saveBaseline = () => {
2756
+ const summary = currentRunSummary()
2757
+ if (!summary || !storage) return
2758
+ try {
2759
+ storage.set(RUN_BASELINE_STORAGE_KEY, JSON.stringify(summary))
2760
+ setBaseline(summary)
2761
+ setShowCompare(true)
2762
+ setBaselineFlash(true)
2763
+ if (copyTimer.current) clearTimeout(copyTimer.current)
2764
+ copyTimer.current = setTimeout(() => setBaselineFlash(false), 1600)
2765
+ } catch (err) {
2766
+ log('failed to persist run baseline', err)
2767
+ }
2768
+ }
2769
+ const clearBaseline = () => {
2770
+ if (storage) {
2771
+ try { storage.remove(RUN_BASELINE_STORAGE_KEY) } catch (err) { log('failed to clear run baseline', err) }
2772
+ }
2773
+ setBaseline(null)
2774
+ setShowCompare(false)
2775
+ }
2776
+
2672
2777
  // ── F2: convergence trend (inline bars, reusing TrendChart) ─────────────
2673
2778
  const renderTrend = () => {
2674
2779
  const conv = manifest.convergence || []
2675
- if (conv.length === 0) {
2676
- return React.createElement('div', { className: 'iterate-obs-empty' }, t('暂无收敛数据'))
2677
- }
2678
2780
  const points = conv.map((n, i) => ({ round: i + 1, count: n }))
2679
2781
  // Gap #13 (step ①): single-session cross-round comparison. One row per
2680
2782
  // round — findings raised / fixed / severity mix / validation outcome.
2681
- // Cross-SESSION comparison rides on the #4 pull instruction instead (the
2682
- // client has no filesystem access, so `iterate_history` aggregates have to
2683
- // be echoed back into the session first).
2783
+ // v3.16: the cross-SESSION half is now the `renderCrossSessionCompare`
2784
+ // block below (save the current run as a baseline, then compare any later
2785
+ // run against it) instead of the old export-two-JSONs-and-diff-by-hand chore.
2684
2786
  const report = latestReport(session)
2685
2787
  const comparison = buildRoundComparison(report, manifest.validations || [], manifest.fixes || [])
2686
2788
  const comparisonTable = comparison.length === 0
@@ -2720,22 +2822,117 @@ function ObservatoryPanel(props: SlotProps) {
2720
2822
  }),
2721
2823
  ),
2722
2824
  )
2723
- return React.createElement('div', {},
2724
- React.createElement(TrendChart, { points }),
2725
- React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
2726
- tf(`各轮发现数量:{a0}{a1}`, { a0: conv.join(' → '), a1: typeof manifest.round === 'number' ? tf(` · 当前 Round {a0}`, { a0: manifest.round }) : '' }),
2727
- ),
2728
- comparisonTable
2729
- ? React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2730
- React.createElement('div', { className: 'iterate-obs-block-head' },
2731
- React.createElement('span', {}, t('跨轮对比(本会话)')),
2732
- React.createElement('span', { className: 'iterate-obs-head-meta' },
2733
- t('跨会话对比:本表只覆盖当前会话;点「拉取磁盘快照」让模型回显 iterate_history,再用「导出 JSON」并排比较两次运行'),
2734
- ),
2825
+ const trendBody = conv.length === 0
2826
+ ? React.createElement('div', { className: 'iterate-obs-empty' }, t('暂无收敛数据'))
2827
+ : React.createElement('div', {},
2828
+ React.createElement(TrendChart, { points }),
2829
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginTop: 6 } },
2830
+ tf(`各轮发现数量:{a0}{a1}`, { a0: conv.join(' → '), a1: typeof manifest.round === 'number' ? tf(` · 当前 Round {a0}`, { a0: manifest.round }) : '' }),
2831
+ ),
2832
+ comparisonTable
2833
+ ? React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2834
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2835
+ React.createElement('span', {}, t('跨轮对比(本会话)')),
2836
+ React.createElement('span', { className: 'iterate-obs-head-meta' },
2837
+ t('跨会话对比:本表只覆盖当前会话;要跨两次迭代对比,用下方「存为对比基准」保存本次运行,再与下一次迭代对比。'),
2838
+ ),
2839
+ ),
2840
+ React.createElement('div', { className: 'iterate-obs-block-body' }, comparisonTable),
2841
+ )
2842
+ : null,
2843
+ )
2844
+ return React.createElement('div', {}, trendBody, renderCrossSessionCompare())
2845
+ }
2846
+
2847
+ // ── v3.16: cross-session run comparison (F2) ─────────────────────────────
2848
+ // Side-by-side comparison of the CURRENT run against a previously saved
2849
+ // baseline, persisted in localStorage. This replaces the manual
2850
+ // export-JSON-and-diff chore the old cross-session hint pointed at: save the
2851
+ // current run once, then compare any later run against it in one click.
2852
+ const renderCrossSessionCompare = () => {
2853
+ const current = currentRunSummary()
2854
+ const hasRun = current !== null
2855
+ const metricLabel = (key: string): string => {
2856
+ const m = RUN_COMPARE_METRIC_LABELS[key]
2857
+ return m ? m[getLang()] : key
2858
+ }
2859
+ const fmtCell = (v: number | null, key: string): string => {
2860
+ if (v === null) return '—'
2861
+ return key === 'validationPassRate' ? `${v}%` : String(v)
2862
+ }
2863
+ const deltaText = (row: RunCompareMetric): string => {
2864
+ if (row.baseline === null || row.current === null) return '—'
2865
+ const d = row.current - row.baseline
2866
+ return d === 0 ? '0' : `${d > 0 ? '+' : ''}${d}`
2867
+ }
2868
+ const deltaTint = (row: RunCompareMetric): { color: string; fontWeight: number } | undefined => {
2869
+ if (row.baseline === null || row.current === null || row.higherIsBetter === null) return undefined
2870
+ const d = row.current - row.baseline
2871
+ if (d === 0) return undefined
2872
+ const improved = row.higherIsBetter ? d > 0 : d < 0
2873
+ return improved
2874
+ ? { color: 'var(--dsw-alias-state-success-primary)', fontWeight: 600 }
2875
+ : { color: 'var(--dsw-alias-state-error-primary)', fontWeight: 600 }
2876
+ }
2877
+ let compareBody: React.ReactNode = null
2878
+ if (baseline && current && showCompare) {
2879
+ const rows = compareRuns(baseline, current)
2880
+ compareBody = React.createElement('table', { className: 'iterate-obs-table' },
2881
+ React.createElement('thead', {},
2882
+ React.createElement('tr', {},
2883
+ React.createElement('th', {}, t('指标')),
2884
+ React.createElement('th', {}, t('基准')),
2885
+ React.createElement('th', {}, t('当前')),
2886
+ React.createElement('th', {}, t('变化')),
2887
+ ),
2888
+ ),
2889
+ React.createElement('tbody', {},
2890
+ ...rows.map((row) =>
2891
+ React.createElement('tr', { key: `rc-${row.key}` },
2892
+ React.createElement('td', {}, metricLabel(row.key)),
2893
+ React.createElement('td', {}, fmtCell(row.baseline, row.key)),
2894
+ React.createElement('td', {}, fmtCell(row.current, row.key)),
2895
+ React.createElement('td', { style: deltaTint(row) }, deltaText(row)),
2735
2896
  ),
2736
- React.createElement('div', { className: 'iterate-obs-block-body' }, comparisonTable),
2737
- )
2738
- : null,
2897
+ ),
2898
+ ),
2899
+ )
2900
+ }
2901
+ return React.createElement('div', { className: 'iterate-obs-block', style: { marginTop: 8 } },
2902
+ React.createElement('div', { className: 'iterate-obs-block-head' },
2903
+ React.createElement('span', {}, t('跨会话对比')),
2904
+ baseline
2905
+ ? React.createElement('span', { className: 'iterate-obs-head-meta' }, tf(`基准已保存:{a0}`, { a0: baseline.savedAt }))
2906
+ : React.createElement('span', { className: 'iterate-obs-head-meta' }, t('先保存当前运行为基准,再跑下一次迭代后一键对比')),
2907
+ ),
2908
+ React.createElement('div', { className: 'iterate-obs-block-body' },
2909
+ React.createElement('div', { className: 'iterate-obs-bar', style: { flexWrap: 'wrap' } },
2910
+ React.createElement('button', {
2911
+ className: 'iterate-btn', 'data-primary': '', disabled: !hasRun,
2912
+ title: hasRun ? t('把当前运行摘要保存为对比基准(含轮次/发现/修复/验证/门禁)') : t('当前没有可保存的运行数据'),
2913
+ onClick: saveBaseline,
2914
+ }, baselineFlash ? t('已存为基准') : t('存为对比基准')),
2915
+ baseline
2916
+ ? React.createElement('button', {
2917
+ className: 'iterate-btn', 'data-copied': showCompare ? '' : undefined,
2918
+ disabled: !hasRun,
2919
+ title: t('显示或隐藏与已保存基准的对比表'),
2920
+ onClick: () => setShowCompare((v) => !v),
2921
+ }, showCompare ? t('收起对比') : t('与基准对比'))
2922
+ : null,
2923
+ baseline
2924
+ ? React.createElement('button', {
2925
+ className: 'iterate-btn', 'data-ghost': '',
2926
+ title: t('清除已保存的对比基准'),
2927
+ onClick: clearBaseline,
2928
+ }, t('清除基准'))
2929
+ : null,
2930
+ ),
2931
+ showCompare && baseline && !hasRun
2932
+ ? React.createElement('div', { className: 'iterate-obs-empty', style: { marginTop: 8 } }, t('当前会话没有运行数据可对比'))
2933
+ : null,
2934
+ compareBody,
2935
+ ),
2739
2936
  )
2740
2937
  }
2741
2938
 
@@ -3907,7 +4104,7 @@ export function apply(ctx: ClientContext): void {
3907
4104
  ),
3908
4105
  )
3909
4106
 
3910
- // F1–F7: runtime observatory board (order 91, below the dashboard's 90 so
4107
+ // F1–F11: runtime observatory board (order 91, below the dashboard's 90 so
3911
4108
  // its collapsed header never occludes the main convergence dashboard).
3912
4109
  slotsSvc.inject('conversation.input.dock', () =>
3913
4110
  slotsSvc?.register(
@@ -3984,6 +4181,26 @@ export function apply(ctx: ClientContext): void {
3984
4181
  }
3985
4182
  }
3986
4183
 
4184
+ // 5c. v3.15: keyboard-command contribution (dsh 0.2.2-rc.1 unified
4185
+ // shortcuts). Optional service: a host that predates the capability has no
4186
+ // keyboard command, and the docked observatory stays the entry point.
4187
+ {
4188
+ const shortcutSvc = readShortcuts(ctx)
4189
+ if (shortcutSvc) {
4190
+ if (shortcutDisposer) {
4191
+ try { shortcutDisposer() } catch { /* noop */ }
4192
+ shortcutDisposer = null
4193
+ }
4194
+ shortcutDisposer = registerIterateShortcuts(shortcutSvc, {
4195
+ openObservatory: (detail) => { openObservatory(detail) },
4196
+ lang: () => getLang(),
4197
+ })
4198
+ if (typeof ctx.effect === 'function') {
4199
+ ctx.effect(() => { if (shortcutDisposer) { shortcutDisposer(); shortcutDisposer = null } })
4200
+ }
4201
+ }
4202
+ }
4203
+
3987
4204
  // 6: keep theme toggle state consistent across theme/change events.
3988
4205
  // Registered through registerThemeListener so repeated apply() and client
3989
4206
  // unmounts never leak a second listener.