iterate-plugin 3.5.3 → 3.5.4
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/session-hooks.js +48 -4
- package/dist/skill-prompt.js +63 -29
- package/dist/tools/context.js +5 -1
- 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/prune.js +168 -14
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +28 -6
- package/dist/tools/triage.js +35 -2
- package/dist/transcript.js +10 -2
- package/lib/client.js +37 -11
- package/lib/parse.js +24 -2
- package/package.json +9 -7
- package/src/client/index.ts +23 -3
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +63 -29
- package/src/tools/context.ts +5 -1
- 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/prune.ts +179 -14
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +26 -5
- package/src/tools/triage.ts +36 -2
- package/src/transcript.ts +9 -2
- 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.
|
|
@@ -26,11 +30,22 @@ import { resolveProjectRootForExec } from "../config-loader.js";
|
|
|
26
30
|
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
27
31
|
import { readDecisionEntries, appendDecisionEntry } 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,57 @@ 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
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
238
|
+
try {
|
|
239
|
+
const entries = readDecisionEntries(projectRoot);
|
|
240
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff);
|
|
241
|
+
const deleted = entries.length - kept.length;
|
|
242
|
+
if (deleted === 0)
|
|
243
|
+
return { deleted: 0 };
|
|
244
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
245
|
+
// A concurrent appender may have landed new lines after our read. If the
|
|
246
|
+
// log grew during the write window, re-read and prune again instead of
|
|
247
|
+
// accepting a lost audit trail.
|
|
248
|
+
const after = readDecisionEntries(projectRoot);
|
|
249
|
+
if (after.length <= kept.length)
|
|
250
|
+
return { deleted };
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Execute the prune: apply every cleanable item reported by `inspectPrune`.
|
|
260
|
+
* Every step is individually hedged so a single disk failure degrades to a
|
|
261
|
+
* reported error rather than aborting the whole cleanup.
|
|
262
|
+
*/
|
|
141
263
|
export function executePrune(projectRoot, retainDays, report) {
|
|
142
264
|
const cutoff = cutoffTimestamp(retainDays);
|
|
143
265
|
const result = {
|
|
@@ -146,22 +268,19 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
146
268
|
deletedBackups: [],
|
|
147
269
|
deletedTemps: [],
|
|
148
270
|
trimmedEmptyRounds: 0,
|
|
271
|
+
deletedExperiences: 0,
|
|
272
|
+
deletedDefenseEvents: 0,
|
|
149
273
|
errors: [],
|
|
150
274
|
};
|
|
151
275
|
// 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;
|
|
276
|
+
// (temp + rename) so a crash mid-write can never truncate the log; the
|
|
277
|
+
// bounded compare-and-append loop guards against a concurrent appender
|
|
278
|
+
// slipping a fresh audit line into the read-before-rewrite window.
|
|
279
|
+
{
|
|
280
|
+
const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff);
|
|
281
|
+
result.deletedLogEntries = deleted;
|
|
282
|
+
if (error)
|
|
283
|
+
result.errors.push(error);
|
|
165
284
|
}
|
|
166
285
|
// 2. Remove checkpoint.
|
|
167
286
|
if (report.hasCheckpoint) {
|
|
@@ -213,6 +332,34 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
213
332
|
result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
|
|
214
333
|
}
|
|
215
334
|
}
|
|
335
|
+
// 6. Trim the experience bank to its entry cap (newest wins).
|
|
336
|
+
if (report.experienceOversize > 0) {
|
|
337
|
+
try {
|
|
338
|
+
const bank = readExperienceBank(projectRoot);
|
|
339
|
+
const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES);
|
|
340
|
+
if (removed > 0) {
|
|
341
|
+
writeExperienceBank(projectRoot, swept);
|
|
342
|
+
result.deletedExperiences = removed;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
result.errors.push(`failed to trim experience bank: ${String(err)}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// 7. Drop defense events older than retainDays (recounted on write).
|
|
350
|
+
if (report.staleDefenseEvents > 0) {
|
|
351
|
+
try {
|
|
352
|
+
const stream = readDefenseEvents(projectRoot);
|
|
353
|
+
const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff);
|
|
354
|
+
if (removed > 0) {
|
|
355
|
+
writeDefenseEvents(projectRoot, swept);
|
|
356
|
+
result.deletedDefenseEvents = removed;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
result.errors.push(`failed to sweep defense events: ${String(err)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
216
363
|
return result;
|
|
217
364
|
}
|
|
218
365
|
/**
|
|
@@ -226,7 +373,8 @@ export function registerPruneTool(ctx) {
|
|
|
226
373
|
description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
227
374
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
228
375
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
229
|
-
'
|
|
376
|
+
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
377
|
+
'and stale defense events. ' +
|
|
230
378
|
'Each deletion is logged to the decision log.',
|
|
231
379
|
parameters: {
|
|
232
380
|
dryRun: {
|
|
@@ -268,6 +416,8 @@ export function registerPruneTool(ctx) {
|
|
|
268
416
|
` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
269
417
|
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
270
418
|
` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
|
|
419
|
+
` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
|
|
420
|
+
` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
|
|
271
421
|
'',
|
|
272
422
|
'Pass dryRun:false to execute the prune.',
|
|
273
423
|
];
|
|
@@ -280,6 +430,8 @@ export function registerPruneTool(ctx) {
|
|
|
280
430
|
` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
|
|
281
431
|
` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
|
|
282
432
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
433
|
+
` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
|
|
434
|
+
` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
|
|
283
435
|
];
|
|
284
436
|
const errs = result?.errors ?? [];
|
|
285
437
|
if (errs.length > 0) {
|
|
@@ -319,6 +471,8 @@ export function registerPruneTool(ctx) {
|
|
|
319
471
|
deletedCheckpoint: result.deletedCheckpoint,
|
|
320
472
|
deletedBackups: result.deletedBackups.length,
|
|
321
473
|
trimmedEmptyRounds: result.trimmedEmptyRounds,
|
|
474
|
+
deletedExperiences: result.deletedExperiences,
|
|
475
|
+
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
322
476
|
},
|
|
323
477
|
});
|
|
324
478
|
return {
|
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) {
|
|
@@ -213,6 +242,10 @@ function applyEntries(projectRoot, incoming) {
|
|
|
213
242
|
error: `Failed to write config: ${String(err)}${rollbackError}`,
|
|
214
243
|
};
|
|
215
244
|
}
|
|
245
|
+
// Success: bound the accumulation of timestamped backups so a long-lived
|
|
246
|
+
// project never collects an unbounded pile of config snapshots.
|
|
247
|
+
if (backupPath)
|
|
248
|
+
pruneOldConfigBackups(configPath);
|
|
216
249
|
return { ok: true, added, skipped, count: merged.length, configPath, backupPath };
|
|
217
250
|
}
|
|
218
251
|
/**
|
package/dist/transcript.js
CHANGED
|
@@ -107,6 +107,7 @@ export class ReviewTranscriptBuilder {
|
|
|
107
107
|
round = 0;
|
|
108
108
|
maxRounds = 0;
|
|
109
109
|
active = true;
|
|
110
|
+
stoppedReason = null;
|
|
110
111
|
rounds = [];
|
|
111
112
|
convergence = [];
|
|
112
113
|
globalFindings = [];
|
|
@@ -158,9 +159,15 @@ export class ReviewTranscriptBuilder {
|
|
|
158
159
|
this.phases.push(n);
|
|
159
160
|
this.touch();
|
|
160
161
|
}
|
|
161
|
-
/**
|
|
162
|
-
|
|
162
|
+
/**
|
|
163
|
+
* End the run (stops the "active" pulsing in the UI). `reason` records WHY
|
|
164
|
+
* it ended, so a run stopped by max-rounds or validation is never mistaken
|
|
165
|
+
* for a live run NOR for a clean convergence.
|
|
166
|
+
*/
|
|
167
|
+
finish(reason) {
|
|
163
168
|
this.active = false;
|
|
169
|
+
if (typeof reason === 'string' && reason.trim())
|
|
170
|
+
this.stoppedReason = reason.trim();
|
|
164
171
|
this.touch();
|
|
165
172
|
}
|
|
166
173
|
/** Open a review round, capturing the current round index. */
|
|
@@ -385,6 +392,7 @@ export class ReviewTranscriptBuilder {
|
|
|
385
392
|
checkpoint: this.checkpoint,
|
|
386
393
|
timeline: this.timeline,
|
|
387
394
|
nudge: this.nudge,
|
|
395
|
+
stoppedReason: this.stoppedReason,
|
|
388
396
|
approval: {
|
|
389
397
|
active: this.approval !== 'allow',
|
|
390
398
|
policy: this.approval,
|
package/lib/client.js
CHANGED
|
@@ -941,18 +941,25 @@ function computeConvergenceProgress(report) {
|
|
|
941
941
|
report.convergence ?? {}
|
|
942
942
|
);
|
|
943
943
|
const totalRounds = typeof convergence.totalRounds === "number" ? convergence.totalRounds : 1;
|
|
944
|
-
const currentRounds = (
|
|
945
|
-
/** @type {Array<unknown>} */
|
|
946
|
-
(report.rounds ?? []).length
|
|
947
|
-
);
|
|
944
|
+
const currentRounds = currentRoundNumber(report);
|
|
948
945
|
if (!(totalRounds > 0)) return 0;
|
|
949
946
|
return Math.min(100, Math.round(currentRounds / totalRounds * 100));
|
|
950
947
|
}
|
|
951
|
-
function
|
|
952
|
-
|
|
953
|
-
/** @type {Array<unknown
|
|
954
|
-
|
|
948
|
+
function currentRoundNumber(report) {
|
|
949
|
+
const rounds = (
|
|
950
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
951
|
+
report.rounds ?? []
|
|
955
952
|
);
|
|
953
|
+
let max = 0;
|
|
954
|
+
for (const r of rounds) {
|
|
955
|
+
if (!r || typeof r !== "object") continue;
|
|
956
|
+
const n = Number(r.round);
|
|
957
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
958
|
+
}
|
|
959
|
+
return rounds.length > 0 && max === 0 ? rounds.length : max;
|
|
960
|
+
}
|
|
961
|
+
function getCurrentRound(report) {
|
|
962
|
+
return currentRoundNumber(report);
|
|
956
963
|
}
|
|
957
964
|
function getTotalRounds(report) {
|
|
958
965
|
const convergence = (
|
|
@@ -1713,6 +1720,25 @@ function severityColor(severity) {
|
|
|
1713
1720
|
function severityLabel(severity) {
|
|
1714
1721
|
return SEVERITY_LABEL[coerceSeverity(severity)];
|
|
1715
1722
|
}
|
|
1723
|
+
function stoppedReasonLabel(reason) {
|
|
1724
|
+
switch (reason) {
|
|
1725
|
+
case "converged":
|
|
1726
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
|
|
1727
|
+
case "max_rounds_reached":
|
|
1728
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
|
|
1729
|
+
case "aborted_by_validation":
|
|
1730
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
|
|
1731
|
+
case "aborted_by_config":
|
|
1732
|
+
return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
|
|
1733
|
+
default:
|
|
1734
|
+
return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
function runStatusText(live, manifest) {
|
|
1738
|
+
if (live) return "\u8FD0\u884C\u4E2D";
|
|
1739
|
+
const reason = manifest && typeof manifest.stoppedReason === "string" ? manifest.stoppedReason : "";
|
|
1740
|
+
return reason ? stoppedReasonLabel(reason) : "\u5DF2\u7ED3\u675F";
|
|
1741
|
+
}
|
|
1716
1742
|
function readSlots(ctx) {
|
|
1717
1743
|
if (ctx && ctx.slots && typeof ctx.slots.inject === "function" && typeof ctx.slots.register === "function") {
|
|
1718
1744
|
return ctx.slots;
|
|
@@ -1858,7 +1884,7 @@ function ConvergenceDashboard(props) {
|
|
|
1858
1884
|
key: "phase",
|
|
1859
1885
|
"data-live": transcript.active === true ? "" : void 0,
|
|
1860
1886
|
title: transcript.active === true ? `\u5F53\u524D\u5904\u4E8E\u300C${phase}\u300D\u9636\u6BB5\uFF08\u8FD0\u884C\u4E2D\uFF09` : `\u6700\u8FD1\u4E00\u6B21\u6267\u884C\u505C\u7559\u5728\u300C${phase}\u300D\u9636\u6BB5\uFF08\u5DF2\u7ED3\u675F\uFF09`
|
|
1861
|
-
}, `${phase} \xB7 ${transcript.active === true
|
|
1887
|
+
}, `${phase} \xB7 ${runStatusText(transcript.active === true, transcript)}`) : null;
|
|
1862
1888
|
const taskModeTranscript = latestTranscript(session);
|
|
1863
1889
|
const taskMode = taskModeTranscript?.taskMode;
|
|
1864
1890
|
const taskModeChip = taskMode ? React.createElement("span", {
|
|
@@ -3171,7 +3197,7 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
|
|
|
3171
3197
|
"div",
|
|
3172
3198
|
{ className: "iterate-obs-block-head" },
|
|
3173
3199
|
React.createElement("span", {}, "\u8FD0\u884C\u63A7\u5236\u53F0"),
|
|
3174
|
-
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live
|
|
3200
|
+
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
|
|
3175
3201
|
React.createElement("span", { className: "iterate-obs-head-meta" }, `\u6279\u51C6\u7B56\u7565\uFF1A${policyLabel}`)
|
|
3176
3202
|
),
|
|
3177
3203
|
React.createElement(
|
|
@@ -3767,7 +3793,7 @@ ${JSON.stringify({ operation: "clear" }, null, 2)}
|
|
|
3767
3793
|
"div",
|
|
3768
3794
|
{ className: "iterate-obs-head", "data-closed": open ? void 0 : "", onClick: () => setOpen((v) => !v) },
|
|
3769
3795
|
React.createElement("span", { className: "iterate-obs-title" }, "iterate \u89C2\u6D4B\u53F0"),
|
|
3770
|
-
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live
|
|
3796
|
+
React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
|
|
3771
3797
|
React.createElement("span", { className: "iterate-obs-head-meta" }, headMeta || "runtime"),
|
|
3772
3798
|
React.createElement("button", {
|
|
3773
3799
|
className: "iterate-btn",
|
package/lib/parse.js
CHANGED
|
@@ -1217,12 +1217,34 @@ export function computeConvergenceProgress(report) {
|
|
|
1217
1217
|
const totalRounds = typeof convergence.totalRounds === 'number'
|
|
1218
1218
|
? convergence.totalRounds
|
|
1219
1219
|
: 1
|
|
1220
|
-
const currentRounds =
|
|
1220
|
+
const currentRounds = currentRoundNumber(report)
|
|
1221
1221
|
// Guard against an empty report (totalRounds <= 0) producing NaN.
|
|
1222
1222
|
if (!(totalRounds > 0)) return 0
|
|
1223
1223
|
return Math.min(100, Math.round((currentRounds / totalRounds) * 100))
|
|
1224
1224
|
}
|
|
1225
1225
|
|
|
1226
|
+
/**
|
|
1227
|
+
* The actual current round (1-indexed) carried by a report. Uses the highest
|
|
1228
|
+
* per-round `round` field: a normal-mode aggregate ships ONLY the live round
|
|
1229
|
+
* (`rounds: [{round: r, ...}]`), so `rounds.length` would be stuck at 1 while
|
|
1230
|
+
* the run is on round 3. Dry-run aggregates ship the cumulative list, where
|
|
1231
|
+
* the round numbers and the array length agree anyway.
|
|
1232
|
+
*
|
|
1233
|
+
* @param {Record<string, unknown>} report
|
|
1234
|
+
* @returns {number}
|
|
1235
|
+
*/
|
|
1236
|
+
function currentRoundNumber(report) {
|
|
1237
|
+
const rounds = /** @type {Array<Record<string, unknown>>} */ (report.rounds ?? [])
|
|
1238
|
+
let max = 0
|
|
1239
|
+
for (const r of rounds) {
|
|
1240
|
+
if (!r || typeof r !== 'object') continue
|
|
1241
|
+
const n = Number(r.round)
|
|
1242
|
+
if (Number.isFinite(n) && n > max) max = n
|
|
1243
|
+
}
|
|
1244
|
+
// Fallback: no usable round numbers → array length (covers all-0 rounds).
|
|
1245
|
+
return rounds.length > 0 && max === 0 ? rounds.length : max
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1226
1248
|
/**
|
|
1227
1249
|
* Get the current round number (1-indexed) from a report.
|
|
1228
1250
|
*
|
|
@@ -1230,7 +1252,7 @@ export function computeConvergenceProgress(report) {
|
|
|
1230
1252
|
* @returns {number}
|
|
1231
1253
|
*/
|
|
1232
1254
|
export function getCurrentRound(report) {
|
|
1233
|
-
return (
|
|
1255
|
+
return currentRoundNumber(report)
|
|
1234
1256
|
}
|
|
1235
1257
|
|
|
1236
1258
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.4",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"0.1.5-alpha.1": "compatible",
|
|
38
38
|
"0.1.5-alpha.2": "compatible",
|
|
39
39
|
"0.1.5-rc.1": "compatible",
|
|
40
|
-
"0.1.5-rc.2": "compatible"
|
|
40
|
+
"0.1.5-rc.2": "compatible",
|
|
41
|
+
"0.1.6-alpha.1": "compatible"
|
|
41
42
|
}
|
|
42
43
|
},
|
|
43
44
|
"client": {
|
|
@@ -75,13 +76,14 @@
|
|
|
75
76
|
},
|
|
76
77
|
"dependencies": {
|
|
77
78
|
"@deepseek-ai/cordis": "4.0.2",
|
|
78
|
-
"@deepseek-ai/dsh-tools": "0.1.
|
|
79
|
-
"@deepseek-ai/dsh-util-values": "0.1.
|
|
80
|
-
"js-yaml": "4.3.
|
|
79
|
+
"@deepseek-ai/dsh-tools": "0.1.6-alpha.1",
|
|
80
|
+
"@deepseek-ai/dsh-util-values": "0.1.6-alpha.1",
|
|
81
|
+
"js-yaml": "4.3.2"
|
|
81
82
|
},
|
|
82
83
|
"devDependencies": {
|
|
83
|
-
"@deepseek-ai/dsh-
|
|
84
|
-
"@deepseek-ai/dsh-
|
|
84
|
+
"@deepseek-ai/dsh-agent": "0.1.6-alpha.1",
|
|
85
|
+
"@deepseek-ai/dsh-jobs": "^0.1.6-alpha.1",
|
|
86
|
+
"@deepseek-ai/dsh-session": "0.1.6-alpha.1",
|
|
85
87
|
"@types/js-yaml": "4.0.9",
|
|
86
88
|
"@types/node": "22.15.0",
|
|
87
89
|
"@types/react": "19.2.2",
|