iterate-plugin 2.8.4 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parse.js CHANGED
@@ -28,6 +28,124 @@ export const SEVERITY_COLOR = {
28
28
  low: '#6b7280',
29
29
  }
30
30
 
31
+ // ─── Interruption / resume + image attachment detection ──────────────────────
32
+
33
+ /**
34
+ * Deep-scan an object tree for a decision-log `resume` marker.
35
+ * The normal-mode workflow appends a `resume` decision-log entry when it
36
+ * continues a previous interrupted run:
37
+ * { type: "resume", data: { resumedFromRound, resumeCount } }
38
+ * This is the durable client-side signal that a run was interrupted and
39
+ * recovered. Returns the highest `resumeCount` observed, or 0 when none.
40
+ *
41
+ * @param {unknown} obj
42
+ * @param {Set<unknown>} [seen]
43
+ * @param {number} [maxDepth=20]
44
+ * @returns {number}
45
+ */
46
+ export function scanSessionForResume(obj, seen, maxDepth = 20) {
47
+ if (maxDepth <= 0) return 0
48
+ if (!obj || typeof obj !== 'object') return 0
49
+
50
+ const s = seen || new Set()
51
+ if (s.has(obj)) return 0
52
+ s.add(obj)
53
+
54
+ let best = 0
55
+
56
+ // Direct marker: { type: "resume", data: { resumeCount } }.
57
+ const direct = /** @type {Record<string, unknown>} */ (obj)
58
+ if (direct.type === 'resume') {
59
+ const data = /** @type {Record<string, unknown>} */ (direct.data || {})
60
+ if (typeof data.resumeCount === 'number' && data.resumeCount > best) {
61
+ best = data.resumeCount
62
+ }
63
+ }
64
+ // Nested entry: { entry: { type: "resume", data: { resumeCount } } }.
65
+ if (direct.entry && typeof direct.entry === 'object') {
66
+ const entry = /** @type {Record<string, unknown>} */ (direct.entry)
67
+ if (entry.type === 'resume') {
68
+ const data = /** @type {Record<string, unknown>} */ (entry.data || {})
69
+ if (typeof data.resumeCount === 'number' && data.resumeCount > best) {
70
+ best = data.resumeCount
71
+ }
72
+ }
73
+ }
74
+
75
+ if (Array.isArray(obj)) {
76
+ for (const item of obj) {
77
+ const found = scanSessionForResume(item, s, maxDepth - 1)
78
+ if (found > best) best = found
79
+ }
80
+ return best
81
+ }
82
+
83
+ for (const key of Object.keys(direct)) {
84
+ const val = direct[key]
85
+ if (val && typeof val === 'object') {
86
+ const found = scanSessionForResume(val, s, maxDepth - 1)
87
+ if (found > best) best = found
88
+ }
89
+ }
90
+
91
+ return best
92
+ }
93
+
94
+ /**
95
+ * Count distinct user-attached images inside a session snapshot.
96
+ * Matches dsh image blocks ({ type: "image", attachment: {...} }) and
97
+ * raw attachment references ({ mediaType, width, height, bytes }). Dedupes by
98
+ * `attachmentId` when present so the same image never counts twice.
99
+ *
100
+ * @param {unknown} session
101
+ * @returns {number}
102
+ */
103
+ export function countSessionImages(session) {
104
+ if (!session || typeof session !== 'object') return 0
105
+
106
+ const ids = new Set()
107
+ let count = 0
108
+
109
+ /** @param {unknown} obj */
110
+ const walk = (obj, depth) => {
111
+ if (depth <= 0 || !obj || typeof obj !== 'object') return
112
+ if (seen.has(obj)) return
113
+ seen.add(obj)
114
+ const o = /** @type {Record<string, unknown>} */ (obj)
115
+
116
+ // Image block: { type: "image", attachment: { ...ref } }.
117
+ let ref = null
118
+ if (o.type === 'image' && o.attachment && typeof o.attachment === 'object') {
119
+ ref = /** @type {Record<string, unknown>} */ (o.attachment)
120
+ }
121
+ // Raw attachment reference shape.
122
+ if (!ref && typeof o.mediaType === 'string' && String(o.mediaType).startsWith('image/')) {
123
+ ref = o
124
+ }
125
+ if (ref) {
126
+ const id = typeof ref.attachmentId === 'string' ? ref.attachmentId : null
127
+ if (id) {
128
+ if (!ids.has(id)) { ids.add(id); count += 1 }
129
+ } else {
130
+ count += 1
131
+ }
132
+ }
133
+
134
+ if (Array.isArray(obj)) {
135
+ for (const item of obj) walk(item, depth - 1)
136
+ return
137
+ }
138
+ for (const key of Object.keys(o)) {
139
+ const val = o[key]
140
+ if (val && typeof val === 'object') walk(val, depth - 1)
141
+ }
142
+ }
143
+
144
+ const seen = new Set()
145
+ walk(session, 12)
146
+ return count
147
+ }
148
+
31
149
  // ─── ReviewReport detection ──────────────────────────────────────────────────
32
150
 
33
151
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.8.4",
3
+ "version": "2.9.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",
@@ -68,6 +68,8 @@ import {
68
68
  keyToVerdict,
69
69
  allVerdictKeys,
70
70
  buildRuntimeStatusGuide,
71
+ scanSessionForResume,
72
+ countSessionImages,
71
73
  SEVERITY_LABEL,
72
74
  SEVERITY_COLOR,
73
75
  } from '../../lib/parse.js'
@@ -320,6 +322,10 @@ const ITERATE_CSS = `
320
322
  .iterate-pill { display: inline-flex; align-items: center; gap: 7px; padding: 3px 11px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 28%, transparent); }
321
323
  .iterate-pill-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
322
324
 
325
+ /* Interruption / resume + attachment chips (dashboard) */
326
+ .iterate-chip-resume { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary) 28%, transparent); }
327
+ .iterate-chip-images { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 28%, transparent); }
328
+
323
329
  /* Accessibility-switch toggle */
324
330
  .iterate-switch { position: relative; width: 42px; height: 24px; border-radius: 999px; padding: 0; cursor: pointer; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); transition: background-color 160ms ease, border-color 160ms ease; }
325
331
  .iterate-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
@@ -552,6 +558,25 @@ function ConvergenceDashboard(props: SlotProps) {
552
558
  const dims = groupByDimension(report)
553
559
  const trend = computeTrendMetrics(report)
554
560
 
561
+ // Interruption / resume awareness: a decision-log `resume` entry in the
562
+ // session means this run continued from an interrupted checkpoint.
563
+ const resumeCount = scanSessionForResume(session)
564
+ const imageCount = countSessionImages(session)
565
+ const resumeChip = resumeCount > 0
566
+ ? React.createElement('span', {
567
+ className: 'iterate-chip-resume',
568
+ key: 'resume',
569
+ title: '本次迭代从上一次中断的断点继续执行',
570
+ }, `已中断恢复 ×${String(resumeCount)}`)
571
+ : null
572
+ const imageChip = imageCount > 0
573
+ ? React.createElement('span', {
574
+ className: 'iterate-chip-images',
575
+ key: 'images',
576
+ title: '会话中检测到用户附带的图片,评审将作为视觉证据参考',
577
+ }, `附件图片 ${String(imageCount)}`)
578
+ : null
579
+
555
580
  const dimBadges = Object.keys(dims).slice(0, 6).map((dim) =>
556
581
  React.createElement(
557
582
  'span',
@@ -598,6 +623,8 @@ function ConvergenceDashboard(props: SlotProps) {
598
623
  stats.medium,
599
624
  ),
600
625
  fixBadge,
626
+ resumeChip,
627
+ imageChip,
601
628
  React.createElement(TrendChart, { points: trend.points }),
602
629
  ...dimBadges,
603
630
  )
@@ -13,14 +13,14 @@ You have the iterate plugin installed, which registers these tools:
13
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
15
  - \`iterate_decision_log\` — append to the decision log, or read entries back for review
16
- - \`iterate_context\` — read SKILL.md / ITERATE.md project context
16
+ - \`iterate_context\` — read SKILL.md / ITERATE.md project context; also relays user-attached image metadata (e.g. UI screenshots, error dialogs) so reviewers can treat them as visual evidence
17
17
  - \`iterate_review\` — deterministic review engine: \`plan\` builds the review plan (for \`review.scope: changed-only\`, it resolves the git-diff file set against \`git.target_branch\` and auto-falls back to \`full\` when nothing changed); \`aggregate\` dedupes/merges findings, validates every finding against the findings schema when \`reviewer.output_schema_validation\` is on (dropping invalid entries and reporting them via \`schemaValidation\`), and computes convergence; \`meta-review\` audits a built report for internal consistency (counts, buckets, sorting, convergence math) and returns a final report with an \`approved\` / \`needs_revision\` verdict. Purely computational.
18
18
  - \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
19
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
20
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
21
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
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
23
+ - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
24
24
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
25
25
  - \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
26
26
 
@@ -31,13 +31,26 @@ When the user asks to review or iterate on the project (e.g. "review this projec
31
31
 
32
32
  ### Workflow script contract
33
33
  Write a plain-JS script (top-level await, ends with \`return <json>\`). Available globals:
34
- - \`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\`.
34
+ - \`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\`. Backend selection (optional): pass \`provider\` (e.g. \`"codex"\`, \`"claude"\`, \`"default"\`) to route the sub-agent to a specific provider backend, and/or \`model\` to pin a model id. When omitted, the sub-agent uses the same provider/model as the parent session.
35
35
  - \`parallel(thunks): Promise<value[]>\` — run zero-arg async functions concurrently, await all.
36
36
  - \`phase(title)\`, \`log(message)\` — progress narration.
37
37
  - \`args\` — the args object passed to the workflow tool.
38
38
 
39
39
  The script CANNOT call tools directly. Subagents are the ones who call tools.
40
40
 
41
+ ### Sub-agent backend selection
42
+ Every \`agent()\` call may carry a backend hint via \`opts.provider\` / \`opts.model\`. Use it deliberately to balance cost, speed, and reliability:
43
+ - **Reviewers** (many, run in parallel, read-only, benefit from strict JSON): prefer a fast/cheap model when one is configured; otherwise omit the hint and inherit the session backend.
44
+ - **Fixers / aggregators** (few, must be reliable and follow tool results exactly): keep them on the parent's default backend unless a specific provider is known-good.
45
+ - **Never invent a provider/model name.** Pass a hint ONLY when the deployment actually registers that adapter (see \`ih config\` / the configured provider list). When in doubt, omit \`provider\`/\`model\` entirely — the sub-agent then runs on the same backend as the parent session, which is always a safe default.
46
+ - The optional \`args.subagentProvider\` / \`args.subagentModel\` allow the caller to override the whole run's sub-agent backend from the invocation; the canonical scripts below read them and spread the hint onto every spawned sub-agent (reviewers, fixers, validators, aggregators).
47
+
48
+ ### User-attached image evidence
49
+ The user may attach images to the conversation (UI screenshots, error dialogs, design references, logs-as-pictures). When they do:
50
+ - You see those images natively in the session. Capture their metadata and pass it into the workflow via \`args.attachments\` — an array of objects, each with optional \`name\`, \`mediaType\`, \`width\`, \`height\`, and a \`note\` describing what the image shows and why it matters for this review.
51
+ - The canonical scripts below read \`args.attachments\` and relay them into every reviewer prompt so reviewers treat the attached visuals as evidence (e.g. "the screenshot in this message shows the broken layout the review should reproduce").
52
+ - If a reviewer needs the images relayed explicitly, it can call \`iterate_context\` with \`attachments\` to get the normalized image descriptions in its context. Never fabricate an attachment — only relay images the user actually attached.
53
+
41
54
  ### Dry-run mode workflow (pure review — the ONLY mode that never touches files)
42
55
  This is iterate's read-only health-check: repeated review rounds until findings converge,
43
56
  then produce an auditable report, then audit the report itself (meta-review) and give a
@@ -47,9 +60,15 @@ Canonical script — reproduce this structure exactly (adjust dims via the plan)
47
60
 
48
61
  \`\`\`js
49
62
  phase('plan')
63
+ // Optional per-run backend override for sub-agents (omit to inherit the session backend).
64
+ const subAgentProvider = (args && args.subagentProvider) || undefined
65
+ const subAgentModel = (args && args.subagentModel) || undefined
66
+ const backend = Object.assign({}, subAgentProvider ? { provider: subAgentProvider } : {}, subAgentModel ? { model: subAgentModel } : {})
67
+ // User-attached image evidence relayed into reviewer prompts (metadata only).
68
+ const attachments = (args && Array.isArray(args.attachments)) ? args.attachments : []
50
69
  const planRes = await agent(
51
70
  'Call iterate_review({operation:"plan", mode:"dry-run"}) and return the plan JSON.',
52
- { label: 'review:plan' }
71
+ Object.assign({ label: 'review:plan' }, backend)
53
72
  )
54
73
  const plan = (planRes && planRes.plan) ? planRes.plan : null
55
74
  if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
@@ -71,16 +90,18 @@ for (let r = 1; r <= maxRounds; r++) {
71
90
  ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
72
91
  : ''
73
92
  const raw = await parallel(dims.map(dim => () => agent(
74
- 'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
93
+ 'Review dimension "' + dim + '".' +
94
+ (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
95
+ ' Already-known findings (do NOT re-report): ' +
75
96
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
76
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
97
+ Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
77
98
  )))
78
99
  const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
79
100
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
80
101
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
81
102
  agg = await agent(
82
103
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
83
- { label: 'review:aggregate:r' + r }
104
+ Object.assign({ label: 'review:aggregate:r' + r }, backend)
84
105
  )
85
106
  // reviewer.output_schema_validation (default on): aggregate returns per-round
86
107
  // schemaValidation; retry the just-finished round (≤2 times) when invalid.
@@ -104,20 +125,20 @@ for (let r = 1; r <= maxRounds; r++) {
104
125
  phase('report')
105
126
  const finalAgg = await agent(
106
127
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
107
- { label: 'review:aggregate:final' }
128
+ Object.assign({ label: 'review:aggregate:final' }, backend)
108
129
  )
109
130
  const report = (finalAgg && finalAgg.report) ? finalAgg.report : null
110
131
  if (!report || !report.convergence) throw new Error('aggregate failed: no valid report was produced')
111
132
  await agent(
112
133
  'Call iterate_decision_log({operation:"append", type:"report", round:' + report.convergence.totalRounds + ', data:{mode:"dry-run", totalFindings:' + report.summary.totalFindings + '}})',
113
- { label: 'review:log' }
134
+ Object.assign({ label: 'review:log' }, backend)
114
135
  )
115
136
 
116
137
  phase('meta-review')
117
138
  // Audit the report itself for internal consistency, then produce the final report.
118
139
  const metaRes = await agent(
119
140
  'Call iterate_review({operation:"meta-review", report:' + JSON.stringify(report) + '}) and return the finalReport JSON.',
120
- { label: 'review:meta' }
141
+ Object.assign({ label: 'review:meta' }, backend)
121
142
  )
122
143
  const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
123
144
  const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
@@ -153,26 +174,42 @@ Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N →
153
174
  Canonical script — reproduce this structure exactly (adjust dims via the plan):
154
175
 
155
176
  \`\`\`js
156
- // args = { mode: "normal", maxRounds? }
177
+ // args = { mode: "normal", maxRounds?, subagentProvider?, subagentModel?, attachments? }
178
+ // Optional per-run backend override for sub-agents (omit to inherit the session backend).
179
+ const subAgentProvider = (args && args.subagentProvider) || undefined
180
+ const subAgentModel = (args && args.subagentModel) || undefined
181
+ const backend = Object.assign({}, subAgentProvider ? { provider: subAgentProvider } : {}, subAgentModel ? { model: subAgentModel } : {})
182
+ // User-attached image evidence relayed into reviewer prompts (metadata only).
183
+ const attachments = (args && Array.isArray(args.attachments)) ? args.attachments : []
157
184
  phase('resume')
158
185
  // If a previous run was interrupted, resume from its checkpoint instead of restarting.
159
186
  const ckRes = await agent(
160
187
  'Call iterate_checkpoint({ operation: "load" }) and return the checkpoint JSON.',
161
- { label: 'checkpoint:load' }
188
+ Object.assign({ label: 'checkpoint:load' }, backend)
162
189
  )
163
190
  const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
164
191
  const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
192
+ // Track how many times this checkpoint has already been resumed (interruption recovery).
193
+ const resumeCount = (checkpoint && typeof checkpoint.resumeCount === 'number') ? checkpoint.resumeCount : 0
194
+ if (checkpoint) {
195
+ // A previous run left a checkpoint — record the recovery so the decision log
196
+ // shows the resume, then continue where it left off.
197
+ await agent(
198
+ 'Call iterate_decision_log({operation:"append", type:"resume", round:' + startRound + ', data:{resumedFromRound:' + checkpoint.round + ', resumeCount:' + (resumeCount + 1) + '}})',
199
+ Object.assign({ label: 'log:resume' }, backend)
200
+ )
201
+ }
165
202
 
166
203
  phase('plan')
167
204
  const configRes = await agent(
168
205
  'Call iterate_config({ validate: true }) and return the config JSON.',
169
- { label: 'config:read' }
206
+ Object.assign({ label: 'config:read' }, backend)
170
207
  )
171
208
  const cfg = (configRes && configRes.config) ? configRes.config : null
172
209
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
173
210
  const planRes = await agent(
174
211
  'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
175
- { label: 'review:plan' }
212
+ Object.assign({ label: 'review:plan' }, backend)
176
213
  )
177
214
  const plan = (planRes && planRes.plan) ? planRes.plan : null
178
215
  if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
@@ -199,8 +236,9 @@ for (let r = startRound; r <= maxRounds; r++) {
199
236
  : ''
200
237
  const raw = await parallel(dims.map(dim => () => agent(
201
238
  'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
239
+ (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
202
240
  'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
203
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
241
+ Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
204
242
  )))
205
243
  const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
206
244
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
@@ -210,7 +248,7 @@ for (let r = startRound; r <= maxRounds; r++) {
210
248
  // show a running "fixes applied" metric for normal mode.
211
249
  agg = await agent(
212
250
  'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
213
- { label: 'review:aggregate:r' + r }
251
+ Object.assign({ label: 'review:aggregate:r' + r }, backend)
214
252
  )
215
253
  // reviewer.output_schema_validation (default on): aggregate returns per-round
216
254
  // schemaValidation; retry the just-finished round (≤2 times) when invalid.
@@ -240,12 +278,12 @@ for (let r = startRound; r <= maxRounds; r++) {
240
278
  'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
241
279
  'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
242
280
  'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
243
- { label: 'fix:' + file, phase: 'fix', schema: {
281
+ Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
244
282
  type: 'object', additionalProperties: false,
245
283
  properties: {
246
284
  fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
247
285
  },
248
- required: ['fixes'] } }
286
+ required: ['fixes'] } }, backend)
249
287
  )))
250
288
  for (const res of fixRes) {
251
289
  if (res && Array.isArray(res.fixes)) {
@@ -267,12 +305,12 @@ for (let r = startRound; r <= maxRounds; r++) {
267
305
  const valRes = await agent(
268
306
  'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
269
307
  '(one tool call per command). Return all results as {command, exitCode} entries.',
270
- { label: 'validate:r' + r, phase: 'validate', schema: {
308
+ Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
271
309
  type: 'object', additionalProperties: false,
272
310
  properties: {
273
311
  results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
274
312
  },
275
- required: ['results'] } }
313
+ required: ['results'] } }, backend)
276
314
  )
277
315
  failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
278
316
  if (failedCommands.length > 0) {
@@ -281,17 +319,17 @@ for (let r = startRound; r <= maxRounds; r++) {
281
319
  if (roundFixIds.length > 0) {
282
320
  await agent(
283
321
  '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}.',
284
- { label: 'rollback:r' + r, phase: 'rollback', schema: {
322
+ Object.assign({ label: 'rollback:r' + r, phase: 'rollback', schema: {
285
323
  type: 'object', additionalProperties: false,
286
324
  properties: {
287
325
  results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
288
326
  },
289
- required: ['results'] } }
327
+ required: ['results'] } }, backend)
290
328
  )
291
329
  }
292
330
  await agent(
293
331
  'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{failedCommands:' + JSON.stringify(failedCommands) + ', rolledBack:' + roundFixIds.length + '}})',
294
- { label: 'log:failed:r' + r }
332
+ Object.assign({ label: 'log:failed:r' + r }, backend)
295
333
  )
296
334
  break
297
335
  }
@@ -299,13 +337,13 @@ for (let r = startRound; r <= maxRounds; r++) {
299
337
  await agent(
300
338
  'Call iterate_decision_log({operation:"append", type:"review_result", round:' + r +
301
339
  ', data:{atomic:' + atomic.length + ', architectural:' + remaining.length + ', fixedSoFar:' + fixedCount + '}})',
302
- { label: 'log:r' + r }
340
+ Object.assign({ label: 'log:r' + r }, backend)
303
341
  )
304
342
 
305
343
  // Persist progress so an interrupted run can resume from the next round.
306
344
  await agent(
307
- '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.',
308
- { label: 'checkpoint:save:r' + r }
345
+ 'Call iterate_checkpoint({ operation: "save", mode: "normal", round:' + r + ', maxRounds:' + maxRounds + ', fixedCount:' + fixedCount + ', architecturalCount:' + architectural.length + ', resumeCount:' + resumeCount + ', findings:' + JSON.stringify(architectural) + ' }) and return the checkpoint JSON.',
346
+ Object.assign({ label: 'checkpoint:save:r' + r }, backend)
309
347
  )
310
348
 
311
349
  if (atomic.length === 0 && remaining.length === 0) {
@@ -42,6 +42,7 @@ export function validateCheckpoint(input: {
42
42
  maxRounds: unknown
43
43
  fixedCount: unknown
44
44
  architecturalCount: unknown
45
+ resumeCount?: unknown
45
46
  }): string | null {
46
47
  if (input.mode !== 'dry-run' && input.mode !== 'normal') {
47
48
  return 'mode must be "dry-run" or "normal"'
@@ -58,6 +59,12 @@ export function validateCheckpoint(input: {
58
59
  if (typeof input.architecturalCount !== 'number' || !Number.isInteger(input.architecturalCount) || input.architecturalCount < 0) {
59
60
  return 'architecturalCount must be a non-negative integer'
60
61
  }
62
+ if (
63
+ input.resumeCount !== undefined &&
64
+ (typeof input.resumeCount !== 'number' || !Number.isInteger(input.resumeCount) || input.resumeCount < 0)
65
+ ) {
66
+ return 'resumeCount must be a non-negative integer'
67
+ }
61
68
  return null
62
69
  }
63
70
 
@@ -103,6 +110,10 @@ export function computeStatus(input: {
103
110
  findingsCount: checkpoint?.findings.length ?? 0,
104
111
  totalDecisionLogEntries: entries.length,
105
112
  hasCheckpoint: checkpoint !== null,
113
+ // A checkpoint left on disk means the previous run was interrupted before
114
+ // it could clear it — this is the durable "interruption" signal.
115
+ interrupted: checkpoint !== null,
116
+ resumeCount: checkpoint?.resumeCount ?? 0,
106
117
  checkpoint,
107
118
  lastUpdated,
108
119
  }
@@ -133,6 +144,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
133
144
  maxRounds: { type: 'integer', description: 'Required for save: total round cap.' },
134
145
  fixedCount: { type: 'integer', description: 'Required for save: number of fixes applied so far.' },
135
146
  architecturalCount: { type: 'integer', description: 'Required for save: architectural findings left unfixed.' },
147
+ resumeCount: { type: 'integer', description: 'Optional for save: how many times this checkpoint has already been resumed after an interruption (default 0).' },
136
148
  findings: { type: 'json', description: 'Optional for save: the current deduped findings to resume from.' },
137
149
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
138
150
  },
@@ -181,6 +193,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
181
193
  maxRounds: args.maxRounds,
182
194
  fixedCount: args.fixedCount,
183
195
  architecturalCount: args.architecturalCount,
196
+ resumeCount: args.resumeCount,
184
197
  })
185
198
  if (invalid) return { operation: 'save', ok: false, error: invalid }
186
199
  const checkpoint: IterationCheckpoint = {
@@ -189,6 +202,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
189
202
  maxRounds: args.maxRounds as number,
190
203
  fixedCount: args.fixedCount as number,
191
204
  architecturalCount: args.architecturalCount as number,
205
+ resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
192
206
  findings: (Array.isArray(args.findings) ? args.findings : []) as unknown as IterationCheckpoint['findings'],
193
207
  startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
194
208
  updatedAt: new Date().toISOString(),
@@ -239,6 +253,8 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
239
253
  findingsCount: { type: 'integer' },
240
254
  totalDecisionLogEntries: { type: 'integer' },
241
255
  hasCheckpoint: { type: 'boolean' },
256
+ interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
257
+ resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
242
258
  lastUpdated: { type: 'string' },
243
259
  error: { type: 'string' },
244
260
  },
@@ -251,7 +267,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
251
267
  `Fixed: ${value.fixedCount} · Architectural remaining: ${value.architecturalCount}`,
252
268
  `Findings in checkpoint: ${value.findingsCount}`,
253
269
  `Decision-log entries: ${value.totalDecisionLogEntries}`,
254
- `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}`,
270
+ `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
255
271
  value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
256
272
  ]
257
273
  return [{ type: 'text', text: lines.filter(Boolean).join('\n') }]
@@ -277,6 +293,8 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
277
293
  findingsCount: status.findingsCount,
278
294
  totalDecisionLogEntries: status.totalDecisionLogEntries,
279
295
  hasCheckpoint: status.hasCheckpoint,
296
+ interrupted: status.interrupted,
297
+ resumeCount: status.resumeCount,
280
298
  lastUpdated: status.lastUpdated ?? undefined,
281
299
  }
282
300
  },