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/parse.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/parse.js — Pure logic for iterate client UI.
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic, DOM-free, single-file, testable with Node.js assert.
|
|
5
|
+
* Every function is exported for unit test coverage.
|
|
6
|
+
*
|
|
7
|
+
* @module iterate-ui/parse
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/** Severity ordering (lowest index = most severe). */
|
|
13
|
+
export const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low']
|
|
14
|
+
|
|
15
|
+
/** Severity labels (short form for badges). */
|
|
16
|
+
export const SEVERITY_LABEL = {
|
|
17
|
+
critical: 'CRIT',
|
|
18
|
+
high: 'HIGH',
|
|
19
|
+
medium: 'MED',
|
|
20
|
+
low: 'LOW',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Severity colors (CSS-compatible). */
|
|
24
|
+
export const SEVERITY_COLOR = {
|
|
25
|
+
critical: '#ef4444',
|
|
26
|
+
high: '#f97316',
|
|
27
|
+
medium: '#eab308',
|
|
28
|
+
low: '#6b7280',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── ReviewReport detection ──────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check whether `obj` is a valid ReviewReport-like object.
|
|
35
|
+
* The minimum requirement: an object with `convergence` (object),
|
|
36
|
+
* `findings` (array), and `rounds` (array).
|
|
37
|
+
*
|
|
38
|
+
* @param {unknown} obj
|
|
39
|
+
* @returns {obj is Record<string, unknown>}
|
|
40
|
+
*/
|
|
41
|
+
export function isReviewReport(obj) {
|
|
42
|
+
if (!obj || typeof obj !== 'object') return false
|
|
43
|
+
const o = /** @type {Record<string, unknown>} */ (obj)
|
|
44
|
+
return (
|
|
45
|
+
typeof o.convergence === 'object' &&
|
|
46
|
+
o.convergence !== null &&
|
|
47
|
+
Array.isArray(o.findings) &&
|
|
48
|
+
Array.isArray(o.rounds)
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Deep-scan an object tree for the first ReviewReport.
|
|
54
|
+
*
|
|
55
|
+
* - Uses a `seen` Set to avoid circular references.
|
|
56
|
+
* - Respects `maxDepth` (default 20) to cap stack depth.
|
|
57
|
+
* - Returns the first Report found (breadth-first precedence), or null.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} obj
|
|
60
|
+
* @param {Set<unknown>} [seen]
|
|
61
|
+
* @param {number} [maxDepth=20]
|
|
62
|
+
* @returns {Record<string, unknown> | null}
|
|
63
|
+
*/
|
|
64
|
+
export function findReportInObject(obj, seen, maxDepth = 20) {
|
|
65
|
+
if (maxDepth <= 0) return null
|
|
66
|
+
if (!obj || typeof obj !== 'object') return null
|
|
67
|
+
|
|
68
|
+
const s = seen || new Set()
|
|
69
|
+
if (s.has(obj)) return null
|
|
70
|
+
s.add(obj)
|
|
71
|
+
|
|
72
|
+
// Check self
|
|
73
|
+
if (isReviewReport(obj)) return /** @type {Record<string, unknown>} */ (obj)
|
|
74
|
+
|
|
75
|
+
// Check arrays first (breadth-first within a node)
|
|
76
|
+
if (Array.isArray(obj)) {
|
|
77
|
+
for (const item of obj) {
|
|
78
|
+
const found = findReportInObject(item, s, maxDepth - 1)
|
|
79
|
+
if (found) return found
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check object values
|
|
85
|
+
const o = /** @type {Record<string, unknown>} */ (obj)
|
|
86
|
+
for (const key of Object.keys(o)) {
|
|
87
|
+
const val = o[key]
|
|
88
|
+
if (val && typeof val === 'object') {
|
|
89
|
+
// Check leaf values that are arrays or objects
|
|
90
|
+
const found = findReportInObject(val, s, maxDepth - 1)
|
|
91
|
+
if (found) return found
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scan a session snapshot (or any object) for the latest iterate_review tool
|
|
100
|
+
* call result that contains a ReviewReport. Prefers the most recent one.
|
|
101
|
+
*
|
|
102
|
+
* @param {unknown} session
|
|
103
|
+
* @returns {Record<string, unknown> | null}
|
|
104
|
+
*/
|
|
105
|
+
export function scanSessionForReport(session) {
|
|
106
|
+
if (!session || typeof session !== 'object') return null
|
|
107
|
+
|
|
108
|
+
// Try direct find first
|
|
109
|
+
const direct = findReportInObject(session)
|
|
110
|
+
if (direct) return direct
|
|
111
|
+
|
|
112
|
+
// Try common session structures
|
|
113
|
+
const s = /** @type {Record<string, unknown>} */ (session)
|
|
114
|
+
|
|
115
|
+
// Common pattern: session.toolCalls[].result.report
|
|
116
|
+
if (Array.isArray(s.toolCalls)) {
|
|
117
|
+
const calls = /** @type {Array<Record<string, unknown>>} */ (s.toolCalls)
|
|
118
|
+
for (let i = calls.length - 1; i >= 0; i--) {
|
|
119
|
+
const call = calls[i]
|
|
120
|
+
if (!call) continue
|
|
121
|
+
if (call.tool === 'iterate_review' || String(call.tool ?? '').endsWith('iterate_review')) {
|
|
122
|
+
const result = call.result
|
|
123
|
+
if (result && typeof result === 'object') {
|
|
124
|
+
const r = /** @type {Record<string, unknown>} */ (result)
|
|
125
|
+
if (r.report && typeof r.report === 'object') {
|
|
126
|
+
return /** @type {Record<string, unknown>} */ (r.report)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Common pattern: session.messages[].tool_calls[].function.arguments
|
|
134
|
+
if (Array.isArray(s.messages)) {
|
|
135
|
+
const msgs = /** @type {Array<Record<string, unknown>>} */ (s.messages)
|
|
136
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
137
|
+
const msg = msgs[i]
|
|
138
|
+
if (!msg || !Array.isArray(msg.tool_calls)) continue
|
|
139
|
+
const calls = /** @type {Array<Record<string, unknown>>} */ (msg.tool_calls)
|
|
140
|
+
for (const call of calls) {
|
|
141
|
+
if (!call) continue
|
|
142
|
+
const fn = call.function
|
|
143
|
+
if (fn && typeof fn === 'object') {
|
|
144
|
+
const f = /** @type {Record<string, unknown>} */ (fn)
|
|
145
|
+
if (String(f.name ?? '').endsWith('iterate_review')) {
|
|
146
|
+
// Try to parse arguments
|
|
147
|
+
try {
|
|
148
|
+
const args = JSON.parse(String(f.arguments ?? '{}'))
|
|
149
|
+
const found = findReportInObject(args)
|
|
150
|
+
if (found) return found
|
|
151
|
+
} catch {
|
|
152
|
+
// Not JSON, skip
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Normalization ───────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Normalize a ReviewReport, filling in missing optional fields with computed
|
|
167
|
+
* defaults. Never mutates the input.
|
|
168
|
+
*
|
|
169
|
+
* @param {Record<string, unknown>} report
|
|
170
|
+
* @returns {Record<string, unknown>}
|
|
171
|
+
*/
|
|
172
|
+
export function normalizeReport(report) {
|
|
173
|
+
const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
|
|
174
|
+
const rounds = /** @type {Array<unknown>} */ (report.rounds ?? [])
|
|
175
|
+
const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
|
|
176
|
+
|
|
177
|
+
// Normalize convergence
|
|
178
|
+
const totalRounds =
|
|
179
|
+
typeof convergence.totalRounds === 'number'
|
|
180
|
+
? convergence.totalRounds
|
|
181
|
+
: rounds.length
|
|
182
|
+
|
|
183
|
+
const normalizedConvergence = {
|
|
184
|
+
totalRounds,
|
|
185
|
+
findingsByRound: Array.isArray(convergence.findingsByRound)
|
|
186
|
+
? convergence.findingsByRound
|
|
187
|
+
: rounds.map((r) => {
|
|
188
|
+
const rr = /** @type {Record<string, unknown>} */ (r)
|
|
189
|
+
return Array.isArray(rr?.findings) ? rr.findings.length : 0
|
|
190
|
+
}),
|
|
191
|
+
converged: convergence.converged === true,
|
|
192
|
+
stoppedReason: convergence.stoppedReason ?? (rounds.length < totalRounds ? 'converged' : 'max_rounds_reached'),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Compute summary if missing. Always build a NEW object so the input's
|
|
196
|
+
// summary (or any other field) is never mutated.
|
|
197
|
+
let summary = report.summary
|
|
198
|
+
if (!summary || typeof summary !== 'object') {
|
|
199
|
+
summary = computeSummaryFromFindings(findings)
|
|
200
|
+
} else {
|
|
201
|
+
const s = /** @type {Record<string, unknown>} */ (summary)
|
|
202
|
+
const computed = computeSummaryFromFindings(findings)
|
|
203
|
+
summary = {
|
|
204
|
+
totalFindings: typeof s.totalFindings === 'number' ? s.totalFindings : findings.length,
|
|
205
|
+
critical: typeof s.critical === 'number' ? s.critical : computed.critical,
|
|
206
|
+
high: typeof s.high === 'number' ? s.high : computed.high,
|
|
207
|
+
medium: typeof s.medium === 'number' ? s.medium : computed.medium,
|
|
208
|
+
low: typeof s.low === 'number' ? s.low : computed.low,
|
|
209
|
+
byDimension: s.byDimension && typeof s.byDimension === 'object'
|
|
210
|
+
? s.byDimension
|
|
211
|
+
: computed.byDimension,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
mode: report.mode ?? 'dry-run',
|
|
217
|
+
goal: report.goal ?? '',
|
|
218
|
+
dimensions: Array.isArray(report.dimensions) ? report.dimensions : [],
|
|
219
|
+
maxReviewRounds: report.maxReviewRounds ?? totalRounds,
|
|
220
|
+
rounds,
|
|
221
|
+
findings,
|
|
222
|
+
convergence: normalizedConvergence,
|
|
223
|
+
summary,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Compute summary stats from findings array.
|
|
229
|
+
*
|
|
230
|
+
* @param {Array<Record<string, unknown>>} findings
|
|
231
|
+
* @returns {{ totalFindings: number, critical: number, high: number, medium: number, low: number, byDimension: Record<string, number> }}
|
|
232
|
+
*/
|
|
233
|
+
function computeSummaryFromFindings(findings) {
|
|
234
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 }
|
|
235
|
+
/** @type {Record<string, number>} */
|
|
236
|
+
const byDimension = {}
|
|
237
|
+
|
|
238
|
+
for (const f of findings) {
|
|
239
|
+
const sev = String(f.severity ?? 'low')
|
|
240
|
+
if (sev in counts) counts[sev]++
|
|
241
|
+
const dim = String(f.dimension ?? 'unknown')
|
|
242
|
+
byDimension[dim] = (byDimension[dim] ?? 0) + 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
totalFindings: findings.length,
|
|
247
|
+
critical: counts.critical,
|
|
248
|
+
high: counts.high,
|
|
249
|
+
medium: counts.medium,
|
|
250
|
+
low: counts.low,
|
|
251
|
+
byDimension,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export { computeSummaryFromFindings }
|
|
256
|
+
|
|
257
|
+
// ─── Convergence helpers ─────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Compute progress percentage (0-100) from a normalized report.
|
|
261
|
+
*
|
|
262
|
+
* @param {Record<string, unknown>} report
|
|
263
|
+
* @returns {number}
|
|
264
|
+
*/
|
|
265
|
+
export function computeConvergenceProgress(report) {
|
|
266
|
+
const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
|
|
267
|
+
const totalRounds = typeof convergence.totalRounds === 'number'
|
|
268
|
+
? convergence.totalRounds
|
|
269
|
+
: 1
|
|
270
|
+
const currentRounds = /** @type {Array<unknown>} */ (report.rounds ?? []).length
|
|
271
|
+
// Guard against an empty report (totalRounds <= 0) producing NaN.
|
|
272
|
+
if (!(totalRounds > 0)) return 0
|
|
273
|
+
return Math.min(100, Math.round((currentRounds / totalRounds) * 100))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get the current round number (1-indexed) from a report.
|
|
278
|
+
*
|
|
279
|
+
* @param {Record<string, unknown>} report
|
|
280
|
+
* @returns {number}
|
|
281
|
+
*/
|
|
282
|
+
export function getCurrentRound(report) {
|
|
283
|
+
return (/** @type {Array<unknown>} */ (report.rounds ?? [])).length
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Get the total round count (max) from a report.
|
|
288
|
+
*
|
|
289
|
+
* @param {Record<string, unknown>} report
|
|
290
|
+
* @returns {number}
|
|
291
|
+
*/
|
|
292
|
+
export function getTotalRounds(report) {
|
|
293
|
+
const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
|
|
294
|
+
return typeof convergence.totalRounds === 'number'
|
|
295
|
+
? convergence.totalRounds
|
|
296
|
+
: 1
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Severity stats ──────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Count findings by severity. Returns an object with `critical`, `high`,
|
|
303
|
+
* `medium`, `low` keys.
|
|
304
|
+
*
|
|
305
|
+
* @param {Record<string, unknown>} report
|
|
306
|
+
* @returns {{ critical: number, high: number, medium: number, low: number }}
|
|
307
|
+
*/
|
|
308
|
+
export function severityStats(report) {
|
|
309
|
+
const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
|
|
310
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 }
|
|
311
|
+
for (const f of findings) {
|
|
312
|
+
const sev = String(f.severity ?? 'low')
|
|
313
|
+
if (sev in counts) counts[sev]++
|
|
314
|
+
}
|
|
315
|
+
return counts
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Dimension grouping ──────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Group findings by dimension. Returns a Record<string, Array<finding>>.
|
|
322
|
+
*
|
|
323
|
+
* @param {Record<string, unknown>} report
|
|
324
|
+
* @returns {Record<string, Array<Record<string, unknown>>>}
|
|
325
|
+
*/
|
|
326
|
+
export function groupByDimension(report) {
|
|
327
|
+
const findings = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])
|
|
328
|
+
/** @type {Record<string, Array<Record<string, unknown>>>} */
|
|
329
|
+
const groups = {}
|
|
330
|
+
for (const f of findings) {
|
|
331
|
+
const dim = String(f.dimension ?? 'unknown')
|
|
332
|
+
if (!groups[dim]) groups[dim] = []
|
|
333
|
+
groups[dim].push(f)
|
|
334
|
+
}
|
|
335
|
+
return groups
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ─── Triage state ────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/** Triage verdict values */
|
|
341
|
+
export const TRIAGE_VERDICTS = /** @type {const} */ (['keep', 'skip', 'ignore'])
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Build initial triage state for a report. Each finding gets a default verdict
|
|
345
|
+
* of 'keep'. Returns a Map where key = finding index (string), value = verdict.
|
|
346
|
+
*
|
|
347
|
+
* @param {Record<string, unknown>} report
|
|
348
|
+
* @returns {Record<string, 'keep' | 'skip' | 'ignore'>}
|
|
349
|
+
*/
|
|
350
|
+
export function buildTriageState(report) {
|
|
351
|
+
const findings = /** @type {Array<unknown>} */ (report.findings ?? [])
|
|
352
|
+
/** @type {Record<string, 'keep' | 'skip' | 'ignore'>} */
|
|
353
|
+
const state = {}
|
|
354
|
+
for (let i = 0; i < findings.length; i++) {
|
|
355
|
+
state[String(i)] = 'keep'
|
|
356
|
+
}
|
|
357
|
+
return state
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Report hashing (for localStorage key) ────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Create a deterministic hash string from a report's key fields.
|
|
364
|
+
* Used as localStorage key for persisting triage verdicts.
|
|
365
|
+
*
|
|
366
|
+
* @param {Record<string, unknown>} report
|
|
367
|
+
* @returns {string}
|
|
368
|
+
*/
|
|
369
|
+
export function hashReport(report) {
|
|
370
|
+
const convergence = /** @type {Record<string, unknown>} */ (report.convergence ?? {})
|
|
371
|
+
const totalRounds = String(convergence.totalRounds ?? '')
|
|
372
|
+
const findingsCount = String((/** @type {Array<unknown>} */ (report.findings ?? [])).length)
|
|
373
|
+
const firstFinding = /** @type {Array<Record<string, unknown>>} */ (report.findings ?? [])[0]
|
|
374
|
+
const firstSummary = firstFinding ? String(firstFinding.summary ?? '') : ''
|
|
375
|
+
const mode = String(report.mode ?? '')
|
|
376
|
+
// Use mode + totalRounds + findingsCount + first 20 chars of first finding summary
|
|
377
|
+
return `iterate-triage-${mode}-${totalRounds}-${findingsCount}-${firstSummary.slice(0, 20)}`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Known-intentional YAML builder ──────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Convert triage entries with verdict 'ignore' to a YAML-compatible text
|
|
384
|
+
* snippet for known_intentional entries.
|
|
385
|
+
*
|
|
386
|
+
* @param {Array<{ file: string, line?: number, dimension: string, reason: string }>} entries
|
|
387
|
+
* @returns {string}
|
|
388
|
+
*/
|
|
389
|
+
export function toKnownIntentionalYaml(entries) {
|
|
390
|
+
if (!entries || entries.length === 0) return ''
|
|
391
|
+
|
|
392
|
+
const lines = ['known_intentional:']
|
|
393
|
+
for (const e of entries) {
|
|
394
|
+
lines.push(` - file: ${JSON.stringify(e.file)}`)
|
|
395
|
+
if (e.line !== undefined && e.line > 0) {
|
|
396
|
+
lines.push(` line: ${e.line}`)
|
|
397
|
+
}
|
|
398
|
+
lines.push(` dimension: ${JSON.stringify(e.dimension)}`)
|
|
399
|
+
lines.push(` reason: ${JSON.stringify(e.reason)}`)
|
|
400
|
+
}
|
|
401
|
+
return lines.join('\n')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Build a text instruction that the user can paste to the model to trigger
|
|
406
|
+
* `iterate_triage` tool call. Works even if the user hasn't yet configured
|
|
407
|
+
* an `iterate_triage` tool — the instruction tells the model what to do.
|
|
408
|
+
*
|
|
409
|
+
* @param {Array<{ file: string, line?: number, dimension: string, reason: string }>} entries
|
|
410
|
+
* @returns {string}
|
|
411
|
+
*/
|
|
412
|
+
export function buildApplyInstruction(entries) {
|
|
413
|
+
if (!entries || entries.length === 0) return ''
|
|
414
|
+
|
|
415
|
+
const payload = JSON.stringify(
|
|
416
|
+
{
|
|
417
|
+
operation: 'apply',
|
|
418
|
+
entries: entries.map((e) => ({
|
|
419
|
+
file: e.file,
|
|
420
|
+
...(e.line !== undefined ? { line: e.line } : {}),
|
|
421
|
+
dimension: e.dimension,
|
|
422
|
+
reason: e.reason,
|
|
423
|
+
})),
|
|
424
|
+
},
|
|
425
|
+
null,
|
|
426
|
+
2,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
`Please call \`iterate_triage\` with the following payload to apply the triage verdicts:\n\n` +
|
|
431
|
+
`\`\`\`json\n${payload}\n\`\`\``
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Collect ignored entries from triage state + findings, returning the
|
|
437
|
+
* structured data ready for `iterate_triage` tool call.
|
|
438
|
+
*
|
|
439
|
+
* @param {Record<string, 'keep' | 'skip' | 'ignore'>} triageState
|
|
440
|
+
* @param {Array<Record<string, unknown>>} findings
|
|
441
|
+
* @returns {Array<{ file: string, line?: number, dimension: string, reason: string }>}
|
|
442
|
+
*/
|
|
443
|
+
export function collectIgnoredEntries(triageState, findings) {
|
|
444
|
+
const entries = []
|
|
445
|
+
for (const [idx, verdict] of Object.entries(triageState)) {
|
|
446
|
+
if (verdict !== 'ignore') continue
|
|
447
|
+
const finding = findings[Number(idx)]
|
|
448
|
+
if (!finding) continue
|
|
449
|
+
entries.push({
|
|
450
|
+
file: String(finding.file ?? ''),
|
|
451
|
+
...(typeof finding.line === 'number' && finding.line > 0
|
|
452
|
+
? { line: finding.line }
|
|
453
|
+
: {}),
|
|
454
|
+
dimension: String(finding.dimension ?? ''),
|
|
455
|
+
reason: String(finding.summary ?? ''),
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
return entries
|
|
459
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,10 +26,17 @@
|
|
|
26
26
|
"dsh": {
|
|
27
27
|
"bundle": {
|
|
28
28
|
"patch": "./cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-connection"
|
|
34
|
+
]
|
|
29
35
|
}
|
|
30
36
|
},
|
|
31
37
|
"files": [
|
|
32
38
|
"src",
|
|
39
|
+
"lib",
|
|
33
40
|
"cordis.patch.yml",
|
|
34
41
|
"README.md",
|
|
35
42
|
"LICENSE"
|
|
@@ -38,7 +45,8 @@
|
|
|
38
45
|
".": {
|
|
39
46
|
"types": "./src/index.ts",
|
|
40
47
|
"default": "./src/index.ts"
|
|
41
|
-
}
|
|
48
|
+
},
|
|
49
|
+
"./client": "./lib/client.js"
|
|
42
50
|
},
|
|
43
51
|
"scripts": {
|
|
44
52
|
"typecheck": "tsc --noEmit",
|
|
@@ -48,11 +56,10 @@
|
|
|
48
56
|
"dependencies": {
|
|
49
57
|
"@deepseek-ai/cordis": "4.0.1",
|
|
50
58
|
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
51
|
-
"@deepseek-ai/dsh-workflow": "0.1.0-rc.6",
|
|
52
|
-
"@deepseek-ai/schemastery": "3.18.1",
|
|
53
59
|
"js-yaml": "4.3.1"
|
|
54
60
|
},
|
|
55
61
|
"devDependencies": {
|
|
62
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
|
56
63
|
"@types/js-yaml": "4.0.9",
|
|
57
64
|
"@types/node": "22.15.0",
|
|
58
65
|
"tsx": "4.20.3",
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
|
|
3
3
|
*
|
|
4
4
|
* Architecture:
|
|
5
|
-
* - The plugin registers
|
|
5
|
+
* - The plugin registers 6 tools (config, validate, decision-log, context, review, triage)
|
|
6
6
|
* - The plugin injects a system prompt section teaching the iterate workflow pattern
|
|
7
7
|
* - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
|
|
8
8
|
* - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
|
|
9
|
-
* - Subagents use the
|
|
9
|
+
* - Subagents use the 6 tools to do real work (read config, run validation, log decisions, review, triage)
|
|
10
10
|
*
|
|
11
11
|
* Tool invocation model:
|
|
12
12
|
* - Workflow script CANNOT call tools directly (sandboxed vm, no Node API)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Key files:
|
|
18
18
|
* - src/index.ts — Plugin entry: register tools + inject skill prompt
|
|
19
|
-
* - src/tools/ —
|
|
19
|
+
* - src/tools/ — 6 tool implementations + meta-review/review engines
|
|
20
20
|
* - src/config-loader.ts — YAML config loading
|
|
21
21
|
* - src/types.ts — Shared types
|
|
22
22
|
*/
|
|
@@ -27,18 +27,20 @@ import { registerValidateTool } from './tools/validate.ts'
|
|
|
27
27
|
import { registerDecisionLogTool } from './tools/decision-log.ts'
|
|
28
28
|
import { registerContextTool } from './tools/context.ts'
|
|
29
29
|
import { registerReviewTool } from './tools/review.ts'
|
|
30
|
+
import { registerTriageTool } from './tools/triage.ts'
|
|
30
31
|
import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
|
|
31
32
|
|
|
32
33
|
export const name = 'iterate-plugin'
|
|
33
34
|
export const inject = ['tools', 'systemPrompt']
|
|
34
35
|
|
|
35
36
|
export function apply(ctx: Context): void {
|
|
36
|
-
// 1. Register the
|
|
37
|
+
// 1. Register the 6 core tools
|
|
37
38
|
registerConfigTool(ctx)
|
|
38
39
|
registerValidateTool(ctx)
|
|
39
40
|
registerDecisionLogTool(ctx)
|
|
40
41
|
registerContextTool(ctx)
|
|
41
42
|
registerReviewTool(ctx)
|
|
43
|
+
registerTriageTool(ctx)
|
|
42
44
|
|
|
43
45
|
// 2. Inject the iterate skill prompt as a system prompt section
|
|
44
46
|
// This teaches the model how to write iterate workflow scripts using the tools.
|
package/src/review.ts
CHANGED
|
@@ -344,21 +344,28 @@ export function buildReviewPlan(input: {
|
|
|
344
344
|
maxReviewRounds: number
|
|
345
345
|
knownIntentional: KnownIntentional[]
|
|
346
346
|
} {
|
|
347
|
+
// Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
|
|
348
|
+
// `review`/`atomic` missing) must degrade to sane defaults instead of
|
|
349
|
+
// throwing an uncaught TypeError inside the tool's `execute`.
|
|
347
350
|
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
|
|
351
|
+
const goal = input.config.goal ?? ''
|
|
352
|
+
const scope = input.config.review?.scope ?? 'full'
|
|
353
|
+
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
|
|
354
|
+
const maxLines = input.config.atomic?.max_lines ?? 20
|
|
348
355
|
return {
|
|
349
356
|
mode: input.mode,
|
|
350
|
-
goal
|
|
351
|
-
scope
|
|
352
|
-
dimensions:
|
|
357
|
+
goal,
|
|
358
|
+
scope,
|
|
359
|
+
dimensions: dimensions.map((d) => ({
|
|
353
360
|
id: d,
|
|
354
361
|
reviewerPrompt: reviewerTaskPrompt({
|
|
355
362
|
dimension: d,
|
|
356
|
-
goal
|
|
357
|
-
scope
|
|
363
|
+
goal,
|
|
364
|
+
scope,
|
|
358
365
|
mode: input.mode,
|
|
359
366
|
alreadyKnown: [],
|
|
360
367
|
outputLanguage: language,
|
|
361
|
-
maxLines
|
|
368
|
+
maxLines,
|
|
362
369
|
}),
|
|
363
370
|
findingsSchema: findingsSchema(),
|
|
364
371
|
})),
|
|
@@ -82,6 +82,16 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
82
82
|
description:
|
|
83
83
|
'Entry type (required for append): round_start, review_result, atomic_fix, ' +
|
|
84
84
|
'architectural_fix, revert, validation, decision, report.',
|
|
85
|
+
enum: [
|
|
86
|
+
'round_start',
|
|
87
|
+
'review_result',
|
|
88
|
+
'atomic_fix',
|
|
89
|
+
'architectural_fix',
|
|
90
|
+
'revert',
|
|
91
|
+
'validation',
|
|
92
|
+
'decision',
|
|
93
|
+
'report',
|
|
94
|
+
],
|
|
85
95
|
},
|
|
86
96
|
round: {
|
|
87
97
|
type: 'integer',
|