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.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/dist/session-hooks.js +48 -4
  4. package/dist/skill-prompt.js +63 -29
  5. package/dist/tools/checkpoint.js +39 -5
  6. package/dist/tools/config.js +11 -2
  7. package/dist/tools/context.js +5 -1
  8. package/dist/tools/defense-events.js +48 -9
  9. package/dist/tools/defense-store.js +63 -5
  10. package/dist/tools/experience-bank.js +14 -11
  11. package/dist/tools/experience-store.js +79 -14
  12. package/dist/tools/fix.js +30 -10
  13. package/dist/tools/history.js +5 -3
  14. package/dist/tools/prune.js +197 -29
  15. package/dist/tools/quality-gate.js +3 -0
  16. package/dist/tools/quality-store.js +8 -2
  17. package/dist/tools/review.js +17 -3
  18. package/dist/tools/transcript.js +72 -15
  19. package/dist/tools/triage.js +35 -2
  20. package/dist/transcript.js +30 -7
  21. package/lib/client.js +89 -21
  22. package/lib/parse.js +27 -2
  23. package/package.json +9 -7
  24. package/src/client/index.ts +53 -8
  25. package/src/session-hooks.ts +50 -4
  26. package/src/skill-prompt.ts +63 -29
  27. package/src/tools/checkpoint.ts +45 -6
  28. package/src/tools/config.ts +11 -2
  29. package/src/tools/context.ts +5 -1
  30. package/src/tools/defense-events.ts +50 -9
  31. package/src/tools/defense-store.ts +64 -8
  32. package/src/tools/experience-bank.ts +14 -11
  33. package/src/tools/experience-store.ts +88 -23
  34. package/src/tools/fix.ts +32 -12
  35. package/src/tools/history.ts +5 -3
  36. package/src/tools/prune.ts +204 -28
  37. package/src/tools/quality-gate.ts +4 -0
  38. package/src/tools/quality-store.ts +7 -2
  39. package/src/tools/review.ts +16 -3
  40. package/src/tools/transcript.ts +71 -14
  41. package/src/tools/triage.ts +36 -2
  42. package/src/transcript.ts +32 -7
  43. package/src/types.ts +2 -0
@@ -265,9 +265,22 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
265
265
  const coverageEnabled = config.reviewer?.coverage_validation !== false
266
266
  let coverage: CoverageResult | null = null
267
267
  if (coverageEnabled) {
268
- const assigned = collectScopeFiles(projectRoot, {
269
- scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
270
- })
268
+ // Build the assigned inventory the SAME way `plan` builds it: a
269
+ // changed-only scope uses the resolved git-diff file set, and
270
+ // falls back to the FULL walk when git is unavailable or nothing
271
+ // changed (mirrors buildReviewPlan's effective-scope decision), so
272
+ // the coverage ratio reflects the files reviewers were actually
273
+ // told to read — never a vacuous 1.0 on an empty inventory.
274
+ let assigned: string[]
275
+ if (config.review?.scope === 'changed-only') {
276
+ const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main')
277
+ assigned =
278
+ gitScope.changedFiles.length > 0
279
+ ? collectScopeFiles(projectRoot, { scope: 'changed-only', changedFiles: gitScope.changedFiles })
280
+ : collectScopeFiles(projectRoot, { scope: 'full' })
281
+ } else {
282
+ assigned = collectScopeFiles(projectRoot, { scope: 'full' })
283
+ }
271
284
  const readFiles = Array.isArray((source as unknown as { readFiles?: unknown }).readFiles)
272
285
  ? ((source as unknown as { readFiles?: unknown }).readFiles as string[])
273
286
  : null
@@ -44,7 +44,10 @@ function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
44
44
  findings?: unknown
45
45
  readFiles?: unknown
46
46
  }
47
- const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0
47
+ const roundNo = typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0
48
+ // NaN round values (`Math.floor(NaN) === NaN`, and `NaN <= 0` is false) would
49
+ // fall through the guard below; a finite check rejects them outright so a
50
+ // malformed round can never collapse into round 1 via the builder's coercion.
48
51
  if (roundNo <= 0) return
49
52
  builder.roundStart(roundNo)
50
53
  const findings = Array.isArray(r.findings) ? r.findings : []
@@ -70,14 +73,16 @@ function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
70
73
  function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
71
74
  if (!input || typeof input !== 'object') return null
72
75
  const c = input as Record<string, unknown>
73
- const round = typeof c.round === 'number' ? c.round : 0
76
+ // Number.isFinite: a NaN round must not pass `round <= 0` (which is false for
77
+ // NaN) and later coerce into a phantom round 1 row.
78
+ const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
74
79
  if (round <= 0) return null
75
80
  return {
76
81
  mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
77
82
  round,
78
- maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
79
- fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
80
- resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
83
+ maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
84
+ fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
85
+ resumeCount: typeof c.resumeCount === 'number' && Number.isFinite(c.resumeCount) ? c.resumeCount : 0,
81
86
  updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
82
87
  }
83
88
  }
@@ -92,7 +97,7 @@ function normalizeFix(input: unknown): TranscriptFix | null {
92
97
  return {
93
98
  id,
94
99
  timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
95
- round: typeof f.round === 'number' ? f.round : 0,
100
+ round: typeof f.round === 'number' && Number.isFinite(f.round) ? f.round : 0,
96
101
  file,
97
102
  summary: typeof f.summary === 'string' ? f.summary : '',
98
103
  linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
@@ -155,6 +160,7 @@ export function registerTranscriptTool(ctx: {
155
160
  description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
156
161
  },
157
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).' },
158
164
  text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
159
165
  path: { type: 'string', description: 'Project root directory (default: current working directory).' },
160
166
  },
@@ -224,11 +230,35 @@ export function registerTranscriptTool(ctx: {
224
230
  manifest = null
225
231
  }
226
232
  }
227
- const builder = manifest
228
- ? rehydrateBuilder(manifest, approval)
229
- : new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
233
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
234
+ // or a non-object) must never crash the nudge — degrade to a fresh
235
+ // builder so the steering text still lands. The fallback must keep
236
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
237
+ // stray malformed field can never silently rewrite a dry-run run
238
+ // into a `normal` one.
239
+ const fallbackOpts = {
240
+ project: projectRoot,
241
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
242
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
243
+ approval,
244
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
245
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
246
+ }
247
+ let builder: ReviewTranscriptBuilder
248
+ if (manifest) {
249
+ try {
250
+ builder = rehydrateBuilder(manifest, approval)
251
+ } catch {
252
+ builder = new ReviewTranscriptBuilder(fallbackOpts)
253
+ }
254
+ } else {
255
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
256
+ }
230
257
  builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
231
- await persist(file, builder.serialize())
258
+ const persisted = await persistChecked(file, builder.serialize())
259
+ if (!persisted.ok) {
260
+ return { operation: 'nudge', updated: false, error: persisted.error }
261
+ }
232
262
  return {
233
263
  operation: 'nudge',
234
264
  updated: true,
@@ -287,11 +317,23 @@ export function registerTranscriptTool(ctx: {
287
317
  if (record) builder.fix(record)
288
318
  }
289
319
  }
290
- // Convergence "found nothing → settled" marker when the trend ends on 0.
320
+ // End state: an explicit stoppedReason wins; otherwise derive it from
321
+ // the trend. A run that settled closes as "converged"; a run that did
322
+ // real work but never trended to 0 closes as "max_rounds_reached";
323
+ // a capture with no rounds stays "active" (nothing recorded yet).
324
+ const explicitReason =
325
+ typeof args.stoppedReason === 'string' && args.stoppedReason.trim()
326
+ ? args.stoppedReason.trim()
327
+ : ''
291
328
  const last = convergence[convergence.length - 1]
292
- if (convergence.length > 0 && last === 0) builder.finish()
329
+ if (explicitReason) builder.finish(explicitReason)
330
+ else if (convergence.length > 0 && last === 0) builder.finish('converged')
331
+ else if (roundsExecuted > 0) builder.finish('max_rounds_reached')
293
332
 
294
- await persist(file, builder.serialize())
333
+ const persisted = await persistChecked(file, builder.serialize())
334
+ if (!persisted.ok) {
335
+ return { operation: 'capture', found: true, updated: false, error: persisted.error }
336
+ }
295
337
  const live = await readLive(projectRoot)
296
338
  return {
297
339
  operation: 'capture',
@@ -332,7 +374,7 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
332
374
  if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
333
375
  if (Array.isArray(manifest.timeline)) for (const e of manifest.timeline) builder.decision(e)
334
376
  builder.setNudge(manifest.nudge?.text ?? null)
335
- if (!manifest.active) builder.finish()
377
+ if (!manifest.active) builder.finish(manifest.stoppedReason ?? undefined)
336
378
  return builder
337
379
  }
338
380
 
@@ -342,6 +384,21 @@ async function persist(file: string, manifest: TranscriptManifest): Promise<void
342
384
  await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2))
343
385
  }
344
386
 
387
+ /** persist wrapped with a structured error channel so disk failures surface
388
+ * as a structured tool result instead of rejecting the whole execute (matches
389
+ * every other write path in this codebase). */
390
+ async function persistChecked(
391
+ file: string,
392
+ manifest: TranscriptManifest,
393
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
394
+ try {
395
+ await persist(file, manifest)
396
+ return { ok: true }
397
+ } catch (err) {
398
+ return { ok: false, error: `failed to persist transcript: ${err instanceof Error ? err.message : String(err)}` }
399
+ }
400
+ }
401
+
345
402
  /**
346
403
  * Mark one applied fix as rolled back in the PERSISTED transcript, so the
347
404
  * client observatory (F4 fix/rollback tab) reflects the reversal instead of
@@ -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 type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import yaml from 'js-yaml'
@@ -16,6 +16,9 @@ const KNOWN_INTENTIONAL_KEY = 'known_intentional'
16
16
  /** Max entries per single `apply` call. */
17
17
  const MAX_ENTRIES = 500
18
18
 
19
+ /** How many timestamped config backups are retained (older ones are removed). */
20
+ export const MAX_TRIAGE_BACKUPS = 5
21
+
19
22
  /** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
20
23
  const WHOLE_FILE_LINE = 0
21
24
 
@@ -171,6 +174,33 @@ export function backupSuffix(now = new Date()): string {
171
174
  return now.toISOString().replace(/[:.]/g, '-')
172
175
  }
173
176
 
177
+ /**
178
+ * Bound the timestamped config backups: after a fresh one is written, delete
179
+ * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
180
+ * filesystem failure here must never fail the apply that just succeeded
181
+ * (backups are a safety net, not a requirement).
182
+ * @returns the absolute paths of the backups that were removed.
183
+ */
184
+ export function pruneOldConfigBackups(configPath: string, keep = MAX_TRIAGE_BACKUPS): string[] {
185
+ const removed: string[] = []
186
+ try {
187
+ const dir = dirname(configPath)
188
+ const prefix = `${basename(configPath)}.bak-`
189
+ const matches = readdirSync(dir)
190
+ .filter((f) => f.startsWith(prefix))
191
+ .sort()
192
+ // Keep the newest `keep`; remove everything older.
193
+ const doomed = matches.slice(0, Math.max(0, matches.length - keep))
194
+ for (const f of doomed) {
195
+ rmSync(join(dir, f), { force: true })
196
+ removed.push(join(dir, f))
197
+ }
198
+ } catch {
199
+ // Best-effort cleanup — never surface a cleanup failure.
200
+ }
201
+ return removed
202
+ }
203
+
174
204
  // ─── File I/O ───────────────────────────────────────────────────────────────
175
205
 
176
206
  /** Load the raw config object (empty when the file is missing). */
@@ -248,6 +278,10 @@ function applyEntries(
248
278
  }
249
279
  }
250
280
 
281
+ // Success: bound the accumulation of timestamped backups so a long-lived
282
+ // project never collects an unbounded pile of config snapshots.
283
+ if (backupPath) pruneOldConfigBackups(configPath)
284
+
251
285
  return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
252
286
  }
253
287
 
package/src/transcript.ts CHANGED
@@ -53,6 +53,11 @@ const MAX_TIMELINE = 500
53
53
  * grow the manifest payload without limit). */
54
54
  const MAX_FIXES = 200
55
55
 
56
+ /** Max round number accepted by the builder. A model-authored/manifest-backed
57
+ * round value is attacker-influenced JSON: `roundStart(1e9)` / `snapshotConvergence`
58
+ * would otherwise preallocate arrays of that size and OOM the host. */
59
+ const MAX_ROUNDS = 1000
60
+
56
61
  /** Thresholds applied when reducing a string list under a cap. */
57
62
  function clampStringList(source: string[], cap: number): string[] {
58
63
  const out: string[] = []
@@ -137,6 +142,7 @@ export class ReviewTranscriptBuilder {
137
142
  private round = 0
138
143
  private maxRounds = 0
139
144
  private active = true
145
+ private stoppedReason: string | null = null
140
146
  private readonly rounds: LiveRound[] = []
141
147
  private readonly convergence: number[] = []
142
148
  private readonly globalFindings: TranscriptFinding[] = []
@@ -198,18 +204,27 @@ export class ReviewTranscriptBuilder {
198
204
  this.touch()
199
205
  }
200
206
 
201
- /** End the run (stops the "active" pulsing in the UI). */
202
- finish(): void {
207
+ /**
208
+ * End the run (stops the "active" pulsing in the UI). `reason` records WHY
209
+ * it ended, so a run stopped by max-rounds or validation is never mistaken
210
+ * for a live run NOR for a clean convergence.
211
+ */
212
+ finish(reason?: string): void {
203
213
  this.active = false
214
+ if (typeof reason === 'string' && reason.trim()) this.stoppedReason = reason.trim()
204
215
  this.touch()
205
216
  }
206
217
 
207
218
  /** Open a review round, capturing the current round index. */
208
219
  roundStart(round: number, maxRounds?: number): void {
209
220
  const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
210
- this.round = r > 0 ? r : 1
221
+ // Clamp to MAX_ROUNDS: `this.rounds.length < this.round` below preallocates
222
+ // an array of size `round` — an unbounded model-controlled value (e.g. 1e9)
223
+ // would OOM the host. Values above the cap are folded into the cap so the
224
+ // real rounds are never silently dropped.
225
+ this.round = Math.min(r > 0 ? r : 1, MAX_ROUNDS)
211
226
  if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
212
- this.maxRounds = Math.floor(maxRounds)
227
+ this.maxRounds = Math.min(Math.floor(maxRounds), MAX_ROUNDS)
213
228
  }
214
229
  while (this.rounds.length < this.round) {
215
230
  this.rounds.push({ round: this.rounds.length + 1, threads: [] })
@@ -294,7 +309,10 @@ export class ReviewTranscriptBuilder {
294
309
 
295
310
  /** Record a round's new-finding count for the convergence series. */
296
311
  snapshotConvergence(round: number, newCount: number): void {
297
- const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
312
+ const rawR = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
313
+ // Clamp (mirrors roundStart): the loop below preallocates `r` slots, so an
314
+ // unbounded model-controlled round number would OOM the host.
315
+ const r = Math.min(rawR > 0 ? rawR : 1, MAX_ROUNDS)
298
316
  const n = typeof newCount === 'number' && Number.isFinite(newCount) ? newCount : 0
299
317
  while (this.convergence.length < r) this.convergence.push(-1)
300
318
  this.convergence[r - 1] = Math.floor(n)
@@ -312,7 +330,10 @@ export class ReviewTranscriptBuilder {
312
330
  this.fixes.push({
313
331
  id,
314
332
  timestamp: typeof record.timestamp === 'string' ? record.timestamp : isoNow(),
315
- round: typeof record.round === 'number' ? record.round : this.round,
333
+ round:
334
+ typeof record.round === 'number' && Number.isFinite(record.round)
335
+ ? Math.floor(record.round)
336
+ : this.round,
316
337
  file,
317
338
  summary: typeof record.summary === 'string' ? record.summary : '',
318
339
  linesAdded:
@@ -363,7 +384,10 @@ export class ReviewTranscriptBuilder {
363
384
  const type = typeof entry.type === 'string' ? entry.type : 'decision'
364
385
  this.timeline.push({
365
386
  timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : isoNow(),
366
- round: typeof entry.round === 'number' ? entry.round : this.round,
387
+ round:
388
+ typeof entry.round === 'number' && Number.isFinite(entry.round)
389
+ ? Math.floor(entry.round)
390
+ : this.round,
367
391
  type,
368
392
  data:
369
393
  entry.data && typeof entry.data === 'object'
@@ -425,6 +449,7 @@ export class ReviewTranscriptBuilder {
425
449
  checkpoint: this.checkpoint,
426
450
  timeline: this.timeline,
427
451
  nudge: this.nudge,
452
+ stoppedReason: this.stoppedReason,
428
453
  approval: {
429
454
  active: this.approval !== 'allow',
430
455
  policy: this.approval,
package/src/types.ts CHANGED
@@ -349,6 +349,8 @@ export interface TranscriptManifest {
349
349
  }
350
350
  /** v3.0: task_mode indicator from harness status */
351
351
  taskMode?: 'code' | 'iterate' | null
352
+ /** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
353
+ stoppedReason?: string | null
352
354
  }
353
355
 
354
356
  // ─── v3.0: Quality Gate ──────────────────────────────────────────────────────