iterate-plugin 2.8.3 → 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.
@@ -12,14 +12,14 @@ You have the iterate plugin installed, which registers these tools:
12
12
  - \`iterate_config\` — read iterate.config.yaml (dimensions, validation commands, personalization) or write a validated partial update (operation:"write", with automatic backup + rollback)
13
13
  - \`iterate_validate\` — run a whitelisted validation command
14
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
15
+ - \`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
16
16
  - \`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.
17
17
  - \`iterate_triage\` — manage "known_intentional" entries in the config (list / apply, with dedupe + backup + rollback)
18
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
19
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
20
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
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
22
+ - \`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)
23
23
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
24
24
  - \`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.
25
25
 
@@ -30,13 +30,26 @@ When the user asks to review or iterate on the project (e.g. "review this projec
30
30
 
31
31
  ### Workflow script contract
32
32
  Write a plain-JS script (top-level await, ends with \`return <json>\`). Available globals:
33
- - \`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\`.
33
+ - \`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.
34
34
  - \`parallel(thunks): Promise<value[]>\` — run zero-arg async functions concurrently, await all.
35
35
  - \`phase(title)\`, \`log(message)\` — progress narration.
36
36
  - \`args\` — the args object passed to the workflow tool.
37
37
 
38
38
  The script CANNOT call tools directly. Subagents are the ones who call tools.
39
39
 
40
+ ### Sub-agent backend selection
41
+ Every \`agent()\` call may carry a backend hint via \`opts.provider\` / \`opts.model\`. Use it deliberately to balance cost, speed, and reliability:
42
+ - **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.
43
+ - **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.
44
+ - **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.
45
+ - 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).
46
+
47
+ ### User-attached image evidence
48
+ The user may attach images to the conversation (UI screenshots, error dialogs, design references, logs-as-pictures). When they do:
49
+ - 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.
50
+ - 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").
51
+ - 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.
52
+
40
53
  ### Dry-run mode workflow (pure review — the ONLY mode that never touches files)
41
54
  This is iterate's read-only health-check: repeated review rounds until findings converge,
42
55
  then produce an auditable report, then audit the report itself (meta-review) and give a
@@ -46,9 +59,15 @@ Canonical script — reproduce this structure exactly (adjust dims via the plan)
46
59
 
47
60
  \`\`\`js
48
61
  phase('plan')
62
+ // Optional per-run backend override for sub-agents (omit to inherit the session backend).
63
+ const subAgentProvider = (args && args.subagentProvider) || undefined
64
+ const subAgentModel = (args && args.subagentModel) || undefined
65
+ const backend = Object.assign({}, subAgentProvider ? { provider: subAgentProvider } : {}, subAgentModel ? { model: subAgentModel } : {})
66
+ // User-attached image evidence relayed into reviewer prompts (metadata only).
67
+ const attachments = (args && Array.isArray(args.attachments)) ? args.attachments : []
49
68
  const planRes = await agent(
50
69
  'Call iterate_review({operation:"plan", mode:"dry-run"}) and return the plan JSON.',
51
- { label: 'review:plan' }
70
+ Object.assign({ label: 'review:plan' }, backend)
52
71
  )
53
72
  const plan = (planRes && planRes.plan) ? planRes.plan : null
54
73
  if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
@@ -70,16 +89,18 @@ for (let r = 1; r <= maxRounds; r++) {
70
89
  ? '\\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).'
71
90
  : ''
72
91
  const raw = await parallel(dims.map(dim => () => agent(
73
- 'Review dimension "' + dim + '". Already-known findings (do NOT re-report): ' +
92
+ 'Review dimension "' + dim + '".' +
93
+ (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
94
+ ' Already-known findings (do NOT re-report): ' +
74
95
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
75
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
96
+ Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
76
97
  )))
77
98
  const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
78
99
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
79
100
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
80
101
  agg = await agent(
81
102
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
82
- { label: 'review:aggregate:r' + r }
103
+ Object.assign({ label: 'review:aggregate:r' + r }, backend)
83
104
  )
84
105
  // reviewer.output_schema_validation (default on): aggregate returns per-round
85
106
  // schemaValidation; retry the just-finished round (≤2 times) when invalid.
@@ -103,20 +124,20 @@ for (let r = 1; r <= maxRounds; r++) {
103
124
  phase('report')
104
125
  const finalAgg = await agent(
105
126
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
106
- { label: 'review:aggregate:final' }
127
+ Object.assign({ label: 'review:aggregate:final' }, backend)
107
128
  )
108
129
  const report = (finalAgg && finalAgg.report) ? finalAgg.report : null
109
130
  if (!report || !report.convergence) throw new Error('aggregate failed: no valid report was produced')
110
131
  await agent(
111
132
  'Call iterate_decision_log({operation:"append", type:"report", round:' + report.convergence.totalRounds + ', data:{mode:"dry-run", totalFindings:' + report.summary.totalFindings + '}})',
112
- { label: 'review:log' }
133
+ Object.assign({ label: 'review:log' }, backend)
113
134
  )
114
135
 
115
136
  phase('meta-review')
116
137
  // Audit the report itself for internal consistency, then produce the final report.
117
138
  const metaRes = await agent(
118
139
  'Call iterate_review({operation:"meta-review", report:' + JSON.stringify(report) + '}) and return the finalReport JSON.',
119
- { label: 'review:meta' }
140
+ Object.assign({ label: 'review:meta' }, backend)
120
141
  )
121
142
  const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
122
143
  const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
@@ -152,26 +173,42 @@ Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N →
152
173
  Canonical script — reproduce this structure exactly (adjust dims via the plan):
153
174
 
154
175
  \`\`\`js
155
- // args = { mode: "normal", maxRounds? }
176
+ // args = { mode: "normal", maxRounds?, subagentProvider?, subagentModel?, attachments? }
177
+ // Optional per-run backend override for sub-agents (omit to inherit the session backend).
178
+ const subAgentProvider = (args && args.subagentProvider) || undefined
179
+ const subAgentModel = (args && args.subagentModel) || undefined
180
+ const backend = Object.assign({}, subAgentProvider ? { provider: subAgentProvider } : {}, subAgentModel ? { model: subAgentModel } : {})
181
+ // User-attached image evidence relayed into reviewer prompts (metadata only).
182
+ const attachments = (args && Array.isArray(args.attachments)) ? args.attachments : []
156
183
  phase('resume')
157
184
  // If a previous run was interrupted, resume from its checkpoint instead of restarting.
158
185
  const ckRes = await agent(
159
186
  'Call iterate_checkpoint({ operation: "load" }) and return the checkpoint JSON.',
160
- { label: 'checkpoint:load' }
187
+ Object.assign({ label: 'checkpoint:load' }, backend)
161
188
  )
162
189
  const checkpoint = (ckRes && ckRes.checkpoint) ? ckRes.checkpoint : null
163
190
  const startRound = (checkpoint && typeof checkpoint.round === 'number') ? checkpoint.round + 1 : 1
191
+ // Track how many times this checkpoint has already been resumed (interruption recovery).
192
+ const resumeCount = (checkpoint && typeof checkpoint.resumeCount === 'number') ? checkpoint.resumeCount : 0
193
+ if (checkpoint) {
194
+ // A previous run left a checkpoint — record the recovery so the decision log
195
+ // shows the resume, then continue where it left off.
196
+ await agent(
197
+ 'Call iterate_decision_log({operation:"append", type:"resume", round:' + startRound + ', data:{resumedFromRound:' + checkpoint.round + ', resumeCount:' + (resumeCount + 1) + '}})',
198
+ Object.assign({ label: 'log:resume' }, backend)
199
+ )
200
+ }
164
201
 
165
202
  phase('plan')
166
203
  const configRes = await agent(
167
204
  'Call iterate_config({ validate: true }) and return the config JSON.',
168
- { label: 'config:read' }
205
+ Object.assign({ label: 'config:read' }, backend)
169
206
  )
170
207
  const cfg = (configRes && configRes.config) ? configRes.config : null
171
208
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
172
209
  const planRes = await agent(
173
210
  'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
174
- { label: 'review:plan' }
211
+ Object.assign({ label: 'review:plan' }, backend)
175
212
  )
176
213
  const plan = (planRes && planRes.plan) ? planRes.plan : null
177
214
  if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
@@ -198,8 +235,9 @@ for (let r = startRound; r <= maxRounds; r++) {
198
235
  : ''
199
236
  const raw = await parallel(dims.map(dim => () => agent(
200
237
  'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
238
+ (attachments.length > 0 ? ' User-attached images are part of the evidence (reproduce/verify against them): ' + JSON.stringify(attachments) + '.' : '') +
201
239
  'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
202
- { label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }
240
+ Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
203
241
  )))
204
242
  const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
205
243
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
@@ -209,7 +247,7 @@ for (let r = startRound; r <= maxRounds; r++) {
209
247
  // show a running "fixes applied" metric for normal mode.
210
248
  agg = await agent(
211
249
  'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + ', fixedCount:' + fixedCount + '}) and return the report JSON.',
212
- { label: 'review:aggregate:r' + r }
250
+ Object.assign({ label: 'review:aggregate:r' + r }, backend)
213
251
  )
214
252
  // reviewer.output_schema_validation (default on): aggregate returns per-round
215
253
  // schemaValidation; retry the just-finished round (≤2 times) when invalid.
@@ -239,12 +277,12 @@ for (let r = startRound; r <= maxRounds; r++) {
239
277
  'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
240
278
  'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
241
279
  'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
242
- { label: 'fix:' + file, phase: 'fix', schema: {
280
+ Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
243
281
  type: 'object', additionalProperties: false,
244
282
  properties: {
245
283
  fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
246
284
  },
247
- required: ['fixes'] } }
285
+ required: ['fixes'] } }, backend)
248
286
  )))
249
287
  for (const res of fixRes) {
250
288
  if (res && Array.isArray(res.fixes)) {
@@ -266,12 +304,12 @@ for (let r = startRound; r <= maxRounds; r++) {
266
304
  const valRes = await agent(
267
305
  'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
268
306
  '(one tool call per command). Return all results as {command, exitCode} entries.',
269
- { label: 'validate:r' + r, phase: 'validate', schema: {
307
+ Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
270
308
  type: 'object', additionalProperties: false,
271
309
  properties: {
272
310
  results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
273
311
  },
274
- required: ['results'] } }
312
+ required: ['results'] } }, backend)
275
313
  )
276
314
  failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
277
315
  if (failedCommands.length > 0) {
@@ -280,17 +318,17 @@ for (let r = startRound; r <= maxRounds; r++) {
280
318
  if (roundFixIds.length > 0) {
281
319
  await agent(
282
320
  '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}.',
283
- { label: 'rollback:r' + r, phase: 'rollback', schema: {
321
+ Object.assign({ label: 'rollback:r' + r, phase: 'rollback', schema: {
284
322
  type: 'object', additionalProperties: false,
285
323
  properties: {
286
324
  results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
287
325
  },
288
- required: ['results'] } }
326
+ required: ['results'] } }, backend)
289
327
  )
290
328
  }
291
329
  await agent(
292
330
  'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{failedCommands:' + JSON.stringify(failedCommands) + ', rolledBack:' + roundFixIds.length + '}})',
293
- { label: 'log:failed:r' + r }
331
+ Object.assign({ label: 'log:failed:r' + r }, backend)
294
332
  )
295
333
  break
296
334
  }
@@ -298,13 +336,13 @@ for (let r = startRound; r <= maxRounds; r++) {
298
336
  await agent(
299
337
  'Call iterate_decision_log({operation:"append", type:"review_result", round:' + r +
300
338
  ', data:{atomic:' + atomic.length + ', architectural:' + remaining.length + ', fixedSoFar:' + fixedCount + '}})',
301
- { label: 'log:r' + r }
339
+ Object.assign({ label: 'log:r' + r }, backend)
302
340
  )
303
341
 
304
342
  // Persist progress so an interrupted run can resume from the next round.
305
343
  await agent(
306
- '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.',
307
- { label: 'checkpoint:save:r' + r }
344
+ '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.',
345
+ Object.assign({ label: 'checkpoint:save:r' + r }, backend)
308
346
  )
309
347
 
310
348
  if (atomic.length === 0 && remaining.length === 0) {
@@ -51,6 +51,10 @@ export function validateCheckpoint(input) {
51
51
  if (typeof input.architecturalCount !== 'number' || !Number.isInteger(input.architecturalCount) || input.architecturalCount < 0) {
52
52
  return 'architecturalCount must be a non-negative integer';
53
53
  }
54
+ if (input.resumeCount !== undefined &&
55
+ (typeof input.resumeCount !== 'number' || !Number.isInteger(input.resumeCount) || input.resumeCount < 0)) {
56
+ return 'resumeCount must be a non-negative integer';
57
+ }
54
58
  return null;
55
59
  }
56
60
  /**
@@ -88,6 +92,10 @@ export function computeStatus(input) {
88
92
  findingsCount: checkpoint?.findings.length ?? 0,
89
93
  totalDecisionLogEntries: entries.length,
90
94
  hasCheckpoint: checkpoint !== null,
95
+ // A checkpoint left on disk means the previous run was interrupted before
96
+ // it could clear it — this is the durable "interruption" signal.
97
+ interrupted: checkpoint !== null,
98
+ resumeCount: checkpoint?.resumeCount ?? 0,
91
99
  checkpoint,
92
100
  lastUpdated,
93
101
  };
@@ -114,6 +122,7 @@ export function registerCheckpointTool(ctx) {
114
122
  maxRounds: { type: 'integer', description: 'Required for save: total round cap.' },
115
123
  fixedCount: { type: 'integer', description: 'Required for save: number of fixes applied so far.' },
116
124
  architecturalCount: { type: 'integer', description: 'Required for save: architectural findings left unfixed.' },
125
+ resumeCount: { type: 'integer', description: 'Optional for save: how many times this checkpoint has already been resumed after an interruption (default 0).' },
117
126
  findings: { type: 'json', description: 'Optional for save: the current deduped findings to resume from.' },
118
127
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
119
128
  },
@@ -161,6 +170,7 @@ export function registerCheckpointTool(ctx) {
161
170
  maxRounds: args.maxRounds,
162
171
  fixedCount: args.fixedCount,
163
172
  architecturalCount: args.architecturalCount,
173
+ resumeCount: args.resumeCount,
164
174
  });
165
175
  if (invalid)
166
176
  return { operation: 'save', ok: false, error: invalid };
@@ -170,6 +180,7 @@ export function registerCheckpointTool(ctx) {
170
180
  maxRounds: args.maxRounds,
171
181
  fixedCount: args.fixedCount,
172
182
  architecturalCount: args.architecturalCount,
183
+ resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
173
184
  findings: (Array.isArray(args.findings) ? args.findings : []),
174
185
  startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
175
186
  updatedAt: new Date().toISOString(),
@@ -214,6 +225,8 @@ export function registerStatusTool(ctx) {
214
225
  findingsCount: { type: 'integer' },
215
226
  totalDecisionLogEntries: { type: 'integer' },
216
227
  hasCheckpoint: { type: 'boolean' },
228
+ interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
229
+ resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
217
230
  lastUpdated: { type: 'string' },
218
231
  error: { type: 'string' },
219
232
  },
@@ -227,7 +240,7 @@ export function registerStatusTool(ctx) {
227
240
  `Fixed: ${value.fixedCount} · Architectural remaining: ${value.architecturalCount}`,
228
241
  `Findings in checkpoint: ${value.findingsCount}`,
229
242
  `Decision-log entries: ${value.totalDecisionLogEntries}`,
230
- `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}`,
243
+ `Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
231
244
  value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
232
245
  ];
233
246
  return [{ type: 'text', text: lines.filter(Boolean).join('\n') }];
@@ -253,6 +266,8 @@ export function registerStatusTool(ctx) {
253
266
  findingsCount: status.findingsCount,
254
267
  totalDecisionLogEntries: status.totalDecisionLogEntries,
255
268
  hasCheckpoint: status.hasCheckpoint,
269
+ interrupted: status.interrupted,
270
+ resumeCount: status.resumeCount,
256
271
  lastUpdated: status.lastUpdated ?? undefined,
257
272
  };
258
273
  },
@@ -5,6 +5,87 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
5
5
  import { resolveProjectRoot } from "../config-loader.js";
6
6
  /** How many ancestor directories we walk up looking for a SKILL.md. */
7
7
  const MAX_SKILL_DIR_LOOKUP_DEPTH = 12;
8
+ /** Maximum number of image attachments relayed into the context in one call. */
9
+ const MAX_ATTACHMENTS = 8;
10
+ /**
11
+ * Validate and normalize one raw image-attachment entry passed by the
12
+ * orchestrator. The top-level model observes user-attached images in its own
13
+ * context (as image blocks) and relays their metadata here so reviewers get the
14
+ * same visual evidence. Pure — exported for unit tests.
15
+ */
16
+ export function normalizeAttachment(raw) {
17
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
18
+ return { ok: false, error: 'attachment must be an object' };
19
+ }
20
+ const entry = raw;
21
+ const out = {};
22
+ if (entry.name !== undefined) {
23
+ if (typeof entry.name !== 'string' || entry.name.length > 256) {
24
+ return { ok: false, error: 'attachment.name must be a string (≤ 256 chars)' };
25
+ }
26
+ out.name = entry.name;
27
+ }
28
+ if (entry.mediaType !== undefined) {
29
+ if (typeof entry.mediaType !== 'string' || !/^image\/(png|jpeg|webp|gif)$/.test(entry.mediaType)) {
30
+ return { ok: false, error: 'attachment.mediaType must be image/png, image/jpeg, image/webp, or image/gif' };
31
+ }
32
+ out.mediaType = entry.mediaType;
33
+ }
34
+ for (const dim of ['width', 'height']) {
35
+ if (entry[dim] !== undefined) {
36
+ if (typeof entry[dim] !== 'number' || !Number.isInteger(entry[dim]) || entry[dim] < 0 || entry[dim] > 16384) {
37
+ return { ok: false, error: `attachment.${dim} must be an integer in [0, 16384]` };
38
+ }
39
+ out[dim] = entry[dim];
40
+ }
41
+ }
42
+ if (entry.note !== undefined) {
43
+ if (typeof entry.note !== 'string' || entry.note.length > 1000) {
44
+ return { ok: false, error: 'attachment.note must be a string (≤ 1000 chars)' };
45
+ }
46
+ out.note = entry.note;
47
+ }
48
+ return { ok: true, value: out };
49
+ }
50
+ /**
51
+ * Validate a whole attachments array, dropping invalid entries.
52
+ * Returns the normalized list plus the reasons for any dropped entries.
53
+ */
54
+ export function normalizeAttachments(raw) {
55
+ const attachments = [];
56
+ const errors = [];
57
+ if (raw === undefined || raw === null)
58
+ return { attachments, errors };
59
+ if (!Array.isArray(raw))
60
+ return { attachments, errors: ['attachments must be an array'] };
61
+ for (let i = 0; i < raw.length; i++) {
62
+ if (attachments.length >= MAX_ATTACHMENTS) {
63
+ errors.push(`attachments capped at ${MAX_ATTACHMENTS}; entry ${i} dropped`);
64
+ break;
65
+ }
66
+ const result = normalizeAttachment(raw[i]);
67
+ if (result.ok)
68
+ attachments.push(result.value);
69
+ else
70
+ errors.push(`attachments[${i}]: ${result.error}`);
71
+ }
72
+ return { attachments, errors };
73
+ }
74
+ /**
75
+ * Render a normalized attachment as a compact text block for the model.
76
+ */
77
+ export function renderAttachment(a, index) {
78
+ const bits = [`[${index + 1}]`];
79
+ if (a.name)
80
+ bits.push(a.name);
81
+ if (a.mediaType)
82
+ bits.push(a.mediaType);
83
+ if (typeof a.width === 'number' && typeof a.height === 'number')
84
+ bits.push(`${a.width}x${a.height}`);
85
+ if (a.note)
86
+ bits.push(a.note);
87
+ return bits.join(' · ');
88
+ }
8
89
  /**
9
90
  * The directory this source file lives in (…/src/tools). The plugin's own
10
91
  * package root is one level up (…/src), and the skill root is typically a few
@@ -82,7 +163,9 @@ export function registerContextTool(ctx) {
82
163
  'SKILL.md contains the original iterate skill instructions; it is searched in ' +
83
164
  'the skill directory (auto-detected), the project root, or an explicit `skillDir`. ' +
84
165
  'ITERATE.md contains the project-specific knowledge base and onboarding information. ' +
85
- 'Use this to understand the skill workflow and project context.',
166
+ 'Also relays user-attached image metadata (e.g. UI screenshots, error dialogs) into ' +
167
+ 'the review context so reviewers can treat them as visual evidence. ' +
168
+ 'Use this to understand the skill workflow, project context, and any attached visuals.',
86
169
  parameters: {
87
170
  files: {
88
171
  type: 'string',
@@ -98,6 +181,24 @@ export function registerContextTool(ctx) {
98
181
  description: 'Custom directory to search for SKILL.md (highest priority). ' +
99
182
  'When omitted, SKILL.md is auto-detected from the skill directory, then the project root.',
100
183
  },
184
+ attachments: {
185
+ type: 'array',
186
+ items: {
187
+ type: 'object',
188
+ additionalProperties: false,
189
+ properties: {
190
+ name: { type: 'string', description: 'Optional display name of the attached image.' },
191
+ mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], description: 'Optional media type of the image.' },
192
+ width: { type: 'integer', description: 'Optional intrinsic width in pixels.' },
193
+ height: { type: 'integer', description: 'Optional intrinsic height in pixels.' },
194
+ note: { type: 'string', description: 'Optional short description of what the image shows and why it matters for this review.' },
195
+ },
196
+ },
197
+ description: 'Optional: image attachments observed in the session (e.g. UI screenshots, error ' +
198
+ 'dialogs, design references) relayed into the review context. The top-level model ' +
199
+ 'sees these images natively and passes their metadata here so reviewers get the same ' +
200
+ 'visual evidence. Up to 8 entries; invalid entries are dropped and reported.',
201
+ },
101
202
  },
102
203
  output: {
103
204
  schema: {
@@ -110,6 +211,8 @@ export function registerContextTool(ctx) {
110
211
  skillSource: { oneOf: [{ type: 'string' }, { type: 'null' }] },
111
212
  error: { type: 'string' },
112
213
  searched: { type: 'array', items: { type: 'string' } },
214
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Normalized attached-image descriptions relayed to reviewers.' },
215
+ attachmentErrors: { type: 'array', items: { type: 'string' }, description: 'Reasons for any attachment entries that were dropped.' },
113
216
  },
114
217
  },
115
218
  render: (_args, value) => {
@@ -118,9 +221,15 @@ export function registerContextTool(ctx) {
118
221
  parts.push(`--- SKILL.md (${value.skillSource ?? '?source?'}) ---\n${value.skill}`);
119
222
  if (value.project)
120
223
  parts.push(`--- ITERATE.md ---\n${value.project}`);
121
- if (!value.skill && !value.project) {
224
+ if (Array.isArray(value.attachments) && value.attachments.length > 0) {
225
+ parts.push(`--- User-attached images (${value.attachments.length}) ---\n${value.attachments.join('\n')}`);
226
+ }
227
+ if (!value.skill && !value.project && !(Array.isArray(value.attachments) && value.attachments.length > 0)) {
122
228
  parts.push('No files found. Searched: ' + (value.searched?.join(', ') ?? 'none'));
123
229
  }
230
+ if (Array.isArray(value.attachmentErrors) && value.attachmentErrors.length > 0) {
231
+ parts.push('Attachment warnings: ' + value.attachmentErrors.join('; '));
232
+ }
124
233
  return [{ type: 'text', text: parts.join('\n\n') }];
125
234
  },
126
235
  },
@@ -135,6 +244,16 @@ export function registerContextTool(ctx) {
135
244
  .map((s) => s.trim().toLowerCase())
136
245
  .filter(Boolean);
137
246
  const result = { found: true, searched: [] };
247
+ // Relay user-attached image metadata into the review context. The
248
+ // orchestrator observes attached images in the session and passes their
249
+ // metadata here; invalid entries are dropped with a reported reason.
250
+ const attachments = normalizeAttachments(args.attachments);
251
+ if (attachments.attachments.length > 0) {
252
+ result.attachments = attachments.attachments.map(renderAttachment);
253
+ }
254
+ if (attachments.errors.length > 0) {
255
+ result.attachmentErrors = attachments.errors;
256
+ }
138
257
  if (requested.includes('skill') || requested.includes('skill.md')) {
139
258
  // Candidate dirs in priority order: custom path → auto-detected skill
140
259
  // root → project root. This is how "skill 目录、项目根、自定义路径"
@@ -15,6 +15,7 @@ const VALID_ENTRY_TYPES = new Set([
15
15
  'validation',
16
16
  'decision',
17
17
  'report',
18
+ 'resume',
18
19
  ]);
19
20
  /**
20
21
  * Validate a candidate (type, round, data) triple for an append operation.
@@ -101,7 +102,7 @@ export function registerDecisionLogTool(ctx) {
101
102
  type: {
102
103
  type: 'string',
103
104
  description: 'Entry type (required for append): round_start, review_result, atomic_fix, ' +
104
- 'architectural_fix, revert, round_failed, validation, decision, report.',
105
+ 'architectural_fix, revert, round_failed, validation, decision, report, resume.',
105
106
  enum: [
106
107
  'round_start',
107
108
  'review_result',
@@ -112,6 +113,7 @@ export function registerDecisionLogTool(ctx) {
112
113
  'validation',
113
114
  'decision',
114
115
  'report',
116
+ 'resume',
115
117
  ],
116
118
  },
117
119
  round: {
package/lib/client.js CHANGED
@@ -43,6 +43,101 @@ var SEVERITY_COLOR = {
43
43
  medium: "#eab308",
44
44
  low: "#6b7280"
45
45
  };
46
+ function scanSessionForResume(obj, seen, maxDepth = 20) {
47
+ if (maxDepth <= 0) return 0;
48
+ if (!obj || typeof obj !== "object") return 0;
49
+ const s = seen || /* @__PURE__ */ new Set();
50
+ if (s.has(obj)) return 0;
51
+ s.add(obj);
52
+ let best = 0;
53
+ const direct = (
54
+ /** @type {Record<string, unknown>} */
55
+ obj
56
+ );
57
+ if (direct.type === "resume") {
58
+ const data = (
59
+ /** @type {Record<string, unknown>} */
60
+ direct.data || {}
61
+ );
62
+ if (typeof data.resumeCount === "number" && data.resumeCount > best) {
63
+ best = data.resumeCount;
64
+ }
65
+ }
66
+ if (direct.entry && typeof direct.entry === "object") {
67
+ const entry = (
68
+ /** @type {Record<string, unknown>} */
69
+ direct.entry
70
+ );
71
+ if (entry.type === "resume") {
72
+ const data = (
73
+ /** @type {Record<string, unknown>} */
74
+ entry.data || {}
75
+ );
76
+ if (typeof data.resumeCount === "number" && data.resumeCount > best) {
77
+ best = data.resumeCount;
78
+ }
79
+ }
80
+ }
81
+ if (Array.isArray(obj)) {
82
+ for (const item of obj) {
83
+ const found = scanSessionForResume(item, s, maxDepth - 1);
84
+ if (found > best) best = found;
85
+ }
86
+ return best;
87
+ }
88
+ for (const key of Object.keys(direct)) {
89
+ const val = direct[key];
90
+ if (val && typeof val === "object") {
91
+ const found = scanSessionForResume(val, s, maxDepth - 1);
92
+ if (found > best) best = found;
93
+ }
94
+ }
95
+ return best;
96
+ }
97
+ function countSessionImages(session) {
98
+ if (!session || typeof session !== "object") return 0;
99
+ const ids = /* @__PURE__ */ new Set();
100
+ let count = 0;
101
+ const walk = (obj, depth) => {
102
+ if (depth <= 0 || !obj || typeof obj !== "object") return;
103
+ if (seen.has(obj)) return;
104
+ seen.add(obj);
105
+ const o = (
106
+ /** @type {Record<string, unknown>} */
107
+ obj
108
+ );
109
+ let ref = null;
110
+ if (o.type === "image" && o.attachment && typeof o.attachment === "object") {
111
+ ref = /** @type {Record<string, unknown>} */
112
+ o.attachment;
113
+ }
114
+ if (!ref && typeof o.mediaType === "string" && String(o.mediaType).startsWith("image/")) {
115
+ ref = o;
116
+ }
117
+ if (ref) {
118
+ const id = typeof ref.attachmentId === "string" ? ref.attachmentId : null;
119
+ if (id) {
120
+ if (!ids.has(id)) {
121
+ ids.add(id);
122
+ count += 1;
123
+ }
124
+ } else {
125
+ count += 1;
126
+ }
127
+ }
128
+ if (Array.isArray(obj)) {
129
+ for (const item of obj) walk(item, depth - 1);
130
+ return;
131
+ }
132
+ for (const key of Object.keys(o)) {
133
+ const val = o[key];
134
+ if (val && typeof val === "object") walk(val, depth - 1);
135
+ }
136
+ };
137
+ const seen = /* @__PURE__ */ new Set();
138
+ walk(session, 12);
139
+ return count;
140
+ }
46
141
  function isReviewReport(obj) {
47
142
  if (!obj || typeof obj !== "object") return false;
48
143
  const o = (
@@ -827,6 +922,10 @@ var ITERATE_CSS = `
827
922
  .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); }
828
923
  .iterate-pill-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
829
924
 
925
+ /* Interruption / resume + attachment chips (dashboard) */
926
+ .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); }
927
+ .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); }
928
+
830
929
  /* Accessibility-switch toggle */
831
930
  .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; }
832
931
  .iterate-switch:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; }
@@ -1010,6 +1109,18 @@ function ConvergenceDashboard(props) {
1010
1109
  const stats = severityStats(report);
1011
1110
  const dims = groupByDimension(report);
1012
1111
  const trend = computeTrendMetrics(report);
1112
+ const resumeCount = scanSessionForResume(session);
1113
+ const imageCount = countSessionImages(session);
1114
+ const resumeChip = resumeCount > 0 ? React.createElement("span", {
1115
+ className: "iterate-chip-resume",
1116
+ key: "resume",
1117
+ title: "\u672C\u6B21\u8FED\u4EE3\u4ECE\u4E0A\u4E00\u6B21\u4E2D\u65AD\u7684\u65AD\u70B9\u7EE7\u7EED\u6267\u884C"
1118
+ }, `\u5DF2\u4E2D\u65AD\u6062\u590D \xD7${String(resumeCount)}`) : null;
1119
+ const imageChip = imageCount > 0 ? React.createElement("span", {
1120
+ className: "iterate-chip-images",
1121
+ key: "images",
1122
+ title: "\u4F1A\u8BDD\u4E2D\u68C0\u6D4B\u5230\u7528\u6237\u9644\u5E26\u7684\u56FE\u7247\uFF0C\u8BC4\u5BA1\u5C06\u4F5C\u4E3A\u89C6\u89C9\u8BC1\u636E\u53C2\u8003"
1123
+ }, `\u9644\u4EF6\u56FE\u7247 ${String(imageCount)}`) : null;
1013
1124
  const dimBadges = Object.keys(dims).slice(0, 6).map(
1014
1125
  (dim) => React.createElement(
1015
1126
  "span",
@@ -1058,6 +1169,8 @@ function ConvergenceDashboard(props) {
1058
1169
  stats.medium
1059
1170
  ),
1060
1171
  fixBadge,
1172
+ resumeChip,
1173
+ imageChip,
1061
1174
  React.createElement(TrendChart, { points: trend.points }),
1062
1175
  ...dimBadges
1063
1176
  );