iterate-plugin 2.6.0 → 2.7.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 +10 -0
- package/dist/config-loader.js +171 -0
- package/dist/config-write.js +174 -0
- package/dist/index.js +58 -0
- package/dist/meta-review.js +181 -0
- package/dist/paths.js +32 -0
- package/dist/review.js +328 -0
- package/dist/skill-prompt.js +337 -0
- package/dist/tools/checkpoint.js +260 -0
- package/dist/tools/config.js +134 -0
- package/dist/tools/context.js +160 -0
- package/dist/tools/decision-log.js +162 -0
- package/dist/tools/fix.js +553 -0
- package/dist/tools/history.js +138 -0
- package/dist/tools/prune.js +268 -0
- package/dist/tools/review.js +159 -0
- package/dist/tools/triage.js +333 -0
- package/dist/tools/validate.js +164 -0
- package/dist/types.js +1 -0
- package/package.json +7 -3
- package/src/meta-review.ts +14 -1
- package/src/review.ts +33 -5
package/dist/review.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
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
|
+
/** Severity ordering: lower rank = more severe. */
|
|
20
|
+
export const SEVERITY_RANK = {
|
|
21
|
+
critical: 0,
|
|
22
|
+
high: 1,
|
|
23
|
+
medium: 2,
|
|
24
|
+
low: 3,
|
|
25
|
+
};
|
|
26
|
+
/** Sort findings by severity (most severe first), then by file path. */
|
|
27
|
+
export function sortFindings(findings) {
|
|
28
|
+
return [...findings].sort((a, b) => {
|
|
29
|
+
// Guard against an out-of-spec severity string (e.g. from a model that
|
|
30
|
+
// bypassed the schema): treat it as the least severe so NaN never enters
|
|
31
|
+
// the comparator and ordering stays deterministic.
|
|
32
|
+
const rankA = SEVERITY_RANK[a.severity] ?? SEVERITY_RANK.low;
|
|
33
|
+
const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low;
|
|
34
|
+
const bySeverity = rankA - rankB;
|
|
35
|
+
if (bySeverity !== 0)
|
|
36
|
+
return bySeverity;
|
|
37
|
+
const byFile = a.file.localeCompare(b.file);
|
|
38
|
+
if (byFile !== 0)
|
|
39
|
+
return byFile;
|
|
40
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Normalize a summary so near-identical duplicates collapse to one key. */
|
|
44
|
+
export function normalizeSummary(summary) {
|
|
45
|
+
return summary
|
|
46
|
+
.trim()
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[\s\n\t]+/g, ' ');
|
|
49
|
+
}
|
|
50
|
+
/** Dedupe key: same file + same dimension + similar summary. */
|
|
51
|
+
export function findingKey(f) {
|
|
52
|
+
return `${f.file}|${f.dimension}|${normalizeSummary(f.summary)}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Remove duplicate findings within a list.
|
|
56
|
+
* Keeps the first occurrence of each dedupe key.
|
|
57
|
+
*/
|
|
58
|
+
export function dedupeFindings(findings) {
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const f of findings) {
|
|
62
|
+
const key = findingKey(f);
|
|
63
|
+
if (seen.has(key))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
out.push(f);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Filter out findings that match a `known_intentional` entry.
|
|
72
|
+
* Match rule (mirrors SKILL.md Phase 1 FILTER):
|
|
73
|
+
* - same `file` AND same `dimension`, AND
|
|
74
|
+
* - entry `line` is 0/undefined (whole file) OR equals the finding's line.
|
|
75
|
+
*/
|
|
76
|
+
export function filterKnownIntentional(findings, known) {
|
|
77
|
+
if (!known || known.length === 0)
|
|
78
|
+
return findings;
|
|
79
|
+
return findings.filter((f) => {
|
|
80
|
+
const matched = known.some((k) => {
|
|
81
|
+
const sameFile = k.file === f.file;
|
|
82
|
+
const sameDim = k.dimension === f.dimension;
|
|
83
|
+
if (!sameFile || !sameDim)
|
|
84
|
+
return false;
|
|
85
|
+
const wholeFile = k.line === undefined || k.line === 0;
|
|
86
|
+
if (wholeFile)
|
|
87
|
+
return true;
|
|
88
|
+
return k.line === f.line;
|
|
89
|
+
});
|
|
90
|
+
return !matched;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Merge per-round findings into one globally-deduped stream while tracking
|
|
95
|
+
* which round first surfaced each finding. This is the deterministic core of
|
|
96
|
+
* "反复多轮审查直至收敛":
|
|
97
|
+
* - `findingsByRound` = number of GLOBALLY new findings first seen in round r,
|
|
98
|
+
* indexed by the actual `round` number (round r → index r-1). The array is
|
|
99
|
+
* sized to the highest round number encountered, so non-contiguous round
|
|
100
|
+
* numbers (e.g. a resumed run that starts at round 5, or a caller that only
|
|
101
|
+
* passes `[{round: 3}]`) still yield correct counts instead of being
|
|
102
|
+
* collapsed onto wrong indices.
|
|
103
|
+
* - `converged` = the last executed round produced 0 new findings
|
|
104
|
+
* - `stoppedReason` = 'converged' | 'max_rounds_reached'
|
|
105
|
+
*/
|
|
106
|
+
export function aggregateRounds(rounds, maxReviewRounds) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const firstRoundByKey = new Map();
|
|
109
|
+
const merged = [];
|
|
110
|
+
// Guard: round numbers are expected to be positive integers. Skip malformed
|
|
111
|
+
// entries defensively rather than letting `firstRoundByKey` key on NaN/0.
|
|
112
|
+
let maxRound = 0;
|
|
113
|
+
for (const round of rounds) {
|
|
114
|
+
if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
|
|
115
|
+
continue;
|
|
116
|
+
if (round.round > maxRound)
|
|
117
|
+
maxRound = round.round;
|
|
118
|
+
for (const f of round.findings) {
|
|
119
|
+
const key = findingKey(f);
|
|
120
|
+
if (seen.has(key))
|
|
121
|
+
continue;
|
|
122
|
+
seen.add(key);
|
|
123
|
+
firstRoundByKey.set(key, round.round);
|
|
124
|
+
merged.push(f);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const findingsByRound = [];
|
|
128
|
+
for (let r = 1; r <= maxRound; r++) {
|
|
129
|
+
let count = 0;
|
|
130
|
+
for (const key of firstRoundByKey.keys()) {
|
|
131
|
+
if (firstRoundByKey.get(key) === r)
|
|
132
|
+
count++;
|
|
133
|
+
}
|
|
134
|
+
findingsByRound.push(count);
|
|
135
|
+
}
|
|
136
|
+
return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Compute convergence statistics for a dry-run review.
|
|
140
|
+
*/
|
|
141
|
+
export function computeConvergence(rounds, maxReviewRounds) {
|
|
142
|
+
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds);
|
|
143
|
+
const totalRounds = rounds.length;
|
|
144
|
+
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
145
|
+
// r-1), so convergence must read the LAST PRESENT round's count using its
|
|
146
|
+
// reported round number — not `totalRounds - 1`, which is only valid for
|
|
147
|
+
// contiguous 1..N round numbers.
|
|
148
|
+
const lastRound = totalRounds > 0 ? rounds[totalRounds - 1].round : 0;
|
|
149
|
+
const lastRoundCount = lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0;
|
|
150
|
+
const converged = totalRounds > 0 && lastRoundCount === 0;
|
|
151
|
+
return {
|
|
152
|
+
totalRounds,
|
|
153
|
+
findingsByRound,
|
|
154
|
+
converged,
|
|
155
|
+
stoppedReason: totalRounds === 0
|
|
156
|
+
? 'max_rounds_reached'
|
|
157
|
+
: converged
|
|
158
|
+
? 'converged'
|
|
159
|
+
: 'max_rounds_reached',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Build a severity/summary breakdown map for the report. */
|
|
163
|
+
function summarize(findings) {
|
|
164
|
+
const summary = {
|
|
165
|
+
totalFindings: findings.length,
|
|
166
|
+
critical: 0,
|
|
167
|
+
high: 0,
|
|
168
|
+
medium: 0,
|
|
169
|
+
low: 0,
|
|
170
|
+
byDimension: {},
|
|
171
|
+
};
|
|
172
|
+
for (const f of findings) {
|
|
173
|
+
if (f.severity === 'critical')
|
|
174
|
+
summary.critical++;
|
|
175
|
+
else if (f.severity === 'high')
|
|
176
|
+
summary.high++;
|
|
177
|
+
else if (f.severity === 'medium')
|
|
178
|
+
summary.medium++;
|
|
179
|
+
else
|
|
180
|
+
summary.low++;
|
|
181
|
+
summary.byDimension[f.dimension] = (summary.byDimension[f.dimension] ?? 0) + 1;
|
|
182
|
+
}
|
|
183
|
+
return summary;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Assemble the final ReviewReport from raw per-round findings.
|
|
187
|
+
* Applies known_intentional filtering, cross-round dedupe, severity sort,
|
|
188
|
+
* and convergence stats in one deterministic pass. Shared by dry-run (pure
|
|
189
|
+
* review) and normal (autonomous loop) modes — the mode only records intent;
|
|
190
|
+
* the math is identical.
|
|
191
|
+
*/
|
|
192
|
+
export function buildReviewReport(input) {
|
|
193
|
+
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
194
|
+
const filteredRounds = input.rounds.map((r) => ({
|
|
195
|
+
round: r.round,
|
|
196
|
+
findings: filterKnownIntentional(r.findings, input.knownIntentional),
|
|
197
|
+
}));
|
|
198
|
+
// 2. Cross-round dedupe + per-round "first seen" tracking.
|
|
199
|
+
const { findings, findingsByRound } = aggregateRounds(filteredRounds, input.maxReviewRounds);
|
|
200
|
+
// 3. Severity sort the global result.
|
|
201
|
+
const sorted = sortFindings(findings);
|
|
202
|
+
// 4. Convergence. Must be identical to `computeConvergence`: `findingsByRound`
|
|
203
|
+
// is indexed by the actual round number (round r → index r-1) and sized to
|
|
204
|
+
// the highest round, so convergence reads the LAST PRESENT round's count
|
|
205
|
+
// using its reported round number — NOT `filteredRounds.length - 1`, which
|
|
206
|
+
// is only valid for contiguous 1..N round numbers (resumed iterations and
|
|
207
|
+
// non-contiguous round sets would otherwise read the wrong count).
|
|
208
|
+
const lastRound = filteredRounds.length > 0 ? filteredRounds[filteredRounds.length - 1].round : 0;
|
|
209
|
+
const lastRoundCount = lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0;
|
|
210
|
+
const converged = filteredRounds.length > 0 && lastRoundCount === 0;
|
|
211
|
+
return {
|
|
212
|
+
mode: input.mode,
|
|
213
|
+
goal: input.goal,
|
|
214
|
+
dimensions: input.dimensions,
|
|
215
|
+
maxReviewRounds: input.maxReviewRounds,
|
|
216
|
+
rounds: filteredRounds,
|
|
217
|
+
findings: sorted,
|
|
218
|
+
convergence: {
|
|
219
|
+
totalRounds: filteredRounds.length,
|
|
220
|
+
findingsByRound,
|
|
221
|
+
converged,
|
|
222
|
+
stoppedReason: filteredRounds.length === 0
|
|
223
|
+
? 'max_rounds_reached'
|
|
224
|
+
: converged
|
|
225
|
+
? 'converged'
|
|
226
|
+
: 'max_rounds_reached',
|
|
227
|
+
},
|
|
228
|
+
summary: summarize(sorted),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* JSON Schema for reviewer subagent structured output.
|
|
233
|
+
* Object-rooted (dsh `agent` opts.schema requires object-rooted schemas with
|
|
234
|
+
* only type/properties/required/additionalProperties/items/enum/const/oneOf).
|
|
235
|
+
*/
|
|
236
|
+
export function findingsSchema() {
|
|
237
|
+
return {
|
|
238
|
+
type: 'object',
|
|
239
|
+
additionalProperties: false,
|
|
240
|
+
properties: {
|
|
241
|
+
findings: {
|
|
242
|
+
type: 'array',
|
|
243
|
+
items: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
properties: {
|
|
247
|
+
dimension: { type: 'string' },
|
|
248
|
+
file: { type: 'string' },
|
|
249
|
+
line: { type: 'integer' },
|
|
250
|
+
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
251
|
+
summary: { type: 'string' },
|
|
252
|
+
failure_scenario: { type: 'string' },
|
|
253
|
+
suggested_fix: { type: 'string' },
|
|
254
|
+
is_atomic: { type: 'boolean' },
|
|
255
|
+
},
|
|
256
|
+
required: [
|
|
257
|
+
'dimension',
|
|
258
|
+
'file',
|
|
259
|
+
'severity',
|
|
260
|
+
'summary',
|
|
261
|
+
'failure_scenario',
|
|
262
|
+
'suggested_fix',
|
|
263
|
+
'is_atomic',
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
required: ['findings'],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Build the task prompt for one dimension's reviewer subagent.
|
|
273
|
+
* In dry-run mode, pass `alreadyKnown` (the findings from earlier rounds) so the
|
|
274
|
+
* reviewer hunts for NEW issues only — that is what makes "反复审查" converge.
|
|
275
|
+
*/
|
|
276
|
+
export function reviewerTaskPrompt(input) {
|
|
277
|
+
const parts = [];
|
|
278
|
+
parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
|
|
279
|
+
if (input.mode === 'dry-run') {
|
|
280
|
+
parts.push('MODE: dry-run / pure review. You MUST NOT modify, create, or delete ANY file. Read-only analysis only.');
|
|
281
|
+
}
|
|
282
|
+
if (input.alreadyKnown && input.alreadyKnown.length > 0) {
|
|
283
|
+
parts.push('Already-known findings from earlier rounds (do NOT re-report these; find NEW issues only):', JSON.stringify(input.alreadyKnown, null, 2));
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
parts.push('This is round 1 — report every issue you find in this dimension.');
|
|
287
|
+
}
|
|
288
|
+
parts.push(`Return a JSON object: {"findings": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
|
|
289
|
+
'line (optional integer), severity (critical/high/medium/low), summary (one line), ' +
|
|
290
|
+
'failure_scenario (how/when it fails, specific evidence), suggested_fix (the concrete fix), ' +
|
|
291
|
+
`is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`, `Write summaries and details in ${input.outputLanguage}.`);
|
|
292
|
+
return parts.join('\n');
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Build a review plan: how many rounds, which dimensions, and the reviewer
|
|
296
|
+
* prompt template for each dimension. Used by the `iterate_review` tool's
|
|
297
|
+
* `plan` operation to give the orchestrator a canonical spec.
|
|
298
|
+
*/
|
|
299
|
+
export function buildReviewPlan(input) {
|
|
300
|
+
// Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
|
|
301
|
+
// `review`/`atomic` missing) must degrade to sane defaults instead of
|
|
302
|
+
// throwing an uncaught TypeError inside the tool's `execute`.
|
|
303
|
+
const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English';
|
|
304
|
+
const goal = input.config.goal ?? '';
|
|
305
|
+
const scope = input.config.review?.scope ?? 'full';
|
|
306
|
+
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : [];
|
|
307
|
+
const maxLines = input.config.atomic?.max_lines ?? 20;
|
|
308
|
+
return {
|
|
309
|
+
mode: input.mode,
|
|
310
|
+
goal,
|
|
311
|
+
scope,
|
|
312
|
+
dimensions: dimensions.map((d) => ({
|
|
313
|
+
id: d,
|
|
314
|
+
reviewerPrompt: reviewerTaskPrompt({
|
|
315
|
+
dimension: d,
|
|
316
|
+
goal,
|
|
317
|
+
scope,
|
|
318
|
+
mode: input.mode,
|
|
319
|
+
alreadyKnown: [],
|
|
320
|
+
outputLanguage: language,
|
|
321
|
+
maxLines,
|
|
322
|
+
}),
|
|
323
|
+
findingsSchema: findingsSchema(),
|
|
324
|
+
})),
|
|
325
|
+
maxReviewRounds: input.maxReviewRounds,
|
|
326
|
+
knownIntentional: input.knownIntentional ?? [],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The iterate skill prompt injected into the system prompt.
|
|
3
|
+
*
|
|
4
|
+
* This teaches the model how to write a correct `workflow` script that
|
|
5
|
+
* performs the iterate autonomous closed-loop (or dry-run pure review),
|
|
6
|
+
* using the registered tools via subagents.
|
|
7
|
+
*/
|
|
8
|
+
export const ITERATE_SKILL_PROMPT = `
|
|
9
|
+
## Iterate Workflow (autonomous code iteration)
|
|
10
|
+
|
|
11
|
+
You have the iterate plugin installed, which registers these tools:
|
|
12
|
+
- \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization) or write a validated partial update (operation:"write", with automatic backup + rollback)
|
|
13
|
+
- \`iterate_validate\` — run a whitelisted validation command
|
|
14
|
+
- \`iterate_decision_log\` — append to the decision log, or read entries back for review
|
|
15
|
+
- \`iterate_context\` — read SKILL.md / ITERATE.md project context
|
|
16
|
+
- \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan; \`aggregate\` dedupes/merges findings and computes convergence. Purely computational.
|
|
17
|
+
- \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
|
|
18
|
+
- \`iterate_fix\` — apply ONE atomic fix: backs up the file, enforces the atomic max_lines threshold, writes the new content, and records the fix (id + diff summary) in \`.iterate/fixes/registry.json\`
|
|
19
|
+
- \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
|
|
20
|
+
- \`iterate_rollback\` — revert a fix by id: restore the file from its backup, remove the fix from the registry, log a \`revert\` entry. Use when a round's validation fails
|
|
21
|
+
- \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
|
|
22
|
+
- \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence
|
|
23
|
+
|
|
24
|
+
### When to use
|
|
25
|
+
When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
|
|
26
|
+
- If the user says "review only" / "dry run" / "不要改文件" / "反复审查" → use \`mode: "dry-run"\`.
|
|
27
|
+
- Otherwise → use \`mode: "normal"\`.
|
|
28
|
+
|
|
29
|
+
### Workflow script contract
|
|
30
|
+
Write a plain-JS script (top-level await, ends with \`return <json>\`). Available globals:
|
|
31
|
+
- \`agent(prompt, opts?): Promise<value>\` — spawn a subagent. \`opts.schema\` gives structured output (object-rooted JSON Schema: type/properties/required/additionalProperties/items/enum/const/oneOf only). Resolves \`null\` on child failure. Other opts: \`label\`, \`phase\`.
|
|
32
|
+
- \`parallel(thunks): Promise<value[]>\` — run zero-arg async functions concurrently, await all.
|
|
33
|
+
- \`phase(title)\`, \`log(message)\` — progress narration.
|
|
34
|
+
- \`args\` — the args object passed to the workflow tool.
|
|
35
|
+
|
|
36
|
+
The script CANNOT call tools directly. Subagents are the ones who call tools.
|
|
37
|
+
|
|
38
|
+
### Dry-run mode workflow (pure review — the ONLY mode that never touches files)
|
|
39
|
+
This is iterate's read-only health-check: repeated review rounds until findings converge,
|
|
40
|
+
then produce an auditable report, then audit the report itself (meta-review) and give a
|
|
41
|
+
final review report. NO file writes, NO git, NO branches, NO worktree.
|
|
42
|
+
|
|
43
|
+
Canonical script — reproduce this structure exactly (adjust dims via the plan):
|
|
44
|
+
|
|
45
|
+
\`\`\`js
|
|
46
|
+
phase('plan')
|
|
47
|
+
const planRes = await agent(
|
|
48
|
+
'Call iterate_review({operation:"plan", mode:"dry-run"}) and return the plan JSON.',
|
|
49
|
+
{ label: 'review:plan' }
|
|
50
|
+
)
|
|
51
|
+
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
52
|
+
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
53
|
+
const dims = plan.dimensions.map(d => d.id)
|
|
54
|
+
const maxRounds = plan.maxReviewRounds
|
|
55
|
+
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
56
|
+
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
57
|
+
const rounds = [] // raw per-round findings
|
|
58
|
+
|
|
59
|
+
phase('review')
|
|
60
|
+
for (let r = 1; r <= maxRounds; r++) {
|
|
61
|
+
log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
|
|
62
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
63
|
+
'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
|
|
64
|
+
JSON.stringify(known) + '\\nReturn the findings JSON object.',
|
|
65
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
66
|
+
)))
|
|
67
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
68
|
+
rounds.push(thisRound)
|
|
69
|
+
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
70
|
+
const agg = await agent(
|
|
71
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
72
|
+
{ label: 'review:aggregate:r' + r }
|
|
73
|
+
)
|
|
74
|
+
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
75
|
+
// list stays bounded and reviewers never see the same issue twice.
|
|
76
|
+
if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
77
|
+
if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
|
|
78
|
+
log('round ' + r + ' found 0 new findings — converged')
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
phase('report')
|
|
84
|
+
const finalAgg = await agent(
|
|
85
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
86
|
+
{ label: 'review:aggregate:final' }
|
|
87
|
+
)
|
|
88
|
+
const report = (finalAgg && finalAgg.report) ? finalAgg.report : null
|
|
89
|
+
if (!report || !report.convergence) throw new Error('aggregate failed: no valid report was produced')
|
|
90
|
+
await agent(
|
|
91
|
+
'Call iterate_decision_log({operation:"append", type:"report", round:' + report.convergence.totalRounds + ', data:{mode:"dry-run", totalFindings:' + report.summary.totalFindings + '}})',
|
|
92
|
+
{ label: 'review:log' }
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
phase('meta-review')
|
|
96
|
+
// Audit the report itself for internal consistency, then produce the final report.
|
|
97
|
+
const metaRes = await agent(
|
|
98
|
+
'Call iterate_review({operation:"meta-review", report:' + JSON.stringify(report) + '}) and return the finalReport JSON.',
|
|
99
|
+
{ label: 'review:meta' }
|
|
100
|
+
)
|
|
101
|
+
const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
|
|
102
|
+
const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
mode: 'dry-run',
|
|
106
|
+
goal: report.goal,
|
|
107
|
+
rounds: rounds.length,
|
|
108
|
+
converged: report.convergence.converged,
|
|
109
|
+
stoppedReason: report.convergence.stoppedReason,
|
|
110
|
+
findingsByRound: report.convergence.findingsByRound,
|
|
111
|
+
totalFindings: report.summary.totalFindings,
|
|
112
|
+
bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
|
|
113
|
+
byDimension: report.summary.byDimension,
|
|
114
|
+
report,
|
|
115
|
+
metaReview: metaAudit ? { verdict: finalReport.verdict, issues: metaAudit.issues || [], checksRun: metaAudit.checksRun || 0 } : null,
|
|
116
|
+
finalReport
|
|
117
|
+
}
|
|
118
|
+
\`\`\`
|
|
119
|
+
|
|
120
|
+
Key rules for dry-run:
|
|
121
|
+
- **NEVER call a fixer / never edit files / never create branches or worktree.** Reviewers read only.
|
|
122
|
+
- Each round feeds the already-known findings to reviewers so they hunt NEW issues only → that is what drives convergence.
|
|
123
|
+
- Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
|
|
124
|
+
- The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
|
|
125
|
+
- **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The \`finalReport.verdict\` is \`approved\` only when the report passes every check; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
|
|
126
|
+
- Only a single \`report\` entry may be appended to the decision log; nothing else is written.
|
|
127
|
+
|
|
128
|
+
### Normal-mode workflow (autonomous closed loop)
|
|
129
|
+
Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N → atomic fixes via \`iterate_fix\` → validate → rollback on failure → checkpoint → loop → auto-stop when zero findings remain.
|
|
130
|
+
Canonical script — reproduce this structure exactly (adjust dims via the plan):
|
|
131
|
+
|
|
132
|
+
\`\`\`js
|
|
133
|
+
// args = { mode: "normal", maxRounds? }
|
|
134
|
+
phase('resume')
|
|
135
|
+
// If a previous run was interrupted, resume from its checkpoint instead of restarting.
|
|
136
|
+
const ckRes = await agent(
|
|
137
|
+
'Call iterate_checkpoint({ operation: "load" }) and return the checkpoint JSON.',
|
|
138
|
+
{ label: 'checkpoint:load' }
|
|
139
|
+
)
|
|
140
|
+
const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
|
|
141
|
+
const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
|
|
142
|
+
|
|
143
|
+
phase('plan')
|
|
144
|
+
const configRes = await agent(
|
|
145
|
+
'Call iterate_config({ validate: true }) and return the config JSON.',
|
|
146
|
+
{ label: 'config:read' }
|
|
147
|
+
)
|
|
148
|
+
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
149
|
+
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
150
|
+
const planRes = await agent(
|
|
151
|
+
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
|
|
152
|
+
{ label: 'review:plan' }
|
|
153
|
+
)
|
|
154
|
+
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
155
|
+
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
156
|
+
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
157
|
+
const dims = plan.dimensions.map(d => d.id)
|
|
158
|
+
const maxRounds = plan.maxReviewRounds
|
|
159
|
+
const rounds = [] // findings per review round (each on the then-current code state)
|
|
160
|
+
const architectural = [] // findings deliberately left unfixed (reported at the end)
|
|
161
|
+
let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
|
|
162
|
+
let converged = false
|
|
163
|
+
let abortedByValidation = false
|
|
164
|
+
let failedCommands = []
|
|
165
|
+
|
|
166
|
+
phase('loop')
|
|
167
|
+
for (let r = startRound; r <= maxRounds; r++) {
|
|
168
|
+
log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
|
|
169
|
+
const raw = await parallel(dims.map(dim => () => agent(
|
|
170
|
+
'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
|
|
171
|
+
'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
|
|
172
|
+
{ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
|
|
173
|
+
)))
|
|
174
|
+
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
175
|
+
rounds.push(thisRound)
|
|
176
|
+
|
|
177
|
+
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
178
|
+
const agg = await agent(
|
|
179
|
+
'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
180
|
+
{ label: 'review:aggregate:r' + r }
|
|
181
|
+
)
|
|
182
|
+
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
183
|
+
const atomic = findings.filter(f => f.is_atomic === true)
|
|
184
|
+
const remaining = findings.filter(f => f.is_atomic !== true)
|
|
185
|
+
|
|
186
|
+
const roundFixIds = []
|
|
187
|
+
if (atomic.length > 0) {
|
|
188
|
+
// Group atomic fixes by file. One fixer agent handles a whole file serially —
|
|
189
|
+
// calling iterate_fix per finding (the ONLY sanctioned writer), then
|
|
190
|
+
// iterate_diff to verify — so the same file is never edited concurrently;
|
|
191
|
+
// different files still run in parallel.
|
|
192
|
+
const byFile = {}
|
|
193
|
+
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
194
|
+
const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
|
|
195
|
+
'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
|
|
196
|
+
'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
|
|
197
|
+
'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
|
|
198
|
+
'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
|
|
199
|
+
'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
|
|
200
|
+
{ label: 'fix:' + file, phase: 'fix', schema: {
|
|
201
|
+
type: 'object', additionalProperties: false,
|
|
202
|
+
properties: {
|
|
203
|
+
fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
|
|
204
|
+
},
|
|
205
|
+
required: ['fixes'] } }
|
|
206
|
+
)))
|
|
207
|
+
for (const res of fixRes) {
|
|
208
|
+
if (res && Array.isArray(res.fixes)) {
|
|
209
|
+
for (const fx of res.fixes) {
|
|
210
|
+
if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Cross-round dedupe of architectural findings before accumulating.
|
|
217
|
+
const seenKeys = architectural.map(a => a.file + '|' + a.dimension + '|' + a.summary)
|
|
218
|
+
for (const f of remaining) {
|
|
219
|
+
const key = f.file + '|' + f.dimension + '|' + f.summary
|
|
220
|
+
if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Validate every configured command; on ANY failure roll back this round's fixes.
|
|
224
|
+
const valRes = await agent(
|
|
225
|
+
'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
|
|
226
|
+
'(one tool call per command). Return all results as {command, exitCode} entries.',
|
|
227
|
+
{ label: 'validate:r' + r, phase: 'validate', schema: {
|
|
228
|
+
type: 'object', additionalProperties: false,
|
|
229
|
+
properties: {
|
|
230
|
+
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
|
|
231
|
+
},
|
|
232
|
+
required: ['results'] } }
|
|
233
|
+
)
|
|
234
|
+
failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
|
|
235
|
+
if (failedCommands.length > 0) {
|
|
236
|
+
log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
|
|
237
|
+
abortedByValidation = true
|
|
238
|
+
if (roundFixIds.length > 0) {
|
|
239
|
+
await agent(
|
|
240
|
+
'Call iterate_rollback({ id: <id> }) for EACH of these fix ids (one call per id): ' + JSON.stringify(roundFixIds) + '. Return the array of {id, ok, error}.',
|
|
241
|
+
{ label: 'rollback:r' + r, phase: 'rollback', schema: {
|
|
242
|
+
type: 'object', additionalProperties: false,
|
|
243
|
+
properties: {
|
|
244
|
+
results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
|
|
245
|
+
},
|
|
246
|
+
required: ['results'] } }
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
await agent(
|
|
250
|
+
'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{failedCommands:' + JSON.stringify(failedCommands) + ', rolledBack:' + roundFixIds.length + '}})',
|
|
251
|
+
{ label: 'log:failed:r' + r }
|
|
252
|
+
)
|
|
253
|
+
break
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await agent(
|
|
257
|
+
'Call iterate_decision_log({operation:"append", type:"review_result", round:' + r +
|
|
258
|
+
', data:{atomic:' + atomic.length + ', architectural:' + remaining.length + ', fixedSoFar:' + fixedCount + '}})',
|
|
259
|
+
{ label: 'log:r' + r }
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// Persist progress so an interrupted run can resume from the next round.
|
|
263
|
+
await agent(
|
|
264
|
+
'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
|
|
265
|
+
{ label: 'checkpoint:save:r' + r }
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if (atomic.length === 0 && remaining.length === 0) {
|
|
269
|
+
log('round ' + r + ' found nothing to fix — converged')
|
|
270
|
+
converged = true
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
phase('report')
|
|
276
|
+
await agent(
|
|
277
|
+
'Call iterate_decision_log({operation:"append", type:"report", round:' + rounds.length +
|
|
278
|
+
', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
|
|
279
|
+
{ label: 'report:log' }
|
|
280
|
+
)
|
|
281
|
+
const statusRes = await agent(
|
|
282
|
+
'Call iterate_status() and return the status JSON.',
|
|
283
|
+
{ label: 'status:final' }
|
|
284
|
+
)
|
|
285
|
+
const status = (statusRes && statusRes.ok) ? statusRes : null
|
|
286
|
+
if (!abortedByValidation) {
|
|
287
|
+
// Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
|
|
288
|
+
await agent(
|
|
289
|
+
'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
|
|
290
|
+
{ label: 'checkpoint:clear' }
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
mode: 'normal',
|
|
295
|
+
goal: plan.goal,
|
|
296
|
+
roundsExecuted: rounds.length,
|
|
297
|
+
maxRounds: maxRounds,
|
|
298
|
+
converged: converged,
|
|
299
|
+
abortedByValidation: abortedByValidation,
|
|
300
|
+
failedCommands: failedCommands,
|
|
301
|
+
findingsFixed: fixedCount,
|
|
302
|
+
remainingArchitecturalCount: architectural.length,
|
|
303
|
+
remainingArchitectural: architectural,
|
|
304
|
+
status: status ? {
|
|
305
|
+
currentRound: status.currentRound,
|
|
306
|
+
totalRounds: status.totalRounds,
|
|
307
|
+
fixedCount: status.fixedCount,
|
|
308
|
+
architecturalCount: status.architecturalCount,
|
|
309
|
+
findingsCount: status.findingsCount,
|
|
310
|
+
hasCheckpoint: status.hasCheckpoint
|
|
311
|
+
} : null
|
|
312
|
+
}
|
|
313
|
+
\`\`\`
|
|
314
|
+
|
|
315
|
+
Key rules for normal mode:
|
|
316
|
+
- Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
|
|
317
|
+
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
318
|
+
- Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
|
|
319
|
+
- **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
|
|
320
|
+
- **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed).
|
|
321
|
+
- **Checkpoint after every round**; clear it only when the iteration completes cleanly.
|
|
322
|
+
- Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
|
|
323
|
+
- Every round, every rollback, and the final report go to the append-only decision log.
|
|
324
|
+
- Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
|
|
325
|
+
|
|
326
|
+
### Finding schema (for reviewer agents)
|
|
327
|
+
{ "dimension": string, "file": string (relative path), "line": number (optional),
|
|
328
|
+
"severity": "critical" | "high" | "medium" | "low", "summary": string (one line),
|
|
329
|
+
"failure_scenario": string (how/when it fails), "suggested_fix": string (the concrete fix),
|
|
330
|
+
"is_atomic": boolean (true if fix ≤ max_lines within a single file/function) }
|
|
331
|
+
Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lines lines change). Architectural = everything else.
|
|
332
|
+
|
|
333
|
+
### Workflow meta
|
|
334
|
+
Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
|
|
335
|
+
|
|
336
|
+
Always end with a clear summary: total findings, count by severity, fixes applied (normal) or convergence stats (dry-run), and remaining architectural findings.
|
|
337
|
+
`;
|