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.
@@ -7,6 +7,100 @@ 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
+ /** A validated, normalized image-attachment entry carried into review context. */
14
+ export interface NormalizedAttachment {
15
+ name?: string
16
+ mediaType?: string
17
+ width?: number
18
+ height?: number
19
+ note?: string
20
+ }
21
+
22
+ /** Validation result for a raw attachment entry. */
23
+ export type AttachmentValidationResult =
24
+ | { ok: true; value: NormalizedAttachment }
25
+ | { ok: false; error: string }
26
+
27
+ /**
28
+ * Validate and normalize one raw image-attachment entry passed by the
29
+ * orchestrator. The top-level model observes user-attached images in its own
30
+ * context (as image blocks) and relays their metadata here so reviewers get the
31
+ * same visual evidence. Pure — exported for unit tests.
32
+ */
33
+ export function normalizeAttachment(raw: unknown): AttachmentValidationResult {
34
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
35
+ return { ok: false, error: 'attachment must be an object' }
36
+ }
37
+ const entry = raw as Record<string, unknown>
38
+ const out: NormalizedAttachment = {}
39
+ if (entry.name !== undefined) {
40
+ if (typeof entry.name !== 'string' || entry.name.length > 256) {
41
+ return { ok: false, error: 'attachment.name must be a string (≤ 256 chars)' }
42
+ }
43
+ out.name = entry.name
44
+ }
45
+ if (entry.mediaType !== undefined) {
46
+ if (typeof entry.mediaType !== 'string' || !/^image\/(png|jpeg|webp|gif)$/.test(entry.mediaType)) {
47
+ return { ok: false, error: 'attachment.mediaType must be image/png, image/jpeg, image/webp, or image/gif' }
48
+ }
49
+ out.mediaType = entry.mediaType
50
+ }
51
+ for (const dim of ['width', 'height'] as const) {
52
+ if (entry[dim] !== undefined) {
53
+ if (typeof entry[dim] !== 'number' || !Number.isInteger(entry[dim]) || entry[dim] < 0 || entry[dim] > 16384) {
54
+ return { ok: false, error: `attachment.${dim} must be an integer in [0, 16384]` }
55
+ }
56
+ out[dim] = entry[dim]
57
+ }
58
+ }
59
+ if (entry.note !== undefined) {
60
+ if (typeof entry.note !== 'string' || entry.note.length > 1000) {
61
+ return { ok: false, error: 'attachment.note must be a string (≤ 1000 chars)' }
62
+ }
63
+ out.note = entry.note
64
+ }
65
+ return { ok: true, value: out }
66
+ }
67
+
68
+ /**
69
+ * Validate a whole attachments array, dropping invalid entries.
70
+ * Returns the normalized list plus the reasons for any dropped entries.
71
+ */
72
+ export function normalizeAttachments(raw: unknown): {
73
+ attachments: NormalizedAttachment[]
74
+ errors: string[]
75
+ } {
76
+ const attachments: NormalizedAttachment[] = []
77
+ const errors: string[] = []
78
+ if (raw === undefined || raw === null) return { attachments, errors }
79
+ if (!Array.isArray(raw)) return { attachments, errors: ['attachments must be an array'] }
80
+ for (let i = 0; i < raw.length; i++) {
81
+ if (attachments.length >= MAX_ATTACHMENTS) {
82
+ errors.push(`attachments capped at ${MAX_ATTACHMENTS}; entry ${i} dropped`)
83
+ break
84
+ }
85
+ const result = normalizeAttachment(raw[i])
86
+ if (result.ok) attachments.push(result.value)
87
+ else errors.push(`attachments[${i}]: ${result.error}`)
88
+ }
89
+ return { attachments, errors }
90
+ }
91
+
92
+ /**
93
+ * Render a normalized attachment as a compact text block for the model.
94
+ */
95
+ export function renderAttachment(a: NormalizedAttachment, index: number): string {
96
+ const bits: string[] = [`[${index + 1}]`]
97
+ if (a.name) bits.push(a.name)
98
+ if (a.mediaType) bits.push(a.mediaType)
99
+ if (typeof a.width === 'number' && typeof a.height === 'number') bits.push(`${a.width}x${a.height}`)
100
+ if (a.note) bits.push(a.note)
101
+ return bits.join(' · ')
102
+ }
103
+
10
104
  /**
11
105
  * The directory this source file lives in (…/src/tools). The plugin's own
12
106
  * package root is one level up (…/src), and the skill root is typically a few
@@ -86,7 +180,9 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
86
180
  'SKILL.md contains the original iterate skill instructions; it is searched in ' +
87
181
  'the skill directory (auto-detected), the project root, or an explicit `skillDir`. ' +
88
182
  'ITERATE.md contains the project-specific knowledge base and onboarding information. ' +
89
- 'Use this to understand the skill workflow and project context.',
183
+ 'Also relays user-attached image metadata (e.g. UI screenshots, error dialogs) into ' +
184
+ 'the review context so reviewers can treat them as visual evidence. ' +
185
+ 'Use this to understand the skill workflow, project context, and any attached visuals.',
90
186
 
91
187
  parameters: {
92
188
  files: {
@@ -105,6 +201,25 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
105
201
  'Custom directory to search for SKILL.md (highest priority). ' +
106
202
  'When omitted, SKILL.md is auto-detected from the skill directory, then the project root.',
107
203
  },
204
+ attachments: {
205
+ type: 'array',
206
+ items: {
207
+ type: 'object',
208
+ additionalProperties: false,
209
+ properties: {
210
+ name: { type: 'string', description: 'Optional display name of the attached image.' },
211
+ mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], description: 'Optional media type of the image.' },
212
+ width: { type: 'integer', description: 'Optional intrinsic width in pixels.' },
213
+ height: { type: 'integer', description: 'Optional intrinsic height in pixels.' },
214
+ note: { type: 'string', description: 'Optional short description of what the image shows and why it matters for this review.' },
215
+ },
216
+ },
217
+ description:
218
+ 'Optional: image attachments observed in the session (e.g. UI screenshots, error ' +
219
+ 'dialogs, design references) relayed into the review context. The top-level model ' +
220
+ 'sees these images natively and passes their metadata here so reviewers get the same ' +
221
+ 'visual evidence. Up to 8 entries; invalid entries are dropped and reported.',
222
+ },
108
223
  },
109
224
 
110
225
  output: {
@@ -118,15 +233,23 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
118
233
  skillSource: { oneOf: [{ type: 'string' }, { type: 'null' }] },
119
234
  error: { type: 'string' },
120
235
  searched: { type: 'array', items: { type: 'string' } },
236
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Normalized attached-image descriptions relayed to reviewers.' },
237
+ attachmentErrors: { type: 'array', items: { type: 'string' }, description: 'Reasons for any attachment entries that were dropped.' },
121
238
  },
122
239
  },
123
240
  render: (_args, value) => {
124
241
  const parts: string[] = []
125
242
  if (value.skill) parts.push(`--- SKILL.md (${value.skillSource ?? '?source?'}) ---\n${value.skill}`)
126
243
  if (value.project) parts.push(`--- ITERATE.md ---\n${value.project}`)
127
- if (!value.skill && !value.project) {
244
+ if (Array.isArray(value.attachments) && value.attachments.length > 0) {
245
+ parts.push(`--- User-attached images (${value.attachments.length}) ---\n${value.attachments.join('\n')}`)
246
+ }
247
+ if (!value.skill && !value.project && !(Array.isArray(value.attachments) && value.attachments.length > 0)) {
128
248
  parts.push('No files found. Searched: ' + (value.searched?.join(', ') ?? 'none'))
129
249
  }
250
+ if (Array.isArray(value.attachmentErrors) && value.attachmentErrors.length > 0) {
251
+ parts.push('Attachment warnings: ' + value.attachmentErrors.join('; '))
252
+ }
130
253
  return [{ type: 'text', text: parts.join('\n\n') }]
131
254
  },
132
255
  },
@@ -148,8 +271,21 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
148
271
  project?: string | null
149
272
  skillSource?: string | null
150
273
  searched: string[]
274
+ attachments?: string[]
275
+ attachmentErrors?: string[]
151
276
  } = { found: true, searched: [] }
152
277
 
278
+ // Relay user-attached image metadata into the review context. The
279
+ // orchestrator observes attached images in the session and passes their
280
+ // metadata here; invalid entries are dropped with a reported reason.
281
+ const attachments = normalizeAttachments(args.attachments)
282
+ if (attachments.attachments.length > 0) {
283
+ result.attachments = attachments.attachments.map(renderAttachment)
284
+ }
285
+ if (attachments.errors.length > 0) {
286
+ result.attachmentErrors = attachments.errors
287
+ }
288
+
153
289
  if (requested.includes('skill') || requested.includes('skill.md')) {
154
290
  // Candidate dirs in priority order: custom path → auto-detected skill
155
291
  // 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
  }