iterate-plugin 3.5.4 → 3.5.6
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 +30 -5
- package/dist/config-write.js +78 -9
- package/dist/evidence.js +33 -2
- package/dist/git-scope.js +14 -5
- package/dist/jobs.js +15 -1
- package/dist/live.js +39 -22
- package/dist/meta-review.js +7 -3
- package/dist/review-scope.js +6 -2
- package/dist/review.js +34 -15
- package/dist/skill-prompt.js +63 -24
- package/dist/tools/checkpoint.js +11 -4
- package/dist/tools/decision-log.js +142 -17
- package/dist/tools/fix.js +24 -3
- package/dist/tools/history.js +1 -1
- package/dist/tools/prune.js +57 -26
- package/dist/tools/transcript.js +17 -6
- package/dist/tools/triage.js +3 -2
- package/dist/transcript.js +136 -34
- package/lib/client.js +67 -31
- package/lib/parse.js +34 -0
- package/package.json +12 -8
- package/src/atomic-fs.ts +20 -4
- package/src/client/index.ts +45 -24
- package/src/config-loader.ts +28 -5
- package/src/config-write.ts +91 -9
- package/src/evidence.ts +34 -2
- package/src/git-scope.ts +14 -3
- package/src/jobs.ts +15 -1
- package/src/live.ts +41 -22
- package/src/meta-review.ts +7 -3
- package/src/review-scope.ts +6 -2
- package/src/review.ts +47 -14
- package/src/skill-prompt.ts +63 -24
- package/src/tools/checkpoint.ts +12 -4
- package/src/tools/decision-log.ts +136 -16
- package/src/tools/fix.ts +25 -3
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +51 -23
- package/src/tools/transcript.ts +16 -5
- package/src/tools/triage.ts +3 -2
- package/src/transcript.ts +144 -31
- package/src/types.ts +9 -4
package/src/tools/prune.ts
CHANGED
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
|
|
27
|
+
import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs'
|
|
28
28
|
import { join } from 'node:path'
|
|
29
29
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
30
30
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
31
31
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
32
32
|
import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
|
|
33
|
-
import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
|
|
33
|
+
import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from './decision-log.ts'
|
|
34
34
|
import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
|
|
35
35
|
import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
|
|
36
36
|
import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
|
|
@@ -154,6 +154,7 @@ export function inspectPrune(
|
|
|
154
154
|
): {
|
|
155
155
|
oldLogEntries: number
|
|
156
156
|
hasCheckpoint: boolean
|
|
157
|
+
checkpointStale: boolean
|
|
157
158
|
staleBackups: string[]
|
|
158
159
|
staleTemps: string[]
|
|
159
160
|
emptyRounds: number[]
|
|
@@ -170,8 +171,20 @@ export function inspectPrune(
|
|
|
170
171
|
const entries = readDecisionEntries(projectRoot)
|
|
171
172
|
const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length
|
|
172
173
|
|
|
173
|
-
// 2. Checkpoint presence.
|
|
174
|
+
// 2. Checkpoint presence. A checkpoint is only STALE once it is older than
|
|
175
|
+
// retainDays — a fresh checkpoint means a run was interrupted recently and
|
|
176
|
+
// may still be resumed, so `hasCheckpoint` is kept as pure existence while
|
|
177
|
+
// `checkpointStale` decides whether a prune actually deletes it.
|
|
174
178
|
const hasCheckpoint = existsSync(checkpointPath(projectRoot))
|
|
179
|
+
let checkpointStale = false
|
|
180
|
+
if (hasCheckpoint) {
|
|
181
|
+
try {
|
|
182
|
+
const st = statSync(checkpointPath(projectRoot))
|
|
183
|
+
if (st && st.mtimeMs < Date.parse(cutoff)) checkpointStale = true
|
|
184
|
+
} catch {
|
|
185
|
+
checkpointStale = true // unreadable mtime — treat as garbage / deletable
|
|
186
|
+
}
|
|
187
|
+
}
|
|
175
188
|
|
|
176
189
|
// 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
|
|
177
190
|
const registry = readRegistry(projectRoot)
|
|
@@ -233,6 +246,7 @@ export function inspectPrune(
|
|
|
233
246
|
return {
|
|
234
247
|
oldLogEntries,
|
|
235
248
|
hasCheckpoint,
|
|
249
|
+
checkpointStale,
|
|
236
250
|
staleBackups,
|
|
237
251
|
staleTemps,
|
|
238
252
|
emptyRounds,
|
|
@@ -264,23 +278,32 @@ export function inspectPrune(
|
|
|
264
278
|
*/
|
|
265
279
|
function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
|
|
266
280
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
+
// Take the cross-process log lock for the ENTIRE loop: with the mutex held,
|
|
282
|
+
// no concurrent appender can slip a fresh line into the read-before-rewrite
|
|
283
|
+
// window, so the first attempt is nearly always the only one. (The retry
|
|
284
|
+
// loop stays as defense-in-depth for the lock-unavailable path.)
|
|
285
|
+
const release = acquireLogLock(projectRoot)
|
|
286
|
+
try {
|
|
287
|
+
for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
|
|
288
|
+
try {
|
|
289
|
+
const entries = readDecisionEntries(projectRoot)
|
|
290
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff)
|
|
291
|
+
const deleted = entries.length - kept.length
|
|
292
|
+
if (deleted === 0) return { deleted: 0 }
|
|
293
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
294
|
+
// A concurrent appender (cross-process, lock-unavailable path) may have
|
|
295
|
+
// landed new lines after our read. If the log grew during the write
|
|
296
|
+
// window, re-read and prune again instead of accepting a lost audit trail.
|
|
297
|
+
const after = readDecisionEntries(projectRoot)
|
|
298
|
+
if (after.length <= kept.length) return { deleted }
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
|
|
301
|
+
}
|
|
281
302
|
}
|
|
303
|
+
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
304
|
+
} finally {
|
|
305
|
+
release()
|
|
282
306
|
}
|
|
283
|
-
return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
|
|
284
307
|
}
|
|
285
308
|
|
|
286
309
|
/**
|
|
@@ -324,8 +347,10 @@ export function executePrune(
|
|
|
324
347
|
if (error) result.errors.push(error)
|
|
325
348
|
}
|
|
326
349
|
|
|
327
|
-
// 2. Remove checkpoint
|
|
328
|
-
|
|
350
|
+
// 2. Remove checkpoint — ONLY when it is actually stale (older than
|
|
351
|
+
// retainDays). A fresh checkpoint is a live resume point for a recently
|
|
352
|
+
// interrupted run; pruning it would silently destroy the ability to resume.
|
|
353
|
+
if (report.checkpointStale) {
|
|
329
354
|
try {
|
|
330
355
|
rmSync(checkpointPath(projectRoot), { force: true })
|
|
331
356
|
result.deletedCheckpoint = true
|
|
@@ -457,7 +482,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
457
482
|
const lines = [
|
|
458
483
|
`[dry-run] prune report (retainDays=${value.retainDays}):`,
|
|
459
484
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
460
|
-
` Checkpoint to delete: ${report?.
|
|
485
|
+
` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
|
|
461
486
|
` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
|
|
462
487
|
` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
|
|
463
488
|
` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
|
|
@@ -507,8 +532,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
507
532
|
|
|
508
533
|
const result = executePrune(projectRoot, retainDays, report)
|
|
509
534
|
|
|
510
|
-
// Log the prune to the decision log.
|
|
511
|
-
|
|
535
|
+
// Log the prune to the decision log. The mutations already happened, so
|
|
536
|
+
// a failed log append is reported via `errors` — a silent audit miss
|
|
537
|
+
// (F2) must never pretend every deletion was recorded.
|
|
538
|
+
const logRes = appendDecisionEntry(projectRoot, {
|
|
512
539
|
timestamp: new Date().toISOString(),
|
|
513
540
|
round: 0,
|
|
514
541
|
type: 'decision',
|
|
@@ -523,6 +550,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
|
|
|
523
550
|
deletedDefenseEvents: result.deletedDefenseEvents,
|
|
524
551
|
},
|
|
525
552
|
})
|
|
553
|
+
if (logRes.error) result.errors.push(logRes.error)
|
|
526
554
|
|
|
527
555
|
return {
|
|
528
556
|
ok: true,
|
package/src/tools/transcript.ts
CHANGED
|
@@ -160,7 +160,7 @@ export function registerTranscriptTool(ctx: {
|
|
|
160
160
|
description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
|
|
161
161
|
},
|
|
162
162
|
refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
|
|
163
|
-
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).' },
|
|
163
|
+
stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
|
|
164
164
|
text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
|
|
165
165
|
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
166
166
|
},
|
|
@@ -358,12 +358,23 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
358
358
|
maxRounds: manifest.maxRounds,
|
|
359
359
|
})
|
|
360
360
|
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
361
|
+
// A malformed/null round row must be skipped, never a throw (the caller
|
|
362
|
+
// wraps rehydrate in a fallback, but preserving sibling rows is better).
|
|
363
|
+
if (!r || typeof r !== 'object') continue
|
|
361
364
|
builder.roundStart(r.round, manifest.maxRounds)
|
|
362
365
|
for (const t of Array.isArray(r.threads) ? r.threads : []) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
366
|
+
// restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
|
|
367
|
+
// message ARRAY boundaries and the thread's own dimension: rehydration
|
|
368
|
+
// previously collapsed each thread's messages into one string and, once a
|
|
369
|
+
// round hit the per-round thread cap, silently DROPPED the extra threads
|
|
370
|
+
// / mis-merged their findings into the previous dimension's thread.
|
|
371
|
+
builder.restoreThread(r.round, {
|
|
372
|
+
dimension: t.dimension,
|
|
373
|
+
attempt: t.attempt,
|
|
374
|
+
messages: t.messages,
|
|
375
|
+
readFiles: t.readFiles,
|
|
376
|
+
findings: t.findings,
|
|
377
|
+
})
|
|
367
378
|
}
|
|
368
379
|
}
|
|
369
380
|
for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
|
package/src/tools/triage.ts
CHANGED
|
@@ -208,8 +208,9 @@ function readConfigFile(configPath: string): Record<string, unknown> {
|
|
|
208
208
|
if (!existsSync(configPath)) return {}
|
|
209
209
|
const content = readFileSync(configPath, 'utf-8')
|
|
210
210
|
const parsed = yaml.load(content)
|
|
211
|
-
if (!parsed || typeof parsed !== 'object') {
|
|
212
|
-
// A config that exists but is
|
|
211
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
212
|
+
// A config that exists but is NOT a YAML mapping (including a YAML list,
|
|
213
|
+
// which `typeof [] === 'object'` alone would accept) must NOT be silently
|
|
213
214
|
// treated as empty: writing over it would destroy user data. Callers
|
|
214
215
|
// surface this as an error and refuse to write.
|
|
215
216
|
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
package/src/transcript.ts
CHANGED
|
@@ -71,6 +71,20 @@ function clampStringList(source: string[], cap: number): string[] {
|
|
|
71
71
|
return out
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Deep-clone a decision-timeline payload so it is decoupled from the caller's
|
|
76
|
+
* live object. structuredClone is available in every supported Node release;
|
|
77
|
+
* on the off chance it is missing, fall back to a JSON round-trip (decision
|
|
78
|
+
* payloads are JSON-serializable by construction).
|
|
79
|
+
*/
|
|
80
|
+
function cloneDecisionData(data: Record<string, unknown>): Record<string, unknown> {
|
|
81
|
+
try {
|
|
82
|
+
return structuredClone(data)
|
|
83
|
+
} catch {
|
|
84
|
+
return JSON.parse(JSON.stringify(data)) as Record<string, unknown>
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
/** Normalize a single finding, dropping malformed entries. */
|
|
75
89
|
function normalizeFinding(input: unknown): TranscriptFinding | null {
|
|
76
90
|
if (!input || typeof input !== 'object') return null
|
|
@@ -84,7 +98,10 @@ function normalizeFinding(input: unknown): TranscriptFinding | null {
|
|
|
84
98
|
sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
|
|
85
99
|
? sev
|
|
86
100
|
: 'low'
|
|
87
|
-
|
|
101
|
+
// Only a non-negative integer is a real line anchor; a fractional/malformed
|
|
102
|
+
// value (e.g. 3.7 from a bypassed schema) must not leak into the manifest.
|
|
103
|
+
const line =
|
|
104
|
+
typeof f.line === 'number' && Number.isInteger(f.line) && f.line >= 0 ? f.line : 0
|
|
88
105
|
return {
|
|
89
106
|
dimension,
|
|
90
107
|
file,
|
|
@@ -113,6 +130,19 @@ interface LiveRound {
|
|
|
113
130
|
threads: LiveThread[]
|
|
114
131
|
}
|
|
115
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Append one raw finding to a thread's list, bound to MAX_FINDINGS_PER_THREAD
|
|
135
|
+
* with NEWEST-WINS eviction (drop the oldest findings once the cap is hit).
|
|
136
|
+
* A single oversized fixer agent could otherwise OOM the client with an
|
|
137
|
+
* unbounded finding dump inside one thread.
|
|
138
|
+
*/
|
|
139
|
+
function pushThreadFinding(thread: LiveThread, raw: unknown): void {
|
|
140
|
+
thread.findings.push(raw)
|
|
141
|
+
if (thread.findings.length > MAX_FINDINGS_PER_THREAD) {
|
|
142
|
+
thread.findings.splice(0, thread.findings.length - MAX_FINDINGS_PER_THREAD)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
116
146
|
/** Merge a report snapshot's findings/readFiles into a thread by dimension. */
|
|
117
147
|
function mergeReportIntoThread(
|
|
118
148
|
thread: LiveThread,
|
|
@@ -120,8 +150,7 @@ function mergeReportIntoThread(
|
|
|
120
150
|
readFiles: readonly unknown[] | undefined,
|
|
121
151
|
): void {
|
|
122
152
|
for (const raw of findings) {
|
|
123
|
-
|
|
124
|
-
if (f) thread.findings.push(raw)
|
|
153
|
+
if (normalizeFinding(raw)) pushThreadFinding(thread, raw)
|
|
125
154
|
}
|
|
126
155
|
for (const r of readFiles ?? []) {
|
|
127
156
|
if (typeof r === 'string') thread.readFiles.push(r)
|
|
@@ -146,6 +175,7 @@ export class ReviewTranscriptBuilder {
|
|
|
146
175
|
private readonly rounds: LiveRound[] = []
|
|
147
176
|
private readonly convergence: number[] = []
|
|
148
177
|
private readonly globalFindings: TranscriptFinding[] = []
|
|
178
|
+
private readonly globalSeenKeys = new Set<string>()
|
|
149
179
|
private readonly fixes: TranscriptFix[] = []
|
|
150
180
|
private checkpoint: TranscriptCheckpoint | null = null
|
|
151
181
|
private readonly timeline: TranscriptEntry[] = []
|
|
@@ -240,7 +270,8 @@ export class ReviewTranscriptBuilder {
|
|
|
240
270
|
const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1
|
|
241
271
|
this.roundStart(this.round)
|
|
242
272
|
const live = this.rounds[this.round - 1]! as LiveRound | undefined
|
|
243
|
-
if (live
|
|
273
|
+
if (!live) return
|
|
274
|
+
if (this.threadCount(live) < MAX_THREADS_PER_ROUND) {
|
|
244
275
|
live.threads.push({
|
|
245
276
|
dimension: dim || 'review',
|
|
246
277
|
attempt: att > 0 ? att : 1,
|
|
@@ -248,10 +279,89 @@ export class ReviewTranscriptBuilder {
|
|
|
248
279
|
readFiles: [],
|
|
249
280
|
findings: [],
|
|
250
281
|
})
|
|
282
|
+
} else {
|
|
283
|
+
// Thread cap reached: route the extra dimension/retry into ONE shared
|
|
284
|
+
// "other" overflow thread instead of silently dropping it. A dropped
|
|
285
|
+
// reviewerStart used to leave `currentThread()` pointing at the PREVIOUS
|
|
286
|
+
// dimension's thread, so its findings were mis-attributed to that
|
|
287
|
+
// dimension. The overflow thread stays bounded (at most ONE extra thread
|
|
288
|
+
// per round) while keeping over-cap findings attributable to "other".
|
|
289
|
+
let overflow = live.threads.find((t) => t.dimension === 'other')
|
|
290
|
+
if (!overflow) {
|
|
291
|
+
overflow = {
|
|
292
|
+
dimension: 'other',
|
|
293
|
+
attempt: att > 0 ? att : 1,
|
|
294
|
+
messages: [],
|
|
295
|
+
readFiles: [],
|
|
296
|
+
findings: [],
|
|
297
|
+
}
|
|
298
|
+
live.threads.push(overflow)
|
|
299
|
+
}
|
|
251
300
|
}
|
|
252
301
|
this.touch()
|
|
253
302
|
}
|
|
254
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Restore ONE persisted thread during rehydration, preserving the message
|
|
306
|
+
* ARRAY boundaries (a rehydrated manifest is re-serialized on the next nudge
|
|
307
|
+
* edit, so collapsed messages would silently rewrite the evidence) and the
|
|
308
|
+
* thread's own dimension (never merging it into a previous dimension's
|
|
309
|
+
* thread). Bypasses the live per-round cap — the manifest already enforced
|
|
310
|
+
* it at capture time — but every restored field is hard-bounded so a hostile
|
|
311
|
+
* hand-edited manifest can never blow up memory.
|
|
312
|
+
*/
|
|
313
|
+
restoreThread(
|
|
314
|
+
round: number,
|
|
315
|
+
thread: {
|
|
316
|
+
dimension?: unknown
|
|
317
|
+
attempt?: unknown
|
|
318
|
+
messages?: unknown
|
|
319
|
+
readFiles?: unknown
|
|
320
|
+
findings?: unknown
|
|
321
|
+
},
|
|
322
|
+
): void {
|
|
323
|
+
const r =
|
|
324
|
+
typeof round === 'number' && Number.isFinite(round)
|
|
325
|
+
? Math.min(Math.floor(round), MAX_ROUNDS)
|
|
326
|
+
: 1
|
|
327
|
+
this.roundStart(r)
|
|
328
|
+
const live = this.rounds[this.round - 1]
|
|
329
|
+
if (!live) return
|
|
330
|
+
if (!thread || typeof thread !== 'object') return
|
|
331
|
+
// Hostile-manifest guard: keep the live hard bound (with headroom for the
|
|
332
|
+
// single overflow thread) and newest-first truncate beyond it.
|
|
333
|
+
if (live.threads.length >= MAX_THREADS_PER_ROUND * 2 + 1) return
|
|
334
|
+
const dim =
|
|
335
|
+
typeof thread.dimension === 'string' && thread.dimension.trim()
|
|
336
|
+
? thread.dimension.trim()
|
|
337
|
+
: 'review'
|
|
338
|
+
const att =
|
|
339
|
+
typeof thread.attempt === 'number' && Number.isFinite(thread.attempt)
|
|
340
|
+
? Math.floor(thread.attempt)
|
|
341
|
+
: 1
|
|
342
|
+
const t: LiveThread = {
|
|
343
|
+
dimension: dim,
|
|
344
|
+
attempt: att > 0 ? att : 1,
|
|
345
|
+
messages: [],
|
|
346
|
+
readFiles: [],
|
|
347
|
+
findings: [],
|
|
348
|
+
}
|
|
349
|
+
const messages = Array.isArray(thread.messages) ? thread.messages : []
|
|
350
|
+
for (const m of messages.slice(0, MAX_MESSAGES_PER_THREAD)) {
|
|
351
|
+
if (typeof m === 'string' && m.trim()) t.messages.push(m)
|
|
352
|
+
}
|
|
353
|
+
if (Array.isArray(thread.readFiles)) {
|
|
354
|
+
t.readFiles.push(...dedupePaths(thread.readFiles as string[]))
|
|
355
|
+
}
|
|
356
|
+
if (Array.isArray(thread.findings)) {
|
|
357
|
+
for (const f of thread.findings) {
|
|
358
|
+
if (normalizeFinding(f)) pushThreadFinding(t, f)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
live.threads.push(t)
|
|
362
|
+
this.touch()
|
|
363
|
+
}
|
|
364
|
+
|
|
255
365
|
/** Append narration (assistant text) to the current reviewer thread. */
|
|
256
366
|
reviewerMessage(text: string): void {
|
|
257
367
|
if (typeof text !== 'string' || !text.trim()) return
|
|
@@ -282,12 +392,11 @@ export class ReviewTranscriptBuilder {
|
|
|
282
392
|
for (const raw of findings) {
|
|
283
393
|
const f = normalizeFinding(raw)
|
|
284
394
|
if (f) {
|
|
285
|
-
thread
|
|
286
|
-
this.
|
|
395
|
+
pushThreadFinding(thread, raw)
|
|
396
|
+
this.addGlobal(f)
|
|
287
397
|
}
|
|
288
398
|
}
|
|
289
399
|
}
|
|
290
|
-
this.reevaluateGlobal()
|
|
291
400
|
this.touch()
|
|
292
401
|
}
|
|
293
402
|
|
|
@@ -299,9 +408,8 @@ export class ReviewTranscriptBuilder {
|
|
|
299
408
|
mergeReportIntoThread(thread, findings, readFiles)
|
|
300
409
|
for (const raw of findings) {
|
|
301
410
|
const f = normalizeFinding(raw)
|
|
302
|
-
if (f) this.
|
|
411
|
+
if (f) this.addGlobal(f)
|
|
303
412
|
}
|
|
304
|
-
this.reevaluateGlobal()
|
|
305
413
|
this.touch()
|
|
306
414
|
}
|
|
307
415
|
|
|
@@ -390,8 +498,12 @@ export class ReviewTranscriptBuilder {
|
|
|
390
498
|
: this.round,
|
|
391
499
|
type,
|
|
392
500
|
data:
|
|
393
|
-
entry.data && typeof entry.data === 'object'
|
|
394
|
-
|
|
501
|
+
entry.data && typeof entry.data === 'object' && !Array.isArray(entry.data)
|
|
502
|
+
// Deep clone so a caller mutating its own object later can never
|
|
503
|
+
// alias into the timeline (serialize would then emit the mutated
|
|
504
|
+
// values instead of what was actually decided). Decision-log payloads
|
|
505
|
+
// are small JSON, so the clone cost is negligible.
|
|
506
|
+
? cloneDecisionData(entry.data as Record<string, unknown>)
|
|
395
507
|
: {},
|
|
396
508
|
})
|
|
397
509
|
if (this.timeline.length > MAX_TIMELINE) {
|
|
@@ -441,10 +553,9 @@ export class ReviewTranscriptBuilder {
|
|
|
441
553
|
maxRounds: this.maxRounds,
|
|
442
554
|
rounds,
|
|
443
555
|
convergence: this.convergence,
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
: this.globalFindings,
|
|
556
|
+
// addGlobal maintains the newest-wins cap incrementally, so the array is
|
|
557
|
+
// already ≤ MAX_FINDINGS_TOTAL — no post-hoc slice needed.
|
|
558
|
+
findings: this.globalFindings,
|
|
448
559
|
fixes: this.fixes,
|
|
449
560
|
checkpoint: this.checkpoint,
|
|
450
561
|
timeline: this.timeline,
|
|
@@ -477,22 +588,24 @@ export class ReviewTranscriptBuilder {
|
|
|
477
588
|
return live.threads.length
|
|
478
589
|
}
|
|
479
590
|
|
|
480
|
-
/**
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
591
|
+
/**
|
|
592
|
+
* Insert one deduplicated finding into the compact global list.
|
|
593
|
+
* NEWEST-WINS: when the global cap (MAX_FINDINGS_TOTAL) is hit, the oldest
|
|
594
|
+
* kept finding is evicted so fresh, still-relevant findings survive — this
|
|
595
|
+
* is the documented contract. Amortized O(1): dedupe via a seen-key set and
|
|
596
|
+
* eviction is one `shift` per overflow insert.
|
|
597
|
+
*/
|
|
598
|
+
private addGlobal(f: TranscriptFinding): void {
|
|
599
|
+
const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`
|
|
600
|
+
if (this.globalSeenKeys.has(key)) return
|
|
601
|
+
this.globalSeenKeys.add(key)
|
|
602
|
+
this.globalFindings.push(f)
|
|
603
|
+
if (this.globalFindings.length > MAX_FINDINGS_TOTAL) {
|
|
604
|
+
const evicted = this.globalFindings.shift()
|
|
605
|
+
if (evicted) {
|
|
606
|
+
this.globalSeenKeys.delete(
|
|
607
|
+
`${evicted.file}\u0000${evicted.line ?? 0}\u0000${evicted.dimension}\u0000${evicted.summary}`,
|
|
608
|
+
)
|
|
496
609
|
}
|
|
497
610
|
}
|
|
498
611
|
}
|
package/src/types.ts
CHANGED
|
@@ -18,9 +18,12 @@ export interface IterateConfig {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* LLM reasoning effort for review passes ('low' | 'medium' | 'high').
|
|
21
|
-
* Absent → follow the provider default.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Absent → follow the provider default. Accepted by `iterate_config` write
|
|
22
|
+
* validation (config-write), exposed in the settings guide, and carried on
|
|
23
|
+
* the review plan (`buildReviewPlan.reasoningEffort`, plus an effort
|
|
24
|
+
* directive injected into every reviewer prompt) so the orchestrating
|
|
25
|
+
* harness can honor it per reviewer subagent. The plugin itself never reads
|
|
26
|
+
* or modifies the provider request body.
|
|
24
27
|
*/
|
|
25
28
|
reasoning_effort?: 'low' | 'medium' | 'high'
|
|
26
29
|
reviewer: {
|
|
@@ -226,7 +229,9 @@ export interface IterationStatus {
|
|
|
226
229
|
findingsCount: number
|
|
227
230
|
totalDecisionLogEntries: number
|
|
228
231
|
hasCheckpoint: boolean
|
|
229
|
-
/** True when a checkpoint is present
|
|
232
|
+
/** True when a checkpoint is present AND no decision-log entry is newer than
|
|
233
|
+
* it — i.e. the previous run left a checkpoint and nothing has logged since
|
|
234
|
+
* (a live run that saved progress keeps `interrupted=false`). */
|
|
230
235
|
interrupted: boolean
|
|
231
236
|
/** How many times the current checkpoint has already been resumed. */
|
|
232
237
|
resumeCount: number
|