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/README.md +7 -4
- package/README.zh-CN.md +5 -2
- package/lib/client.js +361 -9
- package/package.json +24 -20
- package/src/client/context-menu.ts +11 -2
- package/src/client/i18n.ts +21 -2
- package/src/client/index.ts +242 -25
- package/src/client/run-compare.ts +213 -0
- package/src/client/shortcuts.ts +152 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/client/run-compare.ts — cross-session run comparison (the observatory F2 tab).
|
|
3
|
+
*
|
|
4
|
+
* The F2 convergence tab compares ROUNDS *within* one session, but comparing
|
|
5
|
+
* two ITERATIONS of the same project (before / after a refactor) used to be a
|
|
6
|
+
* manual chore: export JSON for each run, open both files, line up the columns.
|
|
7
|
+
* This module turns that into a first-class interaction. The current run is
|
|
8
|
+
* reduced to a compact, serializable summary and saved to localStorage as the
|
|
9
|
+
* comparison baseline; any later run can then be compared against it side by
|
|
10
|
+
* side, with a per-metric delta and a directional "better / worse" tint.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints (mirrors `i18n.ts` / `session-cache.ts`):
|
|
13
|
+
* - **Pure / dependency-free**: no React, no DOM, no Chinese literals (the
|
|
14
|
+
* bilingual labels live next to `t()` in index.ts, which is the layer the
|
|
15
|
+
* i18n dictionary-contract test scans). The summary builder and the
|
|
16
|
+
* compare reducer are unit-testable on their own.
|
|
17
|
+
* - **Versioned**: the stored payload carries a `version` so a future schema
|
|
18
|
+
* change can migrate or reject stale baselines instead of mis-reading them.
|
|
19
|
+
* - **Never fabricated**: every field is derived from real manifest fields
|
|
20
|
+
* with defensive optional chaining; a run with no findings simply reads 0.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Current schema version of the persisted baseline. */
|
|
24
|
+
export const RUN_BASELINE_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** localStorage key holding the single comparison baseline (per browser). */
|
|
27
|
+
export const RUN_BASELINE_STORAGE_KEY = 'iterate.run-baseline'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A compact, serializable summary of one run — everything the side-by-side
|
|
31
|
+
* comparison needs, and nothing the client cannot already see in the manifest.
|
|
32
|
+
*/
|
|
33
|
+
export interface RunBaseline {
|
|
34
|
+
version: 1
|
|
35
|
+
/** ISO timestamp of when this baseline was saved (NOT the run end time). */
|
|
36
|
+
savedAt: string
|
|
37
|
+
/** Project root the run was anchored to (informational). */
|
|
38
|
+
project?: string
|
|
39
|
+
/** 'normal' | 'dry-run' — the mode that produced the run. */
|
|
40
|
+
mode?: string
|
|
41
|
+
/** Number of review rounds captured. */
|
|
42
|
+
rounds: number
|
|
43
|
+
/** Total findings raised across the run. */
|
|
44
|
+
findings: number
|
|
45
|
+
/** critical + high findings (the "must fix" surface). */
|
|
46
|
+
criticalHigh: number
|
|
47
|
+
/** Fixes that succeeded. */
|
|
48
|
+
fixed: number
|
|
49
|
+
/** Fixes that failed (rolled back / validation-rejected). */
|
|
50
|
+
failedFixes: number
|
|
51
|
+
/** Fraction of validation command runs that passed, 0-100, or null if none ran. */
|
|
52
|
+
validationPassRate: number | null
|
|
53
|
+
/** Number of validation command runs. */
|
|
54
|
+
validationTotal: number
|
|
55
|
+
/** 'pass' | 'fail' | null — the quality gate verdict, when it was computed. */
|
|
56
|
+
qualityGateStatus: string | null
|
|
57
|
+
/** Quality gate overall score (0-100) when computed. */
|
|
58
|
+
qualityGateScore: number | null
|
|
59
|
+
/** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
|
|
60
|
+
stoppedReason: string | null
|
|
61
|
+
/** Findings per round (the convergence series), for a side-by-side trend. */
|
|
62
|
+
convergence: number[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The manifest-shaped inputs the summary builder reads (defensively). */
|
|
66
|
+
export interface RunBaselineInput {
|
|
67
|
+
project?: string
|
|
68
|
+
mode?: string
|
|
69
|
+
convergence?: number[]
|
|
70
|
+
findings?: Array<{ severity?: string }>
|
|
71
|
+
fixes?: Array<{ success?: boolean }>
|
|
72
|
+
validations?: Array<{ allowed?: boolean }>
|
|
73
|
+
qualityGateStatus?: string | null
|
|
74
|
+
qualityGateScore?: number | null
|
|
75
|
+
stoppedReason?: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reduce a run manifest (plus the session-scanned quality gate) to a compact,
|
|
80
|
+
* comparison-ready summary. Pure: `savedAt` is injected by the caller so this
|
|
81
|
+
* stays deterministic and testable.
|
|
82
|
+
*/
|
|
83
|
+
export function buildRunBaseline(input: RunBaselineInput, savedAt: string): RunBaseline {
|
|
84
|
+
const convergence = Array.isArray(input.convergence) ? input.convergence : []
|
|
85
|
+
// Only object entries count — a null / primitive in a malformed manifest
|
|
86
|
+
// must never be read as a finding / fix / validation (and skew the rate).
|
|
87
|
+
const findings = (Array.isArray(input.findings) ? input.findings : []).filter(
|
|
88
|
+
(f): f is { severity?: string } => typeof f === 'object' && f !== null,
|
|
89
|
+
)
|
|
90
|
+
const fixes = (Array.isArray(input.fixes) ? input.fixes : []).filter(
|
|
91
|
+
(f): f is { success?: boolean } => typeof f === 'object' && f !== null,
|
|
92
|
+
)
|
|
93
|
+
const validations = (Array.isArray(input.validations) ? input.validations : []).filter(
|
|
94
|
+
(v): v is { allowed?: boolean } => typeof v === 'object' && v !== null,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
let criticalHigh = 0
|
|
98
|
+
for (const f of findings) {
|
|
99
|
+
const sev = String((f && f.severity) || '').toLowerCase()
|
|
100
|
+
if (sev === 'critical' || sev === 'high') criticalHigh += 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let fixed = 0
|
|
104
|
+
let failedFixes = 0
|
|
105
|
+
for (const x of fixes) {
|
|
106
|
+
if (x && x.success === true) fixed += 1
|
|
107
|
+
else if (x && x.success === false) failedFixes += 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let passed = 0
|
|
111
|
+
for (const v of validations) {
|
|
112
|
+
if (v && v.allowed === true) passed += 1
|
|
113
|
+
}
|
|
114
|
+
const validationTotal = validations.length
|
|
115
|
+
const validationPassRate = validationTotal > 0 ? Math.round((passed / validationTotal) * 100) : null
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
version: RUN_BASELINE_VERSION,
|
|
119
|
+
savedAt,
|
|
120
|
+
...(input.project ? { project: input.project } : {}),
|
|
121
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
122
|
+
rounds: convergence.length,
|
|
123
|
+
findings: findings.length,
|
|
124
|
+
criticalHigh,
|
|
125
|
+
fixed,
|
|
126
|
+
failedFixes,
|
|
127
|
+
validationPassRate,
|
|
128
|
+
validationTotal,
|
|
129
|
+
qualityGateStatus: input.qualityGateStatus || null,
|
|
130
|
+
qualityGateScore: typeof input.qualityGateScore === 'number' ? input.qualityGateScore : null,
|
|
131
|
+
stoppedReason: input.stoppedReason || null,
|
|
132
|
+
convergence,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse a persisted baseline JSON string. Returns `null` for anything that is
|
|
138
|
+
* not a well-formed, version-matched baseline — a stale/corrupt payload must
|
|
139
|
+
* never be mis-read as a real run, and never throws.
|
|
140
|
+
*/
|
|
141
|
+
export function parseRunBaseline(raw: string | null): RunBaseline | null {
|
|
142
|
+
if (!raw) return null
|
|
143
|
+
let parsed: unknown
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(raw)
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
150
|
+
const b = parsed as Record<string, unknown>
|
|
151
|
+
if (b.version !== RUN_BASELINE_VERSION) return null
|
|
152
|
+
if (typeof b.savedAt !== 'string' || b.savedAt.length === 0) return null
|
|
153
|
+
if (typeof b.findings !== 'number' || !Number.isFinite(b.findings)) return null
|
|
154
|
+
// convergence must be a number array (defensive; a non-array becomes []).
|
|
155
|
+
const convergence = Array.isArray(b.convergence)
|
|
156
|
+
? b.convergence.filter((n): n is number => typeof n === 'number' && Number.isFinite(n))
|
|
157
|
+
: []
|
|
158
|
+
return {
|
|
159
|
+
version: RUN_BASELINE_VERSION,
|
|
160
|
+
savedAt: b.savedAt,
|
|
161
|
+
...(typeof b.project === 'string' && b.project ? { project: b.project } : {}),
|
|
162
|
+
...(typeof b.mode === 'string' && b.mode ? { mode: b.mode } : {}),
|
|
163
|
+
rounds: typeof b.rounds === 'number' && Number.isFinite(b.rounds) ? b.rounds : convergence.length,
|
|
164
|
+
findings: b.findings,
|
|
165
|
+
criticalHigh: typeof b.criticalHigh === 'number' && Number.isFinite(b.criticalHigh) ? b.criticalHigh : 0,
|
|
166
|
+
fixed: typeof b.fixed === 'number' && Number.isFinite(b.fixed) ? b.fixed : 0,
|
|
167
|
+
failedFixes: typeof b.failedFixes === 'number' && Number.isFinite(b.failedFixes) ? b.failedFixes : 0,
|
|
168
|
+
validationPassRate:
|
|
169
|
+
typeof b.validationPassRate === 'number' && Number.isFinite(b.validationPassRate) ? b.validationPassRate : null,
|
|
170
|
+
validationTotal:
|
|
171
|
+
typeof b.validationTotal === 'number' && Number.isFinite(b.validationTotal) ? b.validationTotal : 0,
|
|
172
|
+
qualityGateStatus: typeof b.qualityGateStatus === 'string' && b.qualityGateStatus ? b.qualityGateStatus : null,
|
|
173
|
+
qualityGateScore:
|
|
174
|
+
typeof b.qualityGateScore === 'number' && Number.isFinite(b.qualityGateScore) ? b.qualityGateScore : null,
|
|
175
|
+
stoppedReason: typeof b.stoppedReason === 'string' && b.stoppedReason ? b.stoppedReason : null,
|
|
176
|
+
convergence,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One row of the side-by-side comparison.
|
|
182
|
+
*
|
|
183
|
+
* `higherIsBetter` tells the renderer how to tint the delta:
|
|
184
|
+
* - `true` → a larger current value is an improvement (fixed, pass rate, score)
|
|
185
|
+
* - `false` → a smaller current value is an improvement (findings, failed fixes)
|
|
186
|
+
* - `null` → neutral (rounds — fewer is usually faster but not always better)
|
|
187
|
+
*/
|
|
188
|
+
export interface RunCompareMetric {
|
|
189
|
+
key: 'rounds' | 'findings' | 'criticalHigh' | 'fixed' | 'failedFixes' | 'validationPassRate' | 'qualityGateScore'
|
|
190
|
+
baseline: number | null
|
|
191
|
+
current: number | null
|
|
192
|
+
higherIsBetter: boolean | null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Compare a saved baseline against the current run. Both sides come from
|
|
197
|
+
* `buildRunBaseline`, so `null` means "that side had no such measurement"
|
|
198
|
+
* (e.g. a dry-run has no fixes; a run that never validated has no pass rate).
|
|
199
|
+
*/
|
|
200
|
+
export function compareRuns(baseline: RunBaseline, current: RunBaseline): RunCompareMetric[] {
|
|
201
|
+
const num = (v: number | null | undefined): number | null =>
|
|
202
|
+
typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
203
|
+
const rows: RunCompareMetric[] = [
|
|
204
|
+
{ key: 'rounds', baseline: num(baseline.rounds), current: num(current.rounds), higherIsBetter: null },
|
|
205
|
+
{ key: 'findings', baseline: num(baseline.findings), current: num(current.findings), higherIsBetter: false },
|
|
206
|
+
{ key: 'criticalHigh', baseline: num(baseline.criticalHigh), current: num(current.criticalHigh), higherIsBetter: false },
|
|
207
|
+
{ key: 'fixed', baseline: num(baseline.fixed), current: num(current.fixed), higherIsBetter: true },
|
|
208
|
+
{ key: 'failedFixes', baseline: num(baseline.failedFixes), current: num(current.failedFixes), higherIsBetter: false },
|
|
209
|
+
{ key: 'validationPassRate', baseline: num(baseline.validationPassRate), current: num(current.validationPassRate), higherIsBetter: true },
|
|
210
|
+
{ key: 'qualityGateScore', baseline: num(baseline.qualityGateScore), current: num(current.qualityGateScore), higherIsBetter: true },
|
|
211
|
+
]
|
|
212
|
+
return rows
|
|
213
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/client/shortcuts.ts — iterate-plugin keyboard-command contributions.
|
|
3
|
+
*
|
|
4
|
+
* dsh 0.2.2-rc.1 added the *unified keyboard-command* capability
|
|
5
|
+
* (`@deepseek-ai/dsh-client-shortcuts`): any client plugin may register a
|
|
6
|
+
* command with a default key binding and a target resolver, and dsh's own
|
|
7
|
+
* keyboard adapter dispatches the gesture to it. The same capability also lets
|
|
8
|
+
* a context-menu action carry a `commandId` so the menu shows the live
|
|
9
|
+
* keycap next to the action (selection still invokes the action's `run`).
|
|
10
|
+
*
|
|
11
|
+
* This module turns that new upstream capability into a *user-perceivable*
|
|
12
|
+
* iterate surface: instead of hunting for the docked observatory (or
|
|
13
|
+
* right-clicking a session), the user can now press **Cmd/Ctrl+Shift+O** to
|
|
14
|
+
* open the iterate quality command center from anywhere in the session. The
|
|
15
|
+
* matching context-menu action carries the same `commandId`, so the shortcut
|
|
16
|
+
* is discoverable in the right-click menu ("open command center" shows the
|
|
17
|
+
* keycap), and dsh's own shortcut settings page lists it as an editable
|
|
18
|
+
* binding.
|
|
19
|
+
*
|
|
20
|
+
* Design (mirrors `context-menu.ts` and the rest of the client half):
|
|
21
|
+
* - **Defensive**: the `shortcuts` service is optional and probed at
|
|
22
|
+
* apply-time (see `readShortcuts` in `index.ts`); a host that predates
|
|
23
|
+
* 0.2.2-rc.1 simply has no keyboard command (the docked panel stays the
|
|
24
|
+
* entry point). Nothing throws.
|
|
25
|
+
* - **Pure builder**: `buildObservatoryShortcutCommand` returns the command
|
|
26
|
+
* shape with no `ctx`/DOM side effects, so it is unit-testable on its own
|
|
27
|
+
* (label resolution, binding defaults, resolve→run). The imperative
|
|
28
|
+
* wiring (`openObservatory` / `lang`) is injected by
|
|
29
|
+
* `registerIterateShortcuts`.
|
|
30
|
+
* - **Bilingual**: the label resolves against the language passed in at
|
|
31
|
+
* registration (a language switch is picked up by dsh's own command
|
|
32
|
+
* catalog on the next render).
|
|
33
|
+
* - **No external import**: like the rest of the client half, the dsh
|
|
34
|
+
* `@deepseek-ai/dsh-client-shortcuts` types are NOT imported (no
|
|
35
|
+
* compile-time coupling to a package a pre-0.2.2-rc.1 host may not have);
|
|
36
|
+
* the surface below is the structural subset this plugin consumes.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { Lang } from './i18n.ts'
|
|
40
|
+
|
|
41
|
+
// ─── Structural service types (defensive; mirrors ContextMenusService) ───────
|
|
42
|
+
|
|
43
|
+
/** Stable command identity owned by this plugin (a branded string upstream). */
|
|
44
|
+
export const OBSERVATORY_SHORTCUT_ID = 'iterate.open-observatory'
|
|
45
|
+
|
|
46
|
+
/** One physical key + modifiers; `primary` expands to Meta on macOS. */
|
|
47
|
+
export interface ShortcutBinding {
|
|
48
|
+
code: string
|
|
49
|
+
secondCode?: string
|
|
50
|
+
modifiers: readonly string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The dsh command catalog shape this plugin registers. */
|
|
54
|
+
export interface ShortcutCommand {
|
|
55
|
+
id: string
|
|
56
|
+
label: () => string
|
|
57
|
+
aliases: readonly string[]
|
|
58
|
+
defaults: Readonly<Record<string, ShortcutBinding>>
|
|
59
|
+
regions: readonly string[]
|
|
60
|
+
modals: readonly string[]
|
|
61
|
+
resolve(context: { region?: string; modal?: string | null; target?: Element | null }): ShortcutResolution
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A resolved command either runs, is blocked with a reason, or passes. */
|
|
65
|
+
export type ShortcutResolution =
|
|
66
|
+
| { status: 'handled'; run: () => void }
|
|
67
|
+
| { status: 'blocked'; reason: string }
|
|
68
|
+
| { status: 'pass' }
|
|
69
|
+
|
|
70
|
+
/** Structural view of the dsh `shortcuts` service this plugin consumes. */
|
|
71
|
+
export interface ShortcutsService {
|
|
72
|
+
register(command: ShortcutCommand): () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Injection points the shortcut wiring needs (kept dependency-light for tests). */
|
|
76
|
+
export interface ShortcutDeps {
|
|
77
|
+
/** Open the observatory panel (optionally for a session, on a tab). */
|
|
78
|
+
openObservatory: (detail?: { sessionId?: string; tab?: string }) => void
|
|
79
|
+
/** Resolve the current UI language at label-build time. */
|
|
80
|
+
lang: () => Lang
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Default key binding for the observatory command: Cmd/Ctrl+Shift+O
|
|
85
|
+
* ("O" = Open), applied uniformly across every runtime/platform profile so the
|
|
86
|
+
* feature is on by default on desktop and web. If an existing dsh command
|
|
87
|
+
* already owns this combination, `register` throws and the plugin degrades
|
|
88
|
+
* gracefully (no keyboard command; the observatory stays mouse-reachable).
|
|
89
|
+
*/
|
|
90
|
+
const DEFAULT_BINDING: ShortcutBinding = { code: 'KeyO', modifiers: ['primary', 'shift'] }
|
|
91
|
+
|
|
92
|
+
const DEFAULT_PROFILES = [
|
|
93
|
+
'desktop:macos',
|
|
94
|
+
'desktop:windows',
|
|
95
|
+
'desktop:linux',
|
|
96
|
+
'web:macos',
|
|
97
|
+
'web:windows',
|
|
98
|
+
'web:linux',
|
|
99
|
+
] as const
|
|
100
|
+
|
|
101
|
+
/** Every runtime/platform profile maps to the same default binding. */
|
|
102
|
+
export const OBSERVATORY_DEFAULT_BINDINGS: Readonly<Record<string, ShortcutBinding>> = Object.fromEntries(
|
|
103
|
+
DEFAULT_PROFILES.map((profile) => [profile, DEFAULT_BINDING]),
|
|
104
|
+
) as Readonly<Record<string, ShortcutBinding>>
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the pure command descriptor for "open iterate observatory". The
|
|
108
|
+
* resolver always handles the gesture and runs the injected opener; it never
|
|
109
|
+
* blocks (opening the observatory is safe from any region and no modal).
|
|
110
|
+
*/
|
|
111
|
+
export function buildObservatoryShortcutCommand(deps: ShortcutDeps): ShortcutCommand {
|
|
112
|
+
const label = (): string =>
|
|
113
|
+
deps.lang() === 'zh' ? '打开 iterate 观测台' : 'Open iterate observatory'
|
|
114
|
+
return {
|
|
115
|
+
id: OBSERVATORY_SHORTCUT_ID,
|
|
116
|
+
label,
|
|
117
|
+
aliases: [],
|
|
118
|
+
defaults: OBSERVATORY_DEFAULT_BINDINGS,
|
|
119
|
+
regions: ['page', 'editable', 'terminal'],
|
|
120
|
+
modals: [],
|
|
121
|
+
resolve: () => ({ status: 'handled', run: () => deps.openObservatory({}) }),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Register the observatory keyboard command on the (already-validated)
|
|
127
|
+
* `shortcuts` service. Returns an unregister function; a host without the
|
|
128
|
+
* service is a no-op that returns `() => {}` (callers guard).
|
|
129
|
+
*/
|
|
130
|
+
export function registerIterateShortcuts(service: ShortcutsService, deps: ShortcutDeps): () => void {
|
|
131
|
+
try {
|
|
132
|
+
const dispose = service.register(buildObservatoryShortcutCommand(deps))
|
|
133
|
+
let done = false
|
|
134
|
+
return () => {
|
|
135
|
+
if (done) return
|
|
136
|
+
done = true
|
|
137
|
+
try {
|
|
138
|
+
dispose()
|
|
139
|
+
} catch {
|
|
140
|
+
/* noop */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
// A duplicate id or an overlapping default binding must not take the
|
|
145
|
+
// plugin down — log and continue with no keyboard command (the docked
|
|
146
|
+
// panel and the context-menu "open" action remain the entry points).
|
|
147
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
148
|
+
console.error('[iterate-ui] keyboard-command registration failed; continuing without it.', err)
|
|
149
|
+
}
|
|
150
|
+
return () => {}
|
|
151
|
+
}
|
|
152
|
+
}
|