iterate-plugin 2.4.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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * src/tools/history.ts — iteration history reader.
3
+ *
4
+ * iterate_history — read the decision-log entries (with optional filters)
5
+ * plus a summary of the fix registry, so the user or the
6
+ * orchestrator can review exactly what the run did.
7
+ *
8
+ * Complements `iterate_status` (compact summary) with the actual detail.
9
+ */
10
+
11
+ import { defineTool } from '@deepseek-ai/dsh-tools'
12
+ import type { JsonValue } from '@deepseek-ai/dsh-session'
13
+ import { resolveProjectRoot } from '../config-loader.ts'
14
+ import { readDecisionEntries } from './decision-log.ts'
15
+ import { readRegistry } from './fix.ts'
16
+ import type { DecisionLogEntry, FixRegistry } from '../types.ts'
17
+
18
+ const DEFAULT_LIMIT = 50
19
+ const MAX_LIMIT = 200
20
+
21
+ /** Clamp a caller-supplied `limit` to a sane range. */
22
+ export function clampHistoryLimit(limit: number | undefined): number {
23
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
24
+ return DEFAULT_LIMIT
25
+ }
26
+ return Math.min(limit, MAX_LIMIT)
27
+ }
28
+
29
+ /**
30
+ * Filter + cap decision-log entries. Pure, unit-tested.
31
+ * Returns the newest `limit` matching entries plus the total match count
32
+ * (before the cap), so callers can tell when the result was truncated.
33
+ */
34
+ export function filterDecisionEntries(
35
+ entries: DecisionLogEntry[],
36
+ opts: { type?: unknown; since?: unknown; limit?: unknown },
37
+ ): { entries: DecisionLogEntry[]; filteredCount: number; limit: number } {
38
+ const type = typeof opts.type === 'string' && opts.type ? opts.type : undefined
39
+ const since = typeof opts.since === 'string' && opts.since ? opts.since : undefined
40
+ const limit = clampHistoryLimit(opts.limit as number | undefined)
41
+
42
+ const matching = (Array.isArray(entries) ? entries : []).filter((e) => {
43
+ if (type && e.type !== type) return false
44
+ if (since && e.timestamp <= since) return false
45
+ return true
46
+ })
47
+ return {
48
+ entries: matching.slice(-limit),
49
+ filteredCount: matching.length,
50
+ limit,
51
+ }
52
+ }
53
+
54
+ /** Per-round fix counts + totals from a fix registry. Pure, unit-tested. */
55
+ export function summarizeFixRegistry(registry: FixRegistry): {
56
+ totalFixed: number
57
+ totalFailed: number
58
+ roundCount: number
59
+ rounds: { round: number; fixedCount: number; failedCount: number }[]
60
+ } {
61
+ const rounds = (registry.rounds ?? []).map((r) => ({
62
+ round: r.round,
63
+ fixedCount: r.fixedCount,
64
+ failedCount: r.failedCount,
65
+ }))
66
+ return {
67
+ totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
68
+ totalFailed: rounds.reduce((s, r) => s + r.failedCount, 0),
69
+ roundCount: rounds.length,
70
+ rounds,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Register the `iterate_history` tool.
76
+ * Reads the decision log (optionally filtered by type / since / limit) and a
77
+ * fix-registry summary. Read-only; never modifies the filesystem.
78
+ */
79
+ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
80
+ ctx.tools.register(
81
+ defineTool({
82
+ name: 'iterate_history',
83
+ description:
84
+ 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
85
+ 'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
86
+ 'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
87
+ parameters: {
88
+ type: {
89
+ type: 'string',
90
+ description:
91
+ 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
92
+ 'revert, validation, decision, report.',
93
+ },
94
+ since: {
95
+ type: 'string',
96
+ description: 'Optional ISO timestamp; only entries AFTER this timestamp are returned.',
97
+ },
98
+ limit: {
99
+ type: 'integer',
100
+ description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Newest first.`,
101
+ },
102
+ path: {
103
+ type: 'string',
104
+ description: 'Project root directory (default: current working directory).',
105
+ },
106
+ },
107
+
108
+ output: {
109
+ schema: {
110
+ type: 'object',
111
+ additionalProperties: false,
112
+ properties: {
113
+ ok: { type: 'boolean', required: true },
114
+ kind: { type: 'string' },
115
+ count: { type: 'integer' },
116
+ filteredCount: { type: 'integer' },
117
+ limit: { type: 'integer' },
118
+ log: { type: 'json' },
119
+ fixes: { type: 'json' },
120
+ error: { type: 'string' },
121
+ },
122
+ },
123
+ render: (_args, value) => {
124
+ if (!value.ok) return [{ type: 'text', text: `history failed: ${value.error}` }]
125
+ const log = (value.log as DecisionLogEntry[] | undefined) ?? []
126
+ const fixes = (value.fixes as { totalFixed: number; totalFailed: number; roundCount: number } | undefined)
127
+ const lines = [
128
+ `Decision-log entries: ${value.count} (filtered to ${value.limit})`,
129
+ fixes
130
+ ? `Fixes: ${fixes.totalFixed} applied · ${fixes.totalFailed} failed · across ${fixes.roundCount} round(s)`
131
+ : 'Fixes: none',
132
+ '',
133
+ ...log.map((e) => `[${e.timestamp}] r${e.round} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
134
+ ]
135
+ return [{ type: 'text', text: lines.join('\n') }]
136
+ },
137
+ },
138
+
139
+ async execute(args) {
140
+ const resolved = resolveProjectRoot(args.path)
141
+ if (!resolved.ok) return { ok: false, kind: 'history', error: resolved.reason }
142
+ const projectRoot = resolved.root
143
+
144
+ const { entries, filteredCount, limit } = filterDecisionEntries(
145
+ readDecisionEntries(projectRoot),
146
+ { type: args.type, since: args.since, limit: args.limit },
147
+ )
148
+ const fixes = summarizeFixRegistry(readRegistry(projectRoot))
149
+
150
+ return {
151
+ ok: true,
152
+ kind: 'history',
153
+ count: entries.length,
154
+ filteredCount,
155
+ limit,
156
+ log: entries as unknown as JsonValue,
157
+ fixes: fixes as unknown as JsonValue,
158
+ }
159
+ },
160
+ }),
161
+ )
162
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * src/tools/prune.ts — runtime artifact cleanup for the iterate loop.
3
+ *
4
+ * iterate_prune — inspect or remove stale runtime artifacts (.iterate/).
5
+ * Defaults to dry-run (report-only); set `dryRun: false` to
6
+ * actually delete.
7
+ *
8
+ * Artifacts managed:
9
+ * - Decision-log entries older than `retainDays` (default 30, via since).
10
+ * - Stale checkpoint files (checkpoint.json).
11
+ * - Fix backups left over from old rounds (backups whose fix-id no longer
12
+ * appears in the registry).
13
+ * - Empty fix rounds (rounds with 0 records).
14
+ *
15
+ * Security model:
16
+ * - Only operates under the resolved project `.iterate/` directory.
17
+ * - dryRun=true by default — the caller must explicitly opt into deletion.
18
+ * - Each deletion is logged to the decision log (when not dry-run).
19
+ */
20
+
21
+ import { existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { defineTool } from '@deepseek-ai/dsh-tools'
24
+ import type { JsonValue } from '@deepseek-ai/dsh-session'
25
+ import { resolveProjectRoot } from '../config-loader.ts'
26
+ import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
27
+ import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
28
+ import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
29
+ import type { FixRegistry } from '../types.ts'
30
+
31
+ /** Default retention for decision-log entries (in days). */
32
+ const DEFAULT_RETAIN_DAYS = 30
33
+ const MIN_RETAIN_DAYS = 1
34
+ const MAX_RETAIN_DAYS = 365
35
+
36
+ /** Clamp retainDays to a sane range. */
37
+ export function clampRetainDays(days: number | undefined): number {
38
+ if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
39
+ return DEFAULT_RETAIN_DAYS
40
+ }
41
+ return Math.min(Math.max(days, MIN_RETAIN_DAYS), MAX_RETAIN_DAYS)
42
+ }
43
+
44
+ /** Build the cutoff timestamp for a given retainDays. */
45
+ export function cutoffTimestamp(retainDays: number): string {
46
+ const d = new Date()
47
+ d.setDate(d.getDate() - retainDays)
48
+ return d.toISOString()
49
+ }
50
+
51
+ /**
52
+ * Inspect the runtime state and report what would be pruned.
53
+ * Pure (no deletions). Returns a structured report.
54
+ */
55
+ export function inspectPrune(
56
+ projectRoot: string,
57
+ retainDays: number,
58
+ ): {
59
+ oldLogEntries: number
60
+ hasCheckpoint: boolean
61
+ staleBackups: string[]
62
+ emptyRounds: number[]
63
+ totalLogEntries: number
64
+ registryRounds: number
65
+ } {
66
+ const cutoff = cutoffTimestamp(retainDays)
67
+
68
+ // 1. Decision-log entries older than retainDays.
69
+ const entries = readDecisionEntries(projectRoot)
70
+ const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length
71
+
72
+ // 2. Checkpoint presence.
73
+ const hasCheckpoint = existsSync(checkpointPath(projectRoot))
74
+
75
+ // 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
76
+ const registry = readRegistry(projectRoot)
77
+ const activeIds = new Set<string>()
78
+ for (const r of registry.rounds) {
79
+ for (const rec of r.records) {
80
+ activeIds.add(rec.id)
81
+ }
82
+ }
83
+ const staleBackups: string[] = []
84
+ const fixDir = fixesDir(projectRoot)
85
+ if (existsSync(fixDir)) {
86
+ for (const entry of readdirSync(fixDir)) {
87
+ if (!entry.endsWith('.bak')) continue
88
+ // Extract the fix-id prefix (up to the first underscore after the id).
89
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
90
+ const match = entry.match(/^(fix-[a-z0-9]+)_/)
91
+ const id = match?.[1]
92
+ if (id && !activeIds.has(id)) {
93
+ staleBackups.push(entry)
94
+ }
95
+ }
96
+ }
97
+
98
+ // 4. Empty rounds (rounds with 0 records).
99
+ const emptyRounds = registry.rounds
100
+ .filter((r) => r.records.length === 0)
101
+ .map((r) => r.round)
102
+
103
+ return {
104
+ oldLogEntries,
105
+ hasCheckpoint,
106
+ staleBackups,
107
+ emptyRounds,
108
+ totalLogEntries: entries.length,
109
+ registryRounds: registry.rounds.length,
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Actually prune the runtime artifacts (only called when dryRun=false).
115
+ * Returns a detailed report of what was deleted.
116
+ */
117
+ export function executePrune(
118
+ projectRoot: string,
119
+ retainDays: number,
120
+ report: ReturnType<typeof inspectPrune>,
121
+ ): {
122
+ deletedLogEntries: number
123
+ deletedCheckpoint: boolean
124
+ deletedBackups: string[]
125
+ trimmedEmptyRounds: number
126
+ errors: string[]
127
+ } {
128
+ const cutoff = cutoffTimestamp(retainDays)
129
+ const result = {
130
+ deletedLogEntries: 0,
131
+ deletedCheckpoint: false,
132
+ deletedBackups: [] as string[],
133
+ trimmedEmptyRounds: 0,
134
+ errors: [] as string[],
135
+ }
136
+
137
+ // 1. Rewrite the decision log, keeping only recent entries.
138
+ try {
139
+ const entries = readDecisionEntries(projectRoot)
140
+ const kept = entries.filter((e) => e.timestamp >= cutoff)
141
+ result.deletedLogEntries = entries.length - kept.length
142
+ if (result.deletedLogEntries > 0) {
143
+ writeFileSync(
144
+ join(iterateDir(projectRoot), 'decision-log.jsonl'),
145
+ kept.map((e) => JSON.stringify(e)).join('\n') + '\n',
146
+ 'utf-8',
147
+ )
148
+ }
149
+ } catch (err) {
150
+ result.errors.push(`failed to rewrite decision log: ${String(err)}`)
151
+ result.deletedLogEntries = 0
152
+ }
153
+
154
+ // 2. Remove checkpoint.
155
+ if (report.hasCheckpoint) {
156
+ try {
157
+ rmSync(checkpointPath(projectRoot), { force: true })
158
+ result.deletedCheckpoint = true
159
+ } catch (err) {
160
+ result.errors.push(`failed to remove checkpoint: ${String(err)}`)
161
+ }
162
+ }
163
+
164
+ // 3. Delete stale backups.
165
+ for (const bak of report.staleBackups) {
166
+ try {
167
+ unlinkSync(join(fixesDir(projectRoot), bak))
168
+ result.deletedBackups.push(bak)
169
+ } catch (err) {
170
+ result.errors.push(`failed to delete backup ${bak}: ${String(err)}`)
171
+ }
172
+ }
173
+
174
+ // 4. Trim empty rounds from the registry.
175
+ if (report.emptyRounds.length > 0) {
176
+ try {
177
+ let registry = readRegistry(projectRoot)
178
+ for (const round of report.emptyRounds) {
179
+ for (const rec of [...registry.rounds.find((r) => r.round === round)?.records ?? []]) {
180
+ registry = removeRecord(registry, rec.id)
181
+ }
182
+ }
183
+ registry = recomputeRoundCounts(registry)
184
+ writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8')
185
+ result.trimmedEmptyRounds = report.emptyRounds.length
186
+ } catch (err) {
187
+ result.errors.push(`failed to trim empty rounds: ${String(err)}`)
188
+ }
189
+ }
190
+
191
+ return result
192
+ }
193
+
194
+ /**
195
+ * Register the `iterate_prune` tool.
196
+ * Defaults to dry-run: inspects the runtime state and reports what would be
197
+ * cleaned up. Pass `dryRun: false` to actually delete.
198
+ */
199
+ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
200
+ ctx.tools.register(
201
+ defineTool({
202
+ name: 'iterate_prune',
203
+ description:
204
+ 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
205
+ 'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
206
+ 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds. ' +
207
+ 'Each deletion is logged to the decision log.',
208
+ parameters: {
209
+ dryRun: {
210
+ type: 'boolean',
211
+ description: 'When true (default), only report what would be pruned without deleting anything.',
212
+ },
213
+ retainDays: {
214
+ type: 'integer',
215
+ description: `Keep entries newer than this many days (default: ${DEFAULT_RETAIN_DAYS}, range: ${MIN_RETAIN_DAYS}-${MAX_RETAIN_DAYS}).`,
216
+ },
217
+ path: {
218
+ type: 'string',
219
+ description: 'Project root directory (default: current working directory).',
220
+ },
221
+ },
222
+
223
+ output: {
224
+ schema: {
225
+ type: 'object',
226
+ additionalProperties: false,
227
+ properties: {
228
+ ok: { type: 'boolean', required: true },
229
+ dryRun: { type: 'boolean', required: true },
230
+ retainDays: { type: 'integer' },
231
+ report: { type: 'json' },
232
+ result: { type: 'json' },
233
+ error: { type: 'string' },
234
+ },
235
+ },
236
+ render: (_args, value) => {
237
+ if (!value.ok) return [{ type: 'text', text: `prune failed: ${value.error}` }]
238
+ const report = value.report as Record<string, unknown> | undefined
239
+ const result = value.result as Record<string, unknown> | undefined
240
+ if (value.dryRun) {
241
+ const lines = [
242
+ `[dry-run] prune report (retainDays=${value.retainDays}):`,
243
+ ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
244
+ ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
245
+ ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
246
+ ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
247
+ '',
248
+ 'Pass dryRun:false to execute the prune.',
249
+ ]
250
+ return [{ type: 'text', text: lines.join('\n') }]
251
+ }
252
+ const lines = [
253
+ `Prune complete (retainDays=${value.retainDays}):`,
254
+ ` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
255
+ ` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
256
+ ` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
257
+ ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
258
+ ]
259
+ const errs = (result?.errors as string[] | undefined) ?? []
260
+ if (errs.length > 0) {
261
+ lines.push('', ' Warnings:')
262
+ for (const e of errs) lines.push(` - ${e}`)
263
+ }
264
+ return [{ type: 'text', text: lines.join('\n') }]
265
+ },
266
+ },
267
+
268
+ async execute(args) {
269
+ const resolved = resolveProjectRoot(args.path)
270
+ if (!resolved.ok) return { ok: false, dryRun: true, error: resolved.reason }
271
+ const projectRoot = resolved.root
272
+ const retainDays = clampRetainDays(args.retainDays as number | undefined)
273
+ const dryRun = args.dryRun !== false
274
+
275
+ const report = inspectPrune(projectRoot, retainDays)
276
+
277
+ if (dryRun) {
278
+ return {
279
+ ok: true,
280
+ dryRun: true,
281
+ retainDays,
282
+ report: report as unknown as JsonValue,
283
+ }
284
+ }
285
+
286
+ const result = executePrune(projectRoot, retainDays, report)
287
+
288
+ // Log the prune to the decision log.
289
+ appendDecisionEntry(projectRoot, {
290
+ timestamp: new Date().toISOString(),
291
+ round: 0,
292
+ type: 'decision',
293
+ data: {
294
+ action: 'prune',
295
+ retainDays,
296
+ deletedLogEntries: result.deletedLogEntries,
297
+ deletedCheckpoint: result.deletedCheckpoint,
298
+ deletedBackups: result.deletedBackups.length,
299
+ trimmedEmptyRounds: result.trimmedEmptyRounds,
300
+ },
301
+ })
302
+
303
+ return {
304
+ ok: true,
305
+ dryRun: false,
306
+ retainDays,
307
+ report: report as unknown as JsonValue,
308
+ result: result as unknown as JsonValue,
309
+ }
310
+ },
311
+ }),
312
+ )
313
+ }
@@ -12,6 +12,9 @@ const CONFIG_FILE = 'iterate.config.yaml'
12
12
  const PERSONALIZATION_KEY = 'personalization'
13
13
  const KNOWN_INTENTIONAL_KEY = 'known_intentional'
14
14
 
15
+ /** Max entries per single `apply` call. */
16
+ const MAX_ENTRIES = 500
17
+
15
18
  /** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
16
19
  const WHOLE_FILE_LINE = 0
17
20
 
@@ -45,6 +48,10 @@ export function validateTriageEntries(entries: unknown): string[] {
45
48
  errors.push('entries must be an array')
46
49
  return errors
47
50
  }
51
+ if (entries.length > MAX_ENTRIES) {
52
+ errors.push(`entries must not exceed ${MAX_ENTRIES} items (got ${entries.length})`)
53
+ return errors
54
+ }
48
55
  for (let i = 0; i < entries.length; i++) {
49
56
  const prefix = `entries[${i}]`
50
57
  const e = entries[i]
package/src/types.ts CHANGED
@@ -97,4 +97,68 @@ export interface KnownIntentional {
97
97
  line?: number
98
98
  dimension: string
99
99
  reason: string
100
+ }
101
+
102
+ /** ─── Fix system ──────────────────────────────────────────────────────────── */
103
+
104
+ /** A single fix record: one finding → one fix operation on one file. */
105
+ export interface FixRecord {
106
+ id: string
107
+ timestamp: string
108
+ round: number
109
+ finding: ReviewFinding
110
+ backupPath: string
111
+ diffSummary: string
112
+ linesAdded: number
113
+ linesRemoved: number
114
+ success: boolean
115
+ error?: string
116
+ }
117
+
118
+ /** Fix registry metadata (per-round summary). */
119
+ export interface FixRegistry {
120
+ rounds: FixRoundRecord[]
121
+ }
122
+ export interface FixRoundRecord {
123
+ round: number
124
+ fixedCount: number
125
+ failedCount: number
126
+ records: FixRecord[]
127
+ }
128
+
129
+ /** Hunk-level diff for a single file. */
130
+ export interface FileDiffHunk {
131
+ oldStart: number
132
+ oldLines: number
133
+ newStart: number
134
+ newLines: number
135
+ content: string
136
+ }
137
+
138
+ /** ─── Checkpoint / resume ─────────────────────────────────────────────────── */
139
+
140
+ export interface IterationCheckpoint {
141
+ mode: 'dry-run' | 'normal'
142
+ round: number
143
+ maxRounds: number
144
+ fixedCount: number
145
+ architecturalCount: number
146
+ findings: ReviewFinding[]
147
+ startedAt: string
148
+ updatedAt: string
149
+ }
150
+
151
+ /** ─── Status summary ──────────────────────────────────────────────────────── */
152
+
153
+ export interface IterationStatus {
154
+ mode: 'dry-run' | 'normal' | null
155
+ currentRound: number
156
+ totalRounds: number
157
+ fixedCount: number
158
+ architecturalCount: number
159
+ findingsCount: number
160
+ totalDecisionLogEntries: number
161
+ hasCheckpoint: boolean
162
+ checkpoint: IterationCheckpoint | null
163
+ lastUpdated: string | null
100
164
  }