iterate-plugin 2.3.6
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/LICENSE +21 -0
- package/README.md +143 -0
- package/cordis.patch.yml +9 -0
- package/package.json +59 -0
- package/src/config-loader.ts +162 -0
- package/src/index.ts +50 -0
- package/src/meta-review.ts +289 -0
- package/src/review.ts +360 -0
- package/src/skill-prompt.ts +227 -0
- package/src/tools/config.ts +90 -0
- package/src/tools/context.ts +170 -0
- package/src/tools/decision-log.ts +163 -0
- package/src/tools/review.ts +174 -0
- package/src/tools/validate.ts +159 -0
- package/src/types.ts +100 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta-review engine: review a ReviewReport and produce a final review report.
|
|
3
|
+
*
|
|
4
|
+
* This is the "纯反复审查" closing step: after the review loop converges on
|
|
5
|
+
* zero new findings, we don't just trust the aggregated report — we audit the
|
|
6
|
+
* report itself for internal consistency (counts, severity buckets, dimension
|
|
7
|
+
* sums, sort order, convergence math). The result is a deterministic
|
|
8
|
+
* `MetaReviewResult` plus a `FinalReviewReport` that pairs the source report
|
|
9
|
+
* with a verdict.
|
|
10
|
+
*
|
|
11
|
+
* Like `review.ts`, this module contains NO I/O and NO agent spawning — it is
|
|
12
|
+
* the pure, testable core. The workflow script (skill-prompt.ts) orchestrates
|
|
13
|
+
* the actual subagent-driven meta-review critique; all deterministic math
|
|
14
|
+
* lives here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ReviewFinding, ReviewReport } from './types.ts'
|
|
18
|
+
import { sortFindings } from './review.ts'
|
|
19
|
+
|
|
20
|
+
/** A single defect found while auditing a review report. */
|
|
21
|
+
export interface MetaReviewIssue {
|
|
22
|
+
/** Stable machine-readable code, e.g. 'SEVERITY_SUM_MISMATCH'. */
|
|
23
|
+
code: string
|
|
24
|
+
severity: 'critical' | 'high' | 'medium' | 'low'
|
|
25
|
+
summary: string
|
|
26
|
+
detail: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deterministic audit result for a ReviewReport. */
|
|
30
|
+
export interface MetaReviewResult {
|
|
31
|
+
/** Whether the report passed every consistency check (no issues). */
|
|
32
|
+
passed: boolean
|
|
33
|
+
/** Human-readable verdict: 'approved' when passed, else 'revise'. */
|
|
34
|
+
verdict: 'approved' | 'revise'
|
|
35
|
+
/** Number of consistency checks performed. */
|
|
36
|
+
checksRun: number
|
|
37
|
+
/** Defects found while auditing the report. Empty when passed. */
|
|
38
|
+
issues: MetaReviewIssue[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Verdict for the overall final report. */
|
|
42
|
+
export type FinalReviewVerdict =
|
|
43
|
+
| 'approved'
|
|
44
|
+
| 'needs_revision'
|
|
45
|
+
|
|
46
|
+
/** The final deliverable: the audited report plus its meta-review. */
|
|
47
|
+
export interface FinalReviewReport {
|
|
48
|
+
/** Verdict of the meta-review over the source report. */
|
|
49
|
+
verdict: FinalReviewVerdict
|
|
50
|
+
/** The (unchanged) source review report being audited. */
|
|
51
|
+
source: ReviewReport
|
|
52
|
+
/** Deterministic audit of the source report's internal consistency. */
|
|
53
|
+
metaReview: MetaReviewResult
|
|
54
|
+
/** Rolled-up summary that mirrors the source but adds the verdict. */
|
|
55
|
+
summary: {
|
|
56
|
+
totalFindings: number
|
|
57
|
+
critical: number
|
|
58
|
+
high: number
|
|
59
|
+
medium: number
|
|
60
|
+
low: number
|
|
61
|
+
converged: boolean
|
|
62
|
+
totalRounds: number
|
|
63
|
+
reportIssues: number
|
|
64
|
+
verdict: FinalReviewVerdict
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Number of distinct consistency checks performed by `metaReviewReport`. */
|
|
69
|
+
export const META_REVIEW_CHECKS = 6
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Audit a ReviewReport for internal consistency.
|
|
73
|
+
*
|
|
74
|
+
* Checks (all deterministic, no I/O):
|
|
75
|
+
* 1. COUNT_MATCH: summary.totalFindings === findings.length
|
|
76
|
+
* 2. SEVERITY_SUM: summary severity buckets (critical+high+medium+low) total
|
|
77
|
+
* to summary.totalFindings AND match the actual per-severity counts.
|
|
78
|
+
* 3. DIMENSION_SUM: summary.byDimension values sum to totalFindings and every
|
|
79
|
+
* finding's dimension is present in report.dimensions.
|
|
80
|
+
* 4. SORT_ORDER: findings are severity-sorted (most severe first).
|
|
81
|
+
* 5. CONVERGENCE: findingsByRound sums to totalFindings and the `converged`
|
|
82
|
+
* flag is consistent with the last round's new-finding count.
|
|
83
|
+
* 6. ROUND_SHAPE: every round has a positive round number; no round is
|
|
84
|
+
* missing from the sequence. A round with zero findings is only flagged
|
|
85
|
+
* when it is NOT the last round — an empty FINAL round means the review
|
|
86
|
+
* converged (the last pass found nothing new), which is the expected,
|
|
87
|
+
* successful termination of a dry-run, not a defect.
|
|
88
|
+
*
|
|
89
|
+
* Returns a MetaReviewResult; `passed` is true only when all checks pass.
|
|
90
|
+
*/
|
|
91
|
+
export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
92
|
+
const issues: MetaReviewIssue[] = []
|
|
93
|
+
const add = (
|
|
94
|
+
code: string,
|
|
95
|
+
severity: MetaReviewIssue['severity'],
|
|
96
|
+
summary: string,
|
|
97
|
+
detail: string,
|
|
98
|
+
): void => {
|
|
99
|
+
issues.push({ code, severity, summary, detail })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Guard: a null/undefined report is a hard failure, not a crash.
|
|
103
|
+
if (!report || typeof report !== 'object') {
|
|
104
|
+
return {
|
|
105
|
+
passed: false,
|
|
106
|
+
verdict: 'revise',
|
|
107
|
+
checksRun: META_REVIEW_CHECKS,
|
|
108
|
+
issues: [
|
|
109
|
+
{
|
|
110
|
+
code: 'REPORT_UNDEFINED',
|
|
111
|
+
severity: 'critical',
|
|
112
|
+
summary: 'Report is missing or not an object',
|
|
113
|
+
detail: 'metaReviewReport received no valid ReviewReport to audit.',
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const findings = Array.isArray(report.findings) ? report.findings : []
|
|
120
|
+
const summary = report.summary ?? {}
|
|
121
|
+
const total = Number(summary.totalFindings ?? 0)
|
|
122
|
+
const dimensions = Array.isArray(report.dimensions) ? report.dimensions : []
|
|
123
|
+
|
|
124
|
+
// 1. COUNT_MATCH
|
|
125
|
+
if (total !== findings.length) {
|
|
126
|
+
add(
|
|
127
|
+
'COUNT_MATCH',
|
|
128
|
+
'high',
|
|
129
|
+
`summary.totalFindings (${total}) does not match findings.length (${findings.length})`,
|
|
130
|
+
`The report claims ${total} findings but lists ${findings.length}.`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2. SEVERITY_SUM
|
|
135
|
+
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 } as Record<
|
|
136
|
+
ReviewFinding['severity'],
|
|
137
|
+
number
|
|
138
|
+
>
|
|
139
|
+
for (const f of findings) {
|
|
140
|
+
const s = f?.severity as ReviewFinding['severity'] | undefined
|
|
141
|
+
if (s && s in sevCounts) sevCounts[s]++
|
|
142
|
+
}
|
|
143
|
+
const bucketSum = sevCounts.critical + sevCounts.high + sevCounts.medium + sevCounts.low
|
|
144
|
+
const declaredSeveritySum =
|
|
145
|
+
Number(summary.critical ?? 0) +
|
|
146
|
+
Number(summary.high ?? 0) +
|
|
147
|
+
Number(summary.medium ?? 0) +
|
|
148
|
+
Number(summary.low ?? 0)
|
|
149
|
+
if (declaredSeveritySum !== total || bucketSum !== total) {
|
|
150
|
+
add(
|
|
151
|
+
'SEVERITY_SUM',
|
|
152
|
+
'high',
|
|
153
|
+
'Severity bucket counts are inconsistent with totalFindings',
|
|
154
|
+
`declared buckets sum to ${declaredSeveritySum}, actual buckets sum to ${bucketSum}, ` +
|
|
155
|
+
`but totalFindings is ${total}.`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. DIMENSION_SUM
|
|
160
|
+
const byDim = summary.byDimension ?? {}
|
|
161
|
+
let dimSum = 0
|
|
162
|
+
for (const v of Object.values(byDim)) dimSum += Number(v) || 0
|
|
163
|
+
if (dimSum !== total) {
|
|
164
|
+
add(
|
|
165
|
+
'DIMENSION_SUM',
|
|
166
|
+
'high',
|
|
167
|
+
'byDimension counts do not sum to totalFindings',
|
|
168
|
+
`byDimension sums to ${dimSum}, but totalFindings is ${total}.`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension))
|
|
172
|
+
if (invalidDim) {
|
|
173
|
+
add(
|
|
174
|
+
'DIMENSION_UNKNOWN',
|
|
175
|
+
'medium',
|
|
176
|
+
`Finding references unknown dimension "${invalidDim.dimension}"`,
|
|
177
|
+
`dimension "${invalidDim.dimension}" is not in report.dimensions ` +
|
|
178
|
+
`(${dimensions.join(', ') || 'none'}).`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 4. SORT_ORDER
|
|
183
|
+
const sorted = sortFindings(findings)
|
|
184
|
+
const isSorted = sorted.every((f, i) => f === findings[i])
|
|
185
|
+
if (!isSorted) {
|
|
186
|
+
add(
|
|
187
|
+
'SORT_ORDER',
|
|
188
|
+
'low',
|
|
189
|
+
'Findings are not severity-sorted',
|
|
190
|
+
'findings should be ordered most-severe first (critical > high > medium > low).',
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 5. CONVERGENCE
|
|
195
|
+
const findingsByRound = Array.isArray(report.convergence?.findingsByRound)
|
|
196
|
+
? report.convergence.findingsByRound
|
|
197
|
+
: []
|
|
198
|
+
const convSum = findingsByRound.reduce((a, b) => a + Number(b) || 0, 0)
|
|
199
|
+
if (convSum !== total) {
|
|
200
|
+
add(
|
|
201
|
+
'CONVERGENCE_SUM',
|
|
202
|
+
'high',
|
|
203
|
+
'convergence.findingsByRound does not sum to totalFindings',
|
|
204
|
+
`findingsByRound ${JSON.stringify(findingsByRound)} sums to ${convSum}, ` +
|
|
205
|
+
`but totalFindings is ${total}.`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
const lastRoundNew = findingsByRound.length > 0 ? Number(findingsByRound[findingsByRound.length - 1]) : null
|
|
209
|
+
const expectedConverged = lastRoundNew === 0
|
|
210
|
+
if (report.convergence?.converged !== expectedConverged) {
|
|
211
|
+
add(
|
|
212
|
+
'CONVERGENCE_FLAG',
|
|
213
|
+
'medium',
|
|
214
|
+
'convergence.converged flag is inconsistent with the last round',
|
|
215
|
+
`last round reported ${lastRoundNew} new findings, so converged should be ` +
|
|
216
|
+
`${expectedConverged}, but it is ${report.convergence?.converged}.`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 6. ROUND_SHAPE
|
|
221
|
+
const rounds = Array.isArray(report.rounds) ? report.rounds : []
|
|
222
|
+
const seenRounds = new Set<number>()
|
|
223
|
+
for (const [index, r] of rounds.entries()) {
|
|
224
|
+
if (!r || typeof r.round !== 'number' || r.round < 1) {
|
|
225
|
+
add(
|
|
226
|
+
'ROUND_NUMBER',
|
|
227
|
+
'medium',
|
|
228
|
+
'A round has a missing or non-positive round number',
|
|
229
|
+
`round: ${JSON.stringify(r)}`,
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
seenRounds.add(r.round)
|
|
234
|
+
const isLastRound = index === rounds.length - 1
|
|
235
|
+
if (!Array.isArray(r.findings) || (r.findings.length === 0 && !isLastRound)) {
|
|
236
|
+
add(
|
|
237
|
+
'ROUND_EMPTY',
|
|
238
|
+
'low',
|
|
239
|
+
`Round ${r.round} has no findings`,
|
|
240
|
+
'A recorded round should contain at least one finding — except a final converged round, ' +
|
|
241
|
+
'which finding nothing new is the expected success signal.',
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (let i = 1; i <= rounds.length; i++) {
|
|
246
|
+
if (!seenRounds.has(i)) {
|
|
247
|
+
add(
|
|
248
|
+
'ROUND_GAP',
|
|
249
|
+
'medium',
|
|
250
|
+
`Round ${i} is missing from the round sequence`,
|
|
251
|
+
`rounds present: ${[...seenRounds].sort((a, b) => a - b).join(', ') || 'none'}.`,
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const passed = issues.length === 0
|
|
257
|
+
return {
|
|
258
|
+
passed,
|
|
259
|
+
verdict: passed ? 'approved' : 'revise',
|
|
260
|
+
checksRun: META_REVIEW_CHECKS,
|
|
261
|
+
issues,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Build the final review report: pair the source report with its meta-review
|
|
267
|
+
* verdict and a rolled-up summary. Pure and deterministic.
|
|
268
|
+
*/
|
|
269
|
+
export function buildFinalReviewReport(report: ReviewReport): FinalReviewReport {
|
|
270
|
+
const meta = metaReviewReport(report)
|
|
271
|
+
const summary = report?.summary ?? {}
|
|
272
|
+
const verdict: FinalReviewVerdict = meta.passed ? 'approved' : 'needs_revision'
|
|
273
|
+
return {
|
|
274
|
+
verdict,
|
|
275
|
+
source: report,
|
|
276
|
+
metaReview: meta,
|
|
277
|
+
summary: {
|
|
278
|
+
totalFindings: Number(summary.totalFindings ?? 0),
|
|
279
|
+
critical: Number(summary.critical ?? 0),
|
|
280
|
+
high: Number(summary.high ?? 0),
|
|
281
|
+
medium: Number(summary.medium ?? 0),
|
|
282
|
+
low: Number(summary.low ?? 0),
|
|
283
|
+
converged: Boolean(report?.convergence?.converged),
|
|
284
|
+
totalRounds: Number(report?.convergence?.totalRounds ?? 0),
|
|
285
|
+
reportIssues: meta.issues.length,
|
|
286
|
+
verdict,
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
}
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic review engine for the iterate review loop (dry-run and normal).
|
|
3
|
+
*
|
|
4
|
+
* This module contains NO I/O and NO agent spawning — it is the pure,
|
|
5
|
+
* testable core of the multi-round convergence loop:
|
|
6
|
+
*
|
|
7
|
+
* 1. dedupe findings across rounds (file + dimension + normalized summary)
|
|
8
|
+
* 2. filter out `known_intentional` entries from personalization
|
|
9
|
+
* 3. sort by severity (critical > high > medium > low)
|
|
10
|
+
* 4. compute multi-round convergence stats ("纯反复审查" 收敛统计)
|
|
11
|
+
* 5. assemble the ReviewReport
|
|
12
|
+
* 6. build reviewer task prompts + structured-output schema for subagents
|
|
13
|
+
*
|
|
14
|
+
* The workflow script (see skill-prompt.ts) does the orchestration:
|
|
15
|
+
* spawn parallel reviewers, feed back already-known findings each round,
|
|
16
|
+
* and stop when a round yields 0 new findings or the round cap is reached.
|
|
17
|
+
* All deterministic math lives here so it can be unit-tested.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
IterateConfig,
|
|
22
|
+
KnownIntentional,
|
|
23
|
+
ReviewFinding,
|
|
24
|
+
ReviewReport,
|
|
25
|
+
ReviewRound,
|
|
26
|
+
} from './types.ts'
|
|
27
|
+
|
|
28
|
+
/** Severity ordering: lower rank = more severe. */
|
|
29
|
+
export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
|
|
30
|
+
critical: 0,
|
|
31
|
+
high: 1,
|
|
32
|
+
medium: 2,
|
|
33
|
+
low: 3,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Sort findings by severity (most severe first), then by file path. */
|
|
37
|
+
export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
38
|
+
return [...findings].sort((a, b) => {
|
|
39
|
+
const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
40
|
+
if (bySeverity !== 0) return bySeverity
|
|
41
|
+
const byFile = a.file.localeCompare(b.file)
|
|
42
|
+
if (byFile !== 0) return byFile
|
|
43
|
+
return (a.line ?? 0) - (b.line ?? 0)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
|
48
|
+
export function normalizeSummary(summary: string): string {
|
|
49
|
+
return summary
|
|
50
|
+
.trim()
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/[\s\n\t]+/g, ' ')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Dedupe key: same file + same dimension + similar summary. */
|
|
56
|
+
export function findingKey(f: ReviewFinding): string {
|
|
57
|
+
return `${f.file}|${f.dimension}|${normalizeSummary(f.summary)}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Remove duplicate findings within a list.
|
|
62
|
+
* Keeps the first occurrence of each dedupe key.
|
|
63
|
+
*/
|
|
64
|
+
export function dedupeFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
65
|
+
const seen = new Set<string>()
|
|
66
|
+
const out: ReviewFinding[] = []
|
|
67
|
+
for (const f of findings) {
|
|
68
|
+
const key = findingKey(f)
|
|
69
|
+
if (seen.has(key)) continue
|
|
70
|
+
seen.add(key)
|
|
71
|
+
out.push(f)
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Filter out findings that match a `known_intentional` entry.
|
|
78
|
+
* Match rule (mirrors SKILL.md Phase 1 FILTER):
|
|
79
|
+
* - same `file` AND same `dimension`, AND
|
|
80
|
+
* - entry `line` is 0/undefined (whole file) OR equals the finding's line.
|
|
81
|
+
*/
|
|
82
|
+
export function filterKnownIntentional(
|
|
83
|
+
findings: ReviewFinding[],
|
|
84
|
+
known: KnownIntentional[] | undefined,
|
|
85
|
+
): ReviewFinding[] {
|
|
86
|
+
if (!known || known.length === 0) return findings
|
|
87
|
+
return findings.filter((f) => {
|
|
88
|
+
const matched = known.some((k) => {
|
|
89
|
+
const sameFile = k.file === f.file
|
|
90
|
+
const sameDim = k.dimension === f.dimension
|
|
91
|
+
if (!sameFile || !sameDim) return false
|
|
92
|
+
const wholeFile = k.line === undefined || k.line === 0
|
|
93
|
+
if (wholeFile) return true
|
|
94
|
+
return k.line === f.line
|
|
95
|
+
})
|
|
96
|
+
return !matched
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Merge per-round findings into one globally-deduped stream while tracking
|
|
102
|
+
* which round first surfaced each finding. This is the deterministic core of
|
|
103
|
+
* "反复多轮审查直至收敛":
|
|
104
|
+
* - `findingsByRound[r]` = number of GLOBALLY new findings first seen in round r
|
|
105
|
+
* - `converged` = the last executed round produced 0 new findings
|
|
106
|
+
* - `stoppedReason` = 'converged' | 'max_rounds_reached'
|
|
107
|
+
*/
|
|
108
|
+
export function aggregateRounds(
|
|
109
|
+
rounds: ReviewRound[],
|
|
110
|
+
maxReviewRounds: number,
|
|
111
|
+
): {
|
|
112
|
+
findings: ReviewFinding[]
|
|
113
|
+
findingsByRound: number[]
|
|
114
|
+
firstRoundByKey: Map<string, number>
|
|
115
|
+
} {
|
|
116
|
+
const seen = new Set<string>()
|
|
117
|
+
const firstRoundByKey = new Map<string, number>()
|
|
118
|
+
const merged: ReviewFinding[] = []
|
|
119
|
+
|
|
120
|
+
for (const round of rounds) {
|
|
121
|
+
for (const f of round.findings) {
|
|
122
|
+
const key = findingKey(f)
|
|
123
|
+
if (seen.has(key)) continue
|
|
124
|
+
seen.add(key)
|
|
125
|
+
firstRoundByKey.set(key, round.round)
|
|
126
|
+
merged.push(f)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const findingsByRound: number[] = []
|
|
131
|
+
for (let r = 1; r <= rounds.length; r++) {
|
|
132
|
+
let count = 0
|
|
133
|
+
for (const key of firstRoundByKey.keys()) {
|
|
134
|
+
if (firstRoundByKey.get(key) === r) count++
|
|
135
|
+
}
|
|
136
|
+
findingsByRound.push(count)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compute convergence statistics for a dry-run review.
|
|
144
|
+
*/
|
|
145
|
+
export function computeConvergence(
|
|
146
|
+
rounds: ReviewRound[],
|
|
147
|
+
maxReviewRounds: number,
|
|
148
|
+
): ReviewReport['convergence'] {
|
|
149
|
+
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds)
|
|
150
|
+
const totalRounds = rounds.length
|
|
151
|
+
const lastRoundCount = totalRounds > 0 ? findingsByRound[totalRounds - 1] ?? 0 : 0
|
|
152
|
+
const converged = totalRounds > 0 && lastRoundCount === 0
|
|
153
|
+
return {
|
|
154
|
+
totalRounds,
|
|
155
|
+
findingsByRound,
|
|
156
|
+
converged,
|
|
157
|
+
stoppedReason:
|
|
158
|
+
totalRounds === 0
|
|
159
|
+
? 'max_rounds_reached'
|
|
160
|
+
: converged
|
|
161
|
+
? 'converged'
|
|
162
|
+
: 'max_rounds_reached',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Build a severity/summary breakdown map for the report. */
|
|
167
|
+
function summarize(findings: ReviewFinding[]): ReviewReport['summary'] {
|
|
168
|
+
const summary: ReviewReport['summary'] = {
|
|
169
|
+
totalFindings: findings.length,
|
|
170
|
+
critical: 0,
|
|
171
|
+
high: 0,
|
|
172
|
+
medium: 0,
|
|
173
|
+
low: 0,
|
|
174
|
+
byDimension: {},
|
|
175
|
+
}
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
if (f.severity === 'critical') summary.critical++
|
|
178
|
+
else if (f.severity === 'high') summary.high++
|
|
179
|
+
else if (f.severity === 'medium') summary.medium++
|
|
180
|
+
else summary.low++
|
|
181
|
+
summary.byDimension[f.dimension] = (summary.byDimension[f.dimension] ?? 0) + 1
|
|
182
|
+
}
|
|
183
|
+
return summary
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Assemble the final ReviewReport from raw per-round findings.
|
|
188
|
+
* Applies known_intentional filtering, cross-round dedupe, severity sort,
|
|
189
|
+
* and convergence stats in one deterministic pass. Shared by dry-run (pure
|
|
190
|
+
* review) and normal (autonomous loop) modes — the mode only records intent;
|
|
191
|
+
* the math is identical.
|
|
192
|
+
*/
|
|
193
|
+
export function buildReviewReport(input: {
|
|
194
|
+
mode: 'dry-run' | 'normal'
|
|
195
|
+
goal: string
|
|
196
|
+
dimensions: string[]
|
|
197
|
+
maxReviewRounds: number
|
|
198
|
+
rounds: ReviewRound[]
|
|
199
|
+
knownIntentional?: KnownIntentional[]
|
|
200
|
+
}): ReviewReport {
|
|
201
|
+
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
202
|
+
const filteredRounds = input.rounds.map((r) => ({
|
|
203
|
+
round: r.round,
|
|
204
|
+
findings: filterKnownIntentional(r.findings, input.knownIntentional),
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
// 2. Cross-round dedupe + per-round "first seen" tracking.
|
|
208
|
+
const { findings, findingsByRound } = aggregateRounds(
|
|
209
|
+
filteredRounds,
|
|
210
|
+
input.maxReviewRounds,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
// 3. Severity sort the global result.
|
|
214
|
+
const sorted = sortFindings(findings)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
mode: input.mode,
|
|
218
|
+
goal: input.goal,
|
|
219
|
+
dimensions: input.dimensions,
|
|
220
|
+
maxReviewRounds: input.maxReviewRounds,
|
|
221
|
+
rounds: filteredRounds,
|
|
222
|
+
findings: sorted,
|
|
223
|
+
convergence: {
|
|
224
|
+
totalRounds: filteredRounds.length,
|
|
225
|
+
findingsByRound,
|
|
226
|
+
converged: filteredRounds.length > 0 && (findingsByRound[filteredRounds.length - 1] ?? 0) === 0,
|
|
227
|
+
stoppedReason:
|
|
228
|
+
filteredRounds.length === 0
|
|
229
|
+
? 'max_rounds_reached'
|
|
230
|
+
: (findingsByRound[filteredRounds.length - 1] ?? 0) === 0
|
|
231
|
+
? 'converged'
|
|
232
|
+
: 'max_rounds_reached',
|
|
233
|
+
},
|
|
234
|
+
summary: summarize(sorted),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* JSON Schema for reviewer subagent structured output.
|
|
240
|
+
* Object-rooted (dsh `agent` opts.schema requires object-rooted schemas with
|
|
241
|
+
* only type/properties/required/additionalProperties/items/enum/const/oneOf).
|
|
242
|
+
*/
|
|
243
|
+
export function findingsSchema(): Record<string, unknown> {
|
|
244
|
+
return {
|
|
245
|
+
type: 'object',
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
properties: {
|
|
248
|
+
findings: {
|
|
249
|
+
type: 'array',
|
|
250
|
+
items: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
properties: {
|
|
254
|
+
dimension: { type: 'string' },
|
|
255
|
+
file: { type: 'string' },
|
|
256
|
+
line: { type: 'integer' },
|
|
257
|
+
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
258
|
+
summary: { type: 'string' },
|
|
259
|
+
failure_scenario: { type: 'string' },
|
|
260
|
+
suggested_fix: { type: 'string' },
|
|
261
|
+
is_atomic: { type: 'boolean' },
|
|
262
|
+
},
|
|
263
|
+
required: [
|
|
264
|
+
'dimension',
|
|
265
|
+
'file',
|
|
266
|
+
'severity',
|
|
267
|
+
'summary',
|
|
268
|
+
'failure_scenario',
|
|
269
|
+
'suggested_fix',
|
|
270
|
+
'is_atomic',
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
required: ['findings'],
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Build the task prompt for one dimension's reviewer subagent.
|
|
281
|
+
* In dry-run mode, pass `alreadyKnown` (the findings from earlier rounds) so the
|
|
282
|
+
* reviewer hunts for NEW issues only — that is what makes "反复审查" converge.
|
|
283
|
+
*/
|
|
284
|
+
export function reviewerTaskPrompt(input: {
|
|
285
|
+
dimension: string
|
|
286
|
+
goal: string
|
|
287
|
+
scope: 'full' | 'changed-only'
|
|
288
|
+
mode: 'normal' | 'dry-run'
|
|
289
|
+
alreadyKnown?: ReviewFinding[]
|
|
290
|
+
outputLanguage: string
|
|
291
|
+
}): string {
|
|
292
|
+
const parts: string[] = []
|
|
293
|
+
parts.push(
|
|
294
|
+
`You are the "${input.dimension}" reviewer for the iterate review.`,
|
|
295
|
+
`Goal: ${input.goal}`,
|
|
296
|
+
`Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
|
|
297
|
+
)
|
|
298
|
+
if (input.mode === 'dry-run') {
|
|
299
|
+
parts.push(
|
|
300
|
+
'MODE: dry-run / pure review. You MUST NOT modify, create, or delete ANY file. Read-only analysis only.',
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
if (input.alreadyKnown && input.alreadyKnown.length > 0) {
|
|
304
|
+
parts.push(
|
|
305
|
+
'Already-known findings from earlier rounds (do NOT re-report these; find NEW issues only):',
|
|
306
|
+
JSON.stringify(input.alreadyKnown, null, 2),
|
|
307
|
+
)
|
|
308
|
+
} else {
|
|
309
|
+
parts.push('This is round 1 — report every issue you find in this dimension.')
|
|
310
|
+
}
|
|
311
|
+
parts.push(
|
|
312
|
+
`Return a JSON object: {"findings": [...]}.`,
|
|
313
|
+
`Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
314
|
+
'line (optional integer), severity (critical/high/medium/low), summary (one line), ' +
|
|
315
|
+
'failure_scenario (how/when it fails, specific evidence), suggested_fix (the concrete fix), ' +
|
|
316
|
+
'is_atomic (true if the fix is <= {atomic.max_lines} lines within a SINGLE file/function, else false).',
|
|
317
|
+
`Write summaries and details in ${input.outputLanguage}.`,
|
|
318
|
+
)
|
|
319
|
+
return parts.join('\n')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Build a review plan: how many rounds, which dimensions, and the reviewer
|
|
324
|
+
* prompt template for each dimension. Used by the `iterate_review` tool's
|
|
325
|
+
* `plan` operation to give the orchestrator a canonical spec.
|
|
326
|
+
*/
|
|
327
|
+
export function buildReviewPlan(input: {
|
|
328
|
+
config: IterateConfig
|
|
329
|
+
mode: 'normal' | 'dry-run'
|
|
330
|
+
maxReviewRounds: number
|
|
331
|
+
knownIntentional?: KnownIntentional[]
|
|
332
|
+
}): {
|
|
333
|
+
mode: 'normal' | 'dry-run'
|
|
334
|
+
goal: string
|
|
335
|
+
scope: 'full' | 'changed-only'
|
|
336
|
+
dimensions: { id: string; reviewerPrompt: string; findingsSchema: Record<string, unknown> }[]
|
|
337
|
+
maxReviewRounds: number
|
|
338
|
+
knownIntentional: KnownIntentional[]
|
|
339
|
+
} {
|
|
340
|
+
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
|
|
341
|
+
return {
|
|
342
|
+
mode: input.mode,
|
|
343
|
+
goal: input.config.goal,
|
|
344
|
+
scope: input.config.review.scope,
|
|
345
|
+
dimensions: input.config.dimensions.map((d) => ({
|
|
346
|
+
id: d,
|
|
347
|
+
reviewerPrompt: reviewerTaskPrompt({
|
|
348
|
+
dimension: d,
|
|
349
|
+
goal: input.config.goal,
|
|
350
|
+
scope: input.config.review.scope,
|
|
351
|
+
mode: input.mode,
|
|
352
|
+
alreadyKnown: [],
|
|
353
|
+
outputLanguage: language,
|
|
354
|
+
}),
|
|
355
|
+
findingsSchema: findingsSchema(),
|
|
356
|
+
})),
|
|
357
|
+
maxReviewRounds: input.maxReviewRounds,
|
|
358
|
+
knownIntentional: input.knownIntentional ?? [],
|
|
359
|
+
}
|
|
360
|
+
}
|