iterate-plugin 2.3.7 → 2.4.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/client.js +635 -0
- package/lib/parse.js +459 -0
- package/package.json +11 -4
- package/src/index.ts +6 -4
- package/src/review.ts +13 -6
- package/src/tools/decision-log.ts +10 -0
- package/src/tools/triage.ts +370 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/client.js — iterate-plugin client entry.
|
|
3
|
+
*
|
|
4
|
+
* Loaded by the dsh web front-end via `package.json.dsh.client` (the
|
|
5
|
+
* `exports["./client"]` subpath). Module contract matches dsh-gui-customization:
|
|
6
|
+
* the file exports an `apply(ctx)` function that is called with the client
|
|
7
|
+
* Cordis context.
|
|
8
|
+
*
|
|
9
|
+
* Implements the §14.4 / §16 six-part UI layer:
|
|
10
|
+
* 1. Convergence dashboard -> conversation.input.dock
|
|
11
|
+
* 2. Findings triage panel -> conversation.chat.turnTail
|
|
12
|
+
* 3. Iteration stats card -> conversation.chat.turnTail (same chain, empty-findings case)
|
|
13
|
+
* 4. iterate theme skin -> theme.overrideTokens
|
|
14
|
+
* 5. Progress event linkage -> ctx.on('slots/changed') + round-pulse + shell.overlay capsule
|
|
15
|
+
* 6. Settings page section -> settings.section
|
|
16
|
+
*
|
|
17
|
+
* Design notes:
|
|
18
|
+
* - Build-free: no bundler. All styles are injected via one <style> tag and
|
|
19
|
+
* colors use the dsh token variables (`--dsw-*`).
|
|
20
|
+
* - Defensive by design: every service lookup is optional and guarded, so a
|
|
21
|
+
* missing slot/theme/React degrades gracefully instead of crashing the UI.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
findReportInObject,
|
|
26
|
+
scanSessionForReport,
|
|
27
|
+
normalizeReport,
|
|
28
|
+
computeConvergenceProgress,
|
|
29
|
+
getCurrentRound,
|
|
30
|
+
getTotalRounds,
|
|
31
|
+
severityStats,
|
|
32
|
+
groupByDimension,
|
|
33
|
+
buildTriageState,
|
|
34
|
+
hashReport,
|
|
35
|
+
toKnownIntentionalYaml,
|
|
36
|
+
buildApplyInstruction,
|
|
37
|
+
collectIgnoredEntries,
|
|
38
|
+
SEVERITY_LABEL,
|
|
39
|
+
SEVERITY_COLOR,
|
|
40
|
+
} from './parse.js'
|
|
41
|
+
|
|
42
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const PLUGIN_TAG = 'iterate-ui'
|
|
45
|
+
|
|
46
|
+
/** localStorage key prefix for triage verdicts. */
|
|
47
|
+
const TRIAGE_STORAGE_PREFIX = 'iterate.triage.'
|
|
48
|
+
|
|
49
|
+
/** localStorage key for the theme skin toggle. */
|
|
50
|
+
const THEME_STORAGE_KEY = 'iterate.theme.enabled'
|
|
51
|
+
|
|
52
|
+
/** Theme override source name (matches the doc §16.1). */
|
|
53
|
+
const THEME_SOURCE = 'iterate'
|
|
54
|
+
|
|
55
|
+
/** Theme tokens: 13 dsw tokens, each with light + dark values (warm amber accent). */
|
|
56
|
+
const ITERATE_TOKENS = {
|
|
57
|
+
'--dsw-alias-bg-base': { light: '#FAF8F5', dark: '#171412' },
|
|
58
|
+
'--dsw-alias-bg-layer-1': { light: '#FFFFFF', dark: '#1F1B17' },
|
|
59
|
+
'--dsw-alias-bg-layer-2': { light: '#F4F1EA', dark: '#27221C' },
|
|
60
|
+
'--dsw-alias-bg-overlay': { light: 'rgba(255,255,255,0.96)', dark: 'rgba(23,20,18,0.96)' },
|
|
61
|
+
'--dsw-alias-border-l1': { light: '#E8E2D8', dark: '#332C25' },
|
|
62
|
+
'--dsw-alias-border-l2': { light: '#DDD5C9', dark: '#3C342B' },
|
|
63
|
+
'--dsw-alias-brand-primary': { light: '#B45309', dark: '#F59E0B' },
|
|
64
|
+
'--dsw-alias-label-primary': { light: '#1C1917', dark: '#F5F0EA' },
|
|
65
|
+
'--dsw-alias-label-secondary': { light: '#57534E', dark: '#A9A29B' },
|
|
66
|
+
'--dsw-alias-state-error-primary': { light: '#DC2626', dark: '#F87171' },
|
|
67
|
+
'--dsw-alias-state-success-primary': { light: '#15803D', dark: '#4ADE80' },
|
|
68
|
+
'--dsw-alias-state-warn-primary': { light: '#D97706', dark: '#FBBF24' },
|
|
69
|
+
'--dsw-specific-sidebar-fill': { light: '#F5F2EC', dark: '#14110E' },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Injected stylesheet. Every selector is prefixed to avoid clobbering dsh. */
|
|
73
|
+
const ITERATE_CSS = `
|
|
74
|
+
[data-iterate-root] { box-sizing: border-box; font-family: var(--dsw-font-sans, system-ui, sans-serif); }
|
|
75
|
+
[data-iterate-root] * , [data-iterate-root] *::before, [data-iterate-root] *::after { box-sizing: border-box; }
|
|
76
|
+
|
|
77
|
+
.iterate-dashboard {
|
|
78
|
+
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
|
79
|
+
padding: 8px 14px; margin: 6px 0;
|
|
80
|
+
border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px;
|
|
81
|
+
background: var(--dsw-alias-bg-layer-1);
|
|
82
|
+
font-size: 12px; color: var(--dsw-alias-label-primary);
|
|
83
|
+
}
|
|
84
|
+
.iterate-round-badge {
|
|
85
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
86
|
+
padding: 3px 10px; border-radius: 999px;
|
|
87
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent);
|
|
88
|
+
color: var(--dsw-alias-brand-primary); font-weight: 600; white-space: nowrap;
|
|
89
|
+
}
|
|
90
|
+
.iterate-round-badge[data-pulse] { animation: iterate-pulse 700ms ease-out; }
|
|
91
|
+
@keyframes iterate-pulse {
|
|
92
|
+
0% { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-brand-primary) 60%, transparent); }
|
|
93
|
+
50% { transform: scale(1.12); box-shadow: 0 0 0 6px color-mix(in srgb, var(--dsw-alias-brand-primary) 0%, transparent); }
|
|
94
|
+
100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
|
|
95
|
+
}
|
|
96
|
+
.iterate-progress { flex: 1 1 120px; min-width: 120px; height: 6px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }
|
|
97
|
+
.iterate-progress-fill { height: 100%; border-radius: 999px; background: var(--dsw-alias-brand-primary); transition: width 300ms ease; }
|
|
98
|
+
.iterate-metric { display: inline-flex; align-items: center; gap: 5px; color: var(--dsw-alias-label-secondary); white-space: nowrap; }
|
|
99
|
+
.iterate-sev-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
100
|
+
.iterate-dim-badge {
|
|
101
|
+
padding: 2px 8px; border-radius: 6px;
|
|
102
|
+
background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1);
|
|
103
|
+
color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.iterate-triage { margin: 10px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); overflow: hidden; }
|
|
107
|
+
.iterate-triage-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
108
|
+
.iterate-triage-hint { font-size: 11px; font-weight: 400; color: var(--dsw-alias-label-secondary); }
|
|
109
|
+
.iterate-finding { padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
110
|
+
.iterate-finding:last-child { border-bottom: none; }
|
|
111
|
+
.iterate-finding-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
112
|
+
.iterate-finding-file { font-family: var(--dsw-font-mono, ui-monospace, monospace); color: var(--dsw-alias-label-primary); }
|
|
113
|
+
.iterate-finding-summary { margin-top: 4px; font-size: 12px; color: var(--dsw-alias-label-primary); line-height: 1.5; }
|
|
114
|
+
.iterate-finding-actions { display: flex; gap: 6px; margin-top: 8px; }
|
|
115
|
+
.iterate-vbtn {
|
|
116
|
+
padding: 3px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
|
|
117
|
+
background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary);
|
|
118
|
+
font-size: 11px; cursor: pointer;
|
|
119
|
+
}
|
|
120
|
+
.iterate-vbtn[data-active="keep"] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); }
|
|
121
|
+
.iterate-vbtn[data-active="skip"] { border-color: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); }
|
|
122
|
+
.iterate-vbtn[data-active="ignore"] { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
|
|
123
|
+
.iterate-triage-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 10px 14px; background: var(--dsw-alias-bg-layer-2); font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
124
|
+
.iterate-btn {
|
|
125
|
+
padding: 4px 10px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l1);
|
|
126
|
+
background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 11px; cursor: pointer;
|
|
127
|
+
}
|
|
128
|
+
.iterate-btn[data-primary] { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
|
|
129
|
+
.iterate-btn[data-copied] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
|
|
130
|
+
.iterate-payload { width: 100%; margin-top: 8px; padding: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11px; white-space: pre-wrap; }
|
|
131
|
+
|
|
132
|
+
.iterate-stats { margin: 10px 0; border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); padding: 12px 14px; }
|
|
133
|
+
.iterate-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px; margin-top: 8px; }
|
|
134
|
+
.iterate-stat { padding: 8px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); text-align: center; }
|
|
135
|
+
.iterate-stat-num { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary); }
|
|
136
|
+
.iterate-stat-label { font-size: 10px; color: var(--dsw-alias-label-secondary); margin-top: 2px; }
|
|
137
|
+
|
|
138
|
+
.iterate-capsule {
|
|
139
|
+
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
|
|
140
|
+
padding: 8px 14px; border-radius: 999px;
|
|
141
|
+
background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-brand-primary);
|
|
142
|
+
color: var(--dsw-alias-label-primary); font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
|
143
|
+
animation: iterate-fadein 200ms ease-out; pointer-events: auto;
|
|
144
|
+
}
|
|
145
|
+
@keyframes iterate-fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
|
146
|
+
|
|
147
|
+
.iterate-settings { display: flex; flex-direction: column; gap: 12px; padding: 4px 2px 12px; }
|
|
148
|
+
.iterate-settings-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--dsw-alias-border-l1); }
|
|
149
|
+
.iterate-settings-row:last-child { border-bottom: none; }
|
|
150
|
+
.iterate-settings-title { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
151
|
+
.iterate-settings-desc { font-size: 12px; color: var(--dsw-alias-label-secondary); margin-top: 3px; line-height: 1.5; }
|
|
152
|
+
.iterate-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
|
153
|
+
`
|
|
154
|
+
|
|
155
|
+
// ─── Small helpers ───────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/** Namespaced logger (kept minimal; only errors/warnings, no debug spam). */
|
|
158
|
+
function log(...args) {
|
|
159
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
160
|
+
console.error(`[${PLUGIN_TAG}]`, ...args)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Resolve the React runtime (ctx.React first, then window.React). */
|
|
165
|
+
function resolveReact(ctx) {
|
|
166
|
+
if (ctx && ctx.React && typeof ctx.React.createElement === 'function') return ctx.React
|
|
167
|
+
const globalScope = typeof window !== 'undefined' ? window : typeof globalThis !== 'undefined' ? globalThis : null
|
|
168
|
+
if (globalScope && globalScope.React && typeof globalScope.React.createElement === 'function') {
|
|
169
|
+
return globalScope.React
|
|
170
|
+
}
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Safe localStorage wrapper (never throws in private mode / SSR). */
|
|
175
|
+
function createStorage() {
|
|
176
|
+
try {
|
|
177
|
+
const testKey = '__iterate_storage_test__'
|
|
178
|
+
window.localStorage.setItem(testKey, '1')
|
|
179
|
+
window.localStorage.removeItem(testKey)
|
|
180
|
+
return {
|
|
181
|
+
get(key) { return window.localStorage.getItem(key) },
|
|
182
|
+
set(key, value) { window.localStorage.setItem(key, value) },
|
|
183
|
+
remove(key) { window.localStorage.removeItem(key) },
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
const mem = new Map()
|
|
187
|
+
return {
|
|
188
|
+
get(key) { return mem.has(key) ? mem.get(key) : null },
|
|
189
|
+
set(key, value) { mem.set(key, value) },
|
|
190
|
+
remove(key) { mem.delete(key) },
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Copy text to the clipboard, returning whether it succeeded. */
|
|
196
|
+
function copyText(text) {
|
|
197
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
198
|
+
navigator.clipboard.writeText(text).then(
|
|
199
|
+
() => true,
|
|
200
|
+
() => false,
|
|
201
|
+
)
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Module-level runtime state (shared across slots) ────────────────────────
|
|
208
|
+
|
|
209
|
+
let React = null
|
|
210
|
+
let slotsSvc = null
|
|
211
|
+
let themeSvc = null
|
|
212
|
+
let storage = null
|
|
213
|
+
let themeDisposer = null
|
|
214
|
+
let themeEnabled = true
|
|
215
|
+
const roundPulseListeners = []
|
|
216
|
+
|
|
217
|
+
/** Emit a "round changed" pulse to subscribed components. */
|
|
218
|
+
function emitRoundPulse(round) {
|
|
219
|
+
for (const fn of roundPulseListeners.slice()) fn(round)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Apply the iterate theme skin (idempotent). */
|
|
223
|
+
function applyThemeSkin() {
|
|
224
|
+
if (!themeSvc || typeof themeSvc.overrideTokens !== 'function') return
|
|
225
|
+
// Never stack disposers: drop any previously applied override first.
|
|
226
|
+
clearThemeSkin()
|
|
227
|
+
themeDisposer = themeSvc.overrideTokens(THEME_SOURCE, ITERATE_TOKENS)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Remove the iterate theme skin (idempotent). */
|
|
231
|
+
function clearThemeSkin() {
|
|
232
|
+
if (themeDisposer) {
|
|
233
|
+
try { themeDisposer() } catch (err) { log('theme disposer failed', err) }
|
|
234
|
+
themeDisposer = null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Persist + apply the theme preference. */
|
|
239
|
+
function setThemeEnabled(enabled) {
|
|
240
|
+
themeEnabled = enabled
|
|
241
|
+
if (storage) storage.set(THEME_STORAGE_KEY, enabled ? '1' : '0')
|
|
242
|
+
if (enabled) applyThemeSkin()
|
|
243
|
+
else clearThemeSkin()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ─── Components (React.createElement trees) ──────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/** Obtain a session snapshot defensively from the slot props. */
|
|
249
|
+
function sessionSnapshot(props) {
|
|
250
|
+
let session = null
|
|
251
|
+
if (props && typeof props.useSession === 'function') {
|
|
252
|
+
try { session = props.useSession() } catch (err) { log('useSession failed', err) }
|
|
253
|
+
}
|
|
254
|
+
if (!session && props && props.session) session = props.session
|
|
255
|
+
return session
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Find the latest report inside a session snapshot (normalized). */
|
|
259
|
+
function latestReport(session) {
|
|
260
|
+
if (!session) return null
|
|
261
|
+
const raw = scanSessionForReport(session) || findReportInObject(session, undefined, 24)
|
|
262
|
+
return raw ? normalizeReport(raw) : null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Dashboard: live convergence strip above the composer. */
|
|
266
|
+
function ConvergenceDashboard(props) {
|
|
267
|
+
const [pulseKey, setPulseKey] = React.useState(0)
|
|
268
|
+
const report = latestReport(sessionSnapshot(props))
|
|
269
|
+
|
|
270
|
+
React.useEffect(() => {
|
|
271
|
+
if (!report) return
|
|
272
|
+
const cur = getCurrentRound(report)
|
|
273
|
+
emitRoundPulse(cur)
|
|
274
|
+
setPulseKey((k) => k + 1)
|
|
275
|
+
}, [report && hashReport(report) + ':' + getCurrentRound(report)])
|
|
276
|
+
|
|
277
|
+
if (!report) return null
|
|
278
|
+
|
|
279
|
+
const round = getCurrentRound(report)
|
|
280
|
+
const total = getTotalRounds(report)
|
|
281
|
+
const progress = computeConvergenceProgress(report)
|
|
282
|
+
const stats = severityStats(report)
|
|
283
|
+
const dims = groupByDimension(report)
|
|
284
|
+
|
|
285
|
+
const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
|
|
286
|
+
React.createElement(
|
|
287
|
+
'span',
|
|
288
|
+
{ key: dim, className: 'iterate-dim-badge' },
|
|
289
|
+
`${dim} · ${dims[dim].length}`,
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return React.createElement(
|
|
294
|
+
'div',
|
|
295
|
+
{ 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
|
|
296
|
+
React.createElement(
|
|
297
|
+
'span',
|
|
298
|
+
{ className: 'iterate-round-badge', 'data-pulse': pulseKey > 0 ? '' : undefined, key: `round-${round}` },
|
|
299
|
+
`Round ${round} / ${total}`,
|
|
300
|
+
),
|
|
301
|
+
React.createElement('div', { className: 'iterate-progress' },
|
|
302
|
+
React.createElement('div', { className: 'iterate-progress-fill', style: { width: `${progress}%` } }),
|
|
303
|
+
),
|
|
304
|
+
React.createElement('span', { className: 'iterate-metric' },
|
|
305
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.critical } }),
|
|
306
|
+
stats.critical,
|
|
307
|
+
),
|
|
308
|
+
React.createElement('span', { className: 'iterate-metric' },
|
|
309
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.high } }),
|
|
310
|
+
stats.high,
|
|
311
|
+
),
|
|
312
|
+
React.createElement('span', { className: 'iterate-metric' },
|
|
313
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
|
|
314
|
+
stats.medium,
|
|
315
|
+
),
|
|
316
|
+
...dimBadges,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Stats card (empty-findings case of the turn-tail chain). */
|
|
321
|
+
function StatsCard(props) {
|
|
322
|
+
const report = props.report
|
|
323
|
+
const stats = severityStats(report)
|
|
324
|
+
const total = stats.critical + stats.high + stats.medium + stats.low
|
|
325
|
+
const rows = [
|
|
326
|
+
{ label: 'Critical', value: stats.critical, color: SEVERITY_COLOR.critical },
|
|
327
|
+
{ label: 'High', value: stats.high, color: SEVERITY_COLOR.high },
|
|
328
|
+
{ label: 'Medium', value: stats.medium, color: SEVERITY_COLOR.medium },
|
|
329
|
+
{ label: 'Low', value: stats.low, color: SEVERITY_COLOR.low },
|
|
330
|
+
].map((r) =>
|
|
331
|
+
React.createElement('div', { key: r.label, className: 'iterate-stat' },
|
|
332
|
+
React.createElement('div', { className: 'iterate-stat-num', style: { color: r.color } }, String(r.value)),
|
|
333
|
+
React.createElement('div', { className: 'iterate-stat-label' }, r.label),
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'stats', className: 'iterate-stats' },
|
|
338
|
+
React.createElement('div', { className: 'iterate-triage-head' },
|
|
339
|
+
React.createElement('span', {}, 'Iterate · 收敛统计'),
|
|
340
|
+
React.createElement('span', { className: 'iterate-triage-hint' },
|
|
341
|
+
`Round ${getCurrentRound(report)}/${getTotalRounds(report)} · ${total} findings · ${report.convergence && report.convergence.converged ? '已收敛' : '未收敛'}`,
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
React.createElement('div', { className: 'iterate-stats-grid' }, ...rows),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Triage panel: per-finding y / n / a with localStorage persistence. */
|
|
349
|
+
function TriagePanel(props) {
|
|
350
|
+
const report = props.report
|
|
351
|
+
const findings = report.findings
|
|
352
|
+
const storageKey = TRIAGE_STORAGE_PREFIX + hashReport(report)
|
|
353
|
+
const [verdicts, setVerdicts] = React.useState(() => {
|
|
354
|
+
const initial = buildTriageState(report)
|
|
355
|
+
const saved = storage ? storage.get(storageKey) : null
|
|
356
|
+
if (saved) {
|
|
357
|
+
try {
|
|
358
|
+
const parsed = JSON.parse(saved)
|
|
359
|
+
if (parsed && typeof parsed === 'object') {
|
|
360
|
+
for (const key of Object.keys(initial)) {
|
|
361
|
+
if (parsed[key] === 'keep' || parsed[key] === 'skip' || parsed[key] === 'ignore') {
|
|
362
|
+
initial[key] = parsed[key]
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch { /* corrupt storage, fall back to defaults */ }
|
|
367
|
+
}
|
|
368
|
+
return initial
|
|
369
|
+
})
|
|
370
|
+
const [payload, setPayload] = React.useState(null)
|
|
371
|
+
const [copied, setCopied] = React.useState(false)
|
|
372
|
+
|
|
373
|
+
const setVerdict = (index, verdict) => {
|
|
374
|
+
setVerdicts((prev) => {
|
|
375
|
+
const next = { ...prev, [String(index)]: verdict }
|
|
376
|
+
if (storage) storage.set(storageKey, JSON.stringify(next))
|
|
377
|
+
return next
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const ignored = collectIgnoredEntries(verdicts, findings)
|
|
382
|
+
const ignoredCount = ignored.length
|
|
383
|
+
|
|
384
|
+
const doCopyYaml = () => {
|
|
385
|
+
const yaml = toKnownIntentionalYaml(ignored)
|
|
386
|
+
if (!yaml) return
|
|
387
|
+
const ok = copyText(yaml)
|
|
388
|
+
setCopied(true)
|
|
389
|
+
setTimeout(() => setCopied(false), 1600)
|
|
390
|
+
if (!ok) setPayload(yaml)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const doBuildInstruction = () => {
|
|
394
|
+
const text = buildApplyInstruction(ignored)
|
|
395
|
+
setPayload(text)
|
|
396
|
+
if (text) copyText(text)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const rows = findings.map((finding, index) => {
|
|
400
|
+
const severity = finding.severity || 'low'
|
|
401
|
+
const verdict = verdicts[String(index)] || 'keep'
|
|
402
|
+
const btn = (label, value, title) =>
|
|
403
|
+
React.createElement(
|
|
404
|
+
'button',
|
|
405
|
+
{
|
|
406
|
+
key: value,
|
|
407
|
+
className: 'iterate-vbtn',
|
|
408
|
+
'data-active': verdict === value ? value : undefined,
|
|
409
|
+
title,
|
|
410
|
+
onClick: () => setVerdict(index, value),
|
|
411
|
+
},
|
|
412
|
+
label,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
return React.createElement('div', { key: String(index), className: 'iterate-finding' },
|
|
416
|
+
React.createElement('div', { className: 'iterate-finding-meta' },
|
|
417
|
+
React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR[severity] || SEVERITY_COLOR.low } }),
|
|
418
|
+
React.createElement('span', {}, String(SEVERITY_LABEL[severity] || 'LOW')),
|
|
419
|
+
React.createElement('span', { className: 'iterate-finding-file' }, String(finding.file || '?')),
|
|
420
|
+
finding.line ? React.createElement('span', {}, `:${finding.line}`) : null,
|
|
421
|
+
React.createElement('span', {}, String(finding.dimension || '')),
|
|
422
|
+
),
|
|
423
|
+
React.createElement('div', { className: 'iterate-finding-summary' }, String(finding.summary || '')),
|
|
424
|
+
React.createElement('div', { className: 'iterate-finding-actions' },
|
|
425
|
+
btn('y 修复', 'keep', '保留该 finding,进入修复'),
|
|
426
|
+
btn('n 跳过', 'skip', '跳过该 finding'),
|
|
427
|
+
btn('a 已知有意', 'ignore', '标记为已知有意并写入 known_intentional'),
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
|
|
433
|
+
React.createElement('div', { className: 'iterate-triage-head' },
|
|
434
|
+
React.createElement('span', {}, `Iterate · Findings 分诊 (${findings.length})`),
|
|
435
|
+
React.createElement('span', { className: 'iterate-triage-hint' }, 'y=修复 · n=跳过 · a=已知有意'),
|
|
436
|
+
),
|
|
437
|
+
...rows,
|
|
438
|
+
React.createElement('div', { className: 'iterate-triage-foot' },
|
|
439
|
+
React.createElement('span', {}, `待写回 known_intentional:${ignoredCount} 条`),
|
|
440
|
+
React.createElement('span', { style: { display: 'flex', gap: 6 } },
|
|
441
|
+
React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyYaml }, copied ? '已复制' : '复制 known_intentional'),
|
|
442
|
+
React.createElement('button', { className: 'iterate-btn', onClick: doBuildInstruction }, '生成应用指令'),
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
payload
|
|
446
|
+
? React.createElement('div', { className: 'iterate-payload' }, payload)
|
|
447
|
+
: null,
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Turn-tail chain entry: triage when findings exist, stats card otherwise. */
|
|
452
|
+
function TurnTailEntry(props) {
|
|
453
|
+
const candidates = []
|
|
454
|
+
if (props && props.turn) candidates.push(props.turn)
|
|
455
|
+
if (props && props.matched) candidates.push(props.matched)
|
|
456
|
+
if (props && props.data) candidates.push(props.data)
|
|
457
|
+
candidates.push(props)
|
|
458
|
+
|
|
459
|
+
let report = null
|
|
460
|
+
for (const c of candidates) {
|
|
461
|
+
const raw = findReportInObject(c, undefined, 24) || scanSessionForReport(c)
|
|
462
|
+
if (raw) { report = normalizeReport(raw); break }
|
|
463
|
+
}
|
|
464
|
+
if (!report) return null
|
|
465
|
+
// Render through React.createElement so each panel is a real component with
|
|
466
|
+
// its own hook identity (calling them as functions would violate the Rules
|
|
467
|
+
// of Hooks and crash when the findings/empty branch flips between renders).
|
|
468
|
+
if (!report.findings || report.findings.length === 0) {
|
|
469
|
+
return React.createElement(StatsCard, { report })
|
|
470
|
+
}
|
|
471
|
+
return React.createElement(TriagePanel, { report })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Progress capsule: briefly surfaces round-completion in a frame overlay. */
|
|
475
|
+
function ProgressCapsule() {
|
|
476
|
+
const [info, setInfo] = React.useState(null)
|
|
477
|
+
React.useEffect(() => {
|
|
478
|
+
let timer = null
|
|
479
|
+
const listener = (round) => {
|
|
480
|
+
setInfo(`Round ${round} 完成`)
|
|
481
|
+
if (timer) clearTimeout(timer)
|
|
482
|
+
timer = setTimeout(() => setInfo(null), 2400)
|
|
483
|
+
}
|
|
484
|
+
roundPulseListeners.push(listener)
|
|
485
|
+
return () => {
|
|
486
|
+
const i = roundPulseListeners.indexOf(listener)
|
|
487
|
+
if (i >= 0) roundPulseListeners.splice(i, 1)
|
|
488
|
+
if (timer) clearTimeout(timer)
|
|
489
|
+
}
|
|
490
|
+
}, [])
|
|
491
|
+
if (!info) return null
|
|
492
|
+
return React.createElement('div', { 'data-iterate-root': '', className: 'iterate-capsule' }, info)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Settings page section (root scope; reads localStorage + latest known data). */
|
|
496
|
+
function SettingsPanel() {
|
|
497
|
+
const [enabled, setEnabled] = React.useState(themeEnabled)
|
|
498
|
+
const [copied, setCopied] = React.useState(false)
|
|
499
|
+
|
|
500
|
+
const toggleTheme = () => {
|
|
501
|
+
const next = !enabled
|
|
502
|
+
setEnabled(next)
|
|
503
|
+
setThemeEnabled(next)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const doCopyGuide = () => {
|
|
507
|
+
const guide =
|
|
508
|
+
'iterate 配置指引\n' +
|
|
509
|
+
'----------------\n' +
|
|
510
|
+
'目标 / 维度 / 最大轮数在项目的 iterate.config.yaml 中配置。\n' +
|
|
511
|
+
'让模型修改配置:请调用 iterate_config(读)与 iterate_triage(写 known_intentional),\n' +
|
|
512
|
+
'并说明你希望调整的字段(goal、dimensions、max_rounds 等)。\n'
|
|
513
|
+
copyText(guide)
|
|
514
|
+
setCopied(true)
|
|
515
|
+
setTimeout(() => setCopied(false), 1600)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'settings', className: 'iterate-settings' },
|
|
519
|
+
React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 设置'),
|
|
520
|
+
React.createElement('div', { className: 'iterate-settings-row' },
|
|
521
|
+
React.createElement('div', {},
|
|
522
|
+
React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 主题'),
|
|
523
|
+
React.createElement('div', { className: 'iterate-settings-desc' }, '启用暖琥珀配色的 iterate 专属皮肤(覆盖 dsh 默认主题令牌)。'),
|
|
524
|
+
),
|
|
525
|
+
React.createElement('button', {
|
|
526
|
+
className: 'iterate-btn',
|
|
527
|
+
'data-primary': enabled ? '' : undefined,
|
|
528
|
+
onClick: toggleTheme,
|
|
529
|
+
}, enabled ? '已启用' : '已关闭'),
|
|
530
|
+
),
|
|
531
|
+
React.createElement('div', { className: 'iterate-settings-row' },
|
|
532
|
+
React.createElement('div', {},
|
|
533
|
+
React.createElement('div', { className: 'iterate-settings-title' }, '分诊持久化'),
|
|
534
|
+
React.createElement('div', { className: 'iterate-settings-desc' }, '分诊面板的 y/n/a 判定保存在本地浏览器(localStorage),刷新会话后仍保留。'),
|
|
535
|
+
),
|
|
536
|
+
React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
|
|
537
|
+
),
|
|
538
|
+
React.createElement('div', { className: 'iterate-settings-row' },
|
|
539
|
+
React.createElement('div', {},
|
|
540
|
+
React.createElement('div', { className: 'iterate-settings-title' }, '配置管理'),
|
|
541
|
+
React.createElement('div', { className: 'iterate-settings-desc' }, '目标 / 维度 / 最大轮数写在项目的 iterate.config.yaml。复制下方指引,让模型为你调整。'),
|
|
542
|
+
),
|
|
543
|
+
React.createElement('button', { className: 'iterate-btn', 'data-primary': '', 'data-copied': copied ? '' : undefined, onClick: doCopyGuide }, copied ? '已复制' : '复制指引'),
|
|
544
|
+
),
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ─── Registration ────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Chain selector for conversation.chat.turnTail.
|
|
552
|
+
* Returns a marker when the turn contains an iterate review report, null otherwise.
|
|
553
|
+
*/
|
|
554
|
+
function selectTurnTail(owner) {
|
|
555
|
+
if (!owner) return null
|
|
556
|
+
const raw = findReportInObject(owner.turn, undefined, 24) || scanSessionForReport(owner.turn)
|
|
557
|
+
return raw ? { matched: true } : null
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Plugin client entry. Called by dsh web with the client Cordis context.
|
|
562
|
+
*/
|
|
563
|
+
export function apply(ctx) {
|
|
564
|
+
// 1. Resolve optional services (degrade, never throw).
|
|
565
|
+
const get = ctx && typeof ctx.get === 'function' ? ctx.get.bind(ctx) : null
|
|
566
|
+
slotsSvc = get ? get('slots') : undefined
|
|
567
|
+
themeSvc = get ? get('theme') : undefined
|
|
568
|
+
React = resolveReact(ctx)
|
|
569
|
+
storage = createStorage()
|
|
570
|
+
|
|
571
|
+
// 2. Theme preference bootstrap.
|
|
572
|
+
const savedTheme = storage.get(THEME_STORAGE_KEY)
|
|
573
|
+
themeEnabled = savedTheme === null ? true : savedTheme === '1'
|
|
574
|
+
if (themeEnabled) applyThemeSkin()
|
|
575
|
+
|
|
576
|
+
// 3. Inject styles (independent of slots / React availability).
|
|
577
|
+
if (typeof document !== 'undefined' && document.createElement && document.head) {
|
|
578
|
+
const style = document.createElement('style')
|
|
579
|
+
style.dataset.plugin = PLUGIN_TAG
|
|
580
|
+
style.dataset.pluginCss = 'iterate-main'
|
|
581
|
+
style.textContent = ITERATE_CSS
|
|
582
|
+
document.head.appendChild(style)
|
|
583
|
+
if (typeof ctx.effect === 'function') {
|
|
584
|
+
ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 4. Slot UI requires React + slots service.
|
|
589
|
+
if (React === null || slotsSvc === undefined) {
|
|
590
|
+
log('React or slots service unavailable — slot UI disabled')
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 5. Register the six-part UI.
|
|
595
|
+
if (typeof slotsSvc.inject === 'function') {
|
|
596
|
+
// 1 + 5: convergence dashboard + round pulse (session scope).
|
|
597
|
+
slotsSvc.inject('conversation.input.dock', () =>
|
|
598
|
+
slotsSvc.register(
|
|
599
|
+
{ name: 'conversation.input.dock', id: 'iterate-dashboard', order: 90 },
|
|
600
|
+
(props) => React.createElement(ConvergenceDashboard, props),
|
|
601
|
+
),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
// 2 + 3: triage panel / stats card (turn-tail chain, session scope).
|
|
605
|
+
slotsSvc.inject('conversation.chat.turnTail', () =>
|
|
606
|
+
slotsSvc.register(
|
|
607
|
+
{ name: 'conversation.chat.turnTail', id: 'iterate-turn-tail', select: selectTurnTail },
|
|
608
|
+
(props) => React.createElement(TurnTailEntry, props),
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
// 5: progress capsule (frame overlay, root scope).
|
|
613
|
+
slotsSvc.inject('shell.overlay', () =>
|
|
614
|
+
slotsSvc.register(
|
|
615
|
+
{ name: 'shell.overlay', id: 'iterate-progress', order: 0 },
|
|
616
|
+
(props) => React.createElement(ProgressCapsule, props),
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
// 6: settings page section (root scope).
|
|
621
|
+
slotsSvc.inject('settings.section', () =>
|
|
622
|
+
slotsSvc.register(
|
|
623
|
+
{ name: 'settings.section', id: 'iterate-settings', order: 30, label: () => 'iterate' },
|
|
624
|
+
(props) => React.createElement(SettingsPanel, props),
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 6: keep theme toggle state consistent across theme/change events.
|
|
630
|
+
if (typeof ctx.on === 'function') {
|
|
631
|
+
ctx.on('theme/change', () => {
|
|
632
|
+
if (themeEnabled) applyThemeSkin()
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
}
|