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 +73 -15
- package/lib/client.js +84 -4
- package/lib/parse.js +68 -0
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/tools/fix.ts +11 -0
- package/src/tools/history.ts +162 -0
- package/src/tools/prune.ts +313 -0
- package/src/tools/triage.ts +7 -0
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/
|
|
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 /
|
|
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
|
-
|
|
145
|
+
## 注册工具(13 个)
|
|
112
146
|
|
|
113
147
|
| 工具 | 功能 |
|
|
114
148
|
|------|------|
|
|
115
|
-
| `iterate_config` |
|
|
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`
|
|
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
|
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
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
|
-
|
|
140
|
-
-
|
|
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,
|
|
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', {
|
|
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.
|
|
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
|
+
}
|
package/src/tools/triage.ts
CHANGED
|
@@ -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]
|