iterate-plugin 3.5.3 → 3.5.5
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/dist/atomic-fs.js +22 -4
- package/dist/config-loader.js +8 -3
- package/dist/config-write.js +50 -9
- package/dist/evidence.js +28 -1
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +2 -2
- package/dist/review-scope.js +6 -2
- package/dist/review.js +7 -6
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +66 -32
- package/dist/tools/context.js +5 -1
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/defense-events.js +7 -3
- package/dist/tools/defense-store.js +14 -7
- package/dist/tools/experience-bank.js +6 -3
- package/dist/tools/experience-store.js +24 -12
- package/dist/tools/fix.js +24 -3
- package/dist/tools/prune.js +185 -17
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +28 -6
- package/dist/tools/triage.js +38 -4
- package/dist/transcript.js +68 -34
- package/lib/client.js +41 -11
- package/lib/parse.js +30 -2
- package/package.json +10 -7
- package/src/atomic-fs.ts +20 -4
- package/src/client/index.ts +23 -3
- package/src/config-loader.ts +8 -3
- package/src/config-write.ts +59 -9
- package/src/evidence.ts +29 -1
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +2 -2
- package/src/review-scope.ts +6 -2
- package/src/review.ts +7 -6
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +66 -32
- package/src/tools/context.ts +5 -1
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/defense-events.ts +7 -3
- package/src/tools/defense-store.ts +14 -7
- package/src/tools/experience-bank.ts +6 -3
- package/src/tools/experience-store.ts +24 -12
- package/src/tools/fix.ts +25 -3
- package/src/tools/prune.ts +194 -17
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +26 -5
- package/src/tools/triage.ts +39 -4
- package/src/transcript.ts +68 -31
- package/src/types.ts +2 -0
package/dist/tools/prune.js
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* - Empty fix rounds (rounds with 0 records).
|
|
14
14
|
* - Stray temp files left behind by a crashed atomic write (see
|
|
15
15
|
* `isPrunableTemp` for the recognized naming conventions).
|
|
16
|
+
* - Experience bank entries beyond `MAX_EXPERIENCE_ENTRIES` (newest kept) —
|
|
17
|
+
* the bank grows across sessions with no natural bound; a sweep caps it.
|
|
18
|
+
* - Defense events older than `retainDays` (stale iteration streams that
|
|
19
|
+
* were never cleared).
|
|
16
20
|
*
|
|
17
21
|
* Security model:
|
|
18
22
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
@@ -24,13 +28,24 @@ import { join } from 'node:path';
|
|
|
24
28
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
25
29
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
26
30
|
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
27
|
-
import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
|
|
31
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
|
|
28
32
|
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
33
|
+
import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
|
|
34
|
+
import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
|
|
29
35
|
import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
|
|
30
36
|
/** Default retention for decision-log entries (in days). */
|
|
31
37
|
const DEFAULT_RETAIN_DAYS = 30;
|
|
32
38
|
const MIN_RETAIN_DAYS = 1;
|
|
33
39
|
const MAX_RETAIN_DAYS = 365;
|
|
40
|
+
/**
|
|
41
|
+
* Upper bound on experience-bank entries retained by a prune sweep. The bank
|
|
42
|
+
* accumulates curated knowledge across sessions and each entry is small, so a
|
|
43
|
+
* generous cap drops only the oldest tail of a pathologically large bank
|
|
44
|
+
* (never recent learning).
|
|
45
|
+
*/
|
|
46
|
+
export const MAX_EXPERIENCE_ENTRIES = 2000;
|
|
47
|
+
/** How many compare-and-append retries bound the decision-log rewrite race. */
|
|
48
|
+
const MAX_LOG_REWRITE_RETRIES = 3;
|
|
34
49
|
/** Clamp retainDays to a sane range. */
|
|
35
50
|
export function clampRetainDays(days) {
|
|
36
51
|
if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
|
|
@@ -66,6 +81,59 @@ export function isPrunableTemp(name) {
|
|
|
66
81
|
// .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
|
|
67
82
|
return name.startsWith('.') && name.includes('.tmp-');
|
|
68
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Bound the experience bank to its newest `cap` entries (by timestamp; ties
|
|
86
|
+
* keep their original order via stable sort). Returns the trimmed bank plus
|
|
87
|
+
* how many entries would be dropped. Never mutates the input bank.
|
|
88
|
+
*/
|
|
89
|
+
export function sweepExperienceBank(bank, cap) {
|
|
90
|
+
const size = Number.isFinite(cap) && cap >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES;
|
|
91
|
+
const total = bank.entries.length;
|
|
92
|
+
if (total <= size)
|
|
93
|
+
return { bank, removed: 0 };
|
|
94
|
+
const kept = [...bank.entries]
|
|
95
|
+
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
|
|
96
|
+
.slice(0, size);
|
|
97
|
+
// Preserve the original (insertion) order of the surviving entries.
|
|
98
|
+
const keptIds = new Set(kept.map((e) => e.id));
|
|
99
|
+
const ordered = bank.entries.filter((e) => keptIds.has(e.id));
|
|
100
|
+
return {
|
|
101
|
+
bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
|
|
102
|
+
removed: total - ordered.length,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Drop defense events older than `cutoff` from the stream using an ISO
|
|
107
|
+
* timestamp comparison, recomputing the counts so they can never drift from
|
|
108
|
+
* the surviving events. Never mutates the input stream.
|
|
109
|
+
*/
|
|
110
|
+
export function sweepDefenseEvents(stream, cutoff) {
|
|
111
|
+
const kept = stream.events.filter((e) => e.timestamp >= cutoff);
|
|
112
|
+
if (kept.length === stream.events.length)
|
|
113
|
+
return { stream, removed: 0 };
|
|
114
|
+
return {
|
|
115
|
+
stream: {
|
|
116
|
+
events: kept,
|
|
117
|
+
lastUpdated: new Date().toISOString(),
|
|
118
|
+
counts: computeDefenseCounts(kept),
|
|
119
|
+
},
|
|
120
|
+
removed: stream.events.length - kept.length,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Recompute per-type defense counts from a list of events. */
|
|
124
|
+
function computeDefenseCounts(events) {
|
|
125
|
+
const counts = {
|
|
126
|
+
precondition_failed: 0,
|
|
127
|
+
rollback: 0,
|
|
128
|
+
invariant_violated: 0,
|
|
129
|
+
assumption_falsified: 0,
|
|
130
|
+
};
|
|
131
|
+
for (const e of events) {
|
|
132
|
+
if (e.type in counts)
|
|
133
|
+
counts[e.type]++;
|
|
134
|
+
}
|
|
135
|
+
return counts;
|
|
136
|
+
}
|
|
69
137
|
/**
|
|
70
138
|
* Inspect the runtime state and report what would be pruned.
|
|
71
139
|
* Pure (no deletions). Returns a structured report.
|
|
@@ -124,6 +192,15 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
124
192
|
iterateEntries = [];
|
|
125
193
|
}
|
|
126
194
|
const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort();
|
|
195
|
+
// 6. Experience-bank oversize (entries beyond the entry cap). The bank grows
|
|
196
|
+
// across sessions with no natural bound — a sweep reports the tail that a
|
|
197
|
+
// prune would drop (newest `MAX_EXPERIENCE_ENTRIES` preserved).
|
|
198
|
+
const bank = readExperienceBank(projectRoot);
|
|
199
|
+
const sweptBank = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES);
|
|
200
|
+
// 7. Defense events older than retainDays (a previous iteration's stream
|
|
201
|
+
// that was never cleared).
|
|
202
|
+
const defenseStream = readDefenseEvents(projectRoot);
|
|
203
|
+
const sweptDefense = sweepDefenseEvents(defenseStream, cutoff);
|
|
127
204
|
return {
|
|
128
205
|
oldLogEntries,
|
|
129
206
|
hasCheckpoint,
|
|
@@ -132,12 +209,67 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
132
209
|
emptyRounds,
|
|
133
210
|
totalLogEntries: entries.length,
|
|
134
211
|
registryRounds: registry.rounds.length,
|
|
212
|
+
totalExperiences: bank.entries.length,
|
|
213
|
+
experienceOversize: sweptBank.removed,
|
|
214
|
+
totalDefenseEvents: defenseStream.events.length,
|
|
215
|
+
staleDefenseEvents: sweptDefense.removed,
|
|
135
216
|
};
|
|
136
217
|
}
|
|
137
218
|
/**
|
|
138
219
|
* Actually prune the runtime artifacts (only called when dryRun=false).
|
|
139
220
|
* Returns a detailed report of what was deleted.
|
|
140
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Rewrite the decision log to the entries younger than `cutoff`.
|
|
224
|
+
*
|
|
225
|
+
* The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
|
|
226
|
+
* a fresh audit line between our read and our atomic rewrite, and an atomic
|
|
227
|
+
* rename would silently discard it. So instead of a single read+write we use a
|
|
228
|
+
* bounded compare-and-append loop — after each rewrite we re-read the log and
|
|
229
|
+
* retry whenever the file grew (a concurrent append slipped in). The loop
|
|
230
|
+
* terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
|
|
231
|
+
* failure here is surfaced as a structured error instead of truncating the log.
|
|
232
|
+
*
|
|
233
|
+
* @returns the number of entries removed (best-effort) or 0 on error.
|
|
234
|
+
*/
|
|
235
|
+
function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
|
|
236
|
+
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
|
|
237
|
+
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
238
|
+
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
239
|
+
// window, so the first attempt is nearly always the only one. (The retry
|
|
240
|
+
// loop stays as defense-in-depth for the lock-unavailable path.)
|
|
241
|
+
const release = acquireLogLock(projectRoot);
|
|
242
|
+
try {
|
|
243
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
244
|
+
try {
|
|
245
|
+
const entries = readDecisionEntries(projectRoot);
|
|
246
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff);
|
|
247
|
+
const deleted = entries.length - kept.length;
|
|
248
|
+
if (deleted === 0)
|
|
249
|
+
return { deleted: 0 };
|
|
250
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
251
|
+
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
252
|
+
// landed new lines after our read. If the log grew during the write
|
|
253
|
+
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
254
|
+
const after = readDecisionEntries(projectRoot);
|
|
255
|
+
if (after.length <= kept.length)
|
|
256
|
+
return { deleted };
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
release();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Execute the prune: apply every cleanable item reported by `inspectPrune`.
|
|
270
|
+
* Every step is individually hedged so a single disk failure degrades to a
|
|
271
|
+
* reported error rather than aborting the whole cleanup.
|
|
272
|
+
*/
|
|
141
273
|
export function executePrune(projectRoot, retainDays, report) {
|
|
142
274
|
const cutoff = cutoffTimestamp(retainDays);
|
|
143
275
|
const result = {
|
|
@@ -146,22 +278,19 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
146
278
|
deletedBackups: [],
|
|
147
279
|
deletedTemps: [],
|
|
148
280
|
trimmedEmptyRounds: 0,
|
|
281
|
+
deletedExperiences: 0,
|
|
282
|
+
deletedDefenseEvents: 0,
|
|
149
283
|
errors: [],
|
|
150
284
|
};
|
|
151
285
|
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
152
|
-
// (temp + rename) so a crash mid-write can never truncate the log
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
result.errors.push(`failed to rewrite decision log: ${String(err)}`);
|
|
164
|
-
result.deletedLogEntries = 0;
|
|
286
|
+
// (temp + rename) so a crash mid-write can never truncate the log; the
|
|
287
|
+
// bounded compare-and-append loop guards against a concurrent appender
|
|
288
|
+
// slipping a fresh audit line into the read-before-rewrite window.
|
|
289
|
+
{
|
|
290
|
+
const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff);
|
|
291
|
+
result.deletedLogEntries = deleted;
|
|
292
|
+
if (error)
|
|
293
|
+
result.errors.push(error);
|
|
165
294
|
}
|
|
166
295
|
// 2. Remove checkpoint.
|
|
167
296
|
if (report.hasCheckpoint) {
|
|
@@ -213,6 +342,34 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
213
342
|
result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
|
|
214
343
|
}
|
|
215
344
|
}
|
|
345
|
+
// 6. Trim the experience bank to its entry cap (newest wins).
|
|
346
|
+
if (report.experienceOversize > 0) {
|
|
347
|
+
try {
|
|
348
|
+
const bank = readExperienceBank(projectRoot);
|
|
349
|
+
const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES);
|
|
350
|
+
if (removed > 0) {
|
|
351
|
+
writeExperienceBank(projectRoot, swept);
|
|
352
|
+
result.deletedExperiences = removed;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
result.errors.push(`failed to trim experience bank: ${String(err)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// 7. Drop defense events older than retainDays (recounted on write).
|
|
360
|
+
if (report.staleDefenseEvents > 0) {
|
|
361
|
+
try {
|
|
362
|
+
const stream = readDefenseEvents(projectRoot);
|
|
363
|
+
const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff);
|
|
364
|
+
if (removed > 0) {
|
|
365
|
+
writeDefenseEvents(projectRoot, swept);
|
|
366
|
+
result.deletedDefenseEvents = removed;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
result.errors.push(`failed to sweep defense events: ${String(err)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
216
373
|
return result;
|
|
217
374
|
}
|
|
218
375
|
/**
|
|
@@ -226,7 +383,8 @@ export function registerPruneTool(ctx) {
|
|
|
226
383
|
description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
227
384
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
228
385
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
229
|
-
'
|
|
386
|
+
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
387
|
+
'and stale defense events. ' +
|
|
230
388
|
'Each deletion is logged to the decision log.',
|
|
231
389
|
parameters: {
|
|
232
390
|
dryRun: {
|
|
@@ -268,6 +426,8 @@ export function registerPruneTool(ctx) {
|
|
|
268
426
|
` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
269
427
|
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
270
428
|
` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
|
|
429
|
+
` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
|
|
430
|
+
` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
|
|
271
431
|
'',
|
|
272
432
|
'Pass dryRun:false to execute the prune.',
|
|
273
433
|
];
|
|
@@ -280,6 +440,8 @@ export function registerPruneTool(ctx) {
|
|
|
280
440
|
` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
|
|
281
441
|
` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
|
|
282
442
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
443
|
+
` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
|
|
444
|
+
` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
|
|
283
445
|
];
|
|
284
446
|
const errs = result?.errors ?? [];
|
|
285
447
|
if (errs.length > 0) {
|
|
@@ -307,8 +469,10 @@ export function registerPruneTool(ctx) {
|
|
|
307
469
|
};
|
|
308
470
|
}
|
|
309
471
|
const result = executePrune(projectRoot, retainDays, report);
|
|
310
|
-
// Log the prune to the decision log.
|
|
311
|
-
|
|
472
|
+
// Log the prune to the decision log. The mutations already happened, so
|
|
473
|
+
// a failed log append is reported via `errors` — a silent audit miss
|
|
474
|
+
// (F2) must never pretend every deletion was recorded.
|
|
475
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
312
476
|
timestamp: new Date().toISOString(),
|
|
313
477
|
round: 0,
|
|
314
478
|
type: 'decision',
|
|
@@ -319,8 +483,12 @@ export function registerPruneTool(ctx) {
|
|
|
319
483
|
deletedCheckpoint: result.deletedCheckpoint,
|
|
320
484
|
deletedBackups: result.deletedBackups.length,
|
|
321
485
|
trimmedEmptyRounds: result.trimmedEmptyRounds,
|
|
486
|
+
deletedExperiences: result.deletedExperiences,
|
|
487
|
+
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
322
488
|
},
|
|
323
489
|
});
|
|
490
|
+
if (logRes.error)
|
|
491
|
+
result.errors.push(logRes.error);
|
|
324
492
|
return {
|
|
325
493
|
ok: true,
|
|
326
494
|
dryRun: false,
|
package/dist/tools/review.js
CHANGED
|
@@ -229,9 +229,23 @@ export function registerReviewTool(ctx) {
|
|
|
229
229
|
const coverageEnabled = config.reviewer?.coverage_validation !== false;
|
|
230
230
|
let coverage = null;
|
|
231
231
|
if (coverageEnabled) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
// Build the assigned inventory the SAME way `plan` builds it: a
|
|
233
|
+
// changed-only scope uses the resolved git-diff file set, and
|
|
234
|
+
// falls back to the FULL walk when git is unavailable or nothing
|
|
235
|
+
// changed (mirrors buildReviewPlan's effective-scope decision), so
|
|
236
|
+
// the coverage ratio reflects the files reviewers were actually
|
|
237
|
+
// told to read — never a vacuous 1.0 on an empty inventory.
|
|
238
|
+
let assigned;
|
|
239
|
+
if (config.review?.scope === 'changed-only') {
|
|
240
|
+
const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
|
|
241
|
+
assigned =
|
|
242
|
+
gitScope.changedFiles.length > 0
|
|
243
|
+
? collectScopeFiles(projectRoot, { scope: 'changed-only', changedFiles: gitScope.changedFiles })
|
|
244
|
+
: collectScopeFiles(projectRoot, { scope: 'full' });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
assigned = collectScopeFiles(projectRoot, { scope: 'full' });
|
|
248
|
+
}
|
|
235
249
|
const readFiles = Array.isArray(source.readFiles)
|
|
236
250
|
? source.readFiles
|
|
237
251
|
: null;
|
package/dist/tools/transcript.js
CHANGED
|
@@ -148,6 +148,7 @@ export function registerTranscriptTool(ctx) {
|
|
|
148
148
|
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
149
149
|
},
|
|
150
150
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
151
|
+
stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
|
|
151
152
|
text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
|
|
152
153
|
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
153
154
|
},
|
|
@@ -218,14 +219,25 @@ export function registerTranscriptTool(ctx) {
|
|
|
218
219
|
}
|
|
219
220
|
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
220
221
|
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
221
|
-
// builder so the steering text still lands.
|
|
222
|
+
// builder so the steering text still lands. The fallback must keep
|
|
223
|
+
// the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
|
|
224
|
+
// stray malformed field can never silently rewrite a dry-run run
|
|
225
|
+
// into a `normal` one.
|
|
226
|
+
const fallbackOpts = {
|
|
227
|
+
project: projectRoot,
|
|
228
|
+
mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
|
|
229
|
+
taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
|
|
230
|
+
approval,
|
|
231
|
+
goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
|
|
232
|
+
maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
|
|
233
|
+
};
|
|
222
234
|
let builder;
|
|
223
235
|
if (manifest) {
|
|
224
236
|
try {
|
|
225
237
|
builder = rehydrateBuilder(manifest, approval);
|
|
226
238
|
}
|
|
227
239
|
catch {
|
|
228
|
-
builder = new ReviewTranscriptBuilder(
|
|
240
|
+
builder = new ReviewTranscriptBuilder(fallbackOpts);
|
|
229
241
|
}
|
|
230
242
|
}
|
|
231
243
|
else {
|
|
@@ -292,10 +304,20 @@ export function registerTranscriptTool(ctx) {
|
|
|
292
304
|
builder.fix(record);
|
|
293
305
|
}
|
|
294
306
|
}
|
|
295
|
-
//
|
|
307
|
+
// End state: an explicit stoppedReason wins; otherwise derive it from
|
|
308
|
+
// the trend. A run that settled closes as "converged"; a run that did
|
|
309
|
+
// real work but never trended to 0 closes as "max_rounds_reached";
|
|
310
|
+
// a capture with no rounds stays "active" (nothing recorded yet).
|
|
311
|
+
const explicitReason = typeof args.stoppedReason === 'string' && args.stoppedReason.trim()
|
|
312
|
+
? args.stoppedReason.trim()
|
|
313
|
+
: '';
|
|
296
314
|
const last = convergence[convergence.length - 1];
|
|
297
|
-
if (
|
|
298
|
-
builder.finish();
|
|
315
|
+
if (explicitReason)
|
|
316
|
+
builder.finish(explicitReason);
|
|
317
|
+
else if (convergence.length > 0 && last === 0)
|
|
318
|
+
builder.finish('converged');
|
|
319
|
+
else if (roundsExecuted > 0)
|
|
320
|
+
builder.finish('max_rounds_reached');
|
|
299
321
|
const persisted = await persistChecked(file, builder.serialize());
|
|
300
322
|
if (!persisted.ok) {
|
|
301
323
|
return { operation: 'capture', found: true, updated: false, error: persisted.error };
|
|
@@ -346,7 +368,7 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
346
368
|
builder.decision(e);
|
|
347
369
|
builder.setNudge(manifest.nudge?.text ?? null);
|
|
348
370
|
if (!manifest.active)
|
|
349
|
-
builder.finish();
|
|
371
|
+
builder.finish(manifest.stoppedReason ?? undefined);
|
|
350
372
|
return builder;
|
|
351
373
|
}
|
|
352
374
|
/** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
|
package/dist/tools/triage.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename } from 'node:path';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
@@ -10,6 +10,8 @@ const PERSONALIZATION_KEY = 'personalization';
|
|
|
10
10
|
const KNOWN_INTENTIONAL_KEY = 'known_intentional';
|
|
11
11
|
/** Max entries per single `apply` call. */
|
|
12
12
|
const MAX_ENTRIES = 500;
|
|
13
|
+
/** How many timestamped config backups are retained (older ones are removed). */
|
|
14
|
+
export const MAX_TRIAGE_BACKUPS = 5;
|
|
13
15
|
/** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
|
|
14
16
|
const WHOLE_FILE_LINE = 0;
|
|
15
17
|
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
@@ -148,6 +150,33 @@ export function readKnownIntentional(config) {
|
|
|
148
150
|
export function backupSuffix(now = new Date()) {
|
|
149
151
|
return now.toISOString().replace(/[:.]/g, '-');
|
|
150
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Bound the timestamped config backups: after a fresh one is written, delete
|
|
155
|
+
* every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
|
|
156
|
+
* filesystem failure here must never fail the apply that just succeeded
|
|
157
|
+
* (backups are a safety net, not a requirement).
|
|
158
|
+
* @returns the absolute paths of the backups that were removed.
|
|
159
|
+
*/
|
|
160
|
+
export function pruneOldConfigBackups(configPath, keep = MAX_TRIAGE_BACKUPS) {
|
|
161
|
+
const removed = [];
|
|
162
|
+
try {
|
|
163
|
+
const dir = dirname(configPath);
|
|
164
|
+
const prefix = `${basename(configPath)}.bak-`;
|
|
165
|
+
const matches = readdirSync(dir)
|
|
166
|
+
.filter((f) => f.startsWith(prefix))
|
|
167
|
+
.sort();
|
|
168
|
+
// Keep the newest `keep`; remove everything older.
|
|
169
|
+
const doomed = matches.slice(0, Math.max(0, matches.length - keep));
|
|
170
|
+
for (const f of doomed) {
|
|
171
|
+
rmSync(join(dir, f), { force: true });
|
|
172
|
+
removed.push(join(dir, f));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Best-effort cleanup — never surface a cleanup failure.
|
|
177
|
+
}
|
|
178
|
+
return removed;
|
|
179
|
+
}
|
|
151
180
|
// ─── File I/O ───────────────────────────────────────────────────────────────
|
|
152
181
|
/** Load the raw config object (empty when the file is missing). */
|
|
153
182
|
function readConfigFile(configPath) {
|
|
@@ -155,8 +184,9 @@ function readConfigFile(configPath) {
|
|
|
155
184
|
return {};
|
|
156
185
|
const content = readFileSync(configPath, 'utf-8');
|
|
157
186
|
const parsed = yaml.load(content);
|
|
158
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
159
|
-
// A config that exists but is
|
|
187
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
188
|
+
// A config that exists but is NOT a YAML mapping (including a YAML list,
|
|
189
|
+
// which `typeof [] === 'object'` alone would accept) must NOT be silently
|
|
160
190
|
// treated as empty: writing over it would destroy user data. Callers
|
|
161
191
|
// surface this as an error and refuse to write.
|
|
162
192
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping');
|
|
@@ -213,6 +243,10 @@ function applyEntries(projectRoot, incoming) {
|
|
|
213
243
|
error: `Failed to write config: ${String(err)}${rollbackError}`,
|
|
214
244
|
};
|
|
215
245
|
}
|
|
246
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
247
|
+
// project never collects an unbounded pile of config snapshots.
|
|
248
|
+
if (backupPath)
|
|
249
|
+
pruneOldConfigBackups(configPath);
|
|
216
250
|
return { ok: true, added, skipped, count: merged.length, configPath, backupPath };
|
|
217
251
|
}
|
|
218
252
|
/**
|
package/dist/transcript.js
CHANGED
|
@@ -54,6 +54,20 @@ function clampStringList(source, cap) {
|
|
|
54
54
|
}
|
|
55
55
|
return out;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Deep-clone a decision-timeline payload so it is decoupled from the caller's
|
|
59
|
+
* live object. structuredClone is available in every supported Node release;
|
|
60
|
+
* on the off chance it is missing, fall back to a JSON round-trip (decision
|
|
61
|
+
* payloads are JSON-serializable by construction).
|
|
62
|
+
*/
|
|
63
|
+
function cloneDecisionData(data) {
|
|
64
|
+
try {
|
|
65
|
+
return structuredClone(data);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return JSON.parse(JSON.stringify(data));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
57
71
|
/** Normalize a single finding, dropping malformed entries. */
|
|
58
72
|
function normalizeFinding(input) {
|
|
59
73
|
if (!input || typeof input !== 'object')
|
|
@@ -81,12 +95,23 @@ function normalizeFinding(input) {
|
|
|
81
95
|
acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
|
|
82
96
|
};
|
|
83
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
|
|
100
|
+
* with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
|
|
101
|
+
* A single oversized fixer agent could otherwise OOM the client with an
|
|
102
|
+
* unbounded finding dump inside one thread.
|
|
103
|
+
*/
|
|
104
|
+
function pushThreadFinding(thread, raw) {
|
|
105
|
+
thread.findings.push(raw);
|
|
106
|
+
if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
|
|
107
|
+
thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
84
110
|
/** Merge a report snapshot's findings/readFiles into a thread by dimension. */
|
|
85
111
|
function mergeReportIntoThread(thread, findings, readFiles) {
|
|
86
112
|
for (const raw of findings) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
thread.findings.push(raw);
|
|
113
|
+
if (normalizeFinding(raw))
|
|
114
|
+
pushThreadFinding(thread, raw);
|
|
90
115
|
}
|
|
91
116
|
for (const r of readFiles ?? []) {
|
|
92
117
|
if (typeof r === 'string')
|
|
@@ -107,9 +132,11 @@ export class ReviewTranscriptBuilder {
|
|
|
107
132
|
round = 0;
|
|
108
133
|
maxRounds = 0;
|
|
109
134
|
active = true;
|
|
135
|
+
stoppedReason = null;
|
|
110
136
|
rounds = [];
|
|
111
137
|
convergence = [];
|
|
112
138
|
globalFindings = [];
|
|
139
|
+
globalSeenKeys = new Set();
|
|
113
140
|
fixes = [];
|
|
114
141
|
checkpoint = null;
|
|
115
142
|
timeline = [];
|
|
@@ -158,9 +185,15 @@ export class ReviewTranscriptBuilder {
|
|
|
158
185
|
this.phases.push(n);
|
|
159
186
|
this.touch();
|
|
160
187
|
}
|
|
161
|
-
/**
|
|
162
|
-
|
|
188
|
+
/**
|
|
189
|
+
* End the run (stops the "active" pulsing in the UI). `reason` records WHY
|
|
190
|
+
* it ended, so a run stopped by max-rounds or validation is never mistaken
|
|
191
|
+
* for a live run NOR for a clean convergence.
|
|
192
|
+
*/
|
|
193
|
+
finish(reason) {
|
|
163
194
|
this.active = false;
|
|
195
|
+
if (typeof reason === 'string' && reason.trim())
|
|
196
|
+
this.stoppedReason = reason.trim();
|
|
164
197
|
this.touch();
|
|
165
198
|
}
|
|
166
199
|
/** Open a review round, capturing the current round index. */
|
|
@@ -230,12 +263,11 @@ export class ReviewTranscriptBuilder {
|
|
|
230
263
|
for (const raw of findings) {
|
|
231
264
|
const f = normalizeFinding(raw);
|
|
232
265
|
if (f) {
|
|
233
|
-
thread
|
|
234
|
-
this.
|
|
266
|
+
pushThreadFinding(thread, raw);
|
|
267
|
+
this.addGlobal(f);
|
|
235
268
|
}
|
|
236
269
|
}
|
|
237
270
|
}
|
|
238
|
-
this.reevaluateGlobal();
|
|
239
271
|
this.touch();
|
|
240
272
|
}
|
|
241
273
|
/** Merge a round-level report snapshot (findings + readFiles) into a thread. */
|
|
@@ -248,9 +280,8 @@ export class ReviewTranscriptBuilder {
|
|
|
248
280
|
for (const raw of findings) {
|
|
249
281
|
const f = normalizeFinding(raw);
|
|
250
282
|
if (f)
|
|
251
|
-
this.
|
|
283
|
+
this.addGlobal(f);
|
|
252
284
|
}
|
|
253
|
-
this.reevaluateGlobal();
|
|
254
285
|
this.touch();
|
|
255
286
|
}
|
|
256
287
|
// ─── Convergence (F2) ───────────────────────────────────────────────────
|
|
@@ -330,8 +361,12 @@ export class ReviewTranscriptBuilder {
|
|
|
330
361
|
? Math.floor(entry.round)
|
|
331
362
|
: this.round,
|
|
332
363
|
type,
|
|
333
|
-
data: entry.data && typeof entry.data === 'object'
|
|
334
|
-
|
|
364
|
+
data: entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
|
|
365
|
+
// Deep clone so a caller mutating its own object later can never
|
|
366
|
+
// alias into the timeline (serialize would then emit the mutated
|
|
367
|
+
// values instead of what was actually decided). Decision-log payloads
|
|
368
|
+
// are small JSON, so the clone cost is negligible.
|
|
369
|
+
? cloneDecisionData(entry.data)
|
|
335
370
|
: {},
|
|
336
371
|
});
|
|
337
372
|
if (this.timeline.length > MAX_TIMELINE) {
|
|
@@ -378,13 +413,14 @@ export class ReviewTranscriptBuilder {
|
|
|
378
413
|
maxRounds: this.maxRounds,
|
|
379
414
|
rounds,
|
|
380
415
|
convergence: this.convergence,
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
416
|
+
// addGlobal maintains the newest-wins cap incrementally, so the array is
|
|
417
|
+
// already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
|
|
418
|
+
findings: this.globalFindings,
|
|
384
419
|
fixes: this.fixes,
|
|
385
420
|
checkpoint: this.checkpoint,
|
|
386
421
|
timeline: this.timeline,
|
|
387
422
|
nudge: this.nudge,
|
|
423
|
+
stoppedReason: this.stoppedReason,
|
|
388
424
|
approval: {
|
|
389
425
|
active: this.approval !== 'allow',
|
|
390
426
|
policy: this.approval,
|
|
@@ -408,25 +444,23 @@ export class ReviewTranscriptBuilder {
|
|
|
408
444
|
threadCount(live) {
|
|
409
445
|
return live.threads.length;
|
|
410
446
|
}
|
|
411
|
-
/**
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
447
|
+
/**
|
|
448
|
+
* Insert one deduplicated finding into the compact global list.
|
|
449
|
+
* NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
|
|
450
|
+
* kept finding is evicted so fresh, still-relevant findings survive — this
|
|
451
|
+
* is the documented contract. Amortized O(1): dedupe via a seen-key set and
|
|
452
|
+
* eviction is one `shift` per overflow insert.
|
|
453
|
+
*/
|
|
454
|
+
addGlobal(f) {
|
|
455
|
+
const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
|
|
456
|
+
if (this.globalSeenKeys.has(key))
|
|
457
|
+
return;
|
|
458
|
+
this.globalSeenKeys.add(key);
|
|
459
|
+
this.globalFindings.push(f);
|
|
460
|
+
if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
|
|
461
|
+
const evicted = this.globalFindings.shift();
|
|
462
|
+
if (evicted) {
|
|
463
|
+
this.globalSeenKeys.delete(`${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`);
|
|
430
464
|
}
|
|
431
465
|
}
|
|
432
466
|
}
|