iterate-plugin 2.3.7 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.3.7",
3
+ "version": "2.5.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",
@@ -0,0 +1,181 @@
1
+ /**
2
+ * src/config-write.ts — shared helpers for safely WRITING iterate.config.yaml.
3
+ *
4
+ * Used by the `iterate_config` write operation. Provides:
5
+ * - validateConfigUpdates : validate a caller-supplied partial update
6
+ * - applyConfigUpdates : merge a partial update into the current config
7
+ * - writeConfigFile : backup + write + rollback on failure
8
+ *
9
+ * The security posture mirrors the triage tool: never overwrite a malformed
10
+ * config, always back up before writing, roll back on failure.
11
+ */
12
+
13
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import yaml from 'js-yaml'
16
+
17
+ /** Config file name (must match config-loader). */
18
+ export const CONFIG_FILE = 'iterate.config.yaml'
19
+
20
+ /** Backup suffix helper (filesystem-safe timestamp). */
21
+ export function configBackupSuffix(now = new Date()): string {
22
+ return now.toISOString().replace(/[:.]/g, '-')
23
+ }
24
+
25
+ /**
26
+ * Validate a partial config update.
27
+ * Returns an array of error strings (empty when the update is valid).
28
+ */
29
+ export function validateConfigUpdates(updates: Record<string, unknown>): string[] {
30
+ const errors: string[] = []
31
+ if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
32
+ return ['updates must be a JSON object']
33
+ }
34
+
35
+ if ('goal' in updates && typeof updates.goal !== 'string') {
36
+ errors.push('updates.goal must be a string')
37
+ }
38
+ if ('language' in updates && updates.language !== 'zh' && updates.language !== 'en') {
39
+ errors.push('updates.language must be "zh" or "en"')
40
+ }
41
+ if ('dimensions' in updates) {
42
+ if (!Array.isArray(updates.dimensions) || updates.dimensions.some((d) => typeof d !== 'string' || d.trim().length === 0)) {
43
+ errors.push('updates.dimensions must be an array of non-empty strings')
44
+ }
45
+ }
46
+ if ('max_rounds' in updates) {
47
+ if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) || updates.max_rounds < 1) {
48
+ errors.push('updates.max_rounds must be a positive integer')
49
+ }
50
+ }
51
+ if ('review' in updates) {
52
+ const r = updates.review as Record<string, unknown> | undefined
53
+ if (!r || typeof r !== 'object') {
54
+ errors.push('updates.review must be an object')
55
+ } else if (r.scope !== undefined && r.scope !== 'full' && r.scope !== 'changed-only') {
56
+ errors.push('updates.review.scope must be "full" or "changed-only"')
57
+ }
58
+ }
59
+ if ('atomic' in updates) {
60
+ const a = updates.atomic as Record<string, unknown> | undefined
61
+ if (!a || typeof a !== 'object') {
62
+ errors.push('updates.atomic must be an object')
63
+ } else {
64
+ if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
65
+ errors.push('updates.atomic.max_lines must be a positive integer')
66
+ }
67
+ if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
68
+ errors.push('updates.atomic.max_adjacent_methods must be a non-negative number')
69
+ }
70
+ }
71
+ }
72
+ if ('git' in updates) {
73
+ const g = updates.git as Record<string, unknown> | undefined
74
+ if (!g || typeof g !== 'object') {
75
+ errors.push('updates.git must be an object')
76
+ } else {
77
+ if (g.target_branch !== undefined && typeof g.target_branch !== 'string') {
78
+ errors.push('updates.git.target_branch must be a string')
79
+ }
80
+ for (const boolKey of ['use_worktree', 'push_per_round', 'auto_merge'] as const) {
81
+ if (g[boolKey] !== undefined && typeof g[boolKey] !== 'boolean') {
82
+ errors.push(`updates.git.${boolKey} must be a boolean`)
83
+ }
84
+ }
85
+ }
86
+ }
87
+ if ('validation' in updates) {
88
+ const v = updates.validation as Record<string, unknown> | undefined
89
+ if (!v || typeof v !== 'object') {
90
+ errors.push('updates.validation must be an object')
91
+ } else if ('commands' in v && v.commands !== undefined && typeof v.commands !== 'object') {
92
+ errors.push('updates.validation.commands must be an object of command arrays')
93
+ }
94
+ }
95
+ if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
96
+ errors.push('updates.personalization must be an object')
97
+ }
98
+ if ('onboarding' in updates && (!updates.onboarding || typeof updates.onboarding !== 'object')) {
99
+ errors.push('updates.onboarding must be an object')
100
+ }
101
+ return errors
102
+ }
103
+
104
+ /** Recursively merge `updates` over `base` (arrays replaced wholesale). */
105
+ export function applyConfigUpdates(
106
+ base: Record<string, unknown>,
107
+ updates: Record<string, unknown>,
108
+ ): Record<string, unknown> {
109
+ const out: Record<string, unknown> = { ...base }
110
+ for (const [key, value] of Object.entries(updates)) {
111
+ if (value === undefined) continue
112
+ const baseValue = out[key]
113
+ if (
114
+ baseValue &&
115
+ typeof baseValue === 'object' &&
116
+ !Array.isArray(baseValue) &&
117
+ value &&
118
+ typeof value === 'object' &&
119
+ !Array.isArray(value)
120
+ ) {
121
+ out[key] = applyConfigUpdates(baseValue as Record<string, unknown>, value as Record<string, unknown>)
122
+ } else {
123
+ out[key] = value
124
+ }
125
+ }
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * Read the raw config object from disk (empty object when missing).
131
+ * Throws when the file exists but cannot be parsed as a YAML mapping
132
+ * (never overwrite a malformed config).
133
+ */
134
+ export function readRawConfig(configPath: string): Record<string, unknown> {
135
+ if (!existsSync(configPath)) return {}
136
+ const content = readFileSync(configPath, 'utf-8')
137
+ let parsed: unknown
138
+ try {
139
+ parsed = yaml.load(content)
140
+ } catch {
141
+ throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
142
+ }
143
+ if (!parsed || typeof parsed !== 'object') {
144
+ throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
145
+ }
146
+ return parsed as Record<string, unknown>
147
+ }
148
+
149
+ /**
150
+ * Write a config object to disk with backup + rollback.
151
+ * Returns `{ ok: true, backupPath }` or `{ ok: false, error }`.
152
+ */
153
+ export function writeConfigFile(
154
+ projectRoot: string,
155
+ config: Record<string, unknown>,
156
+ ): { ok: true; backupPath: string | null } | { ok: false; error: string } {
157
+ const configPath = join(projectRoot, CONFIG_FILE)
158
+ const hadFile = existsSync(configPath)
159
+ const backupPath = hadFile ? `${configPath}.bak-${configBackupSuffix()}` : null
160
+
161
+ if (backupPath) {
162
+ try {
163
+ copyFileSync(configPath, backupPath)
164
+ } catch (err) {
165
+ return { ok: false, error: `failed to create backup: ${String(err)}` }
166
+ }
167
+ }
168
+
169
+ try {
170
+ writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8')
171
+ } catch (err) {
172
+ try {
173
+ if (backupPath) copyFileSync(backupPath, configPath)
174
+ } catch {
175
+ // Rollback failure is reported, never swallowed silently.
176
+ }
177
+ return { ok: false, error: `failed to write config: ${String(err)}` }
178
+ }
179
+
180
+ return { ok: true, backupPath }
181
+ }
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 4 tools (config, validate, decision-log, context)
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 4 tools to do real work (read config, run validation, log decisions)
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/ — 4 tool implementations
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,27 @@ 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'
31
+ import { registerFixTool, registerDiffTool, registerRollbackTool } from './tools/fix.ts'
32
+ import { registerCheckpointTool, registerStatusTool } from './tools/checkpoint.ts'
30
33
  import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
31
34
 
32
35
  export const name = 'iterate-plugin'
33
36
  export const inject = ['tools', 'systemPrompt']
34
37
 
35
38
  export function apply(ctx: Context): void {
36
- // 1. Register the 5 core tools
39
+ // 1. Register the 11 tools
37
40
  registerConfigTool(ctx)
38
41
  registerValidateTool(ctx)
39
42
  registerDecisionLogTool(ctx)
40
43
  registerContextTool(ctx)
41
44
  registerReviewTool(ctx)
45
+ registerTriageTool(ctx)
46
+ registerFixTool(ctx)
47
+ registerDiffTool(ctx)
48
+ registerRollbackTool(ctx)
49
+ registerCheckpointTool(ctx)
50
+ registerStatusTool(ctx)
42
51
 
43
52
  // 2. Inject the iterate skill prompt as a system prompt section
44
53
  // This teaches the model how to write iterate workflow scripts using the tools.
package/src/paths.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared filesystem layout for the iterate plugin's runtime state.
3
+ *
4
+ * All runtime artifacts live under `<projectRoot>/.iterate/`:
5
+ * .iterate/decision-log.jsonl — append-only decision log
6
+ * .iterate/fixes/ — fix system: backups + fix registry
7
+ * .iterate/checkpoint.json — iteration checkpoint (resume support)
8
+ *
9
+ * Kept separate from config-loader so every tool points at the same dirs.
10
+ */
11
+
12
+ import { join } from 'node:path'
13
+
14
+ /** Runtime state root for a project (e.g. `<projectRoot>/.iterate`). */
15
+ export function iterateDir(projectRoot: string): string {
16
+ return join(projectRoot, '.iterate')
17
+ }
18
+
19
+ /** Fix-system directory (backups + registry). */
20
+ export function fixesDir(projectRoot: string): string {
21
+ return join(iterateDir(projectRoot), 'fixes')
22
+ }
23
+
24
+ /** Fix-registry file (JSON). */
25
+ export function fixRegistryPath(projectRoot: string): string {
26
+ return join(fixesDir(projectRoot), 'registry.json')
27
+ }
28
+
29
+ /** Fix-backup file for one fix id + timestamp. */
30
+ export function fixBackupPath(projectRoot: string, id: string, timestamp: string): string {
31
+ const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_')
32
+ return join(fixesDir(projectRoot), `${safe}_${timestamp.replace(/[:.]/g, '-')}.bak`)
33
+ }
34
+
35
+ /** Iteration checkpoint file (JSON). */
36
+ export function checkpointPath(projectRoot: string): string {
37
+ return join(iterateDir(projectRoot), 'checkpoint.json')
38
+ }
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: input.config.goal,
351
- scope: input.config.review.scope,
352
- dimensions: input.config.dimensions.map((d) => ({
357
+ goal,
358
+ scope,
359
+ dimensions: dimensions.map((d) => ({
353
360
  id: d,
354
361
  reviewerPrompt: reviewerTaskPrompt({
355
362
  dimension: d,
356
- goal: input.config.goal,
357
- scope: input.config.review.scope,
363
+ goal,
364
+ scope,
358
365
  mode: input.mode,
359
366
  alreadyKnown: [],
360
367
  outputLanguage: language,
361
- maxLines: input.config.atomic.max_lines,
368
+ maxLines,
362
369
  }),
363
370
  findingsSchema: findingsSchema(),
364
371
  })),
@@ -3,18 +3,24 @@
3
3
  *
4
4
  * This teaches the model how to write a correct `workflow` script that
5
5
  * performs the iterate autonomous closed-loop (or dry-run pure review),
6
- * using the 5 registered tools via subagents.
6
+ * using the registered tools via subagents.
7
7
  */
8
8
 
9
9
  export const ITERATE_SKILL_PROMPT = `
10
10
  ## Iterate Workflow (autonomous code iteration)
11
11
 
12
12
  You have the iterate plugin installed, which registers these tools:
13
- - \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization)
13
+ - \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization) or write a validated partial update (operation:"write", with automatic backup + rollback)
14
14
  - \`iterate_validate\` — run a whitelisted validation command
15
- - \`iterate_decision_log\` — append to the decision log
15
+ - \`iterate_decision_log\` — append to the decision log, or read entries back for review
16
16
  - \`iterate_context\` — read SKILL.md / ITERATE.md project context
17
17
  - \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan; \`aggregate\` dedupes/merges findings and computes convergence. Purely computational.
18
+ - \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
19
+ - \`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\`
20
+ - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
21
+ - \`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
22
+ - \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
23
+ - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence
18
24
 
19
25
  ### When to use
20
26
  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.
@@ -121,11 +127,20 @@ Key rules for dry-run:
121
127
  - Only a single \`report\` entry may be appended to the decision log; nothing else is written.
122
128
 
123
129
  ### Normal-mode workflow (autonomous closed loop)
124
- Set \`args.mode = "normal"\`. Loop: plan → parallel review ×N → fix atomic issues → validate → loop → auto-stop when zero findings remain.
130
+ 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.
125
131
  Canonical script — reproduce this structure exactly (adjust dims via the plan):
126
132
 
127
133
  \`\`\`js
128
134
  // args = { mode: "normal", maxRounds? }
135
+ phase('resume')
136
+ // If a previous run was interrupted, resume from its checkpoint instead of restarting.
137
+ const ckRes = await agent(
138
+ 'Call iterate_checkpoint({ operation: "load" }) and return the checkpoint JSON.',
139
+ { label: 'checkpoint:load' }
140
+ )
141
+ const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
142
+ const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
143
+
129
144
  phase('plan')
130
145
  const configRes = await agent(
131
146
  'Call iterate_config({ validate: true }) and return the config JSON.',
@@ -144,12 +159,14 @@ const dims = plan.dimensions.map(d => d.id)
144
159
  const maxRounds = plan.maxReviewRounds
145
160
  const rounds = [] // findings per review round (each on the then-current code state)
146
161
  const architectural = [] // findings deliberately left unfixed (reported at the end)
147
- let fixedCount = 0
162
+ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
148
163
  let converged = false
164
+ let abortedByValidation = false
165
+ let failedCommands = []
149
166
 
150
167
  phase('loop')
151
- for (let r = 1; r <= maxRounds; r++) {
152
- log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics, validate')
168
+ for (let r = startRound; r <= maxRounds; r++) {
169
+ log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
153
170
  const raw = await parallel(dims.map(dim => () => agent(
154
171
  'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
155
172
  'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + '\\nReturn the findings JSON object.',
@@ -167,18 +184,34 @@ for (let r = 1; r <= maxRounds; r++) {
167
184
  const atomic = findings.filter(f => f.is_atomic === true)
168
185
  const remaining = findings.filter(f => f.is_atomic !== true)
169
186
 
187
+ const roundFixIds = []
170
188
  if (atomic.length > 0) {
171
- // Group atomic fixes by file. All edits to the SAME file are applied by a
172
- // single fixer agent (serial within a file), avoiding concurrent-write
173
- // races; different files still run in parallel.
189
+ // Group atomic fixes by file. One fixer agent handles a whole file serially
190
+ // calling iterate_fix per finding (the ONLY sanctioned writer), then
191
+ // iterate_diff to verify so the same file is never edited concurrently;
192
+ // different files still run in parallel.
174
193
  const byFile = {}
175
194
  atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
176
- await parallel(Object.keys(byFile).map(file => () => agent(
177
- 'Apply ALL of these fixes to ' + file + ' in ONE pass with the smallest possible changes ' +
178
- '(each <= ' + atomicMaxLines + ' lines, single function). ' + JSON.stringify(byFile[file]) + '. Verify the edit locally before finishing.',
179
- { label: 'fix:' + file, phase: 'fix' }
195
+ const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
196
+ 'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
197
+ 'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
198
+ 'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
199
+ 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
200
+ 'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
201
+ { label: 'fix:' + file, phase: 'fix', schema: {
202
+ type: 'object', additionalProperties: false,
203
+ properties: {
204
+ fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
205
+ },
206
+ required: ['fixes'] } }
180
207
  )))
181
- fixedCount += atomic.length
208
+ for (const res of fixRes) {
209
+ if (res && Array.isArray(res.fixes)) {
210
+ for (const fx of res.fixes) {
211
+ if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
212
+ }
213
+ }
214
+ }
182
215
  }
183
216
 
184
217
  // Cross-round dedupe of architectural findings before accumulating.
@@ -188,16 +221,51 @@ for (let r = 1; r <= maxRounds; r++) {
188
221
  if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
189
222
  }
190
223
 
191
- await agent(
192
- 'Call iterate_validate for each command in iterate.config.yaml validation.commands and return all {command, exitCode} results.',
193
- { label: 'validate:r' + r, phase: 'validate' }
224
+ // Validate every configured command; on ANY failure roll back this round's fixes.
225
+ const valRes = await agent(
226
+ 'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
227
+ '(one tool call per command). Return all results as {command, exitCode} entries.',
228
+ { label: 'validate:r' + r, phase: 'validate', schema: {
229
+ type: 'object', additionalProperties: false,
230
+ properties: {
231
+ results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
232
+ },
233
+ required: ['results'] } }
194
234
  )
235
+ failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
236
+ if (failedCommands.length > 0) {
237
+ log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
238
+ abortedByValidation = true
239
+ if (roundFixIds.length > 0) {
240
+ await agent(
241
+ '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}.',
242
+ { label: 'rollback:r' + r, phase: 'rollback', schema: {
243
+ type: 'object', additionalProperties: false,
244
+ properties: {
245
+ results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
246
+ },
247
+ required: ['results'] } }
248
+ )
249
+ }
250
+ await agent(
251
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{failedCommands:' + JSON.stringify(failedCommands) + ', rolledBack:' + roundFixIds.length + '}})',
252
+ { label: 'log:failed:r' + r }
253
+ )
254
+ break
255
+ }
256
+
195
257
  await agent(
196
258
  'Call iterate_decision_log({operation:"append", type:"review_result", round:' + r +
197
259
  ', data:{atomic:' + atomic.length + ', architectural:' + remaining.length + ', fixedSoFar:' + fixedCount + '}})',
198
260
  { label: 'log:r' + r }
199
261
  )
200
262
 
263
+ // Persist progress so an interrupted run can resume from the next round.
264
+ await agent(
265
+ '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.',
266
+ { label: 'checkpoint:save:r' + r }
267
+ )
268
+
201
269
  if (atomic.length === 0 && remaining.length === 0) {
202
270
  log('round ' + r + ' found nothing to fix — converged')
203
271
  converged = true
@@ -211,25 +279,50 @@ await agent(
211
279
  ', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
212
280
  { label: 'report:log' }
213
281
  )
282
+ const statusRes = await agent(
283
+ 'Call iterate_status() and return the status JSON.',
284
+ { label: 'status:final' }
285
+ )
286
+ const status = (statusRes && statusRes.ok) ? statusRes : null
287
+ if (!abortedByValidation) {
288
+ // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
289
+ await agent(
290
+ 'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
291
+ { label: 'checkpoint:clear' }
292
+ )
293
+ }
214
294
  return {
215
295
  mode: 'normal',
216
296
  goal: plan.goal,
217
297
  roundsExecuted: rounds.length,
218
298
  maxRounds: maxRounds,
219
299
  converged: converged,
300
+ abortedByValidation: abortedByValidation,
301
+ failedCommands: failedCommands,
220
302
  findingsFixed: fixedCount,
221
303
  remainingArchitecturalCount: architectural.length,
222
- remainingArchitectural: architectural
304
+ remainingArchitectural: architectural,
305
+ status: status ? {
306
+ currentRound: status.currentRound,
307
+ totalRounds: status.totalRounds,
308
+ fixedCount: status.fixedCount,
309
+ architecturalCount: status.architecturalCount,
310
+ findingsCount: status.findingsCount,
311
+ hasCheckpoint: status.hasCheckpoint
312
+ } : null
223
313
  }
224
314
  \`\`\`
225
315
 
226
316
  Key rules for normal mode:
227
- - Fixers are the ONLY agents allowed to write files; reviewers read only. Architectural findings are reported, never auto-fixed.
317
+ - 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.
228
318
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
229
- - 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.
230
- - Validate after every round of fixes; validation results are logged, not silently dropped.
319
+ - 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.
320
+ - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
321
+ - **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).
322
+ - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
231
323
  - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
232
- - Every round and the final report go to the append-only decision log.
324
+ - Every round, every rollback, and the final report go to the append-only decision log.
325
+ - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
233
326
 
234
327
  ### Finding schema (for reviewer agents)
235
328
  { "dimension": string, "file": string (relative path), "line": number (optional),
@@ -242,4 +335,4 @@ Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lin
242
335
  Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
243
336
 
244
337
  Always end with a clear summary: total findings, count by severity, fixes applied (normal) or convergence stats (dry-run), and remaining architectural findings.
245
- `
338
+ `