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
@@ -9,6 +9,7 @@ import * as fs from 'node:fs'
9
9
  import * as path from 'node:path'
10
10
  import { iterateDir } from '../paths.ts'
11
11
  import { writeJsonAtomic } from '../atomic-fs.ts'
12
+ import { hashString } from './fix.ts'
12
13
  import type { ExperienceBank, ExperienceEntry } from '../types.ts'
13
14
 
14
15
  const EXPERIENCE_FILE = 'experience.json'
@@ -22,14 +23,65 @@ function emptyBank(): ExperienceBank {
22
23
  }
23
24
  }
24
25
 
26
+ /** Valid severity values (kept in sync with ExperienceEntry). */
27
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
28
+
29
+ /** String array guard for fields that must be arrays (`files`, `tags`). */
30
+ function stringArray(v: unknown): string[] {
31
+ if (!Array.isArray(v)) return []
32
+ return v.filter((x): x is string => typeof x === 'string')
33
+ }
34
+
35
+ /**
36
+ * Normalize one persisted experience entry. A hand-edited bank entry can be
37
+ * missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
38
+ * entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
39
+ * throw or emit NaN. Returns null for non-object entries; every required field
40
+ * gets a safe default.
41
+ */
42
+ function normalizeEntry(raw: unknown, index: number): ExperienceEntry | null {
43
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
44
+ const e = raw as Record<string, unknown>
45
+ const pattern = typeof e.pattern === 'string' ? e.pattern : ''
46
+ const dimension = typeof e.dimension === 'string' ? e.dimension : ''
47
+ // Deterministic id (content + position based) so a read→write cycle never
48
+ // churns random ids on entries that were hand-edited without one.
49
+ const derivedId = `exp-${hashString(`${index}|${pattern}|${dimension}`)}`
50
+ const id = typeof e.id === 'string' && e.id ? e.id : derivedId
51
+ if (!pattern && !dimension) return null
52
+ const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
53
+ return {
54
+ id,
55
+ timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
56
+ dimension,
57
+ pattern,
58
+ description: typeof e.description === 'string' ? e.description : '',
59
+ verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
60
+ findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
61
+ files: stringArray(e.files),
62
+ hitCount,
63
+ ...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
64
+ tags: stringArray(e.tags),
65
+ severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as ExperienceEntry['severity']) : 'low',
66
+ }
67
+ }
68
+
25
69
  /** Read the experience bank from disk. Returns empty bank if not found. */
26
70
  export function readExperienceBank(projectRoot: string): ExperienceBank {
27
71
  const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE)
28
72
  try {
29
73
  const content = fs.readFileSync(filePath, 'utf-8')
30
- const parsed = JSON.parse(content) as ExperienceBank
74
+ const parsed = JSON.parse(content) as Partial<ExperienceBank>
31
75
  if (parsed && Array.isArray(parsed.entries)) {
32
- return parsed
76
+ const entries = parsed.entries
77
+ .map((raw, i) => normalizeEntry(raw, i))
78
+ .filter((e): e is ExperienceEntry => e !== null)
79
+ return {
80
+ entries,
81
+ lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
82
+ totalHits:
83
+ typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
84
+ }
33
85
  }
34
86
  } catch {
35
87
  // File not found or invalid JSON
@@ -72,6 +124,9 @@ export function searchExperienceEntries(
72
124
  const lowerQuery = query.toLowerCase()
73
125
 
74
126
  return entries.filter((entry) => {
127
+ if (!entry || typeof entry !== 'object') return false
128
+ const rawEntry = entry as unknown as Record<string, unknown>
129
+ const tags = stringArray(rawEntry.tags)
75
130
  // Dimension filter
76
131
  if (opts.dimension && entry.dimension !== opts.dimension) {
77
132
  return false
@@ -79,22 +134,24 @@ export function searchExperienceEntries(
79
134
 
80
135
  // Tags filter (AND logic)
81
136
  if (opts.tags && opts.tags.length > 0) {
82
- if (!opts.tags.every((t) => entry.tags.includes(t))) {
137
+ if (!opts.tags.every((t) => tags.includes(t))) {
83
138
  return false
84
139
  }
85
140
  }
86
141
 
87
- // Text search across multiple fields
88
- if (query) {
89
- const searchableText = [
90
- entry.pattern,
91
- entry.description,
92
- entry.verifiedFix,
93
- entry.findingSummary,
94
- entry.dimension,
95
- ...entry.files,
96
- ...entry.tags,
97
- ].join(' ').toLowerCase()
142
+ // Text search across multiple fields. Guards against a hand-edited bank entry
143
+ // whose `files`/`tags` are missing or non-array (the spread below would
144
+ // otherwise throw a TypeError on a non-iterable).
145
+ if (query) {
146
+ const searchableText = [
147
+ entry.pattern,
148
+ entry.description,
149
+ entry.verifiedFix,
150
+ entry.findingSummary,
151
+ entry.dimension,
152
+ ...stringArray(entry.files),
153
+ ...stringArray(entry.tags),
154
+ ].join(' ').toLowerCase()
98
155
 
99
156
  if (!searchableText.includes(lowerQuery)) {
100
157
  return false
@@ -114,8 +171,11 @@ export type ExperienceEntryInput = Omit<
114
171
  /**
115
172
  * Add or update an experience entry.
116
173
  *
117
- * An entry with an `id` that already exists, OR a new entry whose
118
- * `pattern`+`dimension` pair matches an existing entry, is treated as a HIT:
174
+ * An entry with an `id` that already exists is treated as an UPDATE + HIT:
175
+ * the caller-supplied fields replace the stored ones and the hitCount is
176
+ * incremented (lastHitAt refreshed) — this fulfills the documented
177
+ * "update a specific entry via add" contract. A NEW entry whose
178
+ * `pattern`+`dimension` pair matches an existing entry is treated as a HIT:
119
179
  * the matching entry's hitCount is incremented (lastHitAt refreshed) so
120
180
  * repeated encounters of the same pattern do not create duplicates. Otherwise
121
181
  * a fresh entry is appended with hitCount 1. Never mutates the input bank.
@@ -133,11 +193,14 @@ export function upsertExperience(
133
193
  : bank.entries.find((e) => e.pattern === entry.pattern && e.dimension === entry.dimension)
134
194
 
135
195
  if (existing) {
136
- const updated: ExperienceEntry = {
137
- ...existing,
138
- hitCount: (existing.hitCount ?? 0) + 1,
139
- lastHitAt: lastUpdated,
140
- }
196
+ // Explicit-id updates REPLACE the editable fields (fulfilling the
197
+ // documented "update a specific entry via add" contract); a pattern+
198
+ // dimension HIT only bumps the hit metadata so a repeat encounter never
199
+ // overwrites the curated entry.
200
+ const byId = typeof entry.id === 'string' && entry.id.length > 0
201
+ const updated: ExperienceEntry = byId
202
+ ? { ...existing, ...entry, id: existing.id, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
203
+ : { ...existing, hitCount: (existing.hitCount ?? 0) + 1, lastHitAt: lastUpdated }
141
204
  return {
142
205
  bank: {
143
206
  ...bank,
@@ -150,14 +213,16 @@ export function upsertExperience(
150
213
  }
151
214
  }
152
215
 
153
- // Add new entry
216
+ // Add new entry. Spread the caller input FIRST so the store-generated
217
+ // `id`/`timestamp`/`hitCount`/`lastHitAt` always win — a hostile or
218
+ // malformed input can never forge its own identity or hit count.
154
219
  const id = entry.id || `exp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
155
220
  const newEntry: ExperienceEntry = {
221
+ ...entry,
156
222
  id,
157
223
  timestamp: lastUpdated,
158
224
  hitCount: 1,
159
225
  lastHitAt: lastUpdated,
160
- ...entry,
161
226
  }
162
227
 
163
228
  return {
package/src/tools/fix.ts CHANGED
@@ -126,11 +126,25 @@ export function readRegistry(projectRoot: string): FixRegistry {
126
126
  try {
127
127
  const parsed = JSON.parse(readFileSync(file, 'utf-8')) as FixRegistry
128
128
  if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds)) return emptyRegistry()
129
- // Defensive: a hand-edited or partially-written registry may contain a
130
- // round without a `records` array — normalize instead of crashing readers.
131
- parsed.rounds = parsed.rounds.filter(
132
- (r) => r && typeof r === 'object' && Array.isArray(r.records),
133
- )
129
+ // Defensive normalization: a hand-edited or partially-written registry may
130
+ // contain a round without a `records` array, or records that are missing
131
+ // their id / finding object — all of which would make readers
132
+ // (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
133
+ parsed.rounds = parsed.rounds
134
+ .filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
135
+ .map((r) => ({
136
+ ...r,
137
+ round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
138
+ records: r.records.filter(
139
+ (rec): rec is FixRecord =>
140
+ !!rec &&
141
+ typeof rec === 'object' &&
142
+ typeof rec.id === 'string' &&
143
+ !!rec.finding &&
144
+ typeof rec.finding === 'object',
145
+ ),
146
+ }))
147
+ .filter((r) => typeof r.round === 'number' && Number.isFinite(r.round))
134
148
  return parsed
135
149
  } catch {
136
150
  return emptyRegistry()
@@ -621,19 +635,25 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
621
635
  for (const round of registry.rounds) {
622
636
  for (const r of round.records) {
623
637
  if (!r.success) continue
624
- const existing = files.find((f) => f.file === r.finding.file)
638
+ const file = typeof r.finding.file === 'string' ? r.finding.file : ''
639
+ if (!file) continue
640
+ // Coerce defensively: a hand-edited registry record missing the
641
+ // numeric fields must not produce NaN in the accumulated summary.
642
+ const added = Number(r.linesAdded) || 0
643
+ const removed = Number(r.linesRemoved) || 0
644
+ const existing = files.find((f) => f.file === file)
625
645
  if (existing) {
626
- existing.linesAdded += r.linesAdded
627
- existing.linesRemoved += r.linesRemoved
646
+ existing.linesAdded += added
647
+ existing.linesRemoved += removed
628
648
  // Recompute the summary from the summed counts so a multi-fix
629
649
  // file's text does not contradict its accumulated numbers.
630
650
  existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`
631
651
  } else {
632
652
  files.push({
633
- file: r.finding.file,
634
- diffSummary: r.diffSummary,
635
- linesAdded: r.linesAdded,
636
- linesRemoved: r.linesRemoved,
653
+ file,
654
+ diffSummary: typeof r.diffSummary === 'string' ? r.diffSummary : `+${added}/-${removed} lines`,
655
+ linesAdded: added,
656
+ linesRemoved: removed,
637
657
  })
638
658
  }
639
659
  }
@@ -60,8 +60,10 @@ export function summarizeFixRegistry(registry: FixRegistry): {
60
60
  } {
61
61
  const rounds = (registry.rounds ?? []).map((r) => ({
62
62
  round: r.round,
63
- fixedCount: r.fixedCount,
64
- failedCount: r.failedCount,
63
+ // Coerce defensively so a hand-edited registry round missing either count
64
+ // can never propagate NaN into the integer output fields.
65
+ fixedCount: Number(r.fixedCount) || 0,
66
+ failedCount: Number(r.failedCount) || 0,
65
67
  }))
66
68
  return {
67
69
  totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
@@ -92,7 +94,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
92
94
  type: 'string',
93
95
  description:
94
96
  'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
95
- 'revert, validation, decision, report.',
97
+ 'revert, round_failed, validation, decision, report, resume.',
96
98
  },
97
99
  since: {
98
100
  type: 'string',
@@ -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.
@@ -28,14 +32,27 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
28
32
  import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
29
33
  import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
30
34
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
35
+ import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
36
+ import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
31
37
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
32
- import type { FixRegistry } from '../types.ts'
38
+ import type { DefenseEventStream, ExperienceBank, FixRegistry } from '../types.ts'
33
39
 
34
40
  /** Default retention for decision-log entries (in days). */
35
41
  const DEFAULT_RETAIN_DAYS = 30
36
42
  const MIN_RETAIN_DAYS = 1
37
43
  const MAX_RETAIN_DAYS = 365
38
44
 
45
+ /**
46
+ * Upper bound on experience-bank entries retained by a prune sweep. The bank
47
+ * accumulates curated knowledge across sessions and each entry is small, so a
48
+ * generous cap drops only the oldest tail of a pathologically large bank
49
+ * (never recent learning).
50
+ */
51
+ export const MAX_EXPERIENCE_ENTRIES = 2000
52
+
53
+ /** How many compare-and-append retries bound the decision-log rewrite race. */
54
+ const MAX_LOG_REWRITE_RETRIES = 3
55
+
39
56
  /** Clamp retainDays to a sane range. */
40
57
  export function clampRetainDays(days: number | undefined): number {
41
58
  if (typeof days !== 'number' || !Number.isInteger(days) || days <= 0) {
@@ -71,6 +88,62 @@ export function isPrunableTemp(name: string): boolean {
71
88
  return name.startsWith('.') && name.includes('.tmp-')
72
89
  }
73
90
 
91
+ /**
92
+ * Bound the experience bank to its newest `cap` entries (by timestamp; ties
93
+ * keep their original order via stable sort). Returns the trimmed bank plus
94
+ * how many entries would be dropped. Never mutates the input bank.
95
+ */
96
+ export function sweepExperienceBank(bank: ExperienceBank, cap: number): { bank: ExperienceBank; removed: number } {
97
+ const size = Number.isFinite(cap) && (cap as number) >= 0 ? Math.floor(cap) : MAX_EXPERIENCE_ENTRIES
98
+ const total = bank.entries.length
99
+ if (total <= size) return { bank, removed: 0 }
100
+ const kept = [...bank.entries]
101
+ .sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''))
102
+ .slice(0, size)
103
+ // Preserve the original (insertion) order of the surviving entries.
104
+ const keptIds = new Set(kept.map((e) => e.id))
105
+ const ordered = bank.entries.filter((e) => keptIds.has(e.id))
106
+ return {
107
+ bank: { ...bank, entries: ordered, lastUpdated: new Date().toISOString() },
108
+ removed: total - ordered.length,
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Drop defense events older than `cutoff` from the stream using an ISO
114
+ * timestamp comparison, recomputing the counts so they can never drift from
115
+ * the surviving events. Never mutates the input stream.
116
+ */
117
+ export function sweepDefenseEvents(
118
+ stream: DefenseEventStream,
119
+ cutoff: string,
120
+ ): { stream: DefenseEventStream; removed: number } {
121
+ const kept = stream.events.filter((e) => e.timestamp >= cutoff)
122
+ if (kept.length === stream.events.length) return { stream, removed: 0 }
123
+ return {
124
+ stream: {
125
+ events: kept,
126
+ lastUpdated: new Date().toISOString(),
127
+ counts: computeDefenseCounts(kept),
128
+ },
129
+ removed: stream.events.length - kept.length,
130
+ }
131
+ }
132
+
133
+ /** Recompute per-type defense counts from a list of events. */
134
+ function computeDefenseCounts(events: DefenseEventStream['events']): DefenseEventStream['counts'] {
135
+ const counts: DefenseEventStream['counts'] = {
136
+ precondition_failed: 0,
137
+ rollback: 0,
138
+ invariant_violated: 0,
139
+ assumption_falsified: 0,
140
+ }
141
+ for (const e of events) {
142
+ if (e.type in counts) counts[e.type]++
143
+ }
144
+ return counts
145
+ }
146
+
74
147
  /**
75
148
  * Inspect the runtime state and report what would be pruned.
76
149
  * Pure (no deletions). Returns a structured report.
@@ -86,6 +159,10 @@ export function inspectPrune(
86
159
  emptyRounds: number[]
87
160
  totalLogEntries: number
88
161
  registryRounds: number
162
+ totalExperiences: number
163
+ experienceOversize: number
164
+ totalDefenseEvents: number
165
+ staleDefenseEvents: number
89
166
  } {
90
167
  const cutoff = cutoffTimestamp(retainDays)
91
168
 
@@ -106,16 +183,22 @@ export function inspectPrune(
106
183
  }
107
184
  const staleBackups: string[] = []
108
185
  const fixDir = fixesDir(projectRoot)
109
- if (existsSync(fixDir)) {
110
- for (const entry of readdirSync(fixDir)) {
111
- if (!entry.endsWith('.bak')) continue
112
- // Extract the fix-id prefix (up to the first underscore after the id).
113
- // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
114
- const match = entry.match(/^(fix-[a-z0-9]+)_/)
115
- const id = match?.[1]
116
- if (id && !activeIds.has(id)) {
117
- staleBackups.push(entry)
118
- }
186
+ // existsSync does not prove readability — a permissions failure must degrade
187
+ // to "nothing listable" (report skipped) instead of throwing out of execute.
188
+ let fixEntries: string[] = []
189
+ try {
190
+ if (existsSync(fixDir)) fixEntries = readdirSync(fixDir)
191
+ } catch {
192
+ fixEntries = []
193
+ }
194
+ for (const entry of fixEntries) {
195
+ if (!entry.endsWith('.bak')) continue
196
+ // Extract the fix-id prefix (up to the first underscore after the id).
197
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
198
+ const match = entry.match(/^(fix-[a-z0-9]+)_/)
199
+ const id = match?.[1]
200
+ if (id && !activeIds.has(id)) {
201
+ staleBackups.push(entry)
119
202
  }
120
203
  }
121
204
 
@@ -126,10 +209,26 @@ export function inspectPrune(
126
209
 
127
210
  // 5. Stray atomic-write temp files left behind by a crashed writer. The
128
211
  // unique prefix makes them recognizable and safe to delete — no live writer
129
- // ever reads another writer's temp file.
130
- const staleTemps = existsSync(iterateDir(projectRoot))
131
- ? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
132
- : []
212
+ // ever reads another writer's temp file. A permissions failure listing the
213
+ // dir degrades to "none found" (report-only) rather than throwing.
214
+ let iterateEntries: string[] = []
215
+ try {
216
+ iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : []
217
+ } catch {
218
+ iterateEntries = []
219
+ }
220
+ const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort()
221
+
222
+ // 6. Experience-bank oversize (entries beyond the entry cap). The bank grows
223
+ // across sessions with no natural bound — a sweep reports the tail that a
224
+ // prune would drop (newest `MAX_EXPERIENCE_ENTRIES` preserved).
225
+ const bank = readExperienceBank(projectRoot)
226
+ const sweptBank = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
227
+
228
+ // 7. Defense events older than retainDays (a previous iteration's stream
229
+ // that was never cleared).
230
+ const defenseStream = readDefenseEvents(projectRoot)
231
+ const sweptDefense = sweepDefenseEvents(defenseStream, cutoff)
133
232
 
134
233
  return {
135
234
  oldLogEntries,
@@ -139,6 +238,10 @@ export function inspectPrune(
139
238
  emptyRounds,
140
239
  totalLogEntries: entries.length,
141
240
  registryRounds: registry.rounds.length,
241
+ totalExperiences: bank.entries.length,
242
+ experienceOversize: sweptBank.removed,
243
+ totalDefenseEvents: defenseStream.events.length,
244
+ staleDefenseEvents: sweptDefense.removed,
142
245
  }
143
246
  }
144
247
 
@@ -146,6 +249,45 @@ export function inspectPrune(
146
249
  * Actually prune the runtime artifacts (only called when dryRun=false).
147
250
  * Returns a detailed report of what was deleted.
148
251
  */
252
+ /**
253
+ * Rewrite the decision log to the entries younger than `cutoff`.
254
+ *
255
+ * The read-before-rewrite is a TOCTOU hot spot: a concurrent worker can append
256
+ * a fresh audit line between our read and our atomic rewrite, and an atomic
257
+ * rename would silently discard it. So instead of a single read+write we use a
258
+ * bounded compare-and-append loop — after each rewrite we re-read the log and
259
+ * retry whenever the file grew (a concurrent append slipped in). The loop
260
+ * terminates when the file is stable or after `MAX_LOG_REWRITE_RETRIES`, and a
261
+ * failure here is surfaced as a structured error instead of truncating the log.
262
+ *
263
+ * @returns the number of entries removed (best-effort) or 0 on error.
264
+ */
265
+ function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
266
+ const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
267
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
268
+ try {
269
+ const entries = readDecisionEntries(projectRoot)
270
+ const kept = entries.filter((e) => e.timestamp >= cutoff)
271
+ const deleted = entries.length - kept.length
272
+ if (deleted === 0) return { deleted: 0 }
273
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
274
+ // A concurrent appender may have landed new lines after our read. If the
275
+ // log grew during the write window, re-read and prune again instead of
276
+ // accepting a lost audit trail.
277
+ const after = readDecisionEntries(projectRoot)
278
+ if (after.length <= kept.length) return { deleted }
279
+ } catch (err) {
280
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
281
+ }
282
+ }
283
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
284
+ }
285
+
286
+ /**
287
+ * Execute the prune: apply every cleanable item reported by `inspectPrune`.
288
+ * Every step is individually hedged so a single disk failure degrades to a
289
+ * reported error rather than aborting the whole cleanup.
290
+ */
149
291
  export function executePrune(
150
292
  projectRoot: string,
151
293
  retainDays: number,
@@ -156,6 +298,8 @@ export function executePrune(
156
298
  deletedBackups: string[]
157
299
  deletedTemps: string[]
158
300
  trimmedEmptyRounds: number
301
+ deletedExperiences: number
302
+ deletedDefenseEvents: number
159
303
  errors: string[]
160
304
  } {
161
305
  const cutoff = cutoffTimestamp(retainDays)
@@ -165,22 +309,19 @@ export function executePrune(
165
309
  deletedBackups: [] as string[],
166
310
  deletedTemps: [] as string[],
167
311
  trimmedEmptyRounds: 0,
312
+ deletedExperiences: 0,
313
+ deletedDefenseEvents: 0,
168
314
  errors: [] as string[],
169
315
  }
170
316
 
171
317
  // 1. Rewrite the decision log, keeping only recent entries. Atomic
172
- // (temp + rename) so a crash mid-write can never truncate the log.
173
- try {
174
- const entries = readDecisionEntries(projectRoot)
175
- const kept = entries.filter((e) => e.timestamp >= cutoff)
176
- result.deletedLogEntries = entries.length - kept.length
177
- if (result.deletedLogEntries > 0) {
178
- const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
179
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
180
- }
181
- } catch (err) {
182
- result.errors.push(`failed to rewrite decision log: ${String(err)}`)
183
- result.deletedLogEntries = 0
318
+ // (temp + rename) so a crash mid-write can never truncate the log; the
319
+ // bounded compare-and-append loop guards against a concurrent appender
320
+ // slipping a fresh audit line into the read-before-rewrite window.
321
+ {
322
+ const { deleted, error } = rewriteDecisionLogKeepingRecent(projectRoot, cutoff)
323
+ result.deletedLogEntries = deleted
324
+ if (error) result.errors.push(error)
184
325
  }
185
326
 
186
327
  // 2. Remove checkpoint.
@@ -233,6 +374,34 @@ export function executePrune(
233
374
  }
234
375
  }
235
376
 
377
+ // 6. Trim the experience bank to its entry cap (newest wins).
378
+ if (report.experienceOversize > 0) {
379
+ try {
380
+ const bank = readExperienceBank(projectRoot)
381
+ const { bank: swept, removed } = sweepExperienceBank(bank, MAX_EXPERIENCE_ENTRIES)
382
+ if (removed > 0) {
383
+ writeExperienceBank(projectRoot, swept)
384
+ result.deletedExperiences = removed
385
+ }
386
+ } catch (err) {
387
+ result.errors.push(`failed to trim experience bank: ${String(err)}`)
388
+ }
389
+ }
390
+
391
+ // 7. Drop defense events older than retainDays (recounted on write).
392
+ if (report.staleDefenseEvents > 0) {
393
+ try {
394
+ const stream = readDefenseEvents(projectRoot)
395
+ const { stream: swept, removed } = sweepDefenseEvents(stream, cutoff)
396
+ if (removed > 0) {
397
+ writeDefenseEvents(projectRoot, swept)
398
+ result.deletedDefenseEvents = removed
399
+ }
400
+ } catch (err) {
401
+ result.errors.push(`failed to sweep defense events: ${String(err)}`)
402
+ }
403
+ }
404
+
236
405
  return result
237
406
  }
238
407
 
@@ -249,7 +418,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
249
418
  'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
250
419
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
251
420
  'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
252
- 'and stray temp files left by crashed atomic writes. ' +
421
+ 'stray temp files left by crashed atomic writes, experience-bank entries beyond the entry cap, ' +
422
+ 'and stale defense events. ' +
253
423
  'Each deletion is logged to the decision log.',
254
424
  parameters: {
255
425
  dryRun: {
@@ -291,6 +461,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
291
461
  ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
292
462
  ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
293
463
  ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
464
+ ` Experience entries to drop (over ${report?.totalExperiences ?? '?'} cap): ${report?.experienceOversize ?? 0} of ${report?.totalExperiences ?? '?'}`,
465
+ ` Stale defense events to remove: ${report?.staleDefenseEvents ?? 0} (of ${report?.totalDefenseEvents ?? '?'})`,
294
466
  '',
295
467
  'Pass dryRun:false to execute the prune.',
296
468
  ]
@@ -303,6 +475,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
303
475
  ` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
304
476
  ` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
305
477
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
478
+ ` Trimmed ${result?.deletedExperiences ?? 0} experience entries (over the entry cap).`,
479
+ ` Removed ${result?.deletedDefenseEvents ?? 0} stale defense events.`,
306
480
  ]
307
481
  const errs = (result?.errors as string[] | undefined) ?? []
308
482
  if (errs.length > 0) {
@@ -345,6 +519,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
345
519
  deletedCheckpoint: result.deletedCheckpoint,
346
520
  deletedBackups: result.deletedBackups.length,
347
521
  trimmedEmptyRounds: result.trimmedEmptyRounds,
522
+ deletedExperiences: result.deletedExperiences,
523
+ deletedDefenseEvents: result.deletedDefenseEvents,
348
524
  },
349
525
  })
350
526
 
@@ -13,6 +13,9 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
13
13
  import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from './quality-store.ts'
14
14
  import type { QualityGateSnapshot } from '../types.ts'
15
15
 
16
+ /** Valid severity values (kept in sync with QualityGateSnapshot counting). */
17
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
18
+
16
19
  /** Validate a single finding object; returns true when well-formed. */
17
20
  function isValidFinding(raw: unknown): raw is { dimension: string; severity: string; file: string; line?: number } {
18
21
  if (!raw || typeof raw !== 'object') return false
@@ -20,6 +23,7 @@ function isValidFinding(raw: unknown): raw is { dimension: string; severity: str
20
23
  return (
21
24
  typeof f.dimension === 'string' &&
22
25
  typeof f.severity === 'string' &&
26
+ VALID_SEVERITIES.has(f.severity) &&
23
27
  typeof f.file === 'string' &&
24
28
  (f.line === undefined || typeof f.line === 'number')
25
29
  )
@@ -232,15 +232,20 @@ export function computeQualityGate(opts: {
232
232
  ? Math.round(dimensionGates.reduce((sum, d) => sum + d.score, 0) / dimensionGates.length)
233
233
  : 0
234
234
 
235
- // Determine overall status
235
+ // Determine overall status. An empty review (no dimensions, no findings, no
236
+ // validation results) is NOT a failure — there is simply nothing to gate —
237
+ // so it reports `pending` (not-applicable) instead of a fabricated FAIL.
236
238
  const hasCritical = criticalCount > 0
237
239
  const hasHighFail = dimensionGates.some((d) => d.status === 'fail')
238
240
  const verificationFails = totalChecks > 0 && failedChecks > 0
241
+ const noData = dimensionGates.length === 0 && totalFindings === 0 && totalChecks === 0
239
242
 
240
243
  let overallStatus: 'pass' | 'fail' | 'pending' = 'pass'
241
244
  let failReason: string | undefined
242
245
 
243
- if (hasCritical) {
246
+ if (noData) {
247
+ overallStatus = 'pending'
248
+ } else if (hasCritical) {
244
249
  overallStatus = 'fail'
245
250
  failReason = `${criticalCount} critical findings present`
246
251
  } else if (hasHighFail) {