iterate-plugin 3.3.1 → 3.4.1

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.
@@ -143,9 +143,20 @@ for (let r = 1; r <= maxRounds; r++) {
143
143
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
144
144
  // list stays bounded and reviewers never see the same issue twice.
145
145
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
146
- if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
147
- log('round ' + r + ' found 0 new findings — converged')
148
- break
146
+ // A round counts as "converged" ONLY when the aggregate accepted it
147
+ // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
148
+ // failed or their output stayed schema-invalid after retries, the round is
149
+ // INCONCLUSIVE — never report an empty-but-broken round as converged (an
150
+ // invalid/failed round must fail the run, not masquerade as a clean pass).
151
+ if (!schemaInvalid) {
152
+ const newCount =
153
+ agg && agg.report && agg.report.convergence
154
+ ? agg.report.convergence.findingsByRound[r - 1]
155
+ : thisRound.findings.length
156
+ if (newCount === 0) {
157
+ log('round ' + r + ' found 0 new findings — converged')
158
+ break
159
+ }
149
160
  }
150
161
  }
151
162
 
@@ -238,7 +249,7 @@ if (checkpoint) {
238
249
 
239
250
  phase('plan')
240
251
  const configRes = await agent(
241
- 'Call iterate_config({ validate: true }) and return the config JSON.',
252
+ 'Call iterate_config({}) and return the config JSON.',
242
253
  Object.assign({ label: 'config:read' }, backend)
243
254
  )
244
255
  const cfg = (configRes && configRes.config) ? configRes.config : null
@@ -421,7 +432,7 @@ for (let r = startRound; r <= maxRounds; r++) {
421
432
  Object.assign({ label: 'checkpoint:save:r' + r }, backend)
422
433
  )
423
434
 
424
- if (atomic.length === 0 && remaining.length === 0) {
435
+ if (atomic.length === 0 && remaining.length === 0 && !schemaInvalid) {
425
436
  log('round ' + r + ' found nothing to fix — converged')
426
437
  converged = true
427
438
  break
@@ -16,7 +16,10 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
16
16
  import { checkpointPath, iterateDir, transcriptPath } from '../paths.ts'
17
17
  import { readRegistry } from './fix.ts'
18
18
  import { readDecisionEntries } from './decision-log.ts'
19
- import type { IterationCheckpoint, IterationStatus } from '../types.ts'
19
+ import { readQualityGate } from './quality-store.ts'
20
+ import { readExperienceBank } from './experience-store.ts'
21
+ import { readDefenseEvents } from './defense-store.ts'
22
+ import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGateSnapshot } from '../types.ts'
20
23
 
21
24
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
22
25
 
@@ -90,6 +93,12 @@ export function computeStatus(input: {
90
93
  taskMode?: 'code' | 'iterate' | null
91
94
  decisionEntries: { timestamp: string; type: string; round?: number; data?: Record<string, unknown> }[]
92
95
  fixRegistry: { rounds: { round: number; fixedCount: number; failedCount: number }[] }
96
+ /** v3.0: persisted quality-gate snapshot (optional; absent → omitted). */
97
+ qualityGate?: QualityGateSnapshot | null
98
+ /** v3.0: experience bank summary (optional; absent → omitted). */
99
+ experienceBank?: { totalEntries: number; totalHits: number } | null
100
+ /** v3.0: defense events summary (optional; absent → omitted). */
101
+ defenseEvents?: { totalEvents: number; counts: Record<DefenseEventType, number> } | null
93
102
  }): IterationStatus {
94
103
  const checkpoint = input.checkpoint
95
104
  const taskMode = input.taskMode ?? null
@@ -134,6 +143,12 @@ export function computeStatus(input: {
134
143
  resumeCount: checkpoint?.resumeCount ?? 0,
135
144
  checkpoint,
136
145
  lastUpdated,
146
+ // v3.0: quality command-center snapshots (present only when the caller
147
+ // supplied a real snapshot — the status never fabricates one that is not
148
+ // on disk, and never emits null for an absent optional field).
149
+ ...(input.qualityGate != null ? { qualityGate: input.qualityGate } : {}),
150
+ ...(input.experienceBank != null ? { experienceBank: input.experienceBank } : {}),
151
+ ...(input.defenseEvents != null ? { defenseEvents: input.defenseEvents } : {}),
137
152
  }
138
153
  }
139
154
 
@@ -148,14 +163,16 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
148
163
  defineTool({
149
164
  name: 'iterate_checkpoint',
150
165
  description:
151
- 'Save / load / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
152
- 'each round (so a long run can resume) and clears it when the iteration completes.',
166
+ 'Save / load / resume / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
167
+ 'each round (so a long run can resume) and clears it when the iteration completes. ' +
168
+ '`resume` loads an existing checkpoint, bumps its resumeCount, and persists it back — ' +
169
+ 'call it when continuing an interrupted run so the resume counter stays accurate.',
153
170
  parameters: {
154
171
  operation: {
155
172
  type: 'string',
156
173
  required: true,
157
- description: '"save" to persist the current progress, "load" to read it back, "clear" to remove it.',
158
- enum: ['save', 'load', 'clear'],
174
+ description: '"save" to persist the current progress, "load" to read it back, "resume" to load + count a resumption, "clear" to remove it.',
175
+ enum: ['save', 'load', 'resume', 'clear'],
159
176
  },
160
177
  mode: { type: 'string', description: 'Required for save: "dry-run" or "normal".' },
161
178
  round: { type: 'integer', description: 'Required for save: current round number (0 = none started).' },
@@ -194,6 +211,28 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
194
211
  return { operation: 'load', ok: true, checkpoint: checkpoint as unknown as JsonValue | null }
195
212
  }
196
213
 
214
+ if (args.operation === 'resume') {
215
+ const current = readCheckpoint(projectRoot)
216
+ if (!current) {
217
+ return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' }
218
+ }
219
+ const resumed: IterationCheckpoint = {
220
+ ...current,
221
+ resumeCount: (current.resumeCount ?? 0) + 1,
222
+ updatedAt: new Date().toISOString(),
223
+ }
224
+ try {
225
+ mkdirSync(iterateDir(projectRoot), { recursive: true })
226
+ const cpPath = checkpointPath(projectRoot)
227
+ const tmpPath = `${cpPath}.tmp-${Date.now()}`
228
+ writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8')
229
+ renameSync(tmpPath, cpPath)
230
+ } catch (err) {
231
+ return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
232
+ }
233
+ return { operation: 'resume', ok: true, checkpoint: resumed as unknown as JsonValue }
234
+ }
235
+
197
236
  if (args.operation === 'clear') {
198
237
  const existed = existsSync(checkpointPath(projectRoot))
199
238
  if (existed) {
@@ -239,7 +278,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
239
278
  return { operation: 'save', ok: true, checkpoint: checkpoint as unknown as JsonValue }
240
279
  }
241
280
 
242
- return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", or "clear".' }
281
+ return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", "resume", or "clear".' }
243
282
  },
244
283
  }),
245
284
  )
@@ -280,6 +319,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
280
319
  interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
281
320
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
282
321
  lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
322
+ qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
323
+ experienceBank: { type: 'json', description: 'v3.0: experience bank summary (.iterate/experience.json), when present.' },
324
+ defenseEvents: { type: 'json', description: 'v3.0: defense events summary (.iterate/defense-events.json), when present.' },
283
325
  error: { type: 'string' },
284
326
  },
285
327
  },
@@ -302,11 +344,31 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
302
344
  const resolved = resolveProjectRootForExec(exec, args.path)
303
345
  if (!resolved.ok) return { ok: false, error: resolved.reason }
304
346
  const projectRoot = resolved.root
347
+ // v3.0: surface the persisted quality command-center snapshots so a
348
+ // single `iterate_status` call reports the whole run state — the gate,
349
+ // the experience bank, and the defense event stream. Each read is
350
+ // defensive (missing/malformed files yield an empty snapshot), so the
351
+ // status never crashes on absent artifacts.
352
+ const qualityGate = readQualityGate(projectRoot)
353
+ const experienceBank = readExperienceBank(projectRoot)
354
+ const defenseEvents = readDefenseEvents(projectRoot)
305
355
  const status = computeStatus({
306
356
  checkpoint: readCheckpoint(projectRoot),
307
357
  taskMode: readTranscriptTaskMode(projectRoot),
308
358
  decisionEntries: readDecisionEntries(projectRoot),
309
359
  fixRegistry: readRegistry(projectRoot),
360
+ qualityGate:
361
+ qualityGate.dimensions.length > 0 || qualityGate.overallStatus === 'pass' || qualityGate.overallStatus === 'fail'
362
+ ? qualityGate
363
+ : null,
364
+ experienceBank: {
365
+ totalEntries: experienceBank.entries.length,
366
+ totalHits: experienceBank.totalHits ?? 0,
367
+ },
368
+ defenseEvents: {
369
+ totalEvents: defenseEvents.events.length,
370
+ counts: defenseEvents.counts,
371
+ },
310
372
  })
311
373
  return {
312
374
  ok: true,
@@ -322,6 +384,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
322
384
  interrupted: status.interrupted,
323
385
  resumeCount: status.resumeCount,
324
386
  lastUpdated: status.lastUpdated ?? null,
387
+ qualityGate: status.qualityGate ? (status.qualityGate as unknown as JsonValue) : null,
388
+ experienceBank: status.experienceBank ? (status.experienceBank as unknown as JsonValue) : null,
389
+ defenseEvents: status.defenseEvents ? (status.defenseEvents as unknown as JsonValue) : null,
325
390
  }
326
391
  },
327
392
  }),
@@ -55,6 +55,7 @@ function validateRecordInput(args: {
55
55
  defense?: unknown
56
56
  outcome?: unknown
57
57
  severity?: unknown
58
+ line?: unknown
58
59
  }): string[] {
59
60
  const errors: string[] = []
60
61
  if (typeof args.type !== 'string' || !EVENT_TYPES.includes(args.type as DefenseEventType)) {
@@ -72,6 +73,9 @@ function validateRecordInput(args: {
72
73
  if (typeof args.outcome !== 'string' || !args.outcome.trim()) {
73
74
  errors.push('outcome is required')
74
75
  }
76
+ if (args.line !== undefined && (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 0)) {
77
+ errors.push('line must be a non-negative integer when present')
78
+ }
75
79
  const severity = args.severity
76
80
  if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
77
81
  errors.push('severity must be one of critical, high, medium, low')
@@ -44,14 +44,28 @@ function emptyStream(): DefenseEventStream {
44
44
  }
45
45
  }
46
46
 
47
- /** Read the defense events stream from disk. */
47
+ /**
48
+ * Read the defense events stream from disk.
49
+ * Normalizes the persisted stream so a hand-edited / partial file can never
50
+ * produce NaN counts: `counts` is recomputed from the events when missing or
51
+ * malformed, and every type key is guaranteed present.
52
+ */
48
53
  export function readDefenseEvents(projectRoot: string): DefenseEventStream {
49
54
  const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE)
50
55
  try {
51
56
  const content = fs.readFileSync(filePath, 'utf-8')
52
- const parsed = JSON.parse(content) as DefenseEventStream
57
+ const parsed = JSON.parse(content) as Partial<DefenseEventStream>
53
58
  if (parsed && Array.isArray(parsed.events)) {
54
- return parsed
59
+ const events = parsed.events.filter(
60
+ (e): e is DefenseEvent =>
61
+ !!e && typeof e === 'object' && typeof (e as DefenseEvent).type === 'string',
62
+ )
63
+ const counts = computeCounts(events)
64
+ return {
65
+ events,
66
+ lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyStream().lastUpdated,
67
+ counts,
68
+ }
55
69
  }
56
70
  } catch {
57
71
  // File not found or invalid JSON
@@ -95,7 +109,10 @@ export function addDefenseEvent(
95
109
  ...event,
96
110
  }
97
111
 
98
- const newCounts = { ...stream.counts }
112
+ // Always recompute from the events array instead of mutating a possibly
113
+ // stale/malformed persisted `counts` object — guarantees the stream counts
114
+ // can never drift from (or NaN out against) its events.
115
+ const newCounts = computeCounts(stream.events)
99
116
  bumpCount(newCounts, event.type)
100
117
 
101
118
  return {
@@ -130,7 +130,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
130
130
  ? `Fixes: ${fixes.totalFixed} applied · ${fixes.totalFailed} failed · across ${fixes.roundCount} round(s)`
131
131
  : 'Fixes: none',
132
132
  '',
133
- ...log.map((e) => `[${e.timestamp}] r${e.round} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
133
+ ...log.map((e) => `[${e.timestamp}] r${e.round ?? '?'} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
134
134
  ]
135
135
  return [{ type: 'text', text: lines.join('\n') }]
136
136
  },
@@ -31,14 +31,59 @@ function emptySnapshot(): QualityGateSnapshot {
31
31
  }
32
32
  }
33
33
 
34
- /** Read the quality gate snapshot from disk. */
34
+ /**
35
+ * Read the quality gate snapshot from disk.
36
+ * Normalizes a hand-edited / partial file so readers and the tool's `render`
37
+ * never crash on missing arrays or non-numeric fields: `dimensions` is
38
+ * guaranteed to be an array and every numeric field degrades to 0.
39
+ */
35
40
  export function readQualityGate(projectRoot: string): QualityGateSnapshot {
36
41
  const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE)
37
42
  try {
38
43
  const content = fs.readFileSync(filePath, 'utf-8')
39
- const parsed = JSON.parse(content) as QualityGateSnapshot
44
+ const parsed = JSON.parse(content) as Partial<QualityGateSnapshot> | null
40
45
  if (parsed && typeof parsed === 'object') {
41
- return parsed
46
+ const num = (v: unknown): number =>
47
+ typeof v === 'number' && Number.isFinite(v) ? v : 0
48
+ const status: QualityGateSnapshot['overallStatus'] =
49
+ parsed.overallStatus === 'pass' || parsed.overallStatus === 'fail' || parsed.overallStatus === 'pending'
50
+ ? parsed.overallStatus
51
+ : 'pending'
52
+ const dimensions = Array.isArray(parsed.dimensions)
53
+ ? (parsed.dimensions as unknown as Array<Record<string, unknown>>)
54
+ .filter((d): d is Record<string, unknown> => !!d && typeof d === 'object' && typeof d.dimension === 'string')
55
+ .map((d) => {
56
+ const dimStatus: QualityGateDimension['status'] =
57
+ d.status === 'pass' || d.status === 'fail' || d.status === 'warn' ? d.status : 'warn'
58
+ return {
59
+ dimension: d.dimension as string,
60
+ convergenceRate: num(d.convergenceRate),
61
+ findingsCount: num(d.findingsCount),
62
+ fixedCount: num(d.fixedCount),
63
+ score: num(d.score),
64
+ status: dimStatus,
65
+ }
66
+ })
67
+ : []
68
+ return {
69
+ timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
70
+ overallStatus: status,
71
+ overallScore: num(parsed.overallScore),
72
+ dimensions,
73
+ verificationPassRate: num(parsed.verificationPassRate),
74
+ totalChecks: num(parsed.totalChecks),
75
+ passedChecks: num(parsed.passedChecks),
76
+ failedChecks: num(parsed.failedChecks),
77
+ totalFindings: num(parsed.totalFindings),
78
+ criticalCount: num(parsed.criticalCount),
79
+ highCount: num(parsed.highCount),
80
+ mediumCount: num(parsed.mediumCount),
81
+ lowCount: num(parsed.lowCount),
82
+ // Only carry an own failReason when it is a real string — an absent
83
+ // persisted reason must not surface as `failReason: undefined` (which
84
+ // deep-equals differently than the JSON round-trip of computeQualityGate).
85
+ ...(typeof parsed.failReason === 'string' ? { failReason: parsed.failReason } : {}),
86
+ }
42
87
  }
43
88
  } catch {
44
89
  // File not found or invalid JSON
package/src/transcript.ts CHANGED
@@ -49,6 +49,10 @@ const MAX_FINDINGS_TOTAL = 2000
49
49
  /** Max timeline entries kept (newest wins). */
50
50
  const MAX_TIMELINE = 500
51
51
 
52
+ /** Max applied-fix records kept (newest wins; bounded so a long run cannot
53
+ * grow the manifest payload without limit). */
54
+ const MAX_FIXES = 200
55
+
52
56
  /** Thresholds applied when reducing a string list under a cap. */
53
57
  function clampStringList(source: string[], cap: number): string[] {
54
58
  const out: string[] = []
@@ -317,6 +321,9 @@ export class ReviewTranscriptBuilder {
317
321
  typeof record.linesRemoved === 'number' ? Math.floor(record.linesRemoved) : 0,
318
322
  success: record.success !== false,
319
323
  })
324
+ if (this.fixes.length > MAX_FIXES) {
325
+ this.fixes.splice(0, this.fixes.length - MAX_FIXES)
326
+ }
320
327
  this.touch()
321
328
  }
322
329