iterate-plugin 2.9.4 → 2.11.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.
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  `iterate-plugin` is the [iterate](https://github.com/jingzhao-l/iterate-skill) integration for the [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) desktop client. It brings iterate's review loop (review → triage → fix → validate → converge) directly into the dsh UI, offering **autonomous closed-loop code iteration** (normal mode) and **dry-run read-only multi-round review**.
25
25
 
26
- Besides 13 pure-function tools, it ships a **build-free Web UI layer** (triage panel, convergence dashboard, stats card, theme skin, etc.) that plugs straight into dsh's existing UI slots. Configuration (`iterate.config.yaml` and the review dimensions) is identical across the other two components of the iterate ecosystem (skill / headless engine) — zero migration cost.
26
+ Besides 13 pure-function tools, it ships a **build-free Web UI layer** (triage panel, convergence dashboard, stats card, theme skin, etc.) that plugs straight into dsh's existing UI slots. Configuration (`iterate.config.yaml` and the review dimensions) is identical across the other two components of the iterate ecosystem ([skill](https://github.com/jingzhao-l/iterate-skill) / [headless engine](https://github.com/jingzhao-l/iterate-harness)) — zero migration cost.
27
27
 
28
28
  ## Features
29
29
 
package/README.zh-CN.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  `iterate-plugin` 是 [iterate](https://github.com/jingzhao-l/iterate-skill) 项目在 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 桌面客户端中的落地插件。它把 iterate 的开环审查闭环(review → triage → fix → validate → 收敛)直接带进 dsh 的界面:提供**自治闭环代码迭代**(normal 模式)与 **dry-run 纯多轮审查**(只读)两种能力。
25
25
 
26
- 除 13 个纯函数工具外,还内置一套**免构建的 Web UI 层**(分诊面板、收敛看板、统计卡片、主题皮肤等),直接挂在 dsh 客户端的既有 UI 槽位上。配置方式(`iterate.config.yaml` 与审查维度)与迭代生态的另外两个组件(技能 / 无头引擎)完全一致,迁移零成本。
26
+ 除 13 个纯函数工具外,还内置一套**免构建的 Web UI 层**(分诊面板、收敛看板、统计卡片、主题皮肤等),直接挂在 dsh 客户端的既有 UI 槽位上。配置方式(`iterate.config.yaml` 与审查维度)与迭代生态的另外两个组件([技能](https://github.com/jingzhao-l/iterate-skill) / [无头引擎](https://github.com/jingzhao-l/iterate-harness))完全一致,迁移零成本。
27
27
 
28
28
  ## 特性
29
29
 
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve, sep } from 'node:path';
4
4
  import yaml from 'js-yaml';
@@ -72,6 +72,11 @@ export function mergeConfig(base, override) {
72
72
  for (const [key, value] of Object.entries(override)) {
73
73
  if (value === undefined)
74
74
  continue;
75
+ // Prototype-pollution guard: a YAML `__proto__`/`constructor`/`prototype`
76
+ // key must never be plain-assigned — js-yaml stores __proto__ as an own
77
+ // data property, and `out[key] = value` would invoke the __proto__ setter.
78
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
79
+ continue;
75
80
  const baseValue = out[key];
76
81
  if (baseValue &&
77
82
  typeof baseValue === 'object' &&
@@ -223,8 +228,14 @@ function effectiveCwd(sessionCwd) {
223
228
  if (encoded && encoded.startsWith('--') && encoded.endsWith('--')) {
224
229
  try {
225
230
  const decoded = decodeURIComponent(encoded.slice(2, -2).replace(/~/g, '%'));
226
- if (decoded && decoded.startsWith(sep))
227
- return decoded;
231
+ // The workspace encoding drops the leading root separator (`/Volumes/…`
232
+ // → `Volumes-…`), so re-attach it when absent. `~<hex>` → `%<hex>` is
233
+ // the documented percent spelling; '-' doubles as the '/' separator, so
234
+ // literal dashes in a path cannot round-trip — verify the result exists
235
+ // and fall through otherwise.
236
+ const candidate = decoded && !decoded.startsWith(sep) ? sep + decoded : decoded;
237
+ if (candidate && candidate.startsWith(sep) && existsSync(candidate))
238
+ return candidate;
228
239
  }
229
240
  catch {
230
241
  // malformed encoding — fall through to cwd
@@ -9,7 +9,7 @@
9
9
  * The security posture mirrors the triage tool: never overwrite a malformed
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import yaml from 'js-yaml';
15
15
  /** Config file name (must match config-loader). */
@@ -161,14 +161,17 @@ export function writeConfigFile(projectRoot, config) {
161
161
  writeFileSync(configPath, yaml.dump(config, { noRefs: true }), 'utf-8');
162
162
  }
163
163
  catch (err) {
164
+ let rollbackError = '';
164
165
  try {
165
166
  if (backupPath)
166
167
  copyFileSync(backupPath, configPath);
168
+ else if (existsSync(configPath))
169
+ rmSync(configPath, { force: true });
167
170
  }
168
- catch {
169
- // Rollback failure is reported, never swallowed silently.
171
+ catch (rbErr) {
172
+ rollbackError = `; rollback also failed: ${String(rbErr)}`;
170
173
  }
171
- return { ok: false, error: `failed to write config: ${String(err)}` };
174
+ return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` };
172
175
  }
173
176
  return { ok: true, backupPath };
174
177
  }
package/dist/evidence.js CHANGED
@@ -22,10 +22,17 @@
22
22
  * The pure math (`countLines`, `verifyLineBounds`) is separated from the
23
23
  * filesystem half (`verifyFinding`) to stay unit-testable without touching disk.
24
24
  */
25
- import { existsSync, readFileSync } from 'node:fs';
25
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
26
26
  import { resolve, sep } from 'node:path';
27
27
  /** Sentinel for whole-file findings (line 0 or omitted means the whole file). */
28
28
  export const WHOLE_FILE_LINE = 0;
29
+ /**
30
+ * Hard cap on a single evidence file read. `verifyFinding` only needs the
31
+ * line count + a NUL check; reading an unbounded file (or a device file
32
+ * reached through a symlink) is a memory/hang hazard, so anything larger is
33
+ * treated as not line-addressable.
34
+ */
35
+ const MAX_EVIDENCE_BYTES = 10 * 1024 * 1024;
29
36
  /** Number of physical lines in `text`. A trailing newline does not add a line. */
30
37
  export function countLines(text) {
31
38
  if (text === '')
@@ -51,6 +58,22 @@ export function resolveWithin(root, rel) {
51
58
  return null;
52
59
  return resolved;
53
60
  }
61
+ /** True when `candidate` is `root` itself or lexically inside `root`. */
62
+ function isWithin(root, candidate) {
63
+ if (candidate === root)
64
+ return true;
65
+ const prefix = root.endsWith(sep) ? root : root + sep;
66
+ return candidate.startsWith(prefix);
67
+ }
68
+ /** best-effort realpath; falls back to the lexical path on any failure. */
69
+ function safeRealpath(p) {
70
+ try {
71
+ return realpathSync(p);
72
+ }
73
+ catch {
74
+ return p;
75
+ }
76
+ }
54
77
  /**
55
78
  * Pure check that `line` (if anchored) exists in `text`.
56
79
  * Whole-file findings (undefined/0) are always bounds-valid.
@@ -79,6 +102,49 @@ export function verifyFinding(root, input, opts = {}) {
79
102
  error: 'file_not_found',
80
103
  };
81
104
  }
105
+ // Symlink containment: resolveWithin is lexical only, but existsSync /
106
+ // readFileSync follow symlinks. Verify the REAL path stays inside the REAL
107
+ // project root so a finding path can never read (or line-count) a file
108
+ // outside the project via a symlinked directory or file.
109
+ const rootReal = safeRealpath(root);
110
+ const real = safeRealpath(resolved);
111
+ if (!isWithin(rootReal, real)) {
112
+ return {
113
+ file: relFile,
114
+ line,
115
+ lineTotal: null,
116
+ resolvedPath: resolved,
117
+ verified: false,
118
+ error: 'file_not_found',
119
+ };
120
+ }
121
+ // Regular-file + size guard: a directory, device file (/dev/zero), FIFO or
122
+ // multi-GB file is not a line-addressable text target. statSync follows
123
+ // symlinks, so a link to a device still lands here and is rejected.
124
+ let st;
125
+ try {
126
+ st = statSync(resolved);
127
+ }
128
+ catch {
129
+ return {
130
+ file: relFile,
131
+ line,
132
+ lineTotal: null,
133
+ resolvedPath: resolved,
134
+ verified: false,
135
+ error: 'file_not_found',
136
+ };
137
+ }
138
+ if (!st.isFile() || st.size > MAX_EVIDENCE_BYTES) {
139
+ return {
140
+ file: relFile,
141
+ line,
142
+ lineTotal: null,
143
+ resolvedPath: resolved,
144
+ verified: false,
145
+ error: 'line_out_of_range',
146
+ };
147
+ }
82
148
  let raw;
83
149
  try {
84
150
  raw = readFileSync(resolved);
package/dist/git-scope.js CHANGED
@@ -24,14 +24,31 @@ import { execFile } from 'node:child_process';
24
24
  import { existsSync, statSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  /**
27
- * Parse `git diff --name-only` stdout into a list of relative paths.
28
- * Pure: strips blank lines, trims whitespace, drops quotes (git can quote
29
- * paths with special characters).
27
+ * Parse `git diff --name-only -z` stdout into a list of relative paths.
28
+ * Pure. NUL-delimited mode is machine-safe (handles any filename); when no
29
+ * NUL is present (callers that did not pass -z) fall back to newline-split
30
+ * with C-style quote/escape unescaping for core.quotePath output.
30
31
  */
31
32
  export function parseChangedFiles(stdout) {
33
+ if (stdout.includes('\0')) {
34
+ return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0);
35
+ }
32
36
  return stdout
33
37
  .split('\n')
34
- .map((line) => line.trim().replace(/^"|"$/g, ''))
38
+ .map((line) => {
39
+ const trimmed = line.trim();
40
+ // git core.quotePath wraps paths with special characters in "..."; the
41
+ // content uses C-style escapes (\" \\ \t \n and \ooo octal for non-ASCII).
42
+ const quoted = trimmed.match(/^"(.*)"$/);
43
+ if (!quoted)
44
+ return trimmed;
45
+ return quoted[1]
46
+ .replace(/\\"/g, '"')
47
+ .replace(/\\\\/g, '\\')
48
+ .replace(/\\t/g, '\t')
49
+ .replace(/\\n/g, '\n')
50
+ .replace(/\\([0-7]{3})/g, (_m, oct) => String.fromCharCode(parseInt(oct, 8)));
51
+ })
35
52
  .filter((line) => line.length > 0);
36
53
  }
37
54
  /**
@@ -90,9 +107,21 @@ export function runGit(args, cwd) {
90
107
  * plan because git is unavailable.
91
108
  */
92
109
  export async function resolveChangedFiles(root, targetBranch) {
93
- const { ok, stdout, stderr } = await runGit(['diff', '--name-only', targetBranch, '--'], root);
110
+ // Option-injection guard: a branch name starting with '-' would be parsed by
111
+ // git as an option (e.g. --output=...), not a ref. Reject it outright.
112
+ if (typeof targetBranch !== 'string' || targetBranch.trim() === '' || targetBranch.startsWith('-')) {
113
+ return {
114
+ scope: 'full',
115
+ changedFiles: [],
116
+ fallbackToFull: true,
117
+ error: `invalid target branch "${String(targetBranch)}"`,
118
+ };
119
+ }
120
+ // -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
121
+ // non-ASCII), and never confused with option-like content.
122
+ const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root);
94
123
  if (!ok) {
95
- const reason = stderr.trim() || `git diff --name-only ${targetBranch} failed`;
124
+ const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`;
96
125
  return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason };
97
126
  }
98
127
  const existing = filterExistingFiles(root, parseChangedFiles(stdout));
@@ -14,8 +14,13 @@
14
14
  * lives here.
15
15
  */
16
16
  import { sortFindings } from "./review.js";
17
- /** Number of distinct consistency checks performed by `metaReviewReport`. */
18
- export const META_REVIEW_CHECKS = 6;
17
+ /**
18
+ * Number of distinct consistency checks performed by `metaReviewReport`.
19
+ * The check set is: COUNT_MATCH, SEVERITY_SUM, DIMENSION_SUM, DIMENSION_UNKNOWN,
20
+ * SORT_ORDER, CONVERGENCE_SUM, CONVERGENCE_FLAG, ROUND_NUMBER, ROUND_EMPTY,
21
+ * ROUND_GAP.
22
+ */
23
+ export const META_REVIEW_CHECKS = 10;
19
24
  /**
20
25
  * How many uncovered scope files are listed in a COVERAGE_GAP hint before the
21
26
  * remainder is folded into a "+N more" suffix.
@@ -146,9 +151,18 @@ export function metaReviewReport(report) {
146
151
  'which finding nothing new is the expected success signal.');
147
152
  }
148
153
  }
149
- for (let i = 1; i <= rounds.length; i++) {
150
- if (!seenRounds.has(i)) {
151
- add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${[...seenRounds].sort((a, b) => a - b).join(', ') || 'none'}.`);
154
+ // ROUND_GAP: only flag gaps WITHIN the range of actually-present round
155
+ // numbers. Non-contiguous starts (e.g. a resumed run beginning at round 5)
156
+ // and arbitrary round numbering are supported by the aggregate engine, so
157
+ // missing 1..N prefixes are NOT defects. Checks min..max of present rounds.
158
+ const present = [...seenRounds].sort((a, b) => a - b);
159
+ if (present.length > 0) {
160
+ const min = present[0];
161
+ const max = present[present.length - 1];
162
+ for (let i = min; i <= max; i++) {
163
+ if (!seenRounds.has(i)) {
164
+ add('ROUND_GAP', 'medium', `Round ${i} is missing from the round sequence`, `rounds present: ${present.join(', ')}.`);
165
+ }
152
166
  }
153
167
  }
154
168
  const passed = issues.length === 0;
@@ -70,6 +70,8 @@ const SIGNATURE_PATTERNS = [
70
70
  export function collectMethodSignatures(text) {
71
71
  const lines = text.split('\n');
72
72
  const out = [];
73
+ // Set lookup instead of scanning the growing array (O(S²) → O(S)).
74
+ const seen = new Set();
73
75
  for (let i = 0; i < lines.length; i++) {
74
76
  const raw = lines[i];
75
77
  const line = i + 1;
@@ -81,8 +83,10 @@ export function collectMethodSignatures(text) {
81
83
  if (!name || RESERVED_WORDS.has(name) || CALLABLE_NOISE.has(name))
82
84
  continue;
83
85
  // Avoid two patterns claiming the same line (e.g. TS method + arrow).
84
- if (out.some((s) => s.line === line && s.name === name))
86
+ const key = `${line}|${name}`;
87
+ if (seen.has(key))
85
88
  break;
89
+ seen.add(key);
86
90
  out.push({ name, line });
87
91
  break;
88
92
  }
@@ -99,22 +99,25 @@ function collectChanged(changedFiles) {
99
99
  return [...out].sort();
100
100
  }
101
101
  function collectFull(root) {
102
- // Deterministic recursive walk built on Node's fs; a code reviewer never
103
- // anchors findings to lock files, images, or vendored builds.
102
+ // Deterministic iterative walk (explicit stack unbounded recursion could
103
+ // overflow on pathologically deep trees); a code reviewer never anchors
104
+ // findings to lock files, images, or vendored builds.
104
105
  const out = [];
105
- const walk = (dir) => {
106
+ const stack = [root];
107
+ while (stack.length > 0) {
108
+ const dir = stack.pop();
106
109
  let entries;
107
110
  try {
108
111
  entries = readdirSync(dir, { withFileTypes: true });
109
112
  }
110
113
  catch {
111
- return;
114
+ continue;
112
115
  }
113
116
  for (const entry of entries) {
114
117
  const abs = join(dir, entry.name);
115
118
  if (entry.isDirectory()) {
116
119
  if (!isIgnoredDir(entry.name))
117
- walk(abs);
120
+ stack.push(abs);
118
121
  continue;
119
122
  }
120
123
  if (!entry.isFile())
@@ -124,13 +127,14 @@ function collectFull(root) {
124
127
  const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs;
125
128
  out.push(rel.split(SEP).join(SEP));
126
129
  }
127
- };
128
- walk(root);
130
+ }
129
131
  return out.sort();
130
132
  }
131
133
  /** Split `files` into stable batches, keeping directory runs together. */
132
134
  export function chunkFiles(files, perChunk) {
133
- const size = perChunk === undefined || perChunk < 1 ? DEFAULT_SCOPE_CHUNK_SIZE : perChunk;
135
+ // Number.isFinite: NaN fails `perChunk < 1` and would yield one unbounded
136
+ // chunk (current.length >= NaN is never true).
137
+ const size = Number.isFinite(perChunk) && perChunk >= 1 ? perChunk : DEFAULT_SCOPE_CHUNK_SIZE;
134
138
  const ordered = [...files].sort();
135
139
  const chunks = [];
136
140
  let current = [];
package/dist/review.js CHANGED
@@ -35,10 +35,12 @@ export function sortFindings(findings) {
35
35
  const bySeverity = rankA - rankB;
36
36
  if (bySeverity !== 0)
37
37
  return bySeverity;
38
- const byFile = a.file.localeCompare(b.file);
38
+ // Defensive coercion: `file`/`line` can be wrong-typed when schema
39
+ // validation is disabled — String()/Number() keep the comparator total.
40
+ const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''));
39
41
  if (byFile !== 0)
40
42
  return byFile;
41
- return (a.line ?? 0) - (b.line ?? 0);
43
+ return (Number(a.line) || 0) - (Number(b.line) || 0);
42
44
  });
43
45
  }
44
46
  /** Normalize a summary so near-identical duplicates collapse to one key. */
@@ -48,9 +50,15 @@ export function normalizeSummary(summary) {
48
50
  .toLowerCase()
49
51
  .replace(/[\s\n\t]+/g, ' ');
50
52
  }
51
- /** Dedupe key: same file + same dimension + similar summary. */
53
+ /**
54
+ * Dedupe key: same file + same dimension + similar summary + explicit line.
55
+ * Including the line keeps two genuine issues with identical wording at
56
+ * different locations from collapsing into one (the line is omitted only when
57
+ * neither side anchors one, i.e. whole-file findings).
58
+ */
52
59
  export function findingKey(f) {
53
- return `${f.file}|${f.dimension}|${normalizeSummary(f.summary)}`;
60
+ const line = typeof f.line === 'number' && f.line > 0 ? f.line : 0;
61
+ return `${f.file}|${f.dimension}|${line}|${normalizeSummary(f.summary)}`;
54
62
  }
55
63
  /**
56
64
  * Remove duplicate findings within a list.
@@ -109,14 +117,22 @@ export function aggregateRounds(rounds, maxReviewRounds) {
109
117
  const firstRoundByKey = new Map();
110
118
  const merged = [];
111
119
  // Guard: round numbers are expected to be positive integers. Skip malformed
112
- // entries defensively rather than letting `firstRoundByKey` key on NaN/0.
120
+ // entries defensively rather than letting `firstRoundByKey` key on NaN/0 or
121
+ // crashing on null / non-array findings.
122
+ // Hard ceiling: round numbers are model-authored JSON; an absurd round (e.g.
123
+ // 1e9) would otherwise allocate an array of that size below (OOM). Round
124
+ // numbers above the configured cap are clamped to the cap.
113
125
  let maxRound = 0;
126
+ const roundCap = Math.max(1, maxReviewRounds);
114
127
  for (const round of rounds) {
128
+ if (!round || typeof round !== 'object')
129
+ continue;
115
130
  if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
116
131
  continue;
132
+ const findings = Array.isArray(round.findings) ? round.findings : [];
117
133
  if (round.round > maxRound)
118
134
  maxRound = round.round;
119
- for (const f of round.findings) {
135
+ for (const f of findings) {
120
136
  const key = findingKey(f);
121
137
  if (seen.has(key))
122
138
  continue;
@@ -125,8 +141,10 @@ export function aggregateRounds(rounds, maxReviewRounds) {
125
141
  merged.push(f);
126
142
  }
127
143
  }
144
+ // Clamp the allocation bound so a hostile round number cannot OOM the tool.
145
+ const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2));
128
146
  const findingsByRound = [];
129
- for (let r = 1; r <= maxRound; r++) {
147
+ for (let r = 1; r <= effectiveMax; r++) {
130
148
  let count = 0;
131
149
  for (const key of firstRoundByKey.keys()) {
132
150
  if (firstRoundByKey.get(key) === r)
@@ -143,11 +161,20 @@ export function computeConvergence(rounds, maxReviewRounds) {
143
161
  const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds);
144
162
  const totalRounds = rounds.length;
145
163
  // `findingsByRound` is indexed by the actual round number (round r → index
146
- // r-1), so convergence must read the LAST PRESENT round's count using its
147
- // reported round number — not `totalRounds - 1`, which is only valid for
148
- // contiguous 1..N round numbers.
149
- const lastRound = totalRounds > 0 ? rounds[totalRounds - 1].round : 0;
150
- const lastRoundCount = lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0;
164
+ // r-1), sized to the highest present round (clamped). Convergence must read
165
+ // the HIGHEST PRESENT round's count — not the last array element (rounds
166
+ // may arrive unsorted) and not `totalRounds - 1` (only valid for contiguous
167
+ // 1..N). The count index is bounded by the array length aggregateRounds
168
+ // actually allocated.
169
+ let lastRound = 0;
170
+ for (const round of rounds) {
171
+ if (!round || typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1)
172
+ continue;
173
+ if (round.round > lastRound)
174
+ lastRound = round.round;
175
+ }
176
+ const idx = Math.min(lastRound, findingsByRound.length) - 1;
177
+ const lastRoundCount = idx >= 0 ? (findingsByRound[idx] ?? 0) : 0;
151
178
  const converged = totalRounds > 0 && lastRoundCount === 0;
152
179
  return {
153
180
  totalRounds,
@@ -193,8 +220,9 @@ function summarize(findings) {
193
220
  export function buildReviewReport(input) {
194
221
  // 1. Filter known-intentional per round (before cross-round dedupe).
195
222
  const filteredRounds = input.rounds.map((r) => ({
196
- round: r.round,
197
- findings: filterKnownIntentional(r.findings, input.knownIntentional),
223
+ round: typeof r?.round === 'number' ? r.round : 0,
224
+ findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
225
+ readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
198
226
  }));
199
227
  // 2. Cross-round dedupe + per-round "first seen" tracking.
200
228
  const { findings, findingsByRound } = aggregateRounds(filteredRounds, input.maxReviewRounds);
@@ -221,6 +249,9 @@ export function buildReviewReport(input) {
221
249
  maxReviewRounds: input.maxReviewRounds,
222
250
  rounds: filteredRounds,
223
251
  findings: sorted,
252
+ // Aggregate of every round's self-reported reads, so the meta-review
253
+ // coverage gate can compare against the assigned inventory.
254
+ readFiles: [].concat(...filteredRounds.map((r) => r.readFiles ?? [])),
224
255
  convergence: {
225
256
  totalRounds: filteredRounds.length,
226
257
  findingsByRound,
@@ -394,8 +425,8 @@ export function validateFindingsSchema(input) {
394
425
  */
395
426
  export function validateRoundsSchema(rounds) {
396
427
  return rounds.map((r) => {
397
- const issues = validateFindingsSchema(r.findings);
398
- return { round: r.round, valid: issues.length === 0, issues };
428
+ const issues = validateFindingsSchema(Array.isArray(r?.findings) ? r.findings : []);
429
+ return { round: typeof r?.round === 'number' ? r.round : 0, valid: issues.length === 0, issues };
399
430
  });
400
431
  }
401
432
  /**
@@ -411,19 +442,25 @@ export function validateRoundsSchema(rounds) {
411
442
  */
412
443
  export function sanitizeRounds(rounds, schemaValidation) {
413
444
  return rounds.map((r, i) => {
445
+ // Defensive: malformed rounds must never crash the deterministic core.
446
+ const findings = Array.isArray(r?.findings) ? r.findings : [];
447
+ const roundNo = typeof r?.round === 'number' ? r.round : 0;
448
+ const readFiles = Array.isArray(r?.readFiles) ? r.readFiles : [];
414
449
  if (schemaValidation) {
415
450
  const issues = schemaValidation[i]?.issues ?? [];
416
451
  if (issues.some((iss) => iss.index === -1))
417
- return { round: r.round, findings: [] };
452
+ return { round: roundNo, findings: [], readFiles };
418
453
  const bad = new Set(issues.map((iss) => iss.index));
419
454
  return {
420
- round: r.round,
421
- findings: r.findings.filter((_, fi) => !bad.has(fi)),
455
+ round: roundNo,
456
+ findings: findings.filter((_, fi) => !bad.has(fi)),
457
+ readFiles,
422
458
  };
423
459
  }
424
460
  return {
425
- round: r.round,
426
- findings: r.findings.filter((f) => Boolean(f) && typeof f === 'object' && !Array.isArray(f)),
461
+ round: roundNo,
462
+ findings: findings.filter((f) => Boolean(f) && typeof f === 'object' && !Array.isArray(f)),
463
+ readFiles,
427
464
  };
428
465
  });
429
466
  }
@@ -435,6 +472,9 @@ export function sanitizeRounds(rounds, schemaValidation) {
435
472
  export function reviewerTaskPrompt(input) {
436
473
  const parts = [];
437
474
  parts.push(`You are the "${input.dimension}" reviewer for the iterate review.`, `Goal: ${input.goal}`, `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`);
475
+ if (input.focus) {
476
+ parts.push(`FOCUS: ${input.focus}`);
477
+ }
438
478
  if (input.scopeFiles && input.scopeFiles.length > 0) {
439
479
  parts.push('COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
440
480
  'assigned to review. You MUST open EVERY file in this inventory with ' +
@@ -465,9 +505,9 @@ export function reviewerTaskPrompt(input) {
465
505
  'disqualifying failure, and fabricated line numbers are treated as ' +
466
506
  'poisoned evidence. Anchor every finding to real code.');
467
507
  parts.push(`Return a JSON object: {"findings": [...], "readFiles": [...]}.`, `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
468
- 'line (REQUIRED positive integer — the exact line you READ for an ' +
469
- 'anchored, line-targeted issue; use 0 for whole-file/module-level ' +
470
- 'issues), severity (critical/high/medium/low), summary (one line), ' +
508
+ 'line (optional; the exact line you READ for a line-targeted issue; ' +
509
+ '0 or omitted for whole-file/module-level issues), ' +
510
+ 'severity (critical/high/medium/low), summary (one line), ' +
471
511
  'failure_scenario (how/when it fails, backed by the code you actually ' +
472
512
  'read), suggested_fix (the concrete fix), ' +
473
513
  `is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`, `Write summaries and details in ${input.outputLanguage}.`);
@@ -508,6 +548,17 @@ export function buildReviewPlan(input) {
508
548
  batches = [undefined];
509
549
  }
510
550
  const dimensionTasks = [];
551
+ // personalization.dimension_focus: [{dimension, focus}] — appended to the
552
+ // matching dimension's reviewer prompt.
553
+ const focusMap = new Map();
554
+ const pf = input.config.personalization;
555
+ if (pf && Array.isArray(pf.dimension_focus)) {
556
+ for (const entry of pf.dimension_focus) {
557
+ if (entry && typeof entry.dimension === 'string' && typeof entry.focus === 'string' && entry.focus) {
558
+ focusMap.set(entry.dimension, entry.focus);
559
+ }
560
+ }
561
+ }
511
562
  for (const d of dimensions) {
512
563
  batches.forEach((batch, index) => {
513
564
  const dimensionId = batches.length === 1 ? d : `${d}#${index + 1}`;
@@ -523,6 +574,7 @@ export function buildReviewPlan(input) {
523
574
  maxLines,
524
575
  changedFiles: effectiveChangedOnly ? changedFiles : undefined,
525
576
  scopeFiles: batch,
577
+ focus: focusMap.get(d),
526
578
  }),
527
579
  findingsSchema: findingsSchema(),
528
580
  });
@@ -88,14 +88,28 @@ for (let r = 1; r <= maxRounds; r++) {
88
88
  const nudge = retries > 0
89
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).'
90
90
  : ''
91
- const raw = await parallel(dims.map(dim => () => agent(
92
- 'Review dimension "' + dim + '".' +
93
- (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
94
- ' Already-known findings (do NOT re-report): ' +
95
- JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.',
96
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
97
- )))
98
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
91
+ const raw = await parallel(dims.map(dim => () => {
92
+ // Pass the plan's full per-dimension reviewerPrompt (goal, COVERAGE RULE
93
+ // with the assigned file inventory, EVIDENCE RULE, output language) and
94
+ // append the round-specific context the reviewers must receive the
95
+ // file inventory or the coverage machinery has nothing to enforce.
96
+ const meta = plan.dimensions.find(x => x.id === dim)
97
+ const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
98
+ ? meta.reviewerPrompt
99
+ : 'Review dimension "' + dim + '".'
100
+ const extra =
101
+ (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
102
+ '\\n Already-known findings (do NOT re-report): ' +
103
+ JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
104
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
105
+ }))
106
+ const thisRound = {
107
+ round: r,
108
+ findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
109
+ // readFiles are threaded through so the aggregate/meta-review coverage
110
+ // gate can compare self-reported reads against the assigned inventory.
111
+ readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
112
+ }
99
113
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
100
114
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
101
115
  agg = await agent(
@@ -228,6 +242,11 @@ let failedCommands = []
228
242
  phase('loop')
229
243
  for (let r = startRound; r <= maxRounds; r++) {
230
244
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
245
+ // Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
246
+ await agent(
247
+ 'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
248
+ Object.assign({ label: 'log:start:r' + r }, backend)
249
+ )
231
250
  let agg = null
232
251
  let schemaInvalid = false
233
252
  let retries = 0
@@ -236,13 +255,24 @@ for (let r = startRound; r <= maxRounds; r++) {
236
255
  const nudge = retries > 0
237
256
  ? '\\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).'
238
257
  : ''
239
- const raw = await parallel(dims.map(dim => () => agent(
240
- 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed). ' +
241
- (attachments.length > 0 ? ' User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
242
- 'Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.',
243
- Object.assign({ label: 'review:' + dim + ':r' + r, schema: plan.dimensions.find(x => x.id === dim).findingsSchema }, backend)
244
- )))
245
- const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
258
+ const raw = await parallel(dims.map(dim => () => {
259
+ // Pass the plan's full per-dimension reviewerPrompt (COVERAGE RULE with
260
+ // the assigned file inventory, EVIDENCE RULE, output language) plus the
261
+ // round-specific context.
262
+ const meta = plan.dimensions.find(x => x.id === dim)
263
+ const base = (meta && typeof meta.reviewerPrompt === 'string' && meta.reviewerPrompt)
264
+ ? meta.reviewerPrompt
265
+ : 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
266
+ const extra =
267
+ (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
268
+ '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
269
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
270
+ }))
271
+ const thisRound = {
272
+ round: r,
273
+ findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
274
+ readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
275
+ }
246
276
  if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
247
277
 
248
278
  // Deterministic dedupe / known_intentional filter / severity sort for this round.