iterate-plugin 2.5.0 → 2.7.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 +83 -15
- package/dist/config-loader.js +171 -0
- package/dist/config-write.js +174 -0
- package/dist/index.js +58 -0
- package/dist/meta-review.js +181 -0
- package/dist/paths.js +32 -0
- package/dist/review.js +328 -0
- package/dist/skill-prompt.js +337 -0
- package/dist/tools/checkpoint.js +260 -0
- package/dist/tools/config.js +134 -0
- package/dist/tools/context.js +160 -0
- package/dist/tools/decision-log.js +162 -0
- package/dist/tools/fix.js +553 -0
- package/dist/tools/history.js +138 -0
- package/dist/tools/prune.js +268 -0
- package/dist/tools/review.js +159 -0
- package/dist/tools/triage.js +333 -0
- package/dist/tools/validate.js +164 -0
- package/dist/types.js +1 -0
- package/lib/client.js +84 -4
- package/lib/parse.js +68 -0
- package/package.json +7 -3
- package/src/index.ts +4 -0
- package/src/meta-review.ts +14 -1
- package/src/review.ts +33 -5
- 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/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.7.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",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"src",
|
|
39
39
|
"lib",
|
|
40
|
+
"dist",
|
|
40
41
|
"cordis.patch.yml",
|
|
41
42
|
"README.md",
|
|
42
43
|
"LICENSE"
|
|
@@ -44,11 +45,14 @@
|
|
|
44
45
|
"exports": {
|
|
45
46
|
".": {
|
|
46
47
|
"types": "./src/index.ts",
|
|
47
|
-
"default": "./
|
|
48
|
+
"default": "./dist/index.js"
|
|
48
49
|
},
|
|
49
|
-
"./client": "./lib/client.js"
|
|
50
|
+
"./client": "./lib/client.js",
|
|
51
|
+
"./package.json": "./package.json"
|
|
50
52
|
},
|
|
51
53
|
"scripts": {
|
|
54
|
+
"build": "tsc -p tsconfig.build.json",
|
|
55
|
+
"prepublishOnly": "npm run build",
|
|
52
56
|
"typecheck": "tsc --noEmit",
|
|
53
57
|
"test": "tsx --test test/*.test.ts",
|
|
54
58
|
"test:validate": "tsx --test test/validate.test.ts"
|
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/meta-review.ts
CHANGED
|
@@ -205,7 +205,20 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
|
|
|
205
205
|
`but totalFindings is ${total}.`,
|
|
206
206
|
)
|
|
207
207
|
}
|
|
208
|
-
|
|
208
|
+
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
209
|
+
// r-1), so the "last round" is the LAST RECORDED round's reported number, not
|
|
210
|
+
// the array's last index (the array is sized to the highest round, which only
|
|
211
|
+
// equals the record count for contiguous 1..N round numbers). Read the flag
|
|
212
|
+
// consistency the same way buildReviewReport/computeConvergence set it.
|
|
213
|
+
const reportRounds = Array.isArray(report.rounds) ? report.rounds : []
|
|
214
|
+
const lastRecordedRound =
|
|
215
|
+
reportRounds.length > 0 && typeof reportRounds[reportRounds.length - 1]?.round === 'number'
|
|
216
|
+
? reportRounds[reportRounds.length - 1]!.round
|
|
217
|
+
: null
|
|
218
|
+
const lastRoundNew =
|
|
219
|
+
lastRecordedRound !== null && lastRecordedRound > 0
|
|
220
|
+
? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
|
|
221
|
+
: null
|
|
209
222
|
const expectedConverged = lastRoundNew === 0
|
|
210
223
|
if (report.convergence?.converged !== expectedConverged) {
|
|
211
224
|
add(
|
package/src/review.ts
CHANGED
|
@@ -106,7 +106,12 @@ export function filterKnownIntentional(
|
|
|
106
106
|
* Merge per-round findings into one globally-deduped stream while tracking
|
|
107
107
|
* which round first surfaced each finding. This is the deterministic core of
|
|
108
108
|
* "反复多轮审查直至收敛":
|
|
109
|
-
* - `findingsByRound
|
|
109
|
+
* - `findingsByRound` = number of GLOBALLY new findings first seen in round r,
|
|
110
|
+
* indexed by the actual `round` number (round r → index r-1). The array is
|
|
111
|
+
* sized to the highest round number encountered, so non-contiguous round
|
|
112
|
+
* numbers (e.g. a resumed run that starts at round 5, or a caller that only
|
|
113
|
+
* passes `[{round: 3}]`) still yield correct counts instead of being
|
|
114
|
+
* collapsed onto wrong indices.
|
|
110
115
|
* - `converged` = the last executed round produced 0 new findings
|
|
111
116
|
* - `stoppedReason` = 'converged' | 'max_rounds_reached'
|
|
112
117
|
*/
|
|
@@ -122,7 +127,12 @@ export function aggregateRounds(
|
|
|
122
127
|
const firstRoundByKey = new Map<string, number>()
|
|
123
128
|
const merged: ReviewFinding[] = []
|
|
124
129
|
|
|
130
|
+
// Guard: round numbers are expected to be positive integers. Skip malformed
|
|
131
|
+
// entries defensively rather than letting `firstRoundByKey` key on NaN/0.
|
|
132
|
+
let maxRound = 0
|
|
125
133
|
for (const round of rounds) {
|
|
134
|
+
if (typeof round.round !== 'number' || !Number.isInteger(round.round) || round.round < 1) continue
|
|
135
|
+
if (round.round > maxRound) maxRound = round.round
|
|
126
136
|
for (const f of round.findings) {
|
|
127
137
|
const key = findingKey(f)
|
|
128
138
|
if (seen.has(key)) continue
|
|
@@ -133,7 +143,7 @@ export function aggregateRounds(
|
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
const findingsByRound: number[] = []
|
|
136
|
-
for (let r = 1; r <=
|
|
146
|
+
for (let r = 1; r <= maxRound; r++) {
|
|
137
147
|
let count = 0
|
|
138
148
|
for (const key of firstRoundByKey.keys()) {
|
|
139
149
|
if (firstRoundByKey.get(key) === r) count++
|
|
@@ -153,7 +163,13 @@ export function computeConvergence(
|
|
|
153
163
|
): ReviewReport['convergence'] {
|
|
154
164
|
const { findingsByRound } = aggregateRounds(rounds, maxReviewRounds)
|
|
155
165
|
const totalRounds = rounds.length
|
|
156
|
-
|
|
166
|
+
// `findingsByRound` is indexed by the actual round number (round r → index
|
|
167
|
+
// r-1), so convergence must read the LAST PRESENT round's count using its
|
|
168
|
+
// reported round number — not `totalRounds - 1`, which is only valid for
|
|
169
|
+
// contiguous 1..N round numbers.
|
|
170
|
+
const lastRound = totalRounds > 0 ? rounds[totalRounds - 1]!.round : 0
|
|
171
|
+
const lastRoundCount =
|
|
172
|
+
lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
|
|
157
173
|
const converged = totalRounds > 0 && lastRoundCount === 0
|
|
158
174
|
return {
|
|
159
175
|
totalRounds,
|
|
@@ -218,6 +234,18 @@ export function buildReviewReport(input: {
|
|
|
218
234
|
// 3. Severity sort the global result.
|
|
219
235
|
const sorted = sortFindings(findings)
|
|
220
236
|
|
|
237
|
+
// 4. Convergence. Must be identical to `computeConvergence`: `findingsByRound`
|
|
238
|
+
// is indexed by the actual round number (round r → index r-1) and sized to
|
|
239
|
+
// the highest round, so convergence reads the LAST PRESENT round's count
|
|
240
|
+
// using its reported round number — NOT `filteredRounds.length - 1`, which
|
|
241
|
+
// is only valid for contiguous 1..N round numbers (resumed iterations and
|
|
242
|
+
// non-contiguous round sets would otherwise read the wrong count).
|
|
243
|
+
const lastRound =
|
|
244
|
+
filteredRounds.length > 0 ? filteredRounds[filteredRounds.length - 1]!.round : 0
|
|
245
|
+
const lastRoundCount =
|
|
246
|
+
lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
|
|
247
|
+
const converged = filteredRounds.length > 0 && lastRoundCount === 0
|
|
248
|
+
|
|
221
249
|
return {
|
|
222
250
|
mode: input.mode,
|
|
223
251
|
goal: input.goal,
|
|
@@ -228,11 +256,11 @@ export function buildReviewReport(input: {
|
|
|
228
256
|
convergence: {
|
|
229
257
|
totalRounds: filteredRounds.length,
|
|
230
258
|
findingsByRound,
|
|
231
|
-
converged
|
|
259
|
+
converged,
|
|
232
260
|
stoppedReason:
|
|
233
261
|
filteredRounds.length === 0
|
|
234
262
|
? 'max_rounds_reached'
|
|
235
|
-
:
|
|
263
|
+
: converged
|
|
236
264
|
? 'converged'
|
|
237
265
|
: 'max_rounds_reached',
|
|
238
266
|
},
|
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
|
+
}
|