iterate-plugin 2.12.1 → 2.12.3

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/src/jobs.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * src/jobs.ts — dsh Job Panel integration for iterate tool executions.
3
+ *
4
+ * dsh's background-job registry (`ctx.jobs`, @deepseek-ai/dsh-jobs) lets
5
+ * plugins surface long-running work in the client's Job Panel
6
+ * (`conversation.session.header.actions` list). We register custom kinds via
7
+ * declaration merging and wrap tool executions so each `iterate_review` /
8
+ * `iterate_fix` call shows up as a tracked job (running -> completed/failed).
9
+ *
10
+ * Defensive by design (matches the plugin's overall philosophy):
11
+ * - `ctx.jobs` only exists when the dsh host loaded a job registry + a
12
+ * controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
13
+ * equivalent). When it is missing, `start()` throws or is absent — we
14
+ * detect both and fall through to plain execution, so the Job Panel is a
15
+ * pure enhancement and never breaks a tool call.
16
+ * - The registry is memory-only and panel rows are read-only (no progress
17
+ * updates), so these jobs are completion records, not control channels.
18
+ */
19
+
20
+ import type { JobOutcome, JobRegistry } from '@deepseek-ai/dsh-jobs'
21
+
22
+ /** Extend dsh's producer-kind registry with iterate's custom kinds. */
23
+ declare module '@deepseek-ai/dsh-jobs' {
24
+ interface JobKindMap {
25
+ 'iterate-review': 'iterate-review'
26
+ 'iterate-fix': 'iterate-fix'
27
+ }
28
+ }
29
+
30
+ /** Custom job kinds this plugin registers. */
31
+ export type IterateJobKind = 'iterate-review' | 'iterate-fix'
32
+
33
+ /** Shape of the `ctx.jobs` surface we rely on (duck-typed for safety). */
34
+ interface JobsLike {
35
+ start(spec: {
36
+ kind: IterateJobKind
37
+ label: string
38
+ run(): { done: Promise<JobOutcome>; cancel?: () => void }
39
+ }): string
40
+ }
41
+
42
+ /**
43
+ * Run `fn` wrapped in a dsh background job, settling it completed/failed
44
+ * with the execution's outcome. When the host exposes no job registry (or
45
+ * refuses the start), `fn` runs untouched and `null` is returned — the Job
46
+ * Panel is an enhancement, never a dependency.
47
+ *
48
+ * @param ctx the dsh plugin context (may or may not expose `jobs`).
49
+ * @param kind iterate job kind registered via {@link IterateJobKind}.
50
+ * @param label one-line job label shown in the panel.
51
+ * @param fn the tool execution to track.
52
+ * @returns the registry-issued job id, or `null` when unavailable.
53
+ */
54
+ export async function runWithJob<T>(
55
+ ctx: unknown,
56
+ kind: IterateJobKind,
57
+ label: string,
58
+ fn: () => Promise<T> | T,
59
+ ): Promise<{ result: T; jobId: string | null }> {
60
+ const jobs = (ctx as { jobs?: JobsLike } | undefined)?.jobs
61
+ if (!jobs || typeof jobs.start !== 'function') {
62
+ return { result: await fn(), jobId: null }
63
+ }
64
+
65
+ let settle!: (outcome: JobOutcome) => void
66
+ const done = new Promise<JobOutcome>((resolve) => {
67
+ settle = resolve
68
+ })
69
+
70
+ let jobId: string | null = null
71
+ try {
72
+ jobId = jobs.start({
73
+ kind,
74
+ label,
75
+ run: () => ({
76
+ done,
77
+ cancel: () => settle({ status: 'killed', detail: 'cancelled' }),
78
+ }),
79
+ })
80
+ } catch {
81
+ // Registry present but refuses work (e.g. no controller serves this
82
+ // owner) — run without panel tracking.
83
+ return { result: await fn(), jobId: null }
84
+ }
85
+
86
+ try {
87
+ const result = await fn()
88
+ settle({ status: 'completed', detail: 'done' })
89
+ return { result, jobId }
90
+ } catch (error) {
91
+ settle({
92
+ status: 'failed',
93
+ detail: error instanceof Error ? error.message : 'execution failed',
94
+ })
95
+ throw error
96
+ }
97
+ }
package/src/review.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  import type {
21
21
  IterateConfig,
22
22
  KnownIntentional,
23
+ ReviewAttachment,
23
24
  ReviewFinding,
24
25
  ReviewReport,
25
26
  ReviewRound,
@@ -546,6 +547,39 @@ export function sanitizeRounds(
546
547
  })
547
548
  }
548
549
 
550
+ /**
551
+ * Build the "attached visual context" instruction block for a reviewer prompt.
552
+ *
553
+ * ``path``/``data`` attachments (screenshots, mockups, failure repros) are
554
+ * evidence a reviewer must weigh alongside the code — this clause names each
555
+ * one and mandates that the reviewer inspect/consider it (e.g. by opening the
556
+ * file with a vision-capable tool or the ``image_to_text`` bridge) before
557
+ * judging. Pure string construction; returns ``""`` when there are none.
558
+ */
559
+ export function attachmentClause(attachments: ReviewAttachment[] | undefined): string {
560
+ if (!attachments || attachments.length === 0) return ''
561
+ const lines: string[] = []
562
+ for (const a of attachments) {
563
+ if (!a || typeof a !== 'object') continue
564
+ if (typeof a.path === 'string' && a.path) {
565
+ lines.push(`- ${a.path}${typeof a.caption === 'string' && a.caption ? ` (${a.caption})` : ''}`)
566
+ } else if (typeof a.data === 'string' && a.data) {
567
+ const kind = typeof a.media_type === 'string' && a.media_type ? a.media_type : 'image'
568
+ lines.push(`- inline ${kind} image${typeof a.caption === 'string' && a.caption ? ` (${a.caption})` : ''}`)
569
+ }
570
+ }
571
+ if (lines.length === 0) return ''
572
+ return (
573
+ 'ATTACHED VISUAL CONTEXT (mandatory): the following image attachment(s) were provided ' +
574
+ 'with this review — each one is part of the evidence you must weigh:\n' +
575
+ lines.join('\n') +
576
+ '\nYou MUST inspect/consider EVERY attachment before judging your dimension (open it ' +
577
+ 'with a vision-capable tool, or use image_to_text if your model cannot see images). ' +
578
+ 'If an attachment is inaccessible, state that and judge solely on the code. Do not ' +
579
+ 'ignore an attachment just because it is not code.'
580
+ )
581
+ }
582
+
549
583
  /**
550
584
  * Build the task prompt for one dimension's reviewer subagent.
551
585
  * In dry-run mode, pass `alreadyKnown` (the findings from earlier rounds) so the
@@ -581,6 +615,12 @@ export function reviewerTaskPrompt(input: {
581
615
  * review concentrates on the areas the user cares about.
582
616
  */
583
617
  focus?: string
618
+ /**
619
+ * Image/visual attachments to weigh alongside the code (screenshots,
620
+ * mockups, failure repros). Injected as a mandatory review-aware clause so
621
+ * every dimension reviewer inspects/considers them before judging.
622
+ */
623
+ attachments?: ReviewAttachment[]
584
624
  }): string {
585
625
  const parts: string[] = []
586
626
  parts.push(
@@ -591,6 +631,10 @@ export function reviewerTaskPrompt(input: {
591
631
  if (input.focus) {
592
632
  parts.push(`FOCUS: ${input.focus}`)
593
633
  }
634
+ const attached = attachmentClause(input.attachments)
635
+ if (attached) {
636
+ parts.push(attached)
637
+ }
594
638
  if (input.scopeFiles && input.scopeFiles.length > 0) {
595
639
  parts.push(
596
640
  'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
@@ -672,6 +716,12 @@ export function buildReviewPlan(input: {
672
716
  * complete inventory it must open file-by-file.
673
717
  */
674
718
  scopeFiles?: string[]
719
+ /**
720
+ * Image/visual attachments to thread into the review. Injected as a
721
+ * mandatory clause into every dimension's reviewer prompt and surfaced on
722
+ * the returned plan so the orchestrator/report can reference them.
723
+ */
724
+ attachments?: ReviewAttachment[]
675
725
  }): {
676
726
  mode: 'normal' | 'dry-run'
677
727
  goal: string
@@ -683,6 +733,8 @@ export function buildReviewPlan(input: {
683
733
  changedFiles: string[]
684
734
  /** True when scope was `changed-only` but no changes were found. */
685
735
  fallbackToFull: boolean
736
+ /** The attachments threaded into every reviewer prompt (empty when none). */
737
+ attachments: ReviewAttachment[]
686
738
  } {
687
739
  // Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
688
740
  // `review`/`atomic` missing) must degrade to sane defaults instead of
@@ -693,6 +745,16 @@ export function buildReviewPlan(input: {
693
745
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
694
746
  const maxLines = input.config.atomic?.max_lines ?? 20
695
747
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
748
+ // Defensive parse: keep only well-formed attachment entries (path or data).
749
+ const attachments = Array.isArray(input.attachments)
750
+ ? input.attachments.filter(
751
+ (a): a is ReviewAttachment =>
752
+ Boolean(a) &&
753
+ typeof a === 'object' &&
754
+ ((typeof a.path === 'string' && a.path.length > 0) ||
755
+ (typeof a.data === 'string' && a.data.length > 0)),
756
+ )
757
+ : []
696
758
  // changed-only with zero detected changes → auto-fallback to full scope.
697
759
  const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0
698
760
  const scope: 'full' | 'changed-only' = effectiveChangedOnly ? 'changed-only' : 'full'
@@ -744,6 +806,7 @@ export function buildReviewPlan(input: {
744
806
  changedFiles: effectiveChangedOnly ? changedFiles : undefined,
745
807
  scopeFiles: batch,
746
808
  focus: focusMap.get(d),
809
+ attachments,
747
810
  }),
748
811
  findingsSchema: findingsSchema(),
749
812
  })
@@ -759,5 +822,6 @@ export function buildReviewPlan(input: {
759
822
  knownIntentional: input.knownIntentional ?? [],
760
823
  changedFiles: effectiveChangedOnly ? changedFiles : [],
761
824
  fallbackToFull,
825
+ attachments,
762
826
  }
763
827
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
- import type { JsonValue } from '@deepseek-ai/dsh-session'
14
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
15
15
  import { resolveProjectRootForExec } from '../config-loader.ts'
16
16
  import { checkpointPath, iterateDir } from '../paths.ts'
17
17
  import { readRegistry } from './fix.ts'
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
- import type { JsonValue } from '@deepseek-ai/dsh-session'
3
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
4
4
  import { loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
5
5
  import {
6
6
  applyConfigUpdates,
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
- import type { JsonValue } from '@deepseek-ai/dsh-session'
4
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import { resolveProjectRootForExec } from '../config-loader.ts'
6
6
  import type { DecisionLogEntry } from '../types.ts'
7
7
 
package/src/tools/fix.ts CHANGED
@@ -20,8 +20,9 @@
20
20
  import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
21
21
  import { join, sep } from 'node:path'
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
- import type { JsonValue } from '@deepseek-ai/dsh-session'
23
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
24
24
  import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
25
+ import { runWithJob } from '../jobs.ts'
25
26
  import { countTouchedMethods } from '../method-scope.ts'
26
27
  import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
27
28
  import { appendDecisionEntry } from './decision-log.ts'
@@ -335,6 +336,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
335
336
  },
336
337
 
337
338
  async execute(args, exec) {
339
+ const { result } = await runWithJob(ctx, 'iterate-fix', `iterate_fix ${typeof args.file === 'string' && args.file ? args.file : '(?)'}`, async () => {
338
340
  const resolved = resolveProjectRootForExec(exec, args.path)
339
341
  if (!resolved.ok) return { ok: false, error: resolved.reason }
340
342
  const projectRoot = resolved.root
@@ -504,6 +506,8 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
504
506
  diffSummary: record.diffSummary,
505
507
  backupPath,
506
508
  }
509
+ })
510
+ return result
507
511
  },
508
512
  }),
509
513
  )
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
12
- import type { JsonValue } from '@deepseek-ai/dsh-session'
12
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
13
13
  import { resolveProjectRootForExec } from '../config-loader.ts'
14
14
  import { readDecisionEntries } from './decision-log.ts'
15
15
  import { readRegistry } from './fix.ts'
@@ -21,7 +21,7 @@
21
21
  import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
22
22
  import { join } from 'node:path'
23
23
  import { defineTool } from '@deepseek-ai/dsh-tools'
24
- import type { JsonValue } from '@deepseek-ai/dsh-session'
24
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
25
25
  import { resolveProjectRootForExec } from '../config-loader.ts'
26
26
  import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
27
27
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from '@deepseek-ai/dsh-session'
2
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
3
3
  import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
4
+ import { runWithJob } from '../jobs.ts'
4
5
  import {
5
6
  buildReviewPlan,
6
7
  buildReviewReport,
@@ -15,7 +16,7 @@ import {
15
16
  coverageToDict,
16
17
  } from '../review-scope.ts'
17
18
  import { resolveChangedFiles } from '../git-scope.ts'
18
- import type { KnownIntentional, ReviewFinding, ReviewReport, ReviewRound } from '../types.ts'
19
+ import type { KnownIntentional, ReviewAttachment, ReviewFinding, ReviewReport, ReviewRound } from '../types.ts'
19
20
  import type { CoverageResult } from '../review-scope.ts'
20
21
 
21
22
  /** Default round cap when neither the arg nor config provides one. */
@@ -85,6 +86,16 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
85
86
  'For `meta-review`: the ReviewReport JSON (as returned by `aggregate`) to audit for ' +
86
87
  'internal consistency and produce the final review report.',
87
88
  },
89
+ attachments: {
90
+ type: 'json',
91
+ description:
92
+ 'Optional (plan only): image/visual attachments to thread into the review, e.g. ' +
93
+ '[{"path":"screens/hits.png","caption":"reproduced layout bug"}]. Each entry: ' +
94
+ '{path?, data?, media_type?, caption?} — path resolves relative to the project root, ' +
95
+ 'data is a base64 payload (media_type e.g. image/png), caption gives human context. ' +
96
+ 'Injected as a mandatory clause into every dimension reviewer prompt so screenshots/' +
97
+ 'mockups/failure repros are weighed alongside the code.',
98
+ },
88
99
  fixedCount: {
89
100
  type: 'integer',
90
101
  description:
@@ -132,6 +143,7 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
132
143
  },
133
144
 
134
145
  async execute(args, exec) {
146
+ const { result } = await runWithJob(ctx, 'iterate-review', `iterate_review ${String(args.operation ?? '')} (${String(args.mode ?? 'dry-run')})`, async () => {
135
147
  const resolved = resolveProjectRootForExec(exec, args.path)
136
148
  if (!resolved.ok) {
137
149
  return { operation: args.operation, error: resolved.reason }
@@ -162,7 +174,18 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
162
174
  if (config.review?.scope === 'full') {
163
175
  scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' })
164
176
  }
165
- const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles })
177
+ // Thread image/visual attachments (screenshots/mockups/failure repros)
178
+ // into the plan so every reviewer prompt weighs them alongside code.
179
+ const attachments = Array.isArray(args.attachments)
180
+ ? (args.attachments as ReviewAttachment[]).filter(
181
+ (a): a is ReviewAttachment =>
182
+ Boolean(a) &&
183
+ typeof a === 'object' &&
184
+ ((typeof a.path === 'string' && a.path.length > 0) ||
185
+ (typeof a.data === 'string' && a.data.length > 0)),
186
+ )
187
+ : []
188
+ const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles, attachments })
166
189
  return { operation: 'plan', mode, found: true, plan: plan as unknown as JsonValue }
167
190
  }
168
191
 
@@ -268,6 +291,8 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
268
291
  operation: args.operation,
269
292
  error: `Unknown operation "${args.operation}". Use "plan", "aggregate", or "meta-review".`,
270
293
  }
294
+ })
295
+ return result
271
296
  },
272
297
  }),
273
298
  )
@@ -22,7 +22,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
22
22
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
23
23
  import { existsSync } from 'node:fs'
24
24
  import { dirname } from 'node:path'
25
- import type { JsonValue } from '@deepseek-ai/dsh-session'
25
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
26
26
  import {
27
27
  loadEffectiveConfig,
28
28
  resolveProjectRootForExec,
@@ -1,7 +1,7 @@
1
1
  import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
- import type { JsonValue } from '@deepseek-ai/dsh-session'
4
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import yaml from 'js-yaml'
6
6
  import { resolveProjectRootForExec } from '../config-loader.ts'
7
7
  import type { KnownIntentional } from '../types.ts'
package/src/types.ts CHANGED
@@ -16,6 +16,13 @@ export interface IterateConfig {
16
16
  command_whitelist: string[]
17
17
  commands: Record<string, string[]>
18
18
  }
19
+ /**
20
+ * LLM reasoning effort for review passes ('low' | 'medium' | 'high').
21
+ * Absent → follow the provider default. The harness forwards it into the
22
+ * OpenAI-compatible request body; the plugin surfaces it in the settings
23
+ * panel and review plan (dsh 0.1.1-rc.7+ exposes the same 'low' effort).
24
+ */
25
+ reasoning_effort?: 'low' | 'medium' | 'high'
19
26
  reviewer: {
20
27
  output_schema_validation: boolean
21
28
  evidence_validation: boolean
@@ -70,6 +77,25 @@ export interface ValidationResult {
70
77
  durationMs: number
71
78
  }
72
79
 
80
+ /**
81
+ * An image (or other visual) attachment threaded into a review.
82
+ *
83
+ * The user/context can attach screenshots, UI mockups, or reproduced-failure
84
+ * images that reviewers should weigh alongside the code. Only ``path`` or
85
+ * ``data`` need be present; ``media_type`` describes ``data`` (base64);
86
+ * ``caption`` supplies human context for the reviewer prompt.
87
+ */
88
+ export interface ReviewAttachment {
89
+ /** Local path to the image (resolved relative to the project root). */
90
+ path?: string
91
+ /** Base64-encoded image content (alternative to ``path``). */
92
+ data?: string
93
+ /** MIME type of ``data`` (e.g. image/png, image/jpeg, image/webp). */
94
+ media_type?: string
95
+ /** Short human caption explaining what the attachment shows and why it matters. */
96
+ caption?: string
97
+ }
98
+
73
99
  /** A single finding from a dimension review */
74
100
  export interface ReviewFinding {
75
101
  dimension: string