iterate-plugin 2.5.0 → 2.6.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
@@ -2,10 +2,12 @@
2
2
 
3
3
  > **开发与评审在 [iterate-skill 主仓库](https://github.com/jingzhao-l/iterate-skill) 完成**:插件代码由主仓库统一维护,通过 `git subtree` 同步到本仓库;**版本发版与 npm 发布在本仓库(插件仓库)进行**,作为 dsh 生态的正式发布位。欢迎 **star / fork 主仓库** 并在 [主仓库 Issues](https://github.com/jingzhao-l/iterate-skill/issues) 反馈问题。
4
4
 
5
- `iterate-plugin` 是 [iterate](https://github.com/iterate-skill/iterate-skill) 技能的 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件,提供**自治闭环代码迭代**和**dry-run 纯多轮审查**能力。
5
+ `iterate-plugin` 是 [iterate](https://github.com/jingzhao-l/iterate-skill) 技能的 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件,提供**自治闭环代码迭代**和 **dry-run 纯多轮审查**能力。除 13 个纯函数工具外,还内置一套**免构建的 Web UI 层**(分诊面板、收敛看板、统计卡片、主题皮肤等),直接挂在 dsh 客户端的既有 UI 槽位上。
6
6
 
7
7
  ## 特性
8
8
 
9
+ ### 两种运行模式
10
+
9
11
  | 功能 | dry-run 模式 | normal 模式 |
10
12
  |------|-------------|------------|
11
13
  | 多轮收敛反复审查 | ✅ | ✅ |
@@ -15,12 +17,37 @@
15
17
  | 零文件修改(只读) | ✅ | ❌ |
16
18
  | 原子问题自动修复 | ❌ | ✅ |
17
19
  | 每轮修复后验证 | ❌ | ✅ |
20
+ | 修复失败回滚 | ❌ | ✅ |
18
21
  | 达标自停 | ✅ | ✅ |
19
22
  | 只修改 atomic 问题,保留 architectural 留待后续 | ❌ | ✅ |
23
+ | 断点保存 / 恢复(长迭代续跑) | ✅ | ✅ |
24
+
25
+ ### 工具层
26
+
27
+ - **13 个注册工具**:`iterate_config` / `iterate_validate` / `iterate_decision_log` / `iterate_context` / `iterate_review` / `iterate_triage` / `iterate_fix` / `iterate_diff` / `iterate_rollback` / `iterate_checkpoint` / `iterate_status` / `iterate_history` / `iterate_prune`
28
+ - **findings 分诊闭环**:审查 → UI 分诊(y/n/a)→ `iterate_triage` 写回 `known_intentional` → 下一轮自动过滤
29
+ - **结构化修复系统**:每次修复先备份、写注册表、记录 diff,验证失败可 `iterate_rollback` 还原
30
+ - **断点续跑**:长迭代在每轮开头保存 checkpoint,中断后可恢复进度
31
+ - **历史审计**:`iterate_history` 读取决策日志(按类型/时间/数量过滤)与修复注册表汇总,审查运行过程与修复明细
32
+ - **运行时清理**:`iterate_prune` 清理过期的决策日志条目、陈旧断点、孤儿修复备份与空轮次;默认 dry-run 只报告不删除,显式 `dryRun:false` 才真正清理,每次清理写入决策日志
33
+ - **配置读写**:`iterate_config` 支持带校验、备份、回滚的局部写入
34
+
35
+ ### UI 层(客户端免构建槽位)
36
+
37
+ | UI 组件 | 挂载槽位 | 功能 |
38
+ |---------|---------|------|
39
+ | 收敛看板 `ConvergenceDashboard` | `conversation.input.dock` | 输入框上方实时显示轮次进度条、严重度统计、维度徽章、趋势迷你图,normal 模式另显示修复计数徽章 |
40
+ | Findings 分诊面板 `TriagePanel` | `conversation.chat.turnTail` | 逐条 y/n/a 判定,支持筛选、批量(含一键全选所有 findings)、键盘快捷键、localStorage 持久化、复制 YAML/应用指令 |
41
+ | 收敛统计卡片 `StatsCard` | `conversation.chat.turnTail` | 无 findings 时显示收敛统计、历史轮次表、趋势图、完成摘要 |
42
+ | iterate 主题皮肤 | `theme.overrideTokens` | 暖琥珀配色的 13 个 dsw token 覆盖,明暗双模式,可在设置页开关 |
43
+ | 进度胶囊 `ProgressCapsule` | `shell.overlay` | 每轮完成/收敛时右下角弹出通知(含收敛确认) |
44
+ | iterate 设置区 `SettingsPanel` | `settings.section` | 主题开关、分诊持久化说明、配置管理指引、运行时状态概览(产物布局 + 查看/清理工具指引)、一键清空分诊数据 |
45
+
46
+ UI 层为**防御式设计**:`slots` / `theme` / `React` 任一不可用时自动降级,不会崩溃客户端。
20
47
 
21
48
  ## 安装
22
49
 
23
- ### 从 npm 安装(发布后)
50
+ ### 从 npm 安装
24
51
 
25
52
  ```bash
26
53
  dsh plugin --profile web add iterate-plugin
@@ -31,7 +58,7 @@ pnpm add iterate-plugin
31
58
  ### 本地开发 / 源码挂载
32
59
 
33
60
  ```bash
34
- dsh plugin --profile web add /Volumes/Eng-Dev/iterate-skill/harness/iterate-plugin
61
+ dsh plugin --profile web add /path/to/iterate-skill/harness/iterate-plugin
35
62
  # 或
36
63
  pnpm add /path/to/iterate-skill/harness/iterate-plugin
37
64
  ```
@@ -44,6 +71,8 @@ pnpm add /path/to/iterate-skill/harness/iterate-plugin
44
71
  name: 'iterate-plugin'
45
72
  ```
46
73
 
74
+ > 插件包自带 `dsh.bundle.patch`(即 `cordis.patch.yml`),npm 包内 `files` 已白名单化(`src` / `lib` / `cordis.patch.yml` / `README.md` / `LICENSE`)。
75
+
47
76
  ## 使用
48
77
 
49
78
  ### dry-run 模式(纯反复审查,不修改文件)
@@ -55,6 +84,7 @@ dry-run review this project, find all issues across all dimensions
55
84
  ```
56
85
 
57
86
  插件会自动触发 iterate 工作流:
87
+
58
88
  1. `plan` → 读取配置,生成评审计划
59
89
  2. `loop` → 每轮并行评审,只找新问题 → 确定性聚合去重 → 统计收敛 → 无新问题则停止
60
90
  3. `meta-review` → 审计报告一致性
@@ -69,8 +99,9 @@ iterate on this project, fix all atomic issues
69
99
  ```
70
100
 
71
101
  工作流:
102
+
72
103
  1. `plan` → 读取配置
73
- 2. `loop` → 并行评审 → 聚合去重 → 原子问题并行修复 → 执行验证命令 → 记录日志 → 无新问题则停止
104
+ 2. `loop` → 并行评审 → 聚合去重 → 原子问题并行修复 → 执行验证命令 → 验证失败则回滚 → 记录日志 → 无新问题则停止
74
105
  3. `report` → 输出修复统计
75
106
 
76
107
  ## 项目配置
@@ -92,6 +123,9 @@ max_rounds: 3
92
123
  # 评审范围
93
124
  review:
94
125
  scope: full # full = 全项目,changed-only = 只看变更文件
126
+ # 原子修复阈值(单次修复允许改动的最大行数,超过需 force)
127
+ atomic:
128
+ max_lines: 20
95
129
  # 已知故意不修复的问题(评审会过滤掉,不再重复报告)
96
130
  personalization:
97
131
  known_intentional:
@@ -106,24 +140,48 @@ validation:
106
140
  - npm run typecheck
107
141
  ```
108
142
 
109
- ## 注册工具
143
+ > 配置可通过 `iterate_config` 工具读取与**校验式局部写入**(自动备份,写入失败自动回滚)。
110
144
 
111
- 插件注册了 5 个工具:
145
+ ## 注册工具(13 个)
112
146
 
113
147
  | 工具 | 功能 |
114
148
  |------|------|
115
- | `iterate_config` | 读取并验证 `iterate.config.yaml` |
149
+ | `iterate_config` | 读取 / 写入 `iterate.config.yaml`。`operation=read` 返回完整配置或指定 section;`operation=write` 做 schema 校验、备份后局部合并写入,失败自动回滚 |
116
150
  | `iterate_validate` | 运行白名单验证命令,返回结果 |
117
- | `iterate_decision_log` | 追加决策日志(只追加,不改旧) |
151
+ | `iterate_decision_log` | 追加决策日志(只追加,不改旧),存储于 `.iterate/decision-log.jsonl` |
118
152
  | `iterate_context` | 读取 `SKILL.md` / `ITERATE.md` 上下文 |
119
- | `iterate_review` | 确定性评审引擎:`plan` 生成计划,`aggregate` 聚合结论,`meta-review` 审计报告 |
153
+ | `iterate_review` | 确定性评审引擎:`plan` 生成计划,`aggregate` 聚合去重 + 收敛统计,`meta-review` 审计报告一致性。纯计算,不触碰文件系统 |
154
+ | `iterate_triage` | 管理 `personalization.known_intentional`:`apply` 校验、去重(file\|dimension\|line)、备份后写回配置;`list` 读回当前条目。是浏览器分诊面板写回配置的唯一通道 |
155
+ | `iterate_fix` | 应用**一个原子修复**:校验相对路径、备份原文件、按 `atomic.max_lines` 强制原子性(可 `force` 跳过)、写入新内容、记录 FixRecord 与 `atomic_fix` 日志。normal 模式唯一合法的改文件入口 |
156
+ | `iterate_diff` | 查看修复累积变更:指定 `file` 返回相对首个备份的 unified diff;省略则返回每个已修复文件的汇总 |
157
+ | `iterate_rollback` | 回滚一个已应用的修复:从备份还原文件、从注册表移除该 FixRecord、追加 `revert` 日志。用于某轮验证失败后 |
158
+ | `iterate_checkpoint` | 迭代断点:`save` 保存当前进度到 `.iterate/checkpoint.json`,`load` 读回,`clear` 清除。长迭代可中断续跑 |
159
+ | `iterate_status` | 汇总当前迭代状态:模式、当前轮/总轮、已修复数、剩余 architectural、决策日志条数、是否存在 checkpoint |
160
+ | `iterate_history` | 读取迭代历史(只读):决策日志条目(可按 `type` / `since` / `limit` 过滤,默认取最新 50 条,上限 200 条)+ 修复注册表汇总(各轮 fixed/failed 计数)。用于审查运行过程、审计日志、盘点修复 |
161
+ | `iterate_prune` | 清理运行时产物:过期决策日志条目(按 `retainDays`,默认 30 天)、陈旧断点、孤儿修复备份、空轮次。默认 dry-run 只报告不删除;`dryRun:false` 才真正清理,每次清理写入决策日志 |
162
+
163
+ ## 运行时产物布局
164
+
165
+ 所有运行时状态都落在项目根目录的 `.iterate/` 下(可由 `.gitignore` 排除):
166
+
167
+ ```
168
+ .iterate/
169
+ decision-log.jsonl # 追加式决策日志(plan/review/fix/revert…)
170
+ checkpoint.json # 迭代断点(断点续跑)
171
+ fixes/
172
+ registry.json # 修复注册表(FixRecord 列表,按轮次组织)
173
+ <fix-id>_<ts>.bak # 每次修复前的原文件备份
174
+ ```
120
175
 
121
176
  ## 设计
122
177
 
123
178
  插件遵循 dsh "everything-is-a-plugin" 架构:
124
- - 只做一件事:注入系统 prompt 教模型写 iterate workflow + 注册 5 个纯函数工具
125
- - 所有 orchestration 通过 dsh 原生 `workflow` + `agent` + `parallel` 完成
126
- - 核心逻辑(去重/过滤/排序/收敛/meta-audit)全部纯函数,可单元测试,无 I/O
179
+
180
+ - **只做两件事**:注入系统 prompt 教模型写 iterate workflow + 注册 13 个纯函数工具
181
+ - **所有 orchestration 通过 dsh 原生 `workflow` + `agent` + `parallel` 完成**
182
+ - **核心逻辑全部纯函数**(去重/过滤/排序/收敛/meta-audit/diff 计算/历史过滤/清理报告),可单元测试,无 I/O
183
+ - **安全模型**:文件写入限定在解析后的项目根目录内(路径遍历防护);写文件前必备份,失败回滚;配置写入同样备份 + 回滚;`iterate_prune` 默认 dry-run、只清理 `.iterate/` 下产物、每次清理写日志;`iterate_fix` 对 content 设字符上限、`iterate_triage` 对 entries 设数量上限,防止异常超大负载
184
+ - **UI 免构建**:`lib/client.js` 用 `React.createElement` 树 + 注入 `<style>` 标签,全部颜色走 `--dsw-*` 令牌,缺服务自动降级
127
185
  - 遵循 iterate 原技能的设计原则:确定性收敛,可审计,最小权限
128
186
 
129
187
  ## 运行测试
@@ -136,9 +194,9 @@ npm test
136
194
  ```
137
195
 
138
196
  所有测试通过:
139
- - 63 个单元测试全绿
140
- - 覆盖去重、过滤、排序、多轮收敛、meta-review 审计、路径安全、超时钳制
141
- - 类型检查通过
197
+
198
+ - **212 个单元测试全绿**,类型检查通过
199
+ - 覆盖:去重、过滤、排序、多轮收敛、meta-review 审计、路径安全、超时钳制、配置读写与回滚、triage 合并、diff 计算、checkpoint 校验、修复注册表、历史读取与过滤、prune 清理报告与 dry-run 语义、UI 纯函数(select-all 键、运行时状态指引)等
142
200
 
143
201
  ## License
144
202
 
package/lib/client.js CHANGED
@@ -47,6 +47,8 @@ import {
47
47
  buildCompletionSummary,
48
48
  buildConfigEditGuide,
49
49
  keyToVerdict,
50
+ allVerdictKeys,
51
+ buildRuntimeStatusGuide,
50
52
  SEVERITY_LABEL,
51
53
  SEVERITY_COLOR,
52
54
  } from './parse.js'
@@ -194,6 +196,9 @@ const ITERATE_CSS = `
194
196
  .iterate-completion[data-warn] { border: 1px solid var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 10%, transparent); }
195
197
  .iterate-capsule[data-ok] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); }
196
198
  .iterate-settings-guide { white-space: pre-wrap; padding: 8px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-family: var(--dsw-font-mono, ui-monospace, monospace); font-size: 11px; max-height: 180px; overflow: auto; }
199
+ .iterate-chip[data-ok] { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); }
200
+ .iterate-batch-check { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 11px; cursor: pointer; }
201
+ .iterate-batch-check input { margin: 0; cursor: pointer; }
197
202
  `
198
203
 
199
204
  // ─── Small helpers ───────────────────────────────────────────────────────────
@@ -225,6 +230,7 @@ function createStorage() {
225
230
  get(key) { return window.localStorage.getItem(key) },
226
231
  set(key, value) { window.localStorage.setItem(key, value) },
227
232
  remove(key) { window.localStorage.removeItem(key) },
233
+ keys() { return Object.keys(window.localStorage) },
228
234
  }
229
235
  } catch {
230
236
  const mem = new Map()
@@ -232,10 +238,31 @@ function createStorage() {
232
238
  get(key) { return mem.has(key) ? mem.get(key) : null },
233
239
  set(key, value) { mem.set(key, value) },
234
240
  remove(key) { mem.delete(key) },
241
+ keys() { return [...mem.keys()] },
235
242
  }
236
243
  }
237
244
  }
238
245
 
246
+ /**
247
+ * Remove every stored key with the given prefix (e.g. all triage verdicts).
248
+ * Returns how many keys were removed.
249
+ */
250
+ function removeStorageByPrefix(prefix) {
251
+ if (!storage) return 0
252
+ let removed = 0
253
+ try {
254
+ for (const key of storage.keys()) {
255
+ if (key.startsWith(prefix)) {
256
+ storage.remove(key)
257
+ removed++
258
+ }
259
+ }
260
+ } catch (err) {
261
+ log('failed to clear storage prefix', prefix, err)
262
+ }
263
+ return removed
264
+ }
265
+
239
266
  /** Copy text to the clipboard, returning whether it succeeded. */
240
267
  function copyText(text) {
241
268
  if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
@@ -357,6 +384,17 @@ function ConvergenceDashboard(props) {
357
384
  ),
358
385
  )
359
386
 
387
+ // Fix-count badge: show when the report has a fixes summary.
388
+ const mode = report.mode
389
+ const summary = report.summary
390
+ const isNormal = mode === 'normal'
391
+ const fixCount = isNormal && summary && typeof summary.fixedCount === 'number' ? summary.fixedCount : null
392
+ const fixBadge = fixCount !== null
393
+ ? React.createElement('span', { className: 'iterate-metric', key: 'fixes' },
394
+ '\u{1F527} ' + String(fixCount) + ' fixes',
395
+ )
396
+ : null
397
+
360
398
  return React.createElement(
361
399
  'div',
362
400
  { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard' },
@@ -380,6 +418,7 @@ function ConvergenceDashboard(props) {
380
418
  React.createElement('span', { className: 'iterate-sev-dot', style: { background: SEVERITY_COLOR.medium } }),
381
419
  stats.medium,
382
420
  ),
421
+ fixBadge,
383
422
  React.createElement(TrendChart, { points: trend.points }),
384
423
  ...dimBadges,
385
424
  )
@@ -484,6 +523,7 @@ function TriagePanel(props) {
484
523
  const [copied, setCopied] = React.useState(false)
485
524
  const [filter, setFilter] = React.useState({ severities: [], dimensions: [], search: '' })
486
525
  const [selected, setSelected] = React.useState(null)
526
+ const [selectAll, setSelectAll] = React.useState(false)
487
527
 
488
528
  /** Persist + return the next verdicts state. */
489
529
  const persistVerdicts = (next) => {
@@ -506,13 +546,20 @@ function TriagePanel(props) {
506
546
  const setSearchFilter = (value) => setFilter((f) => ({ ...f, search: value }))
507
547
  const clearFilter = () => setFilter({ severities: [], dimensions: [], search: '' })
508
548
 
509
- // ── Batch operations (apply to the currently VISIBLE findings) ───────────
549
+ // ── Batch operations (apply to the currently VISIBLE findings, or to ALL
550
+ // findings when the select-all toggle is on) ──────────────────────────
551
+ const allIndices = allVerdictKeys(verdicts)
552
+ const batchTarget = selectAll ? allIndices : indices
510
553
  const applyBatch = (verdict) => {
511
- setVerdicts((prev) => persistVerdicts(batchSetVerdict(prev, indices, verdict)))
554
+ setVerdicts((prev) => persistVerdicts(batchSetVerdict(prev, batchTarget, verdict)))
512
555
  }
513
556
  const applyBatchAll = (verdict) => {
514
557
  setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, verdict)))
515
558
  }
559
+ const doResetVerdicts = () => {
560
+ setVerdicts((prev) => persistVerdicts(setAllVerdicts(prev, 'keep')))
561
+ setSelectAll(false)
562
+ }
516
563
 
517
564
  // ── Keyboard shortcuts (y / n / a on the selected finding, ↑/↓ to move) ──
518
565
  React.useEffect(() => {
@@ -637,7 +684,11 @@ function TriagePanel(props) {
637
684
  ),
638
685
  ),
639
686
  React.createElement('div', { className: 'iterate-batch' },
640
- React.createElement('span', { className: 'iterate-batch-label' }, '批量(当前可见):'),
687
+ React.createElement('span', { className: 'iterate-batch-label' }, '批量:'),
688
+ React.createElement('label', { className: 'iterate-batch-check', title: '勾选后批量按钮作用于全部 findings,否则仅当前可见' },
689
+ React.createElement('input', { type: 'checkbox', checked: selectAll, onChange: (e) => setSelectAll(e.target.checked) }),
690
+ selectAll ? `全部 ${allIndices.length}` : '全选',
691
+ ),
641
692
  React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('keep') }, '全部 y'),
642
693
  React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('skip') }, '全部 n'),
643
694
  React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatch('ignore') }, '全部 a'),
@@ -645,6 +696,7 @@ function TriagePanel(props) {
645
696
  React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('keep') }, 'y'),
646
697
  React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('skip') }, 'n'),
647
698
  React.createElement('button', { className: 'iterate-batch-btn', onClick: () => applyBatchAll('ignore') }, 'a'),
699
+ React.createElement('button', { className: 'iterate-batch-btn', onClick: doResetVerdicts, title: '把所有判定恢复为默认 y(修复)' }, '重置'),
648
700
  ),
649
701
  ...rows,
650
702
  React.createElement('div', { className: 'iterate-triage-foot' },
@@ -712,7 +764,10 @@ function SettingsPanel() {
712
764
  const [enabled, setEnabled] = React.useState(themeEnabled)
713
765
  const [copied, setCopied] = React.useState(false)
714
766
  const [showGuide, setShowGuide] = React.useState(false)
767
+ const [clearedCount, setClearedCount] = React.useState(null)
768
+ const [showStatus, setShowStatus] = React.useState(false)
715
769
  const guide = buildConfigEditGuide()
770
+ const statusGuide = buildRuntimeStatusGuide()
716
771
 
717
772
  const toggleTheme = () => {
718
773
  const next = !enabled
@@ -726,6 +781,12 @@ function SettingsPanel() {
726
781
  setTimeout(() => setCopied(false), 1600)
727
782
  }
728
783
 
784
+ const doClearTriage = () => {
785
+ const count = removeStorageByPrefix(TRIAGE_STORAGE_PREFIX)
786
+ setClearedCount(count)
787
+ setTimeout(() => setClearedCount(null), 3000)
788
+ }
789
+
729
790
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'settings', className: 'iterate-settings' },
730
791
  React.createElement('div', { className: 'iterate-settings-title' }, 'iterate 设置'),
731
792
  React.createElement('div', { className: 'iterate-settings-row' },
@@ -744,7 +805,16 @@ function SettingsPanel() {
744
805
  React.createElement('div', { className: 'iterate-settings-title' }, '分诊持久化'),
745
806
  React.createElement('div', { className: 'iterate-settings-desc' }, '分诊面板的 y/n/a 判定保存在本地浏览器(localStorage),刷新会话后仍保留。'),
746
807
  ),
747
- React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
808
+ React.createElement('span', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
809
+ React.createElement('span', { className: 'iterate-chip' }, '本地保存'),
810
+ React.createElement('button', {
811
+ className: 'iterate-btn',
812
+ onClick: doClearTriage,
813
+ title: '清除所有分诊判定记录',
814
+ 'data-primary': clearedCount !== null ? '' : undefined,
815
+ 'data-copied': clearedCount !== null ? '' : undefined,
816
+ }, clearedCount !== null ? `已清除 ${clearedCount} 条` : '清除分诊'),
817
+ ),
748
818
  ),
749
819
  React.createElement('div', { className: 'iterate-settings-row' },
750
820
  React.createElement('div', {},
@@ -759,6 +829,16 @@ function SettingsPanel() {
759
829
  showGuide
760
830
  ? React.createElement('div', { className: 'iterate-settings-guide' }, guide)
761
831
  : null,
832
+ React.createElement('div', { className: 'iterate-settings-row' },
833
+ React.createElement('div', {},
834
+ React.createElement('div', { className: 'iterate-settings-title' }, '状态概览'),
835
+ React.createElement('div', { className: 'iterate-settings-desc' }, '运行时产物布局与清理指引。iterate_status / iterate_history / iterate_prune 工具用于查看和管理。'),
836
+ ),
837
+ React.createElement('button', { className: 'iterate-btn', onClick: () => setShowStatus((v) => !v) }, showStatus ? '收起' : '查看'),
838
+ ),
839
+ showStatus
840
+ ? React.createElement('div', { className: 'iterate-settings-guide' }, statusGuide)
841
+ : null,
762
842
  )
763
843
  }
764
844
 
package/lib/parse.js CHANGED
@@ -787,4 +787,72 @@ export const VERDICT_SHORTCUTS = {
787
787
  */
788
788
  export function keyToVerdict(key) {
789
789
  return VERDICT_SHORTCUTS[key] ?? null
790
+ }
791
+
792
+ // ─── Select-all keys ────────────────────────────────────────────────────────
793
+
794
+ /**
795
+ * Every finding index in a triage state, sorted ascending.
796
+ * Used by the select-all toggle so batch operations can target ALL findings
797
+ * (not just the currently visible/filtered ones).
798
+ *
799
+ * @param {Record<string, 'keep' | 'skip' | 'ignore'> | null | undefined} triageState
800
+ * @returns {number[]}
801
+ */
802
+ export function allVerdictKeys(triageState) {
803
+ const state = triageState && typeof triageState === 'object' ? triageState : {}
804
+ return Object.keys(state)
805
+ .map(Number)
806
+ .filter((n) => Number.isInteger(n) && n >= 0)
807
+ .sort((a, b) => a - b)
808
+ }
809
+
810
+ // ─── Runtime status guide ────────────────────────────────────────────────────
811
+
812
+ /**
813
+ * Runtime artifacts produced under `<projectRoot>/.iterate/`.
814
+ * @type {Array<{ key: string, label: string, hint: string }>}
815
+ */
816
+ export const RUNTIME_ARTIFACTS = [
817
+ {
818
+ key: 'decision-log.jsonl',
819
+ label: '决策日志',
820
+ hint: '追加式 JSONL,记录每轮 plan / review / fix / revert / validation 决策',
821
+ },
822
+ {
823
+ key: 'checkpoint.json',
824
+ label: '迭代断点',
825
+ hint: '长迭代的进度快照,中断后可恢复(iterate_checkpoint)',
826
+ },
827
+ {
828
+ key: 'fixes/registry.json',
829
+ label: '修复注册表',
830
+ hint: '每个原子修复的 id / diff / 备份路径(iterate_fix / iterate_diff)',
831
+ },
832
+ {
833
+ key: 'fixes/*.bak',
834
+ label: '修复备份',
835
+ hint: '每次修复前的原文件备份,回滚依赖(iterate_rollback)',
836
+ },
837
+ ]
838
+
839
+ /**
840
+ * Copy-paste guide for inspecting / pruning the runtime state. Shown in the
841
+ * settings "状态概览" card so the user knows exactly where artifacts live and
842
+ * which tools inspect them.
843
+ *
844
+ * @returns {string}
845
+ */
846
+ export function buildRuntimeStatusGuide() {
847
+ const lines = [
848
+ 'iterate 运行时状态概览',
849
+ '----------------------',
850
+ '所有运行时产物位于项目根目录 .iterate/ 下:',
851
+ '',
852
+ ...RUNTIME_ARTIFACTS.map((a) => `- ${a.key}(${a.label}):${a.hint}`),
853
+ '',
854
+ '查看状态:让模型调用 iterate_status(汇总)或 iterate_history(明细)。',
855
+ '清理状态:让模型调用 iterate_prune(默认 dry-run,只报告不删除,显式 dryRun:false 才真正清理)。',
856
+ ]
857
+ return lines.join('\n')
790
858
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ import { registerReviewTool } from './tools/review.ts'
30
30
  import { registerTriageTool } from './tools/triage.ts'
31
31
  import { registerFixTool, registerDiffTool, registerRollbackTool } from './tools/fix.ts'
32
32
  import { registerCheckpointTool, registerStatusTool } from './tools/checkpoint.ts'
33
+ import { registerHistoryTool } from './tools/history.ts'
34
+ import { registerPruneTool } from './tools/prune.ts'
33
35
  import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
34
36
 
35
37
  export const name = 'iterate-plugin'
@@ -48,6 +50,8 @@ export function apply(ctx: Context): void {
48
50
  registerRollbackTool(ctx)
49
51
  registerCheckpointTool(ctx)
50
52
  registerStatusTool(ctx)
53
+ registerHistoryTool(ctx)
54
+ registerPruneTool(ctx)
51
55
 
52
56
  // 2. Inject the iterate skill prompt as a system prompt section
53
57
  // This teaches the model how to write iterate workflow scripts using the tools.
package/src/tools/fix.ts CHANGED
@@ -26,6 +26,11 @@ import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
26
26
  import { appendDecisionEntry } from './decision-log.ts'
27
27
  import type { FileDiffHunk, FixRecord, FixRegistry, ReviewFinding } from '../types.ts'
28
28
 
29
+ // ─── Constants ───────────────────────────────────────────────────────────────
30
+
31
+ /** Upper bound for a single fix `content` payload (characters). */
32
+ export const MAX_FIX_CONTENT_CHARS = 1_000_000
33
+
29
34
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
30
35
 
31
36
  /**
@@ -288,6 +293,12 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
288
293
  const file = typeof args.file === 'string' ? args.file : ''
289
294
  if (!file) return { ok: false, error: 'file is required' }
290
295
  if (typeof args.content !== 'string') return { ok: false, error: 'content must be a string' }
296
+ if (args.content.length > MAX_FIX_CONTENT_CHARS) {
297
+ return {
298
+ ok: false,
299
+ error: `content exceeds the ${MAX_FIX_CONTENT_CHARS}-character limit (got ${args.content.length})`,
300
+ }
301
+ }
291
302
  if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
292
303
  return { ok: false, error: 'round must be a positive integer' }
293
304
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * src/tools/history.ts — iteration history reader.
3
+ *
4
+ * iterate_history — read the decision-log entries (with optional filters)
5
+ * plus a summary of the fix registry, so the user or the
6
+ * orchestrator can review exactly what the run did.
7
+ *
8
+ * Complements `iterate_status` (compact summary) with the actual detail.
9
+ */
10
+
11
+ import { defineTool } from '@deepseek-ai/dsh-tools'
12
+ import type { JsonValue } from '@deepseek-ai/dsh-session'
13
+ import { resolveProjectRoot } from '../config-loader.ts'
14
+ import { readDecisionEntries } from './decision-log.ts'
15
+ import { readRegistry } from './fix.ts'
16
+ import type { DecisionLogEntry, FixRegistry } from '../types.ts'
17
+
18
+ const DEFAULT_LIMIT = 50
19
+ const MAX_LIMIT = 200
20
+
21
+ /** Clamp a caller-supplied `limit` to a sane range. */
22
+ export function clampHistoryLimit(limit: number | undefined): number {
23
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
24
+ return DEFAULT_LIMIT
25
+ }
26
+ return Math.min(limit, MAX_LIMIT)
27
+ }
28
+
29
+ /**
30
+ * Filter + cap decision-log entries. Pure, unit-tested.
31
+ * Returns the newest `limit` matching entries plus the total match count
32
+ * (before the cap), so callers can tell when the result was truncated.
33
+ */
34
+ export function filterDecisionEntries(
35
+ entries: DecisionLogEntry[],
36
+ opts: { type?: unknown; since?: unknown; limit?: unknown },
37
+ ): { entries: DecisionLogEntry[]; filteredCount: number; limit: number } {
38
+ const type = typeof opts.type === 'string' && opts.type ? opts.type : undefined
39
+ const since = typeof opts.since === 'string' && opts.since ? opts.since : undefined
40
+ const limit = clampHistoryLimit(opts.limit as number | undefined)
41
+
42
+ const matching = (Array.isArray(entries) ? entries : []).filter((e) => {
43
+ if (type && e.type !== type) return false
44
+ if (since && e.timestamp <= since) return false
45
+ return true
46
+ })
47
+ return {
48
+ entries: matching.slice(-limit),
49
+ filteredCount: matching.length,
50
+ limit,
51
+ }
52
+ }
53
+
54
+ /** Per-round fix counts + totals from a fix registry. Pure, unit-tested. */
55
+ export function summarizeFixRegistry(registry: FixRegistry): {
56
+ totalFixed: number
57
+ totalFailed: number
58
+ roundCount: number
59
+ rounds: { round: number; fixedCount: number; failedCount: number }[]
60
+ } {
61
+ const rounds = (registry.rounds ?? []).map((r) => ({
62
+ round: r.round,
63
+ fixedCount: r.fixedCount,
64
+ failedCount: r.failedCount,
65
+ }))
66
+ return {
67
+ totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
68
+ totalFailed: rounds.reduce((s, r) => s + r.failedCount, 0),
69
+ roundCount: rounds.length,
70
+ rounds,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Register the `iterate_history` tool.
76
+ * Reads the decision log (optionally filtered by type / since / limit) and a
77
+ * fix-registry summary. Read-only; never modifies the filesystem.
78
+ */
79
+ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
80
+ ctx.tools.register(
81
+ defineTool({
82
+ name: 'iterate_history',
83
+ description:
84
+ 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
85
+ 'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
86
+ 'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
87
+ parameters: {
88
+ type: {
89
+ type: 'string',
90
+ description:
91
+ 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
92
+ 'revert, validation, decision, report.',
93
+ },
94
+ since: {
95
+ type: 'string',
96
+ description: 'Optional ISO timestamp; only entries AFTER this timestamp are returned.',
97
+ },
98
+ limit: {
99
+ type: 'integer',
100
+ description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Newest first.`,
101
+ },
102
+ path: {
103
+ type: 'string',
104
+ description: 'Project root directory (default: current working directory).',
105
+ },
106
+ },
107
+
108
+ output: {
109
+ schema: {
110
+ type: 'object',
111
+ additionalProperties: false,
112
+ properties: {
113
+ ok: { type: 'boolean', required: true },
114
+ kind: { type: 'string' },
115
+ count: { type: 'integer' },
116
+ filteredCount: { type: 'integer' },
117
+ limit: { type: 'integer' },
118
+ log: { type: 'json' },
119
+ fixes: { type: 'json' },
120
+ error: { type: 'string' },
121
+ },
122
+ },
123
+ render: (_args, value) => {
124
+ if (!value.ok) return [{ type: 'text', text: `history failed: ${value.error}` }]
125
+ const log = (value.log as DecisionLogEntry[] | undefined) ?? []
126
+ const fixes = (value.fixes as { totalFixed: number; totalFailed: number; roundCount: number } | undefined)
127
+ const lines = [
128
+ `Decision-log entries: ${value.count} (filtered to ${value.limit})`,
129
+ fixes
130
+ ? `Fixes: ${fixes.totalFixed} applied · ${fixes.totalFailed} failed · across ${fixes.roundCount} round(s)`
131
+ : 'Fixes: none',
132
+ '',
133
+ ...log.map((e) => `[${e.timestamp}] r${e.round} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
134
+ ]
135
+ return [{ type: 'text', text: lines.join('\n') }]
136
+ },
137
+ },
138
+
139
+ async execute(args) {
140
+ const resolved = resolveProjectRoot(args.path)
141
+ if (!resolved.ok) return { ok: false, kind: 'history', error: resolved.reason }
142
+ const projectRoot = resolved.root
143
+
144
+ const { entries, filteredCount, limit } = filterDecisionEntries(
145
+ readDecisionEntries(projectRoot),
146
+ { type: args.type, since: args.since, limit: args.limit },
147
+ )
148
+ const fixes = summarizeFixRegistry(readRegistry(projectRoot))
149
+
150
+ return {
151
+ ok: true,
152
+ kind: 'history',
153
+ count: entries.length,
154
+ filteredCount,
155
+ limit,
156
+ log: entries as unknown as JsonValue,
157
+ fixes: fixes as unknown as JsonValue,
158
+ }
159
+ },
160
+ }),
161
+ )
162
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * src/tools/prune.ts — runtime artifact cleanup for the iterate loop.
3
+ *
4
+ * iterate_prune — inspect or remove stale runtime artifacts (.iterate/).
5
+ * Defaults to dry-run (report-only); set `dryRun: false` to
6
+ * actually delete.
7
+ *
8
+ * Artifacts managed:
9
+ * - Decision-log entries older than `retainDays` (default 30, via since).
10
+ * - Stale checkpoint files (checkpoint.json).
11
+ * - Fix backups left over from old rounds (backups whose fix-id no longer
12
+ * appears in the registry).
13
+ * - Empty fix rounds (rounds with 0 records).
14
+ *
15
+ * Security model:
16
+ * - Only operates under the resolved project `.iterate/` directory.
17
+ * - dryRun=true by default — the caller must explicitly opt into deletion.
18
+ * - Each deletion is logged to the decision log (when not dry-run).
19
+ */
20
+
21
+ import { existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { defineTool } from '@deepseek-ai/dsh-tools'
24
+ import type { JsonValue } from '@deepseek-ai/dsh-session'
25
+ import { resolveProjectRoot } from '../config-loader.ts'
26
+ import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
27
+ import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
28
+ import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
29
+ import type { FixRegistry } from '../types.ts'
30
+
31
+ /** Default retention for decision-log entries (in days). */
32
+ const DEFAULT_RETAIN_DAYS = 30
33
+ const MIN_RETAIN_DAYS = 1
34
+ const MAX_RETAIN_DAYS = 365
35
+
36
+ /** Clamp retainDays to a sane range. */
37
+ export function clampRetainDays(days: number | undefined): number {
38
+ if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
39
+ return DEFAULT_RETAIN_DAYS
40
+ }
41
+ return Math.min(Math.max(days, MIN_RETAIN_DAYS), MAX_RETAIN_DAYS)
42
+ }
43
+
44
+ /** Build the cutoff timestamp for a given retainDays. */
45
+ export function cutoffTimestamp(retainDays: number): string {
46
+ const d = new Date()
47
+ d.setDate(d.getDate() - retainDays)
48
+ return d.toISOString()
49
+ }
50
+
51
+ /**
52
+ * Inspect the runtime state and report what would be pruned.
53
+ * Pure (no deletions). Returns a structured report.
54
+ */
55
+ export function inspectPrune(
56
+ projectRoot: string,
57
+ retainDays: number,
58
+ ): {
59
+ oldLogEntries: number
60
+ hasCheckpoint: boolean
61
+ staleBackups: string[]
62
+ emptyRounds: number[]
63
+ totalLogEntries: number
64
+ registryRounds: number
65
+ } {
66
+ const cutoff = cutoffTimestamp(retainDays)
67
+
68
+ // 1. Decision-log entries older than retainDays.
69
+ const entries = readDecisionEntries(projectRoot)
70
+ const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length
71
+
72
+ // 2. Checkpoint presence.
73
+ const hasCheckpoint = existsSync(checkpointPath(projectRoot))
74
+
75
+ // 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
76
+ const registry = readRegistry(projectRoot)
77
+ const activeIds = new Set<string>()
78
+ for (const r of registry.rounds) {
79
+ for (const rec of r.records) {
80
+ activeIds.add(rec.id)
81
+ }
82
+ }
83
+ const staleBackups: string[] = []
84
+ const fixDir = fixesDir(projectRoot)
85
+ if (existsSync(fixDir)) {
86
+ for (const entry of readdirSync(fixDir)) {
87
+ if (!entry.endsWith('.bak')) continue
88
+ // Extract the fix-id prefix (up to the first underscore after the id).
89
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
90
+ const match = entry.match(/^(fix-[a-z0-9]+)_/)
91
+ const id = match?.[1]
92
+ if (id && !activeIds.has(id)) {
93
+ staleBackups.push(entry)
94
+ }
95
+ }
96
+ }
97
+
98
+ // 4. Empty rounds (rounds with 0 records).
99
+ const emptyRounds = registry.rounds
100
+ .filter((r) => r.records.length === 0)
101
+ .map((r) => r.round)
102
+
103
+ return {
104
+ oldLogEntries,
105
+ hasCheckpoint,
106
+ staleBackups,
107
+ emptyRounds,
108
+ totalLogEntries: entries.length,
109
+ registryRounds: registry.rounds.length,
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Actually prune the runtime artifacts (only called when dryRun=false).
115
+ * Returns a detailed report of what was deleted.
116
+ */
117
+ export function executePrune(
118
+ projectRoot: string,
119
+ retainDays: number,
120
+ report: ReturnType<typeof inspectPrune>,
121
+ ): {
122
+ deletedLogEntries: number
123
+ deletedCheckpoint: boolean
124
+ deletedBackups: string[]
125
+ trimmedEmptyRounds: number
126
+ errors: string[]
127
+ } {
128
+ const cutoff = cutoffTimestamp(retainDays)
129
+ const result = {
130
+ deletedLogEntries: 0,
131
+ deletedCheckpoint: false,
132
+ deletedBackups: [] as string[],
133
+ trimmedEmptyRounds: 0,
134
+ errors: [] as string[],
135
+ }
136
+
137
+ // 1. Rewrite the decision log, keeping only recent entries.
138
+ try {
139
+ const entries = readDecisionEntries(projectRoot)
140
+ const kept = entries.filter((e) => e.timestamp >= cutoff)
141
+ result.deletedLogEntries = entries.length - kept.length
142
+ if (result.deletedLogEntries > 0) {
143
+ writeFileSync(
144
+ join(iterateDir(projectRoot), 'decision-log.jsonl'),
145
+ kept.map((e) => JSON.stringify(e)).join('\n') + '\n',
146
+ 'utf-8',
147
+ )
148
+ }
149
+ } catch (err) {
150
+ result.errors.push(`failed to rewrite decision log: ${String(err)}`)
151
+ result.deletedLogEntries = 0
152
+ }
153
+
154
+ // 2. Remove checkpoint.
155
+ if (report.hasCheckpoint) {
156
+ try {
157
+ rmSync(checkpointPath(projectRoot), { force: true })
158
+ result.deletedCheckpoint = true
159
+ } catch (err) {
160
+ result.errors.push(`failed to remove checkpoint: ${String(err)}`)
161
+ }
162
+ }
163
+
164
+ // 3. Delete stale backups.
165
+ for (const bak of report.staleBackups) {
166
+ try {
167
+ unlinkSync(join(fixesDir(projectRoot), bak))
168
+ result.deletedBackups.push(bak)
169
+ } catch (err) {
170
+ result.errors.push(`failed to delete backup ${bak}: ${String(err)}`)
171
+ }
172
+ }
173
+
174
+ // 4. Trim empty rounds from the registry.
175
+ if (report.emptyRounds.length > 0) {
176
+ try {
177
+ let registry = readRegistry(projectRoot)
178
+ for (const round of report.emptyRounds) {
179
+ for (const rec of [...registry.rounds.find((r) => r.round === round)?.records ?? []]) {
180
+ registry = removeRecord(registry, rec.id)
181
+ }
182
+ }
183
+ registry = recomputeRoundCounts(registry)
184
+ writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8')
185
+ result.trimmedEmptyRounds = report.emptyRounds.length
186
+ } catch (err) {
187
+ result.errors.push(`failed to trim empty rounds: ${String(err)}`)
188
+ }
189
+ }
190
+
191
+ return result
192
+ }
193
+
194
+ /**
195
+ * Register the `iterate_prune` tool.
196
+ * Defaults to dry-run: inspects the runtime state and reports what would be
197
+ * cleaned up. Pass `dryRun: false` to actually delete.
198
+ */
199
+ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
200
+ ctx.tools.register(
201
+ defineTool({
202
+ name: 'iterate_prune',
203
+ description:
204
+ 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
205
+ 'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
206
+ 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds. ' +
207
+ 'Each deletion is logged to the decision log.',
208
+ parameters: {
209
+ dryRun: {
210
+ type: 'boolean',
211
+ description: 'When true (default), only report what would be pruned without deleting anything.',
212
+ },
213
+ retainDays: {
214
+ type: 'integer',
215
+ description: `Keep entries newer than this many days (default: ${DEFAULT_RETAIN_DAYS}, range: ${MIN_RETAIN_DAYS}-${MAX_RETAIN_DAYS}).`,
216
+ },
217
+ path: {
218
+ type: 'string',
219
+ description: 'Project root directory (default: current working directory).',
220
+ },
221
+ },
222
+
223
+ output: {
224
+ schema: {
225
+ type: 'object',
226
+ additionalProperties: false,
227
+ properties: {
228
+ ok: { type: 'boolean', required: true },
229
+ dryRun: { type: 'boolean', required: true },
230
+ retainDays: { type: 'integer' },
231
+ report: { type: 'json' },
232
+ result: { type: 'json' },
233
+ error: { type: 'string' },
234
+ },
235
+ },
236
+ render: (_args, value) => {
237
+ if (!value.ok) return [{ type: 'text', text: `prune failed: ${value.error}` }]
238
+ const report = value.report as Record<string, unknown> | undefined
239
+ const result = value.result as Record<string, unknown> | undefined
240
+ if (value.dryRun) {
241
+ const lines = [
242
+ `[dry-run] prune report (retainDays=${value.retainDays}):`,
243
+ ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
244
+ ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
245
+ ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
246
+ ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
247
+ '',
248
+ 'Pass dryRun:false to execute the prune.',
249
+ ]
250
+ return [{ type: 'text', text: lines.join('\n') }]
251
+ }
252
+ const lines = [
253
+ `Prune complete (retainDays=${value.retainDays}):`,
254
+ ` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
255
+ ` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
256
+ ` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
257
+ ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
258
+ ]
259
+ const errs = (result?.errors as string[] | undefined) ?? []
260
+ if (errs.length > 0) {
261
+ lines.push('', ' Warnings:')
262
+ for (const e of errs) lines.push(` - ${e}`)
263
+ }
264
+ return [{ type: 'text', text: lines.join('\n') }]
265
+ },
266
+ },
267
+
268
+ async execute(args) {
269
+ const resolved = resolveProjectRoot(args.path)
270
+ if (!resolved.ok) return { ok: false, dryRun: true, error: resolved.reason }
271
+ const projectRoot = resolved.root
272
+ const retainDays = clampRetainDays(args.retainDays as number | undefined)
273
+ const dryRun = args.dryRun !== false
274
+
275
+ const report = inspectPrune(projectRoot, retainDays)
276
+
277
+ if (dryRun) {
278
+ return {
279
+ ok: true,
280
+ dryRun: true,
281
+ retainDays,
282
+ report: report as unknown as JsonValue,
283
+ }
284
+ }
285
+
286
+ const result = executePrune(projectRoot, retainDays, report)
287
+
288
+ // Log the prune to the decision log.
289
+ appendDecisionEntry(projectRoot, {
290
+ timestamp: new Date().toISOString(),
291
+ round: 0,
292
+ type: 'decision',
293
+ data: {
294
+ action: 'prune',
295
+ retainDays,
296
+ deletedLogEntries: result.deletedLogEntries,
297
+ deletedCheckpoint: result.deletedCheckpoint,
298
+ deletedBackups: result.deletedBackups.length,
299
+ trimmedEmptyRounds: result.trimmedEmptyRounds,
300
+ },
301
+ })
302
+
303
+ return {
304
+ ok: true,
305
+ dryRun: false,
306
+ retainDays,
307
+ report: report as unknown as JsonValue,
308
+ result: result as unknown as JsonValue,
309
+ }
310
+ },
311
+ }),
312
+ )
313
+ }
@@ -12,6 +12,9 @@ const CONFIG_FILE = 'iterate.config.yaml'
12
12
  const PERSONALIZATION_KEY = 'personalization'
13
13
  const KNOWN_INTENTIONAL_KEY = 'known_intentional'
14
14
 
15
+ /** Max entries per single `apply` call. */
16
+ const MAX_ENTRIES = 500
17
+
15
18
  /** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
16
19
  const WHOLE_FILE_LINE = 0
17
20
 
@@ -45,6 +48,10 @@ export function validateTriageEntries(entries: unknown): string[] {
45
48
  errors.push('entries must be an array')
46
49
  return errors
47
50
  }
51
+ if (entries.length > MAX_ENTRIES) {
52
+ errors.push(`entries must not exceed ${MAX_ENTRIES} items (got ${entries.length})`)
53
+ return errors
54
+ }
48
55
  for (let i = 0; i < entries.length; i++) {
49
56
  const prefix = `entries[${i}]`
50
57
  const e = entries[i]