iterate-plugin 3.5.2 → 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/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/session-hooks.js +48 -4
- package/dist/skill-prompt.js +63 -29
- package/dist/tools/checkpoint.js +39 -5
- package/dist/tools/config.js +11 -2
- package/dist/tools/context.js +5 -1
- package/dist/tools/defense-events.js +48 -9
- package/dist/tools/defense-store.js +63 -5
- package/dist/tools/experience-bank.js +14 -11
- package/dist/tools/experience-store.js +79 -14
- package/dist/tools/fix.js +30 -10
- package/dist/tools/history.js +5 -3
- package/dist/tools/prune.js +197 -29
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +8 -2
- package/dist/tools/review.js +17 -3
- package/dist/tools/transcript.js +72 -15
- package/dist/tools/triage.js +35 -2
- package/dist/transcript.js +30 -7
- package/lib/client.js +89 -21
- package/lib/parse.js +27 -2
- package/package.json +9 -7
- package/src/client/index.ts +53 -8
- package/src/session-hooks.ts +50 -4
- package/src/skill-prompt.ts +63 -29
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/context.ts +5 -1
- package/src/tools/defense-events.ts +50 -9
- package/src/tools/defense-store.ts +64 -8
- package/src/tools/experience-bank.ts +14 -11
- package/src/tools/experience-store.ts +88 -23
- package/src/tools/fix.ts +32 -12
- package/src/tools/history.ts +5 -3
- package/src/tools/prune.ts +204 -28
- package/src/tools/quality-gate.ts +4 -0
- package/src/tools/quality-store.ts +7 -2
- package/src/tools/review.ts +16 -3
- package/src/tools/transcript.ts +71 -14
- package/src/tools/triage.ts +36 -2
- package/src/transcript.ts +32 -7
- 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.
|
|
@@ -87,17 +155,25 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
87
155
|
}
|
|
88
156
|
const staleBackups = [];
|
|
89
157
|
const fixDir = fixesDir(projectRoot);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
158
|
+
// existsSync does not prove readability — a permissions failure must degrade
|
|
159
|
+
// to "nothing listable" (report skipped) instead of throwing out of execute.
|
|
160
|
+
let fixEntries = [];
|
|
161
|
+
try {
|
|
162
|
+
if (existsSync(fixDir))
|
|
163
|
+
fixEntries = readdirSync(fixDir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
fixEntries = [];
|
|
167
|
+
}
|
|
168
|
+
for (const entry of fixEntries) {
|
|
169
|
+
if (!entry.endsWith('.bak'))
|
|
170
|
+
continue;
|
|
171
|
+
// Extract the fix-id prefix (up to the first underscore after the id).
|
|
172
|
+
// e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
|
|
173
|
+
const match = entry.match(/^(fix-[a-z0-9]+)_/);
|
|
174
|
+
const id = match?.[1];
|
|
175
|
+
if (id && !activeIds.has(id)) {
|
|
176
|
+
staleBackups.push(entry);
|
|
101
177
|
}
|
|
102
178
|
}
|
|
103
179
|
// 4. Empty rounds (rounds with 0 records).
|
|
@@ -106,10 +182,25 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
106
182
|
.map((r) => r.round);
|
|
107
183
|
// 5. Stray atomic-write temp files left behind by a crashed writer. The
|
|
108
184
|
// unique prefix makes them recognizable and safe to delete — no live writer
|
|
109
|
-
// ever reads another writer's temp file.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
185
|
+
// ever reads another writer's temp file. A permissions failure listing the
|
|
186
|
+
// dir degrades to "none found" (report-only) rather than throwing.
|
|
187
|
+
let iterateEntries = [];
|
|
188
|
+
try {
|
|
189
|
+
iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : [];
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
iterateEntries = [];
|
|
193
|
+
}
|
|
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);
|
|
113
204
|
return {
|
|
114
205
|
oldLogEntries,
|
|
115
206
|
hasCheckpoint,
|
|
@@ -118,12 +209,57 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
118
209
|
emptyRounds,
|
|
119
210
|
totalLogEntries: entries.length,
|
|
120
211
|
registryRounds: registry.rounds.length,
|
|
212
|
+
totalExperiences: bank.entries.length,
|
|
213
|
+
experienceOversize: sweptBank.removed,
|
|
214
|
+
totalDefenseEvents: defenseStream.events.length,
|
|
215
|
+
staleDefenseEvents: sweptDefense.removed,
|
|
121
216
|
};
|
|
122
217
|
}
|
|
123
218
|
/**
|
|
124
219
|
* Actually prune the runtime artifacts (only called when dryRun=false).
|
|
125
220
|
* Returns a detailed report of what was deleted.
|
|
126
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
|
+
*/
|
|
127
263
|
export function executePrune(projectRoot, retainDays, report) {
|
|
128
264
|
const cutoff = cutoffTimestamp(retainDays);
|
|
129
265
|
const result = {
|
|
@@ -132,22 +268,19 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
132
268
|
deletedBackups: [],
|
|
133
269
|
deletedTemps: [],
|
|
134
270
|
trimmedEmptyRounds: 0,
|
|
271
|
+
deletedExperiences: 0,
|
|
272
|
+
deletedDefenseEvents: 0,
|
|
135
273
|
errors: [],
|
|
136
274
|
};
|
|
137
275
|
// 1. Rewrite the decision log, keeping only recent entries. Atomic
|
|
138
|
-
// (temp + rename) so a crash mid-write can never truncate the log
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
result.errors.push(`failed to rewrite decision log: ${String(err)}`);
|
|
150
|
-
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);
|
|
151
284
|
}
|
|
152
285
|
// 2. Remove checkpoint.
|
|
153
286
|
if (report.hasCheckpoint) {
|
|
@@ -199,6 +332,34 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
199
332
|
result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
|
|
200
333
|
}
|
|
201
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
|
+
}
|
|
202
363
|
return result;
|
|
203
364
|
}
|
|
204
365
|
/**
|
|
@@ -212,7 +373,8 @@ export function registerPruneTool(ctx) {
|
|
|
212
373
|
description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
213
374
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
214
375
|
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
215
|
-
'
|
|
376
|
+
'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
|
|
377
|
+
'and stale defense events. ' +
|
|
216
378
|
'Each deletion is logged to the decision log.',
|
|
217
379
|
parameters: {
|
|
218
380
|
dryRun: {
|
|
@@ -254,6 +416,8 @@ export function registerPruneTool(ctx) {
|
|
|
254
416
|
` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
255
417
|
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
256
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 ?? '?'})`,
|
|
257
421
|
'',
|
|
258
422
|
'Pass dryRun:false to execute the prune.',
|
|
259
423
|
];
|
|
@@ -266,6 +430,8 @@ export function registerPruneTool(ctx) {
|
|
|
266
430
|
` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
|
|
267
431
|
` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
|
|
268
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.`,
|
|
269
435
|
];
|
|
270
436
|
const errs = result?.errors ?? [];
|
|
271
437
|
if (errs.length > 0) {
|
|
@@ -305,6 +471,8 @@ export function registerPruneTool(ctx) {
|
|
|
305
471
|
deletedCheckpoint: result.deletedCheckpoint,
|
|
306
472
|
deletedBackups: result.deletedBackups.length,
|
|
307
473
|
trimmedEmptyRounds: result.trimmedEmptyRounds,
|
|
474
|
+
deletedExperiences: result.deletedExperiences,
|
|
475
|
+
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
308
476
|
},
|
|
309
477
|
});
|
|
310
478
|
return {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
10
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
11
11
|
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from "./quality-store.js";
|
|
12
|
+
/** Valid severity values (kept in sync with QualityGateSnapshot counting). */
|
|
13
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
12
14
|
/** Validate a single finding object; returns true when well-formed. */
|
|
13
15
|
function isValidFinding(raw) {
|
|
14
16
|
if (!raw || typeof raw !== 'object')
|
|
@@ -16,6 +18,7 @@ function isValidFinding(raw) {
|
|
|
16
18
|
const f = raw;
|
|
17
19
|
return (typeof f.dimension === 'string' &&
|
|
18
20
|
typeof f.severity === 'string' &&
|
|
21
|
+
VALID_SEVERITIES.has(f.severity) &&
|
|
19
22
|
typeof f.file === 'string' &&
|
|
20
23
|
(f.line === undefined || typeof f.line === 'number'));
|
|
21
24
|
}
|
|
@@ -200,13 +200,19 @@ export function computeQualityGate(opts) {
|
|
|
200
200
|
const overallScore = dimensionGates.length > 0
|
|
201
201
|
? Math.round(dimensionGates.reduce((sum, d) => sum + d.score, 0) / dimensionGates.length)
|
|
202
202
|
: 0;
|
|
203
|
-
// Determine overall status
|
|
203
|
+
// Determine overall status. An empty review (no dimensions, no findings, no
|
|
204
|
+
// validation results) is NOT a failure — there is simply nothing to gate —
|
|
205
|
+
// so it reports `pending` (not-applicable) instead of a fabricated FAIL.
|
|
204
206
|
const hasCritical = criticalCount > 0;
|
|
205
207
|
const hasHighFail = dimensionGates.some((d) => d.status === 'fail');
|
|
206
208
|
const verificationFails = totalChecks > 0 && failedChecks > 0;
|
|
209
|
+
const noData = dimensionGates.length === 0 && totalFindings === 0 && totalChecks === 0;
|
|
207
210
|
let overallStatus = 'pass';
|
|
208
211
|
let failReason;
|
|
209
|
-
if (
|
|
212
|
+
if (noData) {
|
|
213
|
+
overallStatus = 'pending';
|
|
214
|
+
}
|
|
215
|
+
else if (hasCritical) {
|
|
210
216
|
overallStatus = 'fail';
|
|
211
217
|
failReason = `${criticalCount} critical findings present`;
|
|
212
218
|
}
|
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
|
@@ -31,7 +31,10 @@ function captureRound(builder, round) {
|
|
|
31
31
|
if (!round || typeof round !== 'object')
|
|
32
32
|
return;
|
|
33
33
|
const r = round;
|
|
34
|
-
const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0;
|
|
34
|
+
const roundNo = typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0;
|
|
35
|
+
// NaN round values (`Math.floor(NaN) === NaN`, and `NaN <= 0` is false) would
|
|
36
|
+
// fall through the guard below; a finite check rejects them outright so a
|
|
37
|
+
// malformed round can never collapse into round 1 via the builder's coercion.
|
|
35
38
|
if (roundNo <= 0)
|
|
36
39
|
return;
|
|
37
40
|
builder.roundStart(roundNo);
|
|
@@ -61,15 +64,17 @@ function normalizeCheckpoint(input) {
|
|
|
61
64
|
if (!input || typeof input !== 'object')
|
|
62
65
|
return null;
|
|
63
66
|
const c = input;
|
|
64
|
-
|
|
67
|
+
// Number.isFinite: a NaN round must not pass `round <= 0` (which is false for
|
|
68
|
+
// NaN) and later coerce into a phantom round 1 row.
|
|
69
|
+
const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0;
|
|
65
70
|
if (round <= 0)
|
|
66
71
|
return null;
|
|
67
72
|
return {
|
|
68
73
|
mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
|
|
69
74
|
round,
|
|
70
|
-
maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
|
|
71
|
-
fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
|
|
72
|
-
resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
|
|
75
|
+
maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
|
|
76
|
+
fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
|
|
77
|
+
resumeCount: typeof c.resumeCount === 'number' && Number.isFinite(c.resumeCount) ? c.resumeCount : 0,
|
|
73
78
|
updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
|
|
74
79
|
};
|
|
75
80
|
}
|
|
@@ -85,7 +90,7 @@ function normalizeFix(input) {
|
|
|
85
90
|
return {
|
|
86
91
|
id,
|
|
87
92
|
timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
|
|
88
|
-
round: typeof f.round === 'number' ? f.round : 0,
|
|
93
|
+
round: typeof f.round === 'number' && Number.isFinite(f.round) ? f.round : 0,
|
|
89
94
|
file,
|
|
90
95
|
summary: typeof f.summary === 'string' ? f.summary : '',
|
|
91
96
|
linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
|
|
@@ -143,6 +148,7 @@ export function registerTranscriptTool(ctx) {
|
|
|
143
148
|
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
144
149
|
},
|
|
145
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).' },
|
|
146
152
|
text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
|
|
147
153
|
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
148
154
|
},
|
|
@@ -211,11 +217,37 @@ export function registerTranscriptTool(ctx) {
|
|
|
211
217
|
manifest = null;
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
220
|
+
// Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
|
|
221
|
+
// or a non-object) must never crash the nudge — degrade to a fresh
|
|
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
|
+
};
|
|
234
|
+
let builder;
|
|
235
|
+
if (manifest) {
|
|
236
|
+
try {
|
|
237
|
+
builder = rehydrateBuilder(manifest, approval);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
builder = new ReviewTranscriptBuilder(fallbackOpts);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
|
|
245
|
+
}
|
|
217
246
|
builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
|
|
218
|
-
await
|
|
247
|
+
const persisted = await persistChecked(file, builder.serialize());
|
|
248
|
+
if (!persisted.ok) {
|
|
249
|
+
return { operation: 'nudge', updated: false, error: persisted.error };
|
|
250
|
+
}
|
|
219
251
|
return {
|
|
220
252
|
operation: 'nudge',
|
|
221
253
|
updated: true,
|
|
@@ -272,11 +304,24 @@ export function registerTranscriptTool(ctx) {
|
|
|
272
304
|
builder.fix(record);
|
|
273
305
|
}
|
|
274
306
|
}
|
|
275
|
-
//
|
|
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
|
+
: '';
|
|
276
314
|
const last = convergence[convergence.length - 1];
|
|
277
|
-
if (
|
|
278
|
-
builder.finish();
|
|
279
|
-
|
|
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');
|
|
321
|
+
const persisted = await persistChecked(file, builder.serialize());
|
|
322
|
+
if (!persisted.ok) {
|
|
323
|
+
return { operation: 'capture', found: true, updated: false, error: persisted.error };
|
|
324
|
+
}
|
|
280
325
|
const live = await readLive(projectRoot);
|
|
281
326
|
return {
|
|
282
327
|
operation: 'capture',
|
|
@@ -323,7 +368,7 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
323
368
|
builder.decision(e);
|
|
324
369
|
builder.setNudge(manifest.nudge?.text ?? null);
|
|
325
370
|
if (!manifest.active)
|
|
326
|
-
builder.finish();
|
|
371
|
+
builder.finish(manifest.stoppedReason ?? undefined);
|
|
327
372
|
return builder;
|
|
328
373
|
}
|
|
329
374
|
/** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
|
|
@@ -331,6 +376,18 @@ async function persist(file, manifest) {
|
|
|
331
376
|
await mkdir(dirname(file), { recursive: true });
|
|
332
377
|
await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2));
|
|
333
378
|
}
|
|
379
|
+
/** persist wrapped with a structured error channel so disk failures surface
|
|
380
|
+
* as a structured tool result instead of rejecting the whole execute (matches
|
|
381
|
+
* every other write path in this codebase). */
|
|
382
|
+
async function persistChecked(file, manifest) {
|
|
383
|
+
try {
|
|
384
|
+
await persist(file, manifest);
|
|
385
|
+
return { ok: true };
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
return { ok: false, error: `failed to persist transcript: ${err instanceof Error ? err.message : String(err)}` };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
334
391
|
/**
|
|
335
392
|
* Mark one applied fix as rolled back in the PERSISTED transcript, so the
|
|
336
393
|
* client observatory (F4 fix/rollback tab) reflects the reversal instead of
|
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
|
/**
|