iterate-plugin 3.5.2 → 3.5.3

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.
@@ -12,7 +12,7 @@
12
12
  import { defineTool } from '@deepseek-ai/dsh-tools'
13
13
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
14
14
  import { resolveProjectRootForExec, loadEffectiveConfig } from '../config-loader.ts'
15
- import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from './defense-store.ts'
15
+ import { readDefenseEvents, writeDefenseEvents, addDefenseEvent, clearDefenseEvents } from './defense-store.ts'
16
16
  import type { DefenseEvent, DefenseEventType } from '../types.ts'
17
17
 
18
18
  const DEFAULT_LIMIT = 50
@@ -95,16 +95,18 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
95
95
  // join a parallel dispatch group.
96
96
  isConcurrencySafe: (args) => (args as { operation?: unknown }).operation !== 'record',
97
97
  description:
98
- 'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
98
+ 'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
99
99
  'and assumption falsifications. ' +
100
100
  'List/counts return events with descriptions, outcomes, and summary counts; ' +
101
- '"record" persists a new event to .iterate/defense-events.json. ' +
101
+ '"record" persists a new event to .iterate/defense-events.json; ' +
102
+ '"clear" resets the persisted event stream (use it to start a fresh iteration without stale ' +
103
+ 'defense data). ' +
102
104
  'Use it to review defensive actions taken, or to log one when a defense fires.',
103
105
  parameters: {
104
106
  operation: {
105
107
  type: 'string',
106
- description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
107
- enum: ['list', 'counts', 'record'],
108
+ description: 'Operation: list (browse all), counts (summary by type), record (log a new event), clear (reset the stream). Default: list.',
109
+ enum: ['list', 'counts', 'record', 'clear'],
108
110
  },
109
111
  type: {
110
112
  type: 'string',
@@ -162,6 +164,7 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
162
164
  kind: { type: 'string' },
163
165
  operation: { type: 'string' },
164
166
  count: { type: 'integer' },
167
+ counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
165
168
  events: { type: 'json' },
166
169
  counts: { type: 'json' },
167
170
  event: { type: 'json' },
@@ -174,6 +177,17 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
174
177
  if (!value.ok) return [{ type: 'text', text: `defense events query failed: ${value.error}` }]
175
178
  const language: 'zh' | 'en' = value.language === 'zh' ? 'zh' : 'en'
176
179
 
180
+ if (value.operation === 'clear') {
181
+ // Fresh stream: counts are all zero and the event list is empty.
182
+ return [{ type: 'text', text: [
183
+ value.counted === true
184
+ ? 'Defense event stream cleared — a fresh iteration starts with an empty stream.'
185
+ : 'No persisted defense event stream to clear.',
186
+ 'Defense Event Summary:',
187
+ ...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: 0`),
188
+ ].join('\n') }]
189
+ }
190
+
177
191
  if (value.operation === 'counts' && value.counts) {
178
192
  const counts = value.counts as Record<DefenseEventType, number>
179
193
  const lines = [
@@ -220,12 +234,35 @@ export function registerDefenseEventsTool(ctx: { tools: { register: (def: Return
220
234
  if (!resolved.ok) return { ok: false, kind: 'defense_events', error: resolved.reason }
221
235
  const projectRoot = resolved.root
222
236
 
223
- const configLang = loadEffectiveConfig(projectRoot).config.language
237
+ // A hand-edited `language` value outside zh/en must not leak out of the
238
+ // declared 'zh' | 'en' enum (labelFor would return undefined for it) —
239
+ // normalize any non-'zh' config value to 'en'.
240
+ const rawConfigLang = loadEffectiveConfig(projectRoot).config.language
241
+ const configLang: 'zh' | 'en' = rawConfigLang === 'zh' ? 'zh' : 'en'
224
242
  const language: 'zh' | 'en' = args.language === 'zh' || args.language === 'en' ? args.language : configLang
225
243
 
226
244
  const operation = typeof args.operation === 'string' ? args.operation : 'list'
227
245
  const limit = clampLimit(args.limit as number | undefined)
228
246
 
247
+ if (operation === 'clear') {
248
+ // Reset the persisted event stream so a fresh iteration does not carry
249
+ // stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
250
+ const result = clearDefenseEvents(projectRoot)
251
+ if (!result.ok) {
252
+ return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error }
253
+ }
254
+ const empty = readDefenseEvents(projectRoot)
255
+ return {
256
+ ok: true,
257
+ kind: 'defense_events',
258
+ operation: 'clear',
259
+ counted: result.existed,
260
+ language,
261
+ counts: empty.counts as unknown as JsonValue,
262
+ events: empty.events as unknown as JsonValue,
263
+ }
264
+ }
265
+
229
266
  if (operation === 'record') {
230
267
  const errors = validateRecordInput(args)
231
268
  if (errors.length > 0) {
@@ -46,11 +46,41 @@ function emptyStream(): DefenseEventStream {
46
46
  }
47
47
  }
48
48
 
49
+ /** Valid severity values (kept in sync with DefenseEvent). */
50
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
51
+
52
+ /**
53
+ * Normalize one persisted event. Hand-edited files can carry events missing
54
+ * `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
55
+ * (list sort by timestamp, render label selection) must never crash or emit
56
+ * NaN for those. Returns null when the entry is not an object or has no usable
57
+ * `type`; otherwise fills every required field with a safe default.
58
+ */
59
+ function normalizeEvent(raw: unknown): DefenseEvent | null {
60
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
61
+ const e = raw as Record<string, unknown>
62
+ if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type as DefenseEventType)) return null
63
+ return {
64
+ id: typeof e.id === 'string' && e.id ? e.id : `def-${Math.random().toString(36).slice(2, 8)}`,
65
+ timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
66
+ round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
67
+ type: e.type as DefenseEventType,
68
+ description: typeof e.description === 'string' ? e.description : '',
69
+ defense: typeof e.defense === 'string' ? e.defense : '',
70
+ outcome: typeof e.outcome === 'string' ? e.outcome : '',
71
+ ...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
72
+ ...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
73
+ severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as DefenseEvent['severity']) : 'low',
74
+ }
75
+ }
76
+
49
77
  /**
50
78
  * Read the defense events stream from disk.
51
79
  * Normalizes the persisted stream so a hand-edited / partial file can never
52
80
  * produce NaN counts: `counts` is recomputed from the events when missing or
53
- * malformed, and every type key is guaranteed present.
81
+ * malformed, every type key is guaranteed present, and every surviving event is
82
+ * shape-normalized so consumers (timestamp sort, render label selection) cannot
83
+ * throw on missing fields.
54
84
  */
55
85
  export function readDefenseEvents(projectRoot: string): DefenseEventStream {
56
86
  const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE)
@@ -58,10 +88,9 @@ export function readDefenseEvents(projectRoot: string): DefenseEventStream {
58
88
  const content = fs.readFileSync(filePath, 'utf-8')
59
89
  const parsed = JSON.parse(content) as Partial<DefenseEventStream>
60
90
  if (parsed && Array.isArray(parsed.events)) {
61
- const events = parsed.events.filter(
62
- (e): e is DefenseEvent =>
63
- !!e && typeof e === 'object' && typeof (e as DefenseEvent).type === 'string',
64
- )
91
+ const events = parsed.events
92
+ .map(normalizeEvent)
93
+ .filter((e): e is DefenseEvent => e !== null)
65
94
  const counts = computeCounts(events)
66
95
  return {
67
96
  events,
@@ -101,6 +130,26 @@ export function writeDefenseEvents(
101
130
  return { ok: true }
102
131
  }
103
132
 
133
+ /**
134
+ * Clear the persisted defense-event stream (`.iterate/defense-events.json`).
135
+ * Mirrors `clearQualityGate` in quality-store.ts: a stale event stream from a
136
+ * previous iteration must be resettable before a fresh run. Returns whether a
137
+ * file existed and was removed, or a structured error when removal fails.
138
+ */
139
+ export function clearDefenseEvents(
140
+ projectRoot: string,
141
+ ): { ok: true; existed: boolean } | { ok: false; error: string } {
142
+ const filePath = path.join(iterateDir(projectRoot), DEFENSE_EVENTS_FILE)
143
+ const existed = fs.existsSync(filePath)
144
+ if (!existed) return { ok: true, existed: false }
145
+ try {
146
+ fs.rmSync(filePath, { force: true })
147
+ } catch (err) {
148
+ return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` }
149
+ }
150
+ return { ok: true, existed: true }
151
+ }
152
+
104
153
  /** Add a defense event to the stream. */
105
154
  export function addDefenseEvent(
106
155
  stream: DefenseEventStream,
@@ -151,8 +151,8 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
151
151
  `Dimension: ${entry.dimension}`,
152
152
  `Description: ${entry.description}`,
153
153
  `Fix: ${entry.verifiedFix}`,
154
- `Files: ${entry.files.join(', ')}`,
155
- `Tags: ${entry.tags.join(', ')}`,
154
+ `Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
155
+ `Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
156
156
  ].join('\n') }]
157
157
  }
158
158
  if (value.operation === 'remove') {
@@ -165,9 +165,9 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
165
165
  `Pattern: ${entry.pattern}`,
166
166
  `Description: ${entry.description}`,
167
167
  `Fix: ${entry.verifiedFix}`,
168
- `Files: ${entry.files.join(', ')}`,
168
+ `Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
169
169
  `Hits: ${entry.hitCount}`,
170
- `Tags: ${entry.tags.join(', ')}`,
170
+ `Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
171
171
  ].join('\n') }]
172
172
  }
173
173
  const entries = (value.entries as unknown as ExperienceEntry[] | undefined) ?? []
@@ -264,7 +264,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
264
264
  operation: 'get',
265
265
  count: 1,
266
266
  entry: entry as unknown as JsonValue,
267
- totalHits: bank.totalHits,
267
+ totalHits: bank.totalHits ?? 0,
268
268
  }
269
269
  }
270
270
 
@@ -280,7 +280,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
280
280
  operation: 'search',
281
281
  count: entries.length,
282
282
  entries: entries as unknown as JsonValue,
283
- totalHits: bank.totalHits,
283
+ totalHits: bank.totalHits ?? 0,
284
284
  }
285
285
  }
286
286
 
@@ -290,7 +290,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
290
290
  entries = entries.filter((e) => e.dimension === args.dimension)
291
291
  }
292
292
  if (Array.isArray(args.tags) && args.tags.length > 0) {
293
- entries = entries.filter((e) => args.tags!.every((t: string) => e.tags.includes(t)))
293
+ entries = entries.filter((e) => args.tags!.every((t: string) => Array.isArray(e.tags) && e.tags.includes(t)))
294
294
  }
295
295
 
296
296
  return {
@@ -299,7 +299,7 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
299
299
  operation: 'list',
300
300
  count: Math.min(entries.length, limit),
301
301
  entries: entries.slice(0, limit) as unknown as JsonValue,
302
- totalHits: bank.totalHits,
302
+ totalHits: bank.totalHits ?? 0,
303
303
  }
304
304
  },
305
305
  }),
@@ -22,14 +22,62 @@ function emptyBank(): ExperienceBank {
22
22
  }
23
23
  }
24
24
 
25
+ /** Valid severity values (kept in sync with ExperienceEntry). */
26
+ const VALID_SEVERITIES: ReadonlySet<string> = new Set(['critical', 'high', 'medium', 'low'])
27
+
28
+ /** String array guard for fields that must be arrays (`files`, `tags`). */
29
+ function stringArray(v: unknown): string[] {
30
+ if (!Array.isArray(v)) return []
31
+ return v.filter((x): x is string => typeof x === 'string')
32
+ }
33
+
34
+ /**
35
+ * Normalize one persisted experience entry. A hand-edited bank entry can be
36
+ * missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
37
+ * entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
38
+ * throw or emit NaN. Returns null for non-object entries; every required field
39
+ * gets a safe default.
40
+ */
41
+ function normalizeEntry(raw: unknown): ExperienceEntry | null {
42
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
43
+ const e = raw as Record<string, unknown>
44
+ const pattern = typeof e.pattern === 'string' ? e.pattern : ''
45
+ const dimension = typeof e.dimension === 'string' ? e.dimension : ''
46
+ const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`
47
+ if (!pattern && !dimension) return null
48
+ const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0
49
+ return {
50
+ id,
51
+ timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
52
+ dimension,
53
+ pattern,
54
+ description: typeof e.description === 'string' ? e.description : '',
55
+ verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
56
+ findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
57
+ files: stringArray(e.files),
58
+ hitCount,
59
+ ...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
60
+ tags: stringArray(e.tags),
61
+ severity: VALID_SEVERITIES.has(String(e.severity)) ? (e.severity as ExperienceEntry['severity']) : 'low',
62
+ }
63
+ }
64
+
25
65
  /** Read the experience bank from disk. Returns empty bank if not found. */
26
66
  export function readExperienceBank(projectRoot: string): ExperienceBank {
27
67
  const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE)
28
68
  try {
29
69
  const content = fs.readFileSync(filePath, 'utf-8')
30
- const parsed = JSON.parse(content) as ExperienceBank
70
+ const parsed = JSON.parse(content) as Partial<ExperienceBank>
31
71
  if (parsed && Array.isArray(parsed.entries)) {
32
- return parsed
72
+ const entries = parsed.entries
73
+ .map(normalizeEntry)
74
+ .filter((e): e is ExperienceEntry => e !== null)
75
+ return {
76
+ entries,
77
+ lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
78
+ totalHits:
79
+ typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
80
+ }
33
81
  }
34
82
  } catch {
35
83
  // File not found or invalid JSON
@@ -72,6 +120,9 @@ export function searchExperienceEntries(
72
120
  const lowerQuery = query.toLowerCase()
73
121
 
74
122
  return entries.filter((entry) => {
123
+ if (!entry || typeof entry !== 'object') return false
124
+ const rawEntry = entry as unknown as Record<string, unknown>
125
+ const tags = stringArray(rawEntry.tags)
75
126
  // Dimension filter
76
127
  if (opts.dimension && entry.dimension !== opts.dimension) {
77
128
  return false
@@ -79,22 +130,24 @@ export function searchExperienceEntries(
79
130
 
80
131
  // Tags filter (AND logic)
81
132
  if (opts.tags && opts.tags.length > 0) {
82
- if (!opts.tags.every((t) => entry.tags.includes(t))) {
133
+ if (!opts.tags.every((t) => tags.includes(t))) {
83
134
  return false
84
135
  }
85
136
  }
86
137
 
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()
138
+ // Text search across multiple fields. Guards against a hand-edited bank entry
139
+ // whose `files`/`tags` are missing or non-array (the spread below would
140
+ // otherwise throw a TypeError on a non-iterable).
141
+ if (query) {
142
+ const searchableText = [
143
+ entry.pattern,
144
+ entry.description,
145
+ entry.verifiedFix,
146
+ entry.findingSummary,
147
+ entry.dimension,
148
+ ...stringArray(entry.files),
149
+ ...stringArray(entry.tags),
150
+ ].join(' ').toLowerCase()
98
151
 
99
152
  if (!searchableText.includes(lowerQuery)) {
100
153
  return false
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',
@@ -106,16 +106,22 @@ export function inspectPrune(
106
106
  }
107
107
  const staleBackups: string[] = []
108
108
  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
- }
109
+ // existsSync does not prove readability — a permissions failure must degrade
110
+ // to "nothing listable" (report skipped) instead of throwing out of execute.
111
+ let fixEntries: string[] = []
112
+ try {
113
+ if (existsSync(fixDir)) fixEntries = readdirSync(fixDir)
114
+ } catch {
115
+ fixEntries = []
116
+ }
117
+ for (const entry of fixEntries) {
118
+ if (!entry.endsWith('.bak')) continue
119
+ // Extract the fix-id prefix (up to the first underscore after the id).
120
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
121
+ const match = entry.match(/^(fix-[a-z0-9]+)_/)
122
+ const id = match?.[1]
123
+ if (id && !activeIds.has(id)) {
124
+ staleBackups.push(entry)
119
125
  }
120
126
  }
121
127
 
@@ -126,10 +132,15 @@ export function inspectPrune(
126
132
 
127
133
  // 5. Stray atomic-write temp files left behind by a crashed writer. The
128
134
  // 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
- : []
135
+ // ever reads another writer's temp file. A permissions failure listing the
136
+ // dir degrades to "none found" (report-only) rather than throwing.
137
+ let iterateEntries: string[] = []
138
+ try {
139
+ iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : []
140
+ } catch {
141
+ iterateEntries = []
142
+ }
143
+ const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort()
133
144
 
134
145
  return {
135
146
  oldLogEntries,
@@ -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) {
@@ -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,
@@ -224,11 +229,24 @@ export function registerTranscriptTool(ctx: {
224
229
  manifest = null
225
230
  }
226
231
  }
227
- const builder = manifest
228
- ? rehydrateBuilder(manifest, approval)
229
- : new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
232
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
233
+ // or a non-object) must never crash the nudge — degrade to a fresh
234
+ // builder so the steering text still lands.
235
+ let builder: ReviewTranscriptBuilder
236
+ if (manifest) {
237
+ try {
238
+ builder = rehydrateBuilder(manifest, approval)
239
+ } catch {
240
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
241
+ }
242
+ } else {
243
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
244
+ }
230
245
  builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
231
- await persist(file, builder.serialize())
246
+ const persisted = await persistChecked(file, builder.serialize())
247
+ if (!persisted.ok) {
248
+ return { operation: 'nudge', updated: false, error: persisted.error }
249
+ }
232
250
  return {
233
251
  operation: 'nudge',
234
252
  updated: true,
@@ -291,7 +309,10 @@ export function registerTranscriptTool(ctx: {
291
309
  const last = convergence[convergence.length - 1]
292
310
  if (convergence.length > 0 && last === 0) builder.finish()
293
311
 
294
- await persist(file, builder.serialize())
312
+ const persisted = await persistChecked(file, builder.serialize())
313
+ if (!persisted.ok) {
314
+ return { operation: 'capture', found: true, updated: false, error: persisted.error }
315
+ }
295
316
  const live = await readLive(projectRoot)
296
317
  return {
297
318
  operation: 'capture',
@@ -342,6 +363,21 @@ async function persist(file: string, manifest: TranscriptManifest): Promise<void
342
363
  await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2))
343
364
  }
344
365
 
366
+ /** persist wrapped with a structured error channel so disk failures surface
367
+ * as a structured tool result instead of rejecting the whole execute (matches
368
+ * every other write path in this codebase). */
369
+ async function persistChecked(
370
+ file: string,
371
+ manifest: TranscriptManifest,
372
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
373
+ try {
374
+ await persist(file, manifest)
375
+ return { ok: true }
376
+ } catch (err) {
377
+ return { ok: false, error: `failed to persist transcript: ${err instanceof Error ? err.message : String(err)}` }
378
+ }
379
+ }
380
+
345
381
  /**
346
382
  * Mark one applied fix as rolled back in the PERSISTED transcript, so the
347
383
  * client observatory (F4 fix/rollback tab) reflects the reversal instead of