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/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.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": "./src/index.ts"
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.
@@ -205,7 +205,20 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
205
205
  `but totalFindings is ${total}.`,
206
206
  )
207
207
  }
208
- const lastRoundNew = findingsByRound.length > 0 ? Number(findingsByRound[findingsByRound.length - 1]) : null
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[r]` = number of GLOBALLY new findings first seen in round r
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 <= rounds.length; 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
- const lastRoundCount = totalRounds > 0 ? findingsByRound[totalRounds - 1] ?? 0 : 0
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: filteredRounds.length > 0 && (findingsByRound[filteredRounds.length - 1] ?? 0) === 0,
259
+ converged,
232
260
  stoppedReason:
233
261
  filteredRounds.length === 0
234
262
  ? 'max_rounds_reached'
235
- : (findingsByRound[filteredRounds.length - 1] ?? 0) === 0
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
+ }