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.
@@ -0,0 +1,553 @@
1
+ /**
2
+ * src/tools/fix.ts — structured fix system for the iterate loop.
3
+ *
4
+ * Three tools:
5
+ * iterate_fix — apply ONE atomic fix to a file: validates atomicity,
6
+ * backs up the original, writes the new content, and
7
+ * records a FixRecord in `.iterate/fixes/registry.json`
8
+ * plus an `atomic_fix` decision-log entry.
9
+ * iterate_diff — show the accumulated diff for a file (or a summary of
10
+ * every fixed file), derived from the first backup.
11
+ * iterate_rollback — restore a file from a fix's backup and remove the
12
+ * fix from the registry (append a `revert` log entry).
13
+ *
14
+ * Security model:
15
+ * - Only files under the resolved project root may be written.
16
+ * - Backups are written before any write, so a failure never destroys data.
17
+ * - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
18
+ */
19
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { defineTool } from '@deepseek-ai/dsh-tools';
22
+ import { loadEffectiveConfig, resolveProjectRoot } from "../config-loader.js";
23
+ import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
24
+ import { appendDecisionEntry } from "./decision-log.js";
25
+ // ─── Constants ───────────────────────────────────────────────────────────────
26
+ /** Upper bound for a single fix `content` payload (characters). */
27
+ export const MAX_FIX_CONTENT_CHARS = 1_000_000;
28
+ // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
29
+ /**
30
+ * Deterministic 32-bit FNV-1a hash used to derive a stable fix id from a
31
+ * finding (same finding always maps to the same id → dedupe + rollback keys).
32
+ */
33
+ export function hashString(input) {
34
+ let h = 2166136261;
35
+ for (let i = 0; i < input.length; i++) {
36
+ h ^= input.charCodeAt(i);
37
+ h = Math.imul(h, 16777619);
38
+ }
39
+ return (h >>> 0).toString(36);
40
+ }
41
+ /** Stable id for a finding: file|dimension|line|summary. */
42
+ export function fixId(finding) {
43
+ const key = `${finding.file}|${finding.dimension}|${finding.line ?? 0}|${finding.summary}`;
44
+ return `fix-${hashString(key)}`;
45
+ }
46
+ /**
47
+ * Compute a minimal line diff between two texts.
48
+ * Returns an array of hunks (empty when unchanged). Uses common-prefix/suffix
49
+ * trimming then reports the changed middle block — sufficient and deterministic
50
+ * for the small atomic edits this toolchain produces.
51
+ */
52
+ export function diffLines(before, after) {
53
+ const a = before.split('\n');
54
+ const b = after.split('\n');
55
+ let start = 0;
56
+ while (start < a.length && start < b.length && a[start] === b[start])
57
+ start++;
58
+ let endA = a.length;
59
+ let endB = b.length;
60
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
61
+ endA--;
62
+ endB--;
63
+ }
64
+ const removed = a.slice(start, endA);
65
+ const added = b.slice(start, endB);
66
+ if (removed.length === 0 && added.length === 0)
67
+ return [];
68
+ const contentLines = [];
69
+ for (const line of removed)
70
+ contentLines.push(`- ${line}`);
71
+ for (const line of added)
72
+ contentLines.push(`+ ${line}`);
73
+ return [
74
+ {
75
+ oldStart: start + 1,
76
+ oldLines: removed.length,
77
+ newStart: start + 1,
78
+ newLines: added.length,
79
+ content: contentLines.join('\n'),
80
+ },
81
+ ];
82
+ }
83
+ /** Added/removed line counts for a change (derived from diffLines). */
84
+ export function countChangedLines(before, after) {
85
+ const hunks = diffLines(before, after);
86
+ let added = 0;
87
+ let removed = 0;
88
+ for (const h of hunks) {
89
+ added += h.newLines;
90
+ removed += h.oldLines;
91
+ }
92
+ return { added, removed };
93
+ }
94
+ /** Human-readable one-line diff summary. */
95
+ export function buildDiffSummary(hunks) {
96
+ if (hunks.length === 0)
97
+ return 'no changes';
98
+ let added = 0;
99
+ let removed = 0;
100
+ for (const h of hunks) {
101
+ added += h.newLines;
102
+ removed += h.oldLines;
103
+ }
104
+ return `+${added}/-${removed} lines (${hunks.length} hunk${hunks.length === 1 ? '' : 's'})`;
105
+ }
106
+ /** Default empty registry. */
107
+ export function emptyRegistry() {
108
+ return { rounds: [] };
109
+ }
110
+ /** Read the fix registry from disk (missing/corrupt → empty). */
111
+ export function readRegistry(projectRoot) {
112
+ const file = fixRegistryPath(projectRoot);
113
+ if (!existsSync(file))
114
+ return emptyRegistry();
115
+ try {
116
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
117
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds))
118
+ return emptyRegistry();
119
+ return parsed;
120
+ }
121
+ catch {
122
+ return emptyRegistry();
123
+ }
124
+ }
125
+ /** Find a fix record by id across all rounds, or undefined. */
126
+ export function findFixRecord(registry, id) {
127
+ for (const round of registry.rounds) {
128
+ const found = round.records.find((r) => r.id === id);
129
+ if (found)
130
+ return found;
131
+ }
132
+ return undefined;
133
+ }
134
+ /** All fix records for a file, in chronological order. */
135
+ export function recordsForFile(registry, file) {
136
+ const out = [];
137
+ for (const round of registry.rounds) {
138
+ for (const r of round.records) {
139
+ if (r.finding.file === file && r.success)
140
+ out.push(r);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+ /** Insert (or replace) a record in the registry and return a NEW registry. */
146
+ export function upsertRecord(registry, record) {
147
+ const rounds = registry.rounds.map((r) => ({ ...r, records: [...r.records] }));
148
+ let target = rounds.find((r) => r.round === record.round);
149
+ if (!target) {
150
+ target = { round: record.round, fixedCount: 0, failedCount: 0, records: [] };
151
+ rounds.push(target);
152
+ }
153
+ const idx = target.records.findIndex((r) => r.id === record.id);
154
+ if (idx >= 0)
155
+ target.records[idx] = record;
156
+ else
157
+ target.records.push(record);
158
+ rounds.sort((a, b) => a.round - b.round);
159
+ return recomputeRoundCounts({ rounds });
160
+ }
161
+ /** Recompute per-round fixed/failed counts from the raw records. */
162
+ export function recomputeRoundCounts(registry) {
163
+ return {
164
+ rounds: registry.rounds.map((r) => {
165
+ const fixedCount = r.records.filter((rec) => rec.success).length;
166
+ const failedCount = r.records.filter((rec) => !rec.success).length;
167
+ return { ...r, fixedCount, failedCount };
168
+ }),
169
+ };
170
+ }
171
+ /** Remove a record by id and return a NEW registry (rollback). */
172
+ export function removeRecord(registry, id) {
173
+ const rounds = registry.rounds
174
+ .map((r) => ({ ...r, records: r.records.filter((rec) => rec.id !== id) }))
175
+ .filter((r) => r.records.length > 0);
176
+ return recomputeRoundCounts({ rounds });
177
+ }
178
+ /**
179
+ * Ensure a relative file path stays inside the project root.
180
+ * Returns `{ ok: true, resolved }` or `{ ok: false, reason }`.
181
+ */
182
+ export function resolveProjectFile(projectRoot, file) {
183
+ if (typeof file !== 'string' || file.trim().length === 0) {
184
+ return { ok: false, reason: 'file must be a non-empty relative path' };
185
+ }
186
+ if (file.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(file)) {
187
+ return { ok: false, reason: 'file must be a relative path inside the project root' };
188
+ }
189
+ const resolved = join(projectRoot, file);
190
+ if (resolved === projectRoot || !resolved.startsWith(projectRoot + '/') && !resolved.startsWith(projectRoot + '\\')) {
191
+ return { ok: false, reason: 'file resolves outside the project root' };
192
+ }
193
+ return { ok: true, resolved };
194
+ }
195
+ // ─── Shared execute helpers ──────────────────────────────────────────────────
196
+ /** Read the current content of a file under the project root. */
197
+ function readProjectFile(projectRoot, file) {
198
+ const resolved = resolveProjectFile(projectRoot, file);
199
+ if (!resolved.ok)
200
+ return resolved;
201
+ if (!existsSync(resolved.resolved))
202
+ return { ok: false, reason: `file does not exist: ${file}` };
203
+ try {
204
+ return { ok: true, content: readFileSync(resolved.resolved, 'utf-8') };
205
+ }
206
+ catch (err) {
207
+ return { ok: false, reason: `failed to read file: ${String(err)}` };
208
+ }
209
+ }
210
+ // ─── iterate_fix ─────────────────────────────────────────────────────────────
211
+ /**
212
+ * Register the `iterate_fix` tool.
213
+ * The fixer subagent supplies the file + its NEW full content; the tool
214
+ * validates atomicity, backs up, writes, and records the fix.
215
+ */
216
+ export function registerFixTool(ctx) {
217
+ ctx.tools.register(defineTool({
218
+ name: 'iterate_fix',
219
+ description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
220
+ 'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
221
+ 'The tool backs up the original, enforces the atomic `max_lines` threshold (unless `force`), ' +
222
+ 'writes the new content, and records the fix for later diff/rollback. ' +
223
+ 'This is the ONLY sanctioned way to apply fixes in normal mode.',
224
+ parameters: {
225
+ file: {
226
+ type: 'string',
227
+ required: true,
228
+ description: 'Relative path of the file to fix, inside the project root.',
229
+ },
230
+ content: {
231
+ type: 'string',
232
+ required: true,
233
+ description: 'The NEW full content of the file after applying your fix.',
234
+ },
235
+ finding: {
236
+ type: 'json',
237
+ required: true,
238
+ description: 'The finding this fix addresses: {dimension, file, line?, severity, summary, failure_scenario?, suggested_fix?, is_atomic}.',
239
+ },
240
+ round: {
241
+ type: 'integer',
242
+ required: true,
243
+ description: 'Current iteration round (>= 1).',
244
+ },
245
+ force: {
246
+ type: 'boolean',
247
+ description: 'Skip the atomic max_lines threshold check (default: false).',
248
+ },
249
+ path: {
250
+ type: 'string',
251
+ description: 'Project root directory (default: current working directory).',
252
+ },
253
+ },
254
+ output: {
255
+ schema: {
256
+ type: 'object',
257
+ additionalProperties: false,
258
+ properties: {
259
+ ok: { type: 'boolean', required: true },
260
+ id: { type: 'string' },
261
+ file: { type: 'string' },
262
+ round: { type: 'integer' },
263
+ linesAdded: { type: 'integer' },
264
+ linesRemoved: { type: 'integer' },
265
+ diffSummary: { type: 'string' },
266
+ backupPath: { type: 'string' },
267
+ error: { type: 'string' },
268
+ },
269
+ },
270
+ render: (_args, value) => [
271
+ { type: 'text', text: value.ok ? `${value.diffSummary ?? 'fixed'} @ ${value.file} (id: ${value.id})` : `fix failed: ${value.error}` },
272
+ ],
273
+ },
274
+ async execute(args) {
275
+ const resolved = resolveProjectRoot(args.path);
276
+ if (!resolved.ok)
277
+ return { ok: false, error: resolved.reason };
278
+ const projectRoot = resolved.root;
279
+ const { config } = loadEffectiveConfig(projectRoot);
280
+ const maxLines = config.atomic?.max_lines ?? 20;
281
+ const file = typeof args.file === 'string' ? args.file : '';
282
+ if (!file)
283
+ return { ok: false, error: 'file is required' };
284
+ if (typeof args.content !== 'string')
285
+ return { ok: false, error: 'content must be a string' };
286
+ if (args.content.length > MAX_FIX_CONTENT_CHARS) {
287
+ return {
288
+ ok: false,
289
+ error: `content exceeds the ${MAX_FIX_CONTENT_CHARS}-character limit (got ${args.content.length})`,
290
+ };
291
+ }
292
+ if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
293
+ return { ok: false, error: 'round must be a positive integer' };
294
+ }
295
+ const finding = args.finding;
296
+ if (!finding || typeof finding !== 'object') {
297
+ return { ok: false, error: 'finding must be an object' };
298
+ }
299
+ if (typeof finding.file !== 'string' || finding.file.trim().length === 0) {
300
+ return { ok: false, error: 'finding.file must be a non-empty string' };
301
+ }
302
+ if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
303
+ return { ok: false, error: 'finding.dimension must be a non-empty string' };
304
+ }
305
+ const current = readProjectFile(projectRoot, file);
306
+ if (!current.ok)
307
+ return { ok: false, error: current.reason };
308
+ const { added, removed } = countChangedLines(current.content, args.content);
309
+ if (!args.force && (added > maxLines || removed > maxLines)) {
310
+ return {
311
+ ok: false,
312
+ error: `Change to ${file} exceeds the atomic threshold (max_lines=${maxLines}, change is +${added}/-${removed}). ` +
313
+ 'Either split it into smaller atomic fixes or pass force:true if this is a deliberate architectural change.',
314
+ };
315
+ }
316
+ const id = fixId(finding);
317
+ const registry = readRegistry(projectRoot);
318
+ if (findFixRecord(registry, id)) {
319
+ return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
320
+ }
321
+ const target = resolveProjectFile(projectRoot, file);
322
+ if (!target.ok)
323
+ return { ok: false, error: target.reason };
324
+ const timestamp = new Date().toISOString();
325
+ const backupPath = fixBackupPath(projectRoot, id, timestamp);
326
+ try {
327
+ mkdirSync(fixesDir(projectRoot), { recursive: true });
328
+ copyFileSync(target.resolved, backupPath);
329
+ }
330
+ catch (err) {
331
+ return { ok: false, error: `failed to create backup: ${String(err)}` };
332
+ }
333
+ try {
334
+ writeFileSync(target.resolved, args.content, 'utf-8');
335
+ }
336
+ catch (err) {
337
+ return { ok: false, error: `failed to write file: ${String(err)}` };
338
+ }
339
+ const hunks = diffLines(current.content, args.content);
340
+ const record = {
341
+ id,
342
+ timestamp,
343
+ round: args.round,
344
+ finding,
345
+ backupPath,
346
+ diffSummary: buildDiffSummary(hunks),
347
+ linesAdded: added,
348
+ linesRemoved: removed,
349
+ success: true,
350
+ };
351
+ const nextRegistry = upsertRecord(registry, record);
352
+ try {
353
+ writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
354
+ }
355
+ catch (err) {
356
+ return { ok: false, error: `failed to write fix registry: ${String(err)}` };
357
+ }
358
+ appendDecisionEntry(projectRoot, {
359
+ timestamp,
360
+ round: args.round,
361
+ type: 'atomic_fix',
362
+ data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
363
+ });
364
+ return {
365
+ ok: true,
366
+ id,
367
+ file,
368
+ round: args.round,
369
+ linesAdded: added,
370
+ linesRemoved: removed,
371
+ diffSummary: record.diffSummary,
372
+ backupPath,
373
+ };
374
+ },
375
+ }));
376
+ }
377
+ // ─── iterate_diff ────────────────────────────────────────────────────────────
378
+ /**
379
+ * Register the `iterate_diff` tool.
380
+ * Shows the accumulated change for a file (diff vs its first backup) or a
381
+ * summary of every file that has been fixed.
382
+ */
383
+ export function registerDiffTool(ctx) {
384
+ ctx.tools.register(defineTool({
385
+ name: 'iterate_diff',
386
+ description: 'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
387
+ 'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
388
+ parameters: {
389
+ file: {
390
+ type: 'string',
391
+ description: 'Optional relative file path to diff. When omitted, returns a per-file summary.',
392
+ },
393
+ path: {
394
+ type: 'string',
395
+ description: 'Project root directory (default: current working directory).',
396
+ },
397
+ },
398
+ output: {
399
+ schema: {
400
+ type: 'object',
401
+ additionalProperties: false,
402
+ properties: {
403
+ ok: { type: 'boolean', required: true },
404
+ file: { type: 'string' },
405
+ diff: { type: 'json' },
406
+ diffSummary: { type: 'string' },
407
+ files: { type: 'json' },
408
+ error: { type: 'string' },
409
+ },
410
+ },
411
+ render: (_args, value) => {
412
+ if (!value.ok)
413
+ return [{ type: 'text', text: `diff failed: ${value.error}` }];
414
+ if (value.file) {
415
+ const diff = value.diff ?? [];
416
+ const text = diff.length === 0
417
+ ? `No changes for ${value.file}.`
418
+ : diff.map((h) => `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n${h.content}`).join('\n\n');
419
+ return [{ type: 'text', text }];
420
+ }
421
+ const files = value.files ?? [];
422
+ const text = files.length === 0 ? 'No fixes have been applied yet.' : files.map((f) => `${f.file} ${f.diffSummary}`).join('\n');
423
+ return [{ type: 'text', text }];
424
+ },
425
+ },
426
+ async execute(args) {
427
+ const resolved = resolveProjectRoot(args.path);
428
+ if (!resolved.ok)
429
+ return { ok: false, error: resolved.reason };
430
+ const projectRoot = resolved.root;
431
+ const registry = readRegistry(projectRoot);
432
+ const file = typeof args.file === 'string' && args.file.trim() ? args.file : undefined;
433
+ if (file) {
434
+ const records = recordsForFile(registry, file);
435
+ const first = records[0];
436
+ if (!first)
437
+ return { ok: false, error: `no fixes recorded for ${file}` };
438
+ const current = readProjectFile(projectRoot, file);
439
+ if (!current.ok)
440
+ return { ok: false, error: current.reason };
441
+ let original = '';
442
+ try {
443
+ original = readFileSync(first.backupPath, 'utf-8');
444
+ }
445
+ catch (err) {
446
+ return { ok: false, error: `backup missing for ${file}: ${String(err)}` };
447
+ }
448
+ const hunks = diffLines(original, current.content);
449
+ return { ok: true, file, diff: hunks, diffSummary: buildDiffSummary(hunks) };
450
+ }
451
+ const files = [];
452
+ for (const round of registry.rounds) {
453
+ for (const r of round.records) {
454
+ if (!r.success)
455
+ continue;
456
+ const existing = files.find((f) => f.file === r.finding.file);
457
+ if (existing) {
458
+ existing.linesAdded += r.linesAdded;
459
+ existing.linesRemoved += r.linesRemoved;
460
+ }
461
+ else {
462
+ files.push({
463
+ file: r.finding.file,
464
+ diffSummary: r.diffSummary,
465
+ linesAdded: r.linesAdded,
466
+ linesRemoved: r.linesRemoved,
467
+ });
468
+ }
469
+ }
470
+ }
471
+ return { ok: true, files: files };
472
+ },
473
+ }));
474
+ }
475
+ // ─── iterate_rollback ────────────────────────────────────────────────────────
476
+ /**
477
+ * Register the `iterate_rollback` tool.
478
+ * Restores a file from a fix's backup and removes the fix from the registry,
479
+ * appending a `revert` decision-log entry. Use after a failed validation.
480
+ */
481
+ export function registerRollbackTool(ctx) {
482
+ ctx.tools.register(defineTool({
483
+ name: 'iterate_rollback',
484
+ description: 'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
485
+ 'The file is restored from the fix backup, the fix is removed from the registry, ' +
486
+ 'and a `revert` entry is appended to the decision log. Use when a round\'s validation fails.',
487
+ parameters: {
488
+ id: {
489
+ type: 'string',
490
+ required: true,
491
+ description: 'The fix id returned by iterate_fix.',
492
+ },
493
+ path: {
494
+ type: 'string',
495
+ description: 'Project root directory (default: current working directory).',
496
+ },
497
+ },
498
+ output: {
499
+ schema: {
500
+ type: 'object',
501
+ additionalProperties: false,
502
+ properties: {
503
+ ok: { type: 'boolean', required: true },
504
+ id: { type: 'string' },
505
+ file: { type: 'string' },
506
+ error: { type: 'string' },
507
+ },
508
+ },
509
+ render: (_args, value) => [
510
+ { type: 'text', text: value.ok ? `reverted fix ${value.id} in ${value.file}` : `rollback failed: ${value.error}` },
511
+ ],
512
+ },
513
+ async execute(args) {
514
+ const resolved = resolveProjectRoot(args.path);
515
+ if (!resolved.ok)
516
+ return { ok: false, error: resolved.reason };
517
+ const projectRoot = resolved.root;
518
+ const id = typeof args.id === 'string' ? args.id : '';
519
+ if (!id)
520
+ return { ok: false, error: 'id is required' };
521
+ const registry = readRegistry(projectRoot);
522
+ const record = findFixRecord(registry, id);
523
+ if (!record)
524
+ return { ok: false, error: `fix not found: ${id}` };
525
+ if (!existsSync(record.backupPath)) {
526
+ return { ok: false, error: `backup missing for fix ${id}` };
527
+ }
528
+ const target = resolveProjectFile(projectRoot, record.finding.file);
529
+ if (!target.ok)
530
+ return { ok: false, error: target.reason };
531
+ try {
532
+ copyFileSync(record.backupPath, target.resolved);
533
+ }
534
+ catch (err) {
535
+ return { ok: false, error: `failed to restore backup: ${String(err)}` };
536
+ }
537
+ const nextRegistry = removeRecord(registry, id);
538
+ try {
539
+ writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
540
+ }
541
+ catch (err) {
542
+ return { ok: false, error: `failed to update fix registry: ${String(err)}` };
543
+ }
544
+ appendDecisionEntry(projectRoot, {
545
+ timestamp: new Date().toISOString(),
546
+ round: record.round,
547
+ type: 'revert',
548
+ data: { id, file: record.finding.file, revertedDiff: record.diffSummary },
549
+ });
550
+ return { ok: true, id, file: record.finding.file };
551
+ },
552
+ }));
553
+ }
@@ -0,0 +1,138 @@
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
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { resolveProjectRoot } from "../config-loader.js";
12
+ import { readDecisionEntries } from "./decision-log.js";
13
+ import { readRegistry } from "./fix.js";
14
+ const DEFAULT_LIMIT = 50;
15
+ const MAX_LIMIT = 200;
16
+ /** Clamp a caller-supplied `limit` to a sane range. */
17
+ export function clampHistoryLimit(limit) {
18
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
19
+ return DEFAULT_LIMIT;
20
+ }
21
+ return Math.min(limit, MAX_LIMIT);
22
+ }
23
+ /**
24
+ * Filter + cap decision-log entries. Pure, unit-tested.
25
+ * Returns the newest `limit` matching entries plus the total match count
26
+ * (before the cap), so callers can tell when the result was truncated.
27
+ */
28
+ export function filterDecisionEntries(entries, opts) {
29
+ const type = typeof opts.type === 'string' && opts.type ? opts.type : undefined;
30
+ const since = typeof opts.since === 'string' && opts.since ? opts.since : undefined;
31
+ const limit = clampHistoryLimit(opts.limit);
32
+ const matching = (Array.isArray(entries) ? entries : []).filter((e) => {
33
+ if (type && e.type !== type)
34
+ return false;
35
+ if (since && e.timestamp <= since)
36
+ return false;
37
+ return true;
38
+ });
39
+ return {
40
+ entries: matching.slice(-limit),
41
+ filteredCount: matching.length,
42
+ limit,
43
+ };
44
+ }
45
+ /** Per-round fix counts + totals from a fix registry. Pure, unit-tested. */
46
+ export function summarizeFixRegistry(registry) {
47
+ const rounds = (registry.rounds ?? []).map((r) => ({
48
+ round: r.round,
49
+ fixedCount: r.fixedCount,
50
+ failedCount: r.failedCount,
51
+ }));
52
+ return {
53
+ totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
54
+ totalFailed: rounds.reduce((s, r) => s + r.failedCount, 0),
55
+ roundCount: rounds.length,
56
+ rounds,
57
+ };
58
+ }
59
+ /**
60
+ * Register the `iterate_history` tool.
61
+ * Reads the decision log (optionally filtered by type / since / limit) and a
62
+ * fix-registry summary. Read-only; never modifies the filesystem.
63
+ */
64
+ export function registerHistoryTool(ctx) {
65
+ ctx.tools.register(defineTool({
66
+ name: 'iterate_history',
67
+ description: 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
68
+ 'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
69
+ 'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
70
+ parameters: {
71
+ type: {
72
+ type: 'string',
73
+ description: 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
74
+ 'revert, validation, decision, report.',
75
+ },
76
+ since: {
77
+ type: 'string',
78
+ description: 'Optional ISO timestamp; only entries AFTER this timestamp are returned.',
79
+ },
80
+ limit: {
81
+ type: 'integer',
82
+ description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Newest first.`,
83
+ },
84
+ path: {
85
+ type: 'string',
86
+ description: 'Project root directory (default: current working directory).',
87
+ },
88
+ },
89
+ output: {
90
+ schema: {
91
+ type: 'object',
92
+ additionalProperties: false,
93
+ properties: {
94
+ ok: { type: 'boolean', required: true },
95
+ kind: { type: 'string' },
96
+ count: { type: 'integer' },
97
+ filteredCount: { type: 'integer' },
98
+ limit: { type: 'integer' },
99
+ log: { type: 'json' },
100
+ fixes: { type: 'json' },
101
+ error: { type: 'string' },
102
+ },
103
+ },
104
+ render: (_args, value) => {
105
+ if (!value.ok)
106
+ return [{ type: 'text', text: `history failed: ${value.error}` }];
107
+ const log = value.log ?? [];
108
+ const fixes = value.fixes;
109
+ const lines = [
110
+ `Decision-log entries: ${value.count} (filtered to ${value.limit})`,
111
+ fixes
112
+ ? `Fixes: ${fixes.totalFixed} applied · ${fixes.totalFailed} failed · across ${fixes.roundCount} round(s)`
113
+ : 'Fixes: none',
114
+ '',
115
+ ...log.map((e) => `[${e.timestamp}] r${e.round} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
116
+ ];
117
+ return [{ type: 'text', text: lines.join('\n') }];
118
+ },
119
+ },
120
+ async execute(args) {
121
+ const resolved = resolveProjectRoot(args.path);
122
+ if (!resolved.ok)
123
+ return { ok: false, kind: 'history', error: resolved.reason };
124
+ const projectRoot = resolved.root;
125
+ const { entries, filteredCount, limit } = filterDecisionEntries(readDecisionEntries(projectRoot), { type: args.type, since: args.since, limit: args.limit });
126
+ const fixes = summarizeFixRegistry(readRegistry(projectRoot));
127
+ return {
128
+ ok: true,
129
+ kind: 'history',
130
+ count: entries.length,
131
+ filteredCount,
132
+ limit,
133
+ log: entries,
134
+ fixes: fixes,
135
+ };
136
+ },
137
+ }));
138
+ }