iterate-plugin 2.8.0 → 2.8.2

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * File-inventory collection, chunking, and coverage scoring for review scope.
3
+ *
4
+ * Mirrors `harness/iterate-harness/.../review_scope.py`. The iterate review
5
+ * loop must force each reviewer subagent to actually open EVERY file in the
6
+ * scope it is responsible for (not silently skip or assume files). This
7
+ * module supplies the deterministic building blocks:
8
+ *
9
+ * - `collectScopeFiles`: produce the sorted relative-path inventory for a
10
+ * review scope (changed-only delta, or a full walk filtered to source files
11
+ * and stripped of dependency/build/vendor dirs).
12
+ * - `chunkFiles`: split a large inventory into stable batches so `full`
13
+ * reviews stay bounded; consecutive files from the same directory are kept
14
+ * together to avoid splitting a module's review across two reviewers.
15
+ * - `computeCoverage`: compare a reviewer's self-reported `readFiles` against
16
+ * the inventory it was assigned, returning a coverage ratio plus the list of
17
+ * files that were not opened. Consumed by meta-review as a
18
+ * *prompt-informative* metric (never a hard gate).
19
+ *
20
+ * Pure math (chunkFiles / computeCoverage) has no I/O so it unit-tests
21
+ * cleanly; collectScopeFiles walks the filesystem.
22
+ */
23
+
24
+ import { readdirSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+
27
+ export interface CoverageResult {
28
+ assigned: string[]
29
+ read: string[]
30
+ covered: string[]
31
+ uncovered: string[]
32
+ ratio: number
33
+ }
34
+
35
+ /** Relative-scope sentinel for whole-module findings. */
36
+ export const WHOLE_FILE_LINE = 0
37
+
38
+ /** Source extensions a full-scope walk includes. */
39
+ const SOURCE_EXTENSIONS = new Set([
40
+ '.py', '.pyi', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
41
+ '.go', '.java', '.rs', '.c', '.h', '.cc', '.cpp', '.cs',
42
+ '.swift', '.kt', '.scala', '.rb', '.php', '.sh', '.bash', '.zsh',
43
+ '.sql', '.html', '.htm', '.css', '.scss', '.vue', '.svelte',
44
+ ])
45
+
46
+ /** Directory names always excluded from a full-scope walk. */
47
+ const IGNORED_DIRS = new Set([
48
+ '.git', '.hg', '.svn', 'node_modules', '.venv', 'venv', 'env',
49
+ '__pycache__', '.cache', '.pytest_cache', '.mypy_cache', 'dist',
50
+ 'build', 'out', '.next', '.nuxt', 'coverage', '.tox', '.idea',
51
+ '.vscode', 'target', '.release', '.dist_tmp',
52
+ ])
53
+
54
+ /** Default chunk size for a `full` scope review (files per batch). */
55
+ export const DEFAULT_SCOPE_CHUNK_SIZE = 25
56
+
57
+ /** Coverage ratio at/above which a scope is fully covered. */
58
+ export const COVERAGE_TARGET = 0.95
59
+
60
+ const SEP = '/'
61
+
62
+ /**
63
+ * Canonicalize separators + dot-segments. Leading `..` PATH segments are
64
+ * PRESERVED (mirrors Python `os.path.normpath`, which never resolves beyond
65
+ * the root), so callers can still detect path-escaping (`..`) after
66
+ * normalization — a full `..`-driven traversal must not be silently folded
67
+ * into a bare filename.
68
+ */
69
+ function normalizePath(path: string): string {
70
+ const cleaned = path.replace(/\\/g, SEP)
71
+ const parts: string[] = []
72
+ for (const part of cleaned.split(SEP)) {
73
+ if (part === '' || part === '.') continue
74
+ if (part === '..') {
75
+ if (parts.length > 0) parts.pop()
76
+ else parts.push(part) // no root segment to pop — keep the leading '..'
77
+ continue
78
+ }
79
+ parts.push(part)
80
+ }
81
+ return parts.join(SEP)
82
+ }
83
+
84
+ function sourceExt(path: string): boolean {
85
+ const dot = path.lastIndexOf('.')
86
+ if (dot < 0) return false
87
+ return SOURCE_EXTENSIONS.has(path.slice(dot).toLowerCase())
88
+ }
89
+
90
+ function isIgnoredDir(name: string): boolean {
91
+ return IGNORED_DIRS.has(name)
92
+ }
93
+
94
+ /** Collect the sorted relative-path inventory for a review scope. */
95
+ export function collectScopeFiles(
96
+ root: string,
97
+ opts: { scope: 'full' | 'changed-only'; changedFiles?: string[] },
98
+ ): string[] {
99
+ if (opts.scope === 'changed-only') return collectChanged(opts.changedFiles ?? [])
100
+ return collectFull(root)
101
+ }
102
+
103
+ function collectChanged(changedFiles: string[]): string[] {
104
+ const out = new Set<string>()
105
+ for (const rel of changedFiles) {
106
+ if (typeof rel !== 'string' || !rel.trim()) continue
107
+ if (rel === String(WHOLE_FILE_LINE)) continue
108
+ const cleaned = normalizePath(rel)
109
+ if (cleaned.startsWith('..')) continue
110
+ if (!sourceExt(cleaned)) continue
111
+ out.add(cleaned)
112
+ }
113
+ return [...out].sort()
114
+ }
115
+
116
+ function collectFull(root: string): string[] {
117
+ // Deterministic recursive walk built on Node's fs; a code reviewer never
118
+ // anchors findings to lock files, images, or vendored builds.
119
+ const out: string[] = []
120
+ const walk = (dir: string): void => {
121
+ let entries: import('node:fs').Dirent[]
122
+ try {
123
+ entries = readdirSync(dir, { withFileTypes: true })
124
+ } catch {
125
+ return
126
+ }
127
+ for (const entry of entries) {
128
+ const abs = join(dir, entry.name)
129
+ if (entry.isDirectory()) {
130
+ if (!isIgnoredDir(entry.name)) walk(abs)
131
+ continue
132
+ }
133
+ if (!entry.isFile()) continue
134
+ if (!sourceExt(entry.name)) continue
135
+ const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs
136
+ out.push(rel.split(SEP).join(SEP))
137
+ }
138
+ }
139
+ walk(root)
140
+ return out.sort()
141
+ }
142
+
143
+ /** Split `files` into stable batches, keeping directory runs together. */
144
+ export function chunkFiles(files: string[], perChunk?: number): string[][] {
145
+ const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk
146
+ const ordered = [...files].sort()
147
+ const chunks: string[][] = []
148
+ let current: string[] = []
149
+ let lastDir: string | undefined
150
+ for (const rel of ordered) {
151
+ const parent = rel.includes(SEP) ? rel.slice(0, rel.lastIndexOf(SEP)) : '.'
152
+ if (current.length > 0 && lastDir !== undefined && parent !== lastDir) {
153
+ chunks.push(current)
154
+ current = []
155
+ lastDir = undefined
156
+ }
157
+ current.push(rel)
158
+ lastDir = parent
159
+ if (current.length >= size) {
160
+ chunks.push(current)
161
+ current = []
162
+ lastDir = undefined
163
+ }
164
+ }
165
+ if (current.length > 0) chunks.push(current)
166
+ return chunks
167
+ }
168
+
169
+ /** Score self-reported reads against the assigned inventory. */
170
+ export function computeCoverage(
171
+ assigned: string[],
172
+ readFiles?: string[] | null,
173
+ ): CoverageResult {
174
+ const readNorm = new Set<string>()
175
+ for (const p of readFiles ?? []) {
176
+ if (typeof p === 'string' && p) readNorm.add(normalizePath(p))
177
+ }
178
+ const assignedSorted = [...assigned].sort()
179
+ const covered = assignedSorted.filter((rel) => readNorm.has(normalizePath(rel)))
180
+ const uncovered = assignedSorted.filter((rel) => !readNorm.has(normalizePath(rel)))
181
+ const rawRatio =
182
+ assignedSorted.length === 0 ? 1 : covered.length / assignedSorted.length
183
+ const ratio = Math.round(rawRatio * 1000) / 1000
184
+ return {
185
+ assigned: assignedSorted,
186
+ read: [...new Set((readFiles ?? []).filter((p): p is string => typeof p === 'string'))].sort(),
187
+ covered,
188
+ uncovered,
189
+ ratio,
190
+ }
191
+ }
192
+
193
+ /** Serialize a coverage result for the tool-layer JSON wire shape. */
194
+ export function coverageToDict(c: CoverageResult): Record<string, unknown> {
195
+ return {
196
+ assigned: c.assigned,
197
+ read: c.read,
198
+ covered: c.covered,
199
+ uncovered: c.uncovered,
200
+ ratio: c.ratio,
201
+ met: c.ratio >= COVERAGE_TARGET,
202
+ }
203
+ }
package/src/review.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  ReviewReport,
25
25
  ReviewRound,
26
26
  } from './types.ts'
27
+ import { DEFAULT_SCOPE_CHUNK_SIZE, chunkFiles } from './review-scope.ts'
27
28
 
28
29
  /** Severity ordering: lower rank = more severe. */
29
30
  export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
@@ -312,9 +313,201 @@ export function findingsSchema(): Record<string, unknown> {
312
313
  ],
313
314
  },
314
315
  },
316
+ readFiles: {
317
+ type: 'array',
318
+ items: { type: 'string' },
319
+ description:
320
+ 'Every file you actually opened with read_file while reviewing your assigned scope. Used to audit coverage; files you never opened count as un-reviewed.',
321
+ },
315
322
  },
316
- required: ['findings'],
323
+ required: ['findings', 'readFiles'],
324
+ }
325
+ }
326
+
327
+ // ─── Output schema validation ──────────────────────────────────────────────
328
+ //
329
+ // `config.reviewer.output_schema_validation` (default true) turns on a
330
+ // deterministic schema gate at the `aggregate` boundary: reviewer subagent
331
+ // outputs are parsed as JSON by the orchestrator, but models sometimes return
332
+ // malformed findings (missing fields, wrong types, out-of-range severity).
333
+ // Before any finding reaches the deterministic core (dedupe/sort/report) —
334
+ // which would crash on e.g. a missing `summary` — we validate every entry
335
+ // against the same shape `findingsSchema()` describes and surface the issues
336
+ // so the workflow can retry that round (≤2 times) with a strict-JSON nudge.
337
+ // Schema-invalid findings are dropped from the report; the workflow never
338
+ // forwards them into fixes.
339
+
340
+ /** Fields every finding object MUST carry (mirrors findingsSchema().required). */
341
+ export const REQUIRED_FINDING_FIELDS = [
342
+ 'dimension',
343
+ 'file',
344
+ 'severity',
345
+ 'summary',
346
+ 'failure_scenario',
347
+ 'suggested_fix',
348
+ 'is_atomic',
349
+ ] as const
350
+
351
+ /** Allowed severity values (mirrors the schema enum). */
352
+ export const SEVERITY_VALUES = ['critical', 'high', 'medium', 'low'] as const
353
+
354
+ /** String-typed finding fields (type check only, presence handled by REQUIRED). */
355
+ const STRING_FINDING_FIELDS = [
356
+ 'dimension',
357
+ 'file',
358
+ 'summary',
359
+ 'failure_scenario',
360
+ 'suggested_fix',
361
+ ] as const
362
+
363
+ /** One schema violation for a single finding entry. */
364
+ export interface FindingSchemaIssue {
365
+ /** Index into the findings array; -1 when the whole `findings` is malformed. */
366
+ index: number
367
+ /** Field path, e.g. "findings[2].severity". */
368
+ field: string
369
+ /** Human-readable reason. */
370
+ message: string
371
+ }
372
+
373
+ /**
374
+ * Validate an arbitrary parsed value against the findings schema shape.
375
+ * Accepts the `{findings: [...]}` wrapper OR a bare findings array, so callers
376
+ * can validate either the raw reviewer output object or a round's findings.
377
+ * Pure and deterministic — never touches the filesystem.
378
+ */
379
+ export function validateFindingsSchema(input: unknown): FindingSchemaIssue[] {
380
+ const raw = (input as { findings?: unknown } | null)?.findings ?? input
381
+ if (!Array.isArray(raw)) {
382
+ return [
383
+ {
384
+ index: -1,
385
+ field: 'findings',
386
+ message: 'expected a JSON array of finding objects',
387
+ },
388
+ ]
317
389
  }
390
+
391
+ const issues: FindingSchemaIssue[] = []
392
+ for (let i = 0; i < raw.length; i++) {
393
+ const item = raw[i]
394
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
395
+ issues.push({
396
+ index: i,
397
+ field: `findings[${i}]`,
398
+ message: 'expected a finding object',
399
+ })
400
+ continue
401
+ }
402
+ const f = item as Record<string, unknown>
403
+
404
+ for (const key of REQUIRED_FINDING_FIELDS) {
405
+ if (f[key] === undefined || f[key] === null) {
406
+ issues.push({
407
+ index: i,
408
+ field: `findings[${i}].${key}`,
409
+ message: `required field "${key}" is missing`,
410
+ })
411
+ }
412
+ }
413
+ for (const key of STRING_FINDING_FIELDS) {
414
+ if (f[key] !== undefined && f[key] !== null && typeof f[key] !== 'string') {
415
+ issues.push({
416
+ index: i,
417
+ field: `findings[${i}].${key}`,
418
+ message: `"${key}" must be a string`,
419
+ })
420
+ }
421
+ }
422
+ if (
423
+ f.severity !== undefined &&
424
+ f.severity !== null &&
425
+ !SEVERITY_VALUES.includes(f.severity as (typeof SEVERITY_VALUES)[number])
426
+ ) {
427
+ issues.push({
428
+ index: i,
429
+ field: `findings[${i}].severity`,
430
+ message: `severity must be one of: ${SEVERITY_VALUES.join(', ')}`,
431
+ })
432
+ }
433
+ if (
434
+ f.is_atomic !== undefined &&
435
+ f.is_atomic !== null &&
436
+ typeof f.is_atomic !== 'boolean'
437
+ ) {
438
+ issues.push({
439
+ index: i,
440
+ field: `findings[${i}].is_atomic`,
441
+ message: 'is_atomic must be a boolean',
442
+ })
443
+ }
444
+ if (
445
+ f.line !== undefined &&
446
+ f.line !== null &&
447
+ (typeof f.line !== 'number' || !Number.isInteger(f.line) || f.line < 0)
448
+ ) {
449
+ issues.push({
450
+ index: i,
451
+ field: `findings[${i}].line`,
452
+ message: 'line must be a non-negative integer (0 = whole-file)',
453
+ })
454
+ }
455
+ }
456
+ return issues
457
+ }
458
+
459
+ /** Per-round schema validation outcome. */
460
+ export interface RoundSchemaValidation {
461
+ round: number
462
+ /** True when every finding in the round conforms to the schema. */
463
+ valid: boolean
464
+ issues: FindingSchemaIssue[]
465
+ }
466
+
467
+ /**
468
+ * Validate every round's findings against the findings schema.
469
+ * Round order matches the input `rounds` array.
470
+ */
471
+ export function validateRoundsSchema(rounds: ReviewRound[]): RoundSchemaValidation[] {
472
+ return rounds.map((r) => {
473
+ const issues = validateFindingsSchema(r.findings)
474
+ return { round: r.round, valid: issues.length === 0, issues }
475
+ })
476
+ }
477
+
478
+ /**
479
+ * Drop schema-invalid findings before they reach the deterministic core.
480
+ *
481
+ * - With a non-null `schemaValidation` (validation enabled): drop every finding
482
+ * flagged by a schema issue; a round-level issue (index -1) empties the round.
483
+ * - With `schemaValidation === null` (validation disabled): still drop entries
484
+ * that are not plain objects, which would crash `findingKey`/dedupe.
485
+ *
486
+ * Round order and round numbers are preserved so downstream convergence math
487
+ * keeps working on the sanitized stream.
488
+ */
489
+ export function sanitizeRounds(
490
+ rounds: ReviewRound[],
491
+ schemaValidation: RoundSchemaValidation[] | null,
492
+ ): ReviewRound[] {
493
+ return rounds.map((r, i) => {
494
+ if (schemaValidation) {
495
+ const issues = schemaValidation[i]?.issues ?? []
496
+ if (issues.some((iss) => iss.index === -1)) return { round: r.round, findings: [] }
497
+ const bad = new Set(issues.map((iss) => iss.index))
498
+ return {
499
+ round: r.round,
500
+ findings: r.findings.filter((_, fi) => !bad.has(fi)),
501
+ }
502
+ }
503
+ return {
504
+ round: r.round,
505
+ findings: r.findings.filter(
506
+ (f): f is ReviewFinding =>
507
+ Boolean(f) && typeof f === 'object' && !Array.isArray(f),
508
+ ),
509
+ }
510
+ })
318
511
  }
319
512
 
320
513
  /**
@@ -331,6 +524,21 @@ export function reviewerTaskPrompt(input: {
331
524
  outputLanguage: string
332
525
  /** Atomic fix threshold from config.atomic. */
333
526
  maxLines: number
527
+ /**
528
+ * Files to review under `changed-only` scope (relative paths, resolved via
529
+ * git diff against `git.target_branch`). Omitted/empty for `full` scope or
530
+ * when no changes were detected (auto-fallback to full).
531
+ */
532
+ changedFiles?: string[]
533
+ /**
534
+ * The exact file inventory this reviewer is RESPONSIBLE for. When provided,
535
+ * a mandatory COVERAGE RULE is injected: the reviewer must actually open
536
+ * every listed file with read_file and return a `readFiles` array of what it
537
+ * opened (the enforcement half of "每个子 agent 必须逐文件读取自己负责的审查
538
+ * 范围"). Used for the chunked full-scope case; takes precedence over
539
+ * `changedFiles`.
540
+ */
541
+ scopeFiles?: string[]
334
542
  }): string {
335
543
  const parts: string[] = []
336
544
  parts.push(
@@ -338,6 +546,29 @@ export function reviewerTaskPrompt(input: {
338
546
  `Goal: ${input.goal}`,
339
547
  `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
340
548
  )
549
+ if (input.scopeFiles && input.scopeFiles.length > 0) {
550
+ parts.push(
551
+ 'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
552
+ 'assigned to review. You MUST open EVERY file in this inventory with ' +
553
+ 'the read_file tool before judging it — do not skip, skim-declare, or ' +
554
+ 'assume any file without reading it. Files you did not actually open ' +
555
+ 'are considered un-reviewed and will lower your coverage score. ' +
556
+ 'Return a `readFiles` array listing every file you actually opened.',
557
+ 'Assigned file inventory:',
558
+ input.scopeFiles.map((p) => `- ${p}`).join('\n'),
559
+ )
560
+ } else if (
561
+ input.scope === 'changed-only' &&
562
+ input.changedFiles &&
563
+ input.changedFiles.length > 0
564
+ ) {
565
+ parts.push(
566
+ 'Changed files to review (review ONLY these files; they are the diff against ' +
567
+ 'the target branch). You MUST open EVERY listed file with read_file ' +
568
+ 'before judging it — never skip or assume a file:',
569
+ input.changedFiles.map((p) => `- ${p}`).join('\n'),
570
+ )
571
+ }
341
572
  if (input.mode === 'dry-run') {
342
573
  parts.push(
343
574
  'MODE: dry-run / pure review. You MUST NOT modify, create, or delete ANY file. Read-only analysis only.',
@@ -359,7 +590,7 @@ export function reviewerTaskPrompt(input: {
359
590
  'poisoned evidence. Anchor every finding to real code.',
360
591
  )
361
592
  parts.push(
362
- `Return a JSON object: {"findings": [...]}.`,
593
+ `Return a JSON object: {"findings": [...], "readFiles": [...]}.`,
363
594
  `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
364
595
  'line (REQUIRED positive integer — the exact line you READ for an ' +
365
596
  'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
@@ -382,6 +613,20 @@ export function buildReviewPlan(input: {
382
613
  mode: 'normal' | 'dry-run'
383
614
  maxReviewRounds: number
384
615
  knownIntentional?: KnownIntentional[]
616
+ /**
617
+ * Files changed against `git.target_branch` (relative paths), resolved by the
618
+ * tool when `review.scope` is `changed-only`. When the configured scope is
619
+ * `changed-only` but no changes are detected, the plan auto-falls back to
620
+ * `full` (mirrors SKILL.md: "无改动文件时自动 fallback 为 full").
621
+ */
622
+ changedFiles?: string[]
623
+ /**
624
+ * Pre-collected source inventory for a `full`-scope review. When provided,
625
+ * it is split into `config.reviewer.scope_chunk_size` batches and every
626
+ * (dimension × batch) pair gets its own reviewer task owning a bounded,
627
+ * complete inventory it must open file-by-file.
628
+ */
629
+ scopeFiles?: string[]
385
630
  }): {
386
631
  mode: 'normal' | 'dry-run'
387
632
  goal: string
@@ -389,33 +634,73 @@ export function buildReviewPlan(input: {
389
634
  dimensions: { id: string; reviewerPrompt: string; findingsSchema: Record<string, unknown> }[]
390
635
  maxReviewRounds: number
391
636
  knownIntentional: KnownIntentional[]
637
+ /** Files reviewed under `changed-only` scope (empty for `full` / fallback). */
638
+ changedFiles: string[]
639
+ /** True when scope was `changed-only` but no changes were found. */
640
+ fallbackToFull: boolean
392
641
  } {
393
642
  // Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
394
643
  // `review`/`atomic` missing) must degrade to sane defaults instead of
395
644
  // throwing an uncaught TypeError inside the tool's `execute`.
396
645
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
397
646
  const goal = input.config.goal ?? ''
398
- const scope = input.config.review?.scope ?? 'full'
647
+ const configuredScope = input.config.review?.scope ?? 'full'
399
648
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
400
649
  const maxLines = input.config.atomic?.max_lines ?? 20
650
+ const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
651
+ // changed-only with zero detected changes → auto-fallback to full scope.
652
+ const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0
653
+ const scope: 'full' | 'changed-only' = effectiveChangedOnly ? 'changed-only' : 'full'
654
+ const fallbackToFull = configuredScope === 'changed-only' && changedFiles.length === 0
655
+
656
+ // Scope batching (coverage enforcement): changed-only is a single batch
657
+ // owning the full delta; full scope splits the collected inventory into
658
+ // per-chunk reviewer tasks when scopeFiles is supplied.
659
+ const chunkSize = Number(input.config.reviewer?.scope_chunk_size)
660
+ const perChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : DEFAULT_SCOPE_CHUNK_SIZE
661
+ let batches: (string[] | undefined)[]
662
+ if (effectiveChangedOnly) {
663
+ batches = [undefined]
664
+ } else if (input.scopeFiles && input.scopeFiles.length > 0) {
665
+ batches = chunkFiles(input.scopeFiles, perChunk).filter((b) => b.length > 0)
666
+ } else {
667
+ batches = [undefined]
668
+ }
669
+
670
+ const dimensionTasks: {
671
+ id: string
672
+ reviewerPrompt: string
673
+ findingsSchema: Record<string, unknown>
674
+ }[] = []
675
+ for (const d of dimensions) {
676
+ batches.forEach((batch, index) => {
677
+ const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`
678
+ dimensionTasks.push({
679
+ id: dimensionId,
680
+ reviewerPrompt: reviewerTaskPrompt({
681
+ dimension: d,
682
+ goal,
683
+ scope,
684
+ mode: input.mode,
685
+ alreadyKnown: [],
686
+ outputLanguage: language,
687
+ maxLines,
688
+ changedFiles: effectiveChangedOnly ? changedFiles : undefined,
689
+ scopeFiles: batch,
690
+ }),
691
+ findingsSchema: findingsSchema(),
692
+ })
693
+ })
694
+ }
695
+
401
696
  return {
402
697
  mode: input.mode,
403
698
  goal,
404
699
  scope,
405
- dimensions: dimensions.map((d) => ({
406
- id: d,
407
- reviewerPrompt: reviewerTaskPrompt({
408
- dimension: d,
409
- goal,
410
- scope,
411
- mode: input.mode,
412
- alreadyKnown: [],
413
- outputLanguage: language,
414
- maxLines,
415
- }),
416
- findingsSchema: findingsSchema(),
417
- })),
700
+ dimensions: dimensionTasks,
418
701
  maxReviewRounds: input.maxReviewRounds,
419
702
  knownIntentional: input.knownIntentional ?? [],
703
+ changedFiles: effectiveChangedOnly ? changedFiles : [],
704
+ fallbackToFull,
420
705
  }
421
706
  }