iterate-plugin 3.5.5 → 3.5.6

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/lib/client.js CHANGED
@@ -1211,6 +1211,26 @@ function trendMax(points) {
1211
1211
  }
1212
1212
  return max;
1213
1213
  }
1214
+ function stoppedReasonLabel(reason) {
1215
+ switch (reason) {
1216
+ case "converged":
1217
+ return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
1218
+ case "max_rounds_reached":
1219
+ return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
1220
+ case "aborted_by_validation":
1221
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
1222
+ case "aborted_by_config":
1223
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
1224
+ case "inconclusive":
1225
+ return "\u5DF2\u7ED3\u675F \xB7 \u8F6E\u6B21\u7ED3\u8BBA\u4E0D\u660E\uFF08\u5BA1\u67E5\u8F93\u51FA\u65E0\u6548\uFF09";
1226
+ case "schema_invalid":
1227
+ return "\u5DF2\u7ED3\u675F \xB7 \u5BA1\u67E5\u8F93\u51FA\u8FDE\u7EED schema \u6821\u9A8C\u5931\u8D25";
1228
+ case "no_usable_reviewer_output":
1229
+ return "\u5DF2\u7ED3\u675F \xB7 \u65E0\u53EF\u7528\u5BA1\u67E5\u8F93\u51FA";
1230
+ default:
1231
+ return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
1232
+ }
1233
+ }
1214
1234
  function buildCompletionSummary(report) {
1215
1235
  const conv = (
1216
1236
  /** @type {Record<string, unknown>} */
@@ -1229,6 +1249,7 @@ var CONFIG_EDIT_FIELDS = [
1229
1249
  { key: "dimensions", label: "\u5BA1\u67E5\u7EF4\u5EA6", hint: '\u6570\u7EC4\uFF0C\u5982 ["correctness","security"]' },
1230
1250
  { key: "max_rounds", label: "\u6700\u5927\u8F6E\u6570", hint: "\u6B63\u6574\u6570" },
1231
1251
  { key: "review.scope", label: "\u5BA1\u67E5\u8303\u56F4", hint: '"full" \u6216 "changed-only"' },
1252
+ { key: "reasoning_effort", label: "\u5BA1\u67E5\u63A8\u7406\u5F3A\u5EA6", hint: '"low" / "medium" / "high"\uFF0C\u7F3A\u7701\u8DDF\u968F\u6A21\u578B\u9ED8\u8BA4' },
1232
1253
  { key: "atomic.max_lines", label: "\u539F\u5B50\u4FEE\u590D\u4E0A\u9650\u884C\u6570", hint: "\u6B63\u6574\u6570" },
1233
1254
  { key: "git.push_per_round", label: "\u6BCF\u8F6E\u63A8\u9001", hint: "true / false" }
1234
1255
  ];
@@ -1724,20 +1745,6 @@ function severityColor(severity) {
1724
1745
  function severityLabel(severity) {
1725
1746
  return SEVERITY_LABEL[coerceSeverity(severity)];
1726
1747
  }
1727
- function stoppedReasonLabel(reason) {
1728
- switch (reason) {
1729
- case "converged":
1730
- return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
1731
- case "max_rounds_reached":
1732
- return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
1733
- case "aborted_by_validation":
1734
- return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
1735
- case "aborted_by_config":
1736
- return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
1737
- default:
1738
- return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
1739
- }
1740
- }
1741
1748
  function runStatusText(live, manifest) {
1742
1749
  if (live) return "\u8FD0\u884C\u4E2D";
1743
1750
  const reason = manifest && typeof manifest.stoppedReason === "string" ? manifest.stoppedReason : "";
@@ -1767,6 +1774,7 @@ var slotsSvc = void 0;
1767
1774
  var themeSvc = void 0;
1768
1775
  var storage = null;
1769
1776
  var themeDisposer = null;
1777
+ var themeListener = null;
1770
1778
  var themeEnabled = true;
1771
1779
  var roundPulseListeners = [];
1772
1780
  function emitRoundPulse(round, converged) {
@@ -1788,6 +1796,31 @@ function clearThemeSkin() {
1788
1796
  themeDisposer = null;
1789
1797
  }
1790
1798
  }
1799
+ function registerThemeListener(ctx) {
1800
+ if (typeof ctx.on !== "function") return;
1801
+ if (themeListener) {
1802
+ try {
1803
+ themeListener();
1804
+ } catch {
1805
+ }
1806
+ themeListener = null;
1807
+ }
1808
+ const off = ctx.on("theme/change", () => {
1809
+ if (themeEnabled) applyThemeSkin();
1810
+ });
1811
+ themeListener = typeof off === "function" ? off : null;
1812
+ if (typeof ctx.effect === "function") {
1813
+ ctx.effect(() => {
1814
+ if (themeListener) {
1815
+ try {
1816
+ themeListener();
1817
+ } catch {
1818
+ }
1819
+ themeListener = null;
1820
+ }
1821
+ });
1822
+ }
1823
+ }
1791
1824
  function setThemeEnabled(enabled) {
1792
1825
  themeEnabled = enabled;
1793
1826
  if (storage) storage.set(THEME_STORAGE_KEY, enabled ? "1" : "0");
@@ -3874,18 +3907,21 @@ function apply(ctx) {
3874
3907
  themeEnabled = savedTheme === null ? true : savedTheme === "1";
3875
3908
  if (themeEnabled) applyThemeSkin();
3876
3909
  if (typeof document !== "undefined" && document.createElement && document.head) {
3877
- const style = document.createElement("style");
3878
- style.dataset.plugin = PLUGIN_TAG;
3879
- style.dataset.pluginCss = "iterate-main";
3880
- style.textContent = ITERATE_CSS;
3881
- document.head.appendChild(style);
3882
- if (typeof ctx.effect === "function") {
3883
- ctx.effect(() => {
3884
- try {
3885
- style.remove();
3886
- } catch {
3887
- }
3888
- });
3910
+ if (!document.head.querySelector('style[data-iterate-lock="1"]')) {
3911
+ const style = document.createElement("style");
3912
+ style.dataset.iterateLock = "1";
3913
+ style.dataset.plugin = PLUGIN_TAG;
3914
+ style.dataset.pluginCss = "iterate-main";
3915
+ style.textContent = ITERATE_CSS;
3916
+ document.head.appendChild(style);
3917
+ if (typeof ctx.effect === "function") {
3918
+ ctx.effect(() => {
3919
+ try {
3920
+ style.remove();
3921
+ } catch {
3922
+ }
3923
+ });
3924
+ }
3889
3925
  }
3890
3926
  }
3891
3927
  if (slotsSvc === void 0) {
@@ -3929,11 +3965,7 @@ function apply(ctx) {
3929
3965
  )
3930
3966
  );
3931
3967
  }
3932
- if (typeof ctx.on === "function") {
3933
- ctx.on("theme/change", () => {
3934
- if (themeEnabled) applyThemeSkin();
3935
- });
3936
- }
3968
+ registerThemeListener(ctx);
3937
3969
  }
3938
3970
  return module.exports; } });
3939
3971
 
package/lib/parse.js CHANGED
@@ -1687,6 +1687,33 @@ export function trendMax(points) {
1687
1687
  return max
1688
1688
  }
1689
1689
 
1690
+ // ─── Stop reason badges (v3.5.5) ─────────────────────────────────────────────
1691
+ //
1692
+ // Every value the iterate loop can report as `stoppedReason` — including the
1693
+ // schema-retry batch (schema_invalid / no_usable_reviewer_output / inconclusive)
1694
+ // — maps to a concrete Chinese label. Unknown values still get a readable
1695
+ // default instead of a bare "已结束". Kept in parse.js so both the client
1696
+ // observatory badge and Node unit tests share one source of truth.
1697
+
1698
+ /**
1699
+ * Chinese badge label for a run's `stoppedReason`, or null/undefined-safe.
1700
+ *
1701
+ * @param {string | null | undefined} reason
1702
+ * @returns {string}
1703
+ */
1704
+ export function stoppedReasonLabel(reason) {
1705
+ switch (reason) {
1706
+ case 'converged': return '已结束 · 已收敛(无新发现)'
1707
+ case 'max_rounds_reached': return '已结束 · 达到轮数上限'
1708
+ case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
1709
+ case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
1710
+ case 'inconclusive': return '已结束 · 轮次结论不明(审查输出无效)'
1711
+ case 'schema_invalid': return '已结束 · 审查输出连续 schema 校验失败'
1712
+ case 'no_usable_reviewer_output': return '已结束 · 无可用审查输出'
1713
+ default: return reason ? `已结束 · ${reason}` : '已结束'
1714
+ }
1715
+ }
1716
+
1690
1717
  // ─── Completion notification ────────────────────────────────────────────────
1691
1718
 
1692
1719
  /**
@@ -1717,6 +1744,7 @@ export const CONFIG_EDIT_FIELDS = [
1717
1744
  { key: 'dimensions', label: '审查维度', hint: '数组,如 ["correctness","security"]' },
1718
1745
  { key: 'max_rounds', label: '最大轮数', hint: '正整数' },
1719
1746
  { key: 'review.scope', label: '审查范围', hint: '"full" 或 "changed-only"' },
1747
+ { key: 'reasoning_effort', label: '审查推理强度', hint: '"low" / "medium" / "high",缺省跟随模型默认' },
1720
1748
  { key: 'atomic.max_lines', label: '原子修复上限行数', hint: '正整数' },
1721
1749
  { key: 'git.push_per_round', label: '每轮推送', hint: 'true / false' },
1722
1750
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.5",
3
+ "version": "3.5.6",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,10 @@
39
39
  "0.1.5-rc.1": "compatible",
40
40
  "0.1.5-rc.2": "compatible",
41
41
  "0.1.6-alpha.1": "compatible",
42
- "0.1.6-alpha.2": "compatible"
42
+ "0.1.6-alpha.2": "compatible",
43
+ "0.1.7-alpha.1": "compatible",
44
+ "0.1.7-alpha.2": "compatible",
45
+ "0.1.7-rc.1": "compatible"
43
46
  }
44
47
  },
45
48
  "client": {
@@ -76,15 +79,15 @@
76
79
  "test:validate": "tsx --test test/validate.test.ts"
77
80
  },
78
81
  "dependencies": {
79
- "@deepseek-ai/cordis": "4.0.2",
80
- "@deepseek-ai/dsh-tools": "0.1.6-alpha.2",
81
- "@deepseek-ai/dsh-util-values": "0.1.6-alpha.2",
82
+ "@deepseek-ai/cordis": "4.0.4",
83
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
84
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.1",
82
85
  "js-yaml": "4.3.2"
83
86
  },
84
87
  "devDependencies": {
85
- "@deepseek-ai/dsh-agent": "0.1.6-alpha.2",
86
- "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.2",
87
- "@deepseek-ai/dsh-session": "0.1.6-alpha.2",
88
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
89
+ "@deepseek-ai/dsh-jobs": "0.1.7-rc.1",
90
+ "@deepseek-ai/dsh-session": "0.1.7-rc.1",
88
91
  "@types/js-yaml": "4.0.9",
89
92
  "@types/node": "22.15.0",
90
93
  "@types/react": "19.2.2",
@@ -81,6 +81,7 @@ import {
81
81
  latestPhase,
82
82
  SEVERITY_LABEL,
83
83
  SEVERITY_COLOR,
84
+ stoppedReasonLabel,
84
85
  } from '../../lib/parse.js'
85
86
 
86
87
  // ─── Module contract ─────────────────────────────────────────────────────────
@@ -737,17 +738,6 @@ function severityLabel(severity: string | undefined): string {
737
738
  return SEVERITY_LABEL[coerceSeverity(severity)]
738
739
  }
739
740
 
740
- /** Human label for WHY a finished run stopped (transcript manifest). */
741
- function stoppedReasonLabel(reason: string | null | undefined): string {
742
- switch (reason) {
743
- case 'converged': return '已结束 · 已收敛(无新发现)'
744
- case 'max_rounds_reached': return '已结束 · 达到轮数上限'
745
- case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
746
- case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
747
- default: return reason ? `已结束 · ${reason}` : '已结束'
748
- }
749
- }
750
-
751
741
  /** Badge text for the run's live/finished state, annotated with the stop reason. */
752
742
  function runStatusText(live: boolean, manifest: ObsManifest | null | undefined): string {
753
743
  if (live) return '运行中'
@@ -785,6 +775,7 @@ let slotsSvc: SlotsService | undefined = undefined
785
775
  let themeSvc: ThemeService | undefined = undefined
786
776
  let storage: ReturnType<typeof createStorage> | null = null
787
777
  let themeDisposer: (() => void) | null = null
778
+ let themeListener: (() => void) | null = null
788
779
  let themeEnabled = true
789
780
  const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
790
781
 
@@ -814,6 +805,33 @@ function clearThemeSkin(): void {
814
805
  }
815
806
  }
816
807
 
808
+ /**
809
+ * Register the theme/change hook exactly once per client lifecycle.
810
+ * The listener is tracked so a re-`apply()` (hot reload / re-mount) never
811
+ * stacks a second copy — every new registration first drops the previous
812
+ * one, and `ctx.effect` pairs it off when the client unmounts.
813
+ */
814
+ function registerThemeListener(ctx: ClientContext): void {
815
+ if (typeof ctx.on !== 'function') return
816
+ if (themeListener) {
817
+ try { themeListener() } catch { /* noop */ }
818
+ themeListener = null
819
+ }
820
+ const off = ctx.on('theme/change', () => {
821
+ if (themeEnabled) applyThemeSkin()
822
+ })
823
+ // `ctx.on` may return void (no unsubscribe support) — only track a callback.
824
+ themeListener = typeof off === 'function' ? off : null
825
+ if (typeof ctx.effect === 'function') {
826
+ ctx.effect(() => {
827
+ if (themeListener) {
828
+ try { themeListener() } catch { /* noop */ }
829
+ themeListener = null
830
+ }
831
+ })
832
+ }
833
+ }
834
+
817
835
  /** Persist + apply the theme preference. */
818
836
  function setThemeEnabled(enabled: boolean): void {
819
837
  themeEnabled = enabled
@@ -2826,15 +2844,20 @@ export function apply(ctx: ClientContext): void {
2826
2844
  themeEnabled = savedTheme === null ? true : savedTheme === '1'
2827
2845
  if (themeEnabled) applyThemeSkin()
2828
2846
 
2829
- // 3. Inject styles (independent of slots / React availability).
2847
+ // 3. Inject styles (independent of slots / React availability). Guarded so a
2848
+ // re-`apply()` never appends a duplicate <style>: the tag is keyed by a
2849
+ // `dataset.iterateLock` marker instead of relying on the CSS content.
2830
2850
  if (typeof document !== 'undefined' && document.createElement && document.head) {
2831
- const style = document.createElement('style')
2832
- style.dataset.plugin = PLUGIN_TAG
2833
- style.dataset.pluginCss = 'iterate-main'
2834
- style.textContent = ITERATE_CSS
2835
- document.head.appendChild(style)
2836
- if (typeof ctx.effect === 'function') {
2837
- ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
2851
+ if (!document.head.querySelector('style[data-iterate-lock="1"]')) {
2852
+ const style = document.createElement('style')
2853
+ style.dataset.iterateLock = '1'
2854
+ style.dataset.plugin = PLUGIN_TAG
2855
+ style.dataset.pluginCss = 'iterate-main'
2856
+ style.textContent = ITERATE_CSS
2857
+ document.head.appendChild(style)
2858
+ if (typeof ctx.effect === 'function') {
2859
+ ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
2860
+ }
2838
2861
  }
2839
2862
  }
2840
2863
 
@@ -2889,9 +2912,7 @@ export function apply(ctx: ClientContext): void {
2889
2912
  }
2890
2913
 
2891
2914
  // 6: keep theme toggle state consistent across theme/change events.
2892
- if (typeof ctx.on === 'function') {
2893
- ctx.on('theme/change', () => {
2894
- if (themeEnabled) applyThemeSkin()
2895
- })
2896
- }
2915
+ // Registered through registerThemeListener so repeated apply() and client
2916
+ // unmounts never leak a second listener.
2917
+ registerThemeListener(ctx)
2897
2918
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, realpathSync } 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'
@@ -217,7 +217,25 @@ export function resolveProjectRoot(input?: string, sessionCwd?: string): Project
217
217
  if (raw.includes('\0')) {
218
218
  return { ok: false, reason: 'Refusing project root containing NUL bytes.' }
219
219
  }
220
- const root = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
220
+ const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
221
+ // Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
222
+ // a path through a symlinked directory must resolve to its REAL location so
223
+ // two aliases of the same project share one `.iterate/` state, and tools can
224
+ // never read outside the resolved root via a link. Only when the path
225
+ // exists: a not-yet-created target (dirs created through the tools) keeps
226
+ // the lexical path so the caller can still write into it. The filesystem
227
+ // root check runs against the NORMALIZED path, so a symlink to `/` is
228
+ // refused too.
229
+ let root = rootLexical
230
+ try {
231
+ if (existsSync(rootLexical)) {
232
+ const real = realpathSync(rootLexical)
233
+ if (real) root = real
234
+ }
235
+ } catch {
236
+ // realpath can throw on a broken link or a permission fence — keep the
237
+ // lexical path (existing behavior) rather than failing the resolution.
238
+ }
221
239
  if (!root || root === sep) {
222
240
  return { ok: false, reason: 'Refusing filesystem root as project root.' }
223
241
  }
@@ -32,6 +32,9 @@ export const MAX_ATOMIC_MAX_LINES = 10_000
32
32
  /** Upper bound on `atomic.max_adjacent_methods`. */
33
33
  export const MAX_MAX_ADJACENT_METHODS = 200
34
34
 
35
+ /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
36
+ export const MAX_SCOPE_CHUNK_SIZE = 1000
37
+
35
38
  /** Keep at most this many timestamped config backups (older ones are removed). */
36
39
  export const MAX_CONFIG_BACKUPS = 5
37
40
 
@@ -92,6 +95,35 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
92
95
  errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`)
93
96
  }
94
97
  }
98
+ if ('reasoning_effort' in updates) {
99
+ if (
100
+ updates.reasoning_effort !== undefined &&
101
+ updates.reasoning_effort !== 'low' &&
102
+ updates.reasoning_effort !== 'medium' &&
103
+ updates.reasoning_effort !== 'high'
104
+ ) {
105
+ errors.push('updates.reasoning_effort must be "low", "medium", or "high"')
106
+ }
107
+ }
108
+ if ('reviewer' in updates) {
109
+ const rv = updates.reviewer as Record<string, unknown> | undefined
110
+ if (!rv || typeof rv !== 'object') {
111
+ errors.push('updates.reviewer must be an object')
112
+ } else {
113
+ for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation'] as const) {
114
+ if (rv[boolKey] !== undefined && typeof rv[boolKey] !== 'boolean') {
115
+ errors.push(`updates.reviewer.${boolKey} must be a boolean`)
116
+ }
117
+ }
118
+ if (
119
+ rv.scope_chunk_size !== undefined &&
120
+ (typeof rv.scope_chunk_size !== 'number' || !Number.isInteger(rv.scope_chunk_size) ||
121
+ rv.scope_chunk_size < 1 || rv.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)
122
+ ) {
123
+ errors.push(`updates.reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`)
124
+ }
125
+ }
126
+ }
95
127
  if ('review' in updates) {
96
128
  const r = updates.review as Record<string, unknown> | undefined
97
129
  if (!r || typeof r !== 'object') {
package/src/evidence.ts CHANGED
@@ -115,7 +115,11 @@ export function verifyLineBounds(
115
115
  if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
116
116
  return { inBounds: true, lineTotal }
117
117
  }
118
- if (line < 1) return { inBounds: false, lineTotal }
118
+ // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
119
+ // 3.7 from a bypassed schema) or a negative value is out of bounds — a
120
+ // fractional line "within" the file would otherwise be accepted and poison
121
+ // the evidence trail with a location nothing can jump to.
122
+ if (line < 1 || !Number.isInteger(line)) return { inBounds: false, lineTotal }
119
123
  return { inBounds: line <= lineTotal, lineTotal }
120
124
  }
121
125
 
@@ -233,7 +233,11 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
233
233
  : null
234
234
  const lastRoundNew =
235
235
  lastRecordedRound !== null && lastRecordedRound > 0
236
- ? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
236
+ // Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
237
+ // reported round number may exceed the array length — read the SAME
238
+ // clamped index buildReviewReport/computeConvergence use, or a real
239
+ // over-cap round would be mis-read as 0 new (flag mismatch).
240
+ ? Number(findingsByRound[Math.min(lastRecordedRound, findingsByRound.length) - 1] ?? 0)
237
241
  : null
238
242
  const expectedConverged = lastRoundNew === 0
239
243
  if (report.convergence?.converged !== expectedConverged) {
package/src/review.ts CHANGED
@@ -162,13 +162,17 @@ export function aggregateRounds(
162
162
  // Clamp the allocation bound so a hostile round number cannot OOM the tool.
163
163
  const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
164
164
 
165
- const findingsByRound: number[] = []
166
- for (let r = 1; r <= effectiveMax; r++) {
167
- let count = 0
168
- for (const key of firstRoundByKey.keys()) {
169
- if (firstRoundByKey.get(key) === r) count++
170
- }
171
- findingsByRound.push(count)
165
+ const findingsByRound: number[] = new Array(effectiveMax).fill(0)
166
+ for (const key of firstRoundByKey.keys()) {
167
+ const firstRound = firstRoundByKey.get(key) ?? 1
168
+ // Fold rounds BEYOND the allocation bound into the highest slot instead of
169
+ // dropping them: a finding first seen in an over-cap round was previously
170
+ // invisible to `findingsByRound`, so a run whose final rounds exceeded the
171
+ // cap looked "converged" even though a real round found new issues. Folding
172
+ // keeps the counts truthful (the sum still equals totalFindings) while the
173
+ // memory bound stays fixed.
174
+ const idx = Math.min(firstRound, effectiveMax) - 1
175
+ findingsByRound[idx] = (findingsByRound[idx] || 0) + 1
172
176
  }
173
177
 
174
178
  return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey }
@@ -282,8 +286,13 @@ export function buildReviewReport(input: {
282
286
  for (const r of filteredRounds) {
283
287
  if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
284
288
  }
289
+ // `lastRound` can exceed the array length when over-cap rounds were FOLDED
290
+ // into the last slot (see aggregateRounds). Read the same clamped index
291
+ // computeConvergence uses, so a real over-cap round is never mis-read as 0
292
+ // new findings (which would falsely report "converged").
293
+ const idx = Math.min(lastRound, findingsByRound.length) - 1
285
294
  const lastRoundCount =
286
- lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
295
+ lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
287
296
  const converged = filteredRounds.length > 0 && lastRoundCount === 0
288
297
 
289
298
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
@@ -630,6 +639,12 @@ export function reviewerTaskPrompt(input: {
630
639
  * every dimension reviewer inspects/considers them before judging.
631
640
  */
632
641
  attachments?: ReviewAttachment[]
642
+ /**
643
+ * Configured reasoning effort ('low' | 'medium' | 'high') to apply to this
644
+ * review pass. Injected as a directive so the harness can honor it when
645
+ * running each reviewer subagent. Omitted → follow the provider default.
646
+ */
647
+ effort?: 'low' | 'medium' | 'high'
633
648
  }): string {
634
649
  const parts: string[] = []
635
650
  parts.push(
@@ -637,6 +652,9 @@ export function reviewerTaskPrompt(input: {
637
652
  `Goal: ${input.goal}`,
638
653
  `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
639
654
  )
655
+ if (input.effort) {
656
+ parts.push(`Reasoning effort for this review pass: ${input.effort}.`)
657
+ }
640
658
  if (input.focus) {
641
659
  parts.push(`FOCUS: ${input.focus}`)
642
660
  }
@@ -744,6 +762,12 @@ export function buildReviewPlan(input: {
744
762
  fallbackToFull: boolean
745
763
  /** The attachments threaded into every reviewer prompt (empty when none). */
746
764
  attachments: ReviewAttachment[]
765
+ /**
766
+ * Configured reasoning effort carried on the plan for the orchestrator to
767
+ * honor (null when unset → provider default). Also injected as a directive
768
+ * into every dimension's reviewer prompt.
769
+ */
770
+ reasoningEffort: 'low' | 'medium' | 'high' | null
747
771
  } {
748
772
  // Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
749
773
  // `review`/`atomic` missing) must degrade to sane defaults instead of
@@ -751,6 +775,12 @@ export function buildReviewPlan(input: {
751
775
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
752
776
  const goal = input.config.goal ?? ''
753
777
  const configuredScope = input.config.review?.scope ?? 'full'
778
+ const reasoningEffort =
779
+ input.config.reasoning_effort === 'low' ||
780
+ input.config.reasoning_effort === 'medium' ||
781
+ input.config.reasoning_effort === 'high'
782
+ ? input.config.reasoning_effort
783
+ : null
754
784
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
755
785
  const maxLines = input.config.atomic?.max_lines ?? 20
756
786
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
@@ -816,6 +846,7 @@ export function buildReviewPlan(input: {
816
846
  scopeFiles: batch,
817
847
  focus: focusMap.get(d),
818
848
  attachments,
849
+ effort: reasoningEffort ?? undefined,
819
850
  }),
820
851
  findingsSchema: findingsSchema(),
821
852
  })
@@ -832,5 +863,6 @@ export function buildReviewPlan(input: {
832
863
  changedFiles: effectiveChangedOnly ? changedFiles : [],
833
864
  fallbackToFull,
834
865
  attachments,
866
+ reasoningEffort,
835
867
  }
836
868
  }