iterate-plugin 2.8.4 → 2.9.1

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.
@@ -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
  },
@@ -7,6 +7,103 @@ import { resolveProjectRoot } from '../config-loader.ts'
7
7
  /** How many ancestor directories we walk up looking for a SKILL.md. */
8
8
  const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
9
9
 
10
+ /** Maximum number of image attachments relayed into the context in one call. */
11
+ const MAX_ATTACHMENTS = 8
12
+
13
+ /** Maximum intrinsic width/height (px) accepted for an attached image. */
14
+ const MAX_ATTACHMENT_DIMENSION = 16384
15
+
16
+ /** A validated, normalized image-attachment entry carried into review context. */
17
+ export interface NormalizedAttachment {
18
+ name?: string
19
+ mediaType?: string
20
+ width?: number
21
+ height?: number
22
+ note?: string
23
+ }
24
+
25
+ /** Validation result for a raw attachment entry. */
26
+ export type AttachmentValidationResult =
27
+ | { ok: true; value: NormalizedAttachment }
28
+ | { ok: false; error: string }
29
+
30
+ /**
31
+ * Validate and normalize one raw image-attachment entry passed by the
32
+ * orchestrator. The top-level model observes user-attached images in its own
33
+ * context (as image blocks) and relays their metadata here so reviewers get the
34
+ * same visual evidence. Pure — exported for unit tests.
35
+ */
36
+ export function normalizeAttachment(raw: unknown): AttachmentValidationResult {
37
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
38
+ return { ok: false, error: 'attachment must be an object' }
39
+ }
40
+ const entry = raw as Record<string, unknown>
41
+ const out: NormalizedAttachment = {}
42
+ if (entry.name !== undefined) {
43
+ if (typeof entry.name !== 'string' || entry.name.length > 256) {
44
+ return { ok: false, error: 'attachment.name must be a string (≤ 256 chars)' }
45
+ }
46
+ out.name = entry.name
47
+ }
48
+ if (entry.mediaType !== undefined) {
49
+ if (typeof entry.mediaType !== 'string' || !/^image\/(png|jpeg|webp|gif)$/.test(entry.mediaType)) {
50
+ return { ok: false, error: 'attachment.mediaType must be image/png, image/jpeg, image/webp, or image/gif' }
51
+ }
52
+ out.mediaType = entry.mediaType
53
+ }
54
+ for (const dim of ['width', 'height'] as const) {
55
+ if (entry[dim] !== undefined) {
56
+ if (typeof entry[dim] !== 'number' || !Number.isInteger(entry[dim]) || entry[dim] < 0 || entry[dim] > MAX_ATTACHMENT_DIMENSION) {
57
+ return { ok: false, error: `attachment.${dim} must be an integer in [0, ${MAX_ATTACHMENT_DIMENSION}]` }
58
+ }
59
+ out[dim] = entry[dim]
60
+ }
61
+ }
62
+ if (entry.note !== undefined) {
63
+ if (typeof entry.note !== 'string' || entry.note.length > 1000) {
64
+ return { ok: false, error: 'attachment.note must be a string (≤ 1000 chars)' }
65
+ }
66
+ out.note = entry.note
67
+ }
68
+ return { ok: true, value: out }
69
+ }
70
+
71
+ /**
72
+ * Validate a whole attachments array, dropping invalid entries.
73
+ * Returns the normalized list plus the reasons for any dropped entries.
74
+ */
75
+ export function normalizeAttachments(raw: unknown): {
76
+ attachments: NormalizedAttachment[]
77
+ errors: string[]
78
+ } {
79
+ const attachments: NormalizedAttachment[] = []
80
+ const errors: string[] = []
81
+ if (raw === undefined || raw === null) return { attachments, errors }
82
+ if (!Array.isArray(raw)) return { attachments, errors: ['attachments must be an array'] }
83
+ for (let i = 0; i < raw.length; i++) {
84
+ if (attachments.length >= MAX_ATTACHMENTS) {
85
+ errors.push(`attachments capped at ${MAX_ATTACHMENTS}; entry ${i} dropped`)
86
+ break
87
+ }
88
+ const result = normalizeAttachment(raw[i])
89
+ if (result.ok) attachments.push(result.value)
90
+ else errors.push(`attachments[${i}]: ${result.error}`)
91
+ }
92
+ return { attachments, errors }
93
+ }
94
+
95
+ /**
96
+ * Render a normalized attachment as a compact text block for the model.
97
+ */
98
+ export function renderAttachment(a: NormalizedAttachment, index: number): string {
99
+ const bits: string[] = [`[${index + 1}]`]
100
+ if (a.name) bits.push(a.name)
101
+ if (a.mediaType) bits.push(a.mediaType)
102
+ if (typeof a.width === 'number' && typeof a.height === 'number') bits.push(`${a.width}x${a.height}`)
103
+ if (a.note) bits.push(a.note)
104
+ return bits.join(' · ')
105
+ }
106
+
10
107
  /**
11
108
  * The directory this source file lives in (…/src/tools). The plugin's own
12
109
  * package root is one level up (…/src), and the skill root is typically a few
@@ -86,7 +183,9 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
86
183
  'SKILL.md contains the original iterate skill instructions; it is searched in ' +
87
184
  'the skill directory (auto-detected), the project root, or an explicit `skillDir`. ' +
88
185
  'ITERATE.md contains the project-specific knowledge base and onboarding information. ' +
89
- 'Use this to understand the skill workflow and project context.',
186
+ 'Also relays user-attached image metadata (e.g. UI screenshots, error dialogs) into ' +
187
+ 'the review context so reviewers can treat them as visual evidence. ' +
188
+ 'Use this to understand the skill workflow, project context, and any attached visuals.',
90
189
 
91
190
  parameters: {
92
191
  files: {
@@ -105,6 +204,25 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
105
204
  'Custom directory to search for SKILL.md (highest priority). ' +
106
205
  'When omitted, SKILL.md is auto-detected from the skill directory, then the project root.',
107
206
  },
207
+ attachments: {
208
+ type: 'array',
209
+ items: {
210
+ type: 'object',
211
+ additionalProperties: false,
212
+ properties: {
213
+ name: { type: 'string', description: 'Optional display name of the attached image.' },
214
+ mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], description: 'Optional media type of the image.' },
215
+ width: { type: 'integer', description: 'Optional intrinsic width in pixels.' },
216
+ height: { type: 'integer', description: 'Optional intrinsic height in pixels.' },
217
+ note: { type: 'string', description: 'Optional short description of what the image shows and why it matters for this review.' },
218
+ },
219
+ },
220
+ description:
221
+ 'Optional: image attachments observed in the session (e.g. UI screenshots, error ' +
222
+ 'dialogs, design references) relayed into the review context. The top-level model ' +
223
+ 'sees these images natively and passes their metadata here so reviewers get the same ' +
224
+ 'visual evidence. Up to 8 entries; invalid entries are dropped and reported.',
225
+ },
108
226
  },
109
227
 
110
228
  output: {
@@ -118,15 +236,23 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
118
236
  skillSource: { oneOf: [{ type: 'string' }, { type: 'null' }] },
119
237
  error: { type: 'string' },
120
238
  searched: { type: 'array', items: { type: 'string' } },
239
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Normalized attached-image descriptions relayed to reviewers.' },
240
+ attachmentErrors: { type: 'array', items: { type: 'string' }, description: 'Reasons for any attachment entries that were dropped.' },
121
241
  },
122
242
  },
123
243
  render: (_args, value) => {
124
244
  const parts: string[] = []
125
245
  if (value.skill) parts.push(`--- SKILL.md (${value.skillSource ?? '?source?'}) ---\n${value.skill}`)
126
246
  if (value.project) parts.push(`--- ITERATE.md ---\n${value.project}`)
127
- if (!value.skill && !value.project) {
247
+ if (Array.isArray(value.attachments) && value.attachments.length > 0) {
248
+ parts.push(`--- User-attached images (${value.attachments.length}) ---\n${value.attachments.join('\n')}`)
249
+ }
250
+ if (!value.skill && !value.project && !(Array.isArray(value.attachments) && value.attachments.length > 0)) {
128
251
  parts.push('No files found. Searched: ' + (value.searched?.join(', ') ?? 'none'))
129
252
  }
253
+ if (Array.isArray(value.attachmentErrors) && value.attachmentErrors.length > 0) {
254
+ parts.push('Attachment warnings: ' + value.attachmentErrors.join('; '))
255
+ }
130
256
  return [{ type: 'text', text: parts.join('\n\n') }]
131
257
  },
132
258
  },
@@ -148,8 +274,21 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
148
274
  project?: string | null
149
275
  skillSource?: string | null
150
276
  searched: string[]
277
+ attachments?: string[]
278
+ attachmentErrors?: string[]
151
279
  } = { found: true, searched: [] }
152
280
 
281
+ // Relay user-attached image metadata into the review context. The
282
+ // orchestrator observes attached images in the session and passes their
283
+ // metadata here; invalid entries are dropped with a reported reason.
284
+ const attachments = normalizeAttachments(args.attachments)
285
+ if (attachments.attachments.length > 0) {
286
+ result.attachments = attachments.attachments.map(renderAttachment)
287
+ }
288
+ if (attachments.errors.length > 0) {
289
+ result.attachmentErrors = attachments.errors
290
+ }
291
+
153
292
  if (requested.includes('skill') || requested.includes('skill.md')) {
154
293
  // Candidate dirs in priority order: custom path → auto-detected skill
155
294
  // root → project root. This is how "skill 目录、项目根、自定义路径"
@@ -19,6 +19,7 @@ const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
19
19
  'validation',
20
20
  'decision',
21
21
  'report',
22
+ 'resume',
22
23
  ])
23
24
 
24
25
  /**
@@ -111,7 +112,7 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
111
112
  type: 'string',
112
113
  description:
113
114
  'Entry type (required for append): round_start, review_result, atomic_fix, ' +
114
- 'architectural_fix, revert, round_failed, validation, decision, report.',
115
+ 'architectural_fix, revert, round_failed, validation, decision, report, resume.',
115
116
  enum: [
116
117
  'round_start',
117
118
  'review_result',
@@ -122,6 +123,7 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
122
123
  'validation',
123
124
  'decision',
124
125
  'report',
126
+ 'resume',
125
127
  ],
126
128
  },
127
129
  round: {
package/src/types.ts CHANGED
@@ -40,6 +40,7 @@ export interface DecisionLogEntry {
40
40
  | 'decision'
41
41
  | 'report'
42
42
  | 'round_failed'
43
+ | 'resume'
43
44
  data: Record<string, unknown>
44
45
  }
45
46
 
@@ -151,6 +152,8 @@ export interface IterationCheckpoint {
151
152
  maxRounds: number
152
153
  fixedCount: number
153
154
  architecturalCount: number
155
+ /** How many times this checkpoint has been resumed after an interruption/abort. */
156
+ resumeCount: number
154
157
  findings: ReviewFinding[]
155
158
  startedAt: string
156
159
  updatedAt: string
@@ -167,6 +170,10 @@ export interface IterationStatus {
167
170
  findingsCount: number
168
171
  totalDecisionLogEntries: number
169
172
  hasCheckpoint: boolean
173
+ /** True when a checkpoint is present — i.e. the previous run was interrupted before finishing. */
174
+ interrupted: boolean
175
+ /** How many times the current checkpoint has already been resumed. */
176
+ resumeCount: number
170
177
  checkpoint: IterationCheckpoint | null
171
178
  lastUpdated: string | null
172
179
  }