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.
package/README.md CHANGED
@@ -106,7 +106,7 @@ Both modes share:
106
106
  ### UI layer (build-free client slots, v3.2: 10 tabs)
107
107
 
108
108
  - **ConvergenceDashboard** (`conversation.input.dock`) — live round progress bar, severity stats, dimension badges, trend mini-chart above the input; normal mode also shows fix-count badges; plus a live workflow-phase chip (current phase + running/stopped) and a **v3.2 task_mode indicator (code / iterate)**, now backed by the persisted transcript (`iterate_status` / `iterate_transcript` emit `taskMode`).
109
- - **ObservatoryPanel** (`conversation.input.dock`) — a **ten-tab** runtime observatory below the input: live activity stream (type filter), review threads (expand / collapse all), convergence trend, finding locations (severity / dimension / search filter), fixes + rollback, checkpoint resume, decision timeline (type / round filter + search). **v3.2 tabs: Quality Gate (F8), Experience Bank (F9), Defense Events (F10)**; one-click JSON export of all observatory data (download, copy fallback). **v3.3: F8/F9/F10 are now live** — they scan the session stream for the latest `iterate_quality_gate` / `iterate_experience` / `iterate_defense_events` results and render real data (PASS/FAIL chip + per-dimension score bars, experience entries with adopt buttons + client-side search, defense type-count chips + filtered event stream), degrading to copy-able query instructions only when the session has none.
109
+ - **ObservatoryPanel** (`conversation.input.dock`) — a **ten-tab** runtime observatory below the input: live activity stream (type filter), review threads (expand / collapse all), convergence trend, finding locations (severity / dimension / search filter), fixes + rollback, checkpoint resume, decision timeline (type / round filter + search). **v3.2 tabs: Quality Gate (F8), Experience Bank (F9), Defense Events (F10)**; one-click JSON export of all observatory data (download, copy fallback). **v3.3+: F8/F9/F10 are live** — they scan the session stream for the latest `iterate_quality_gate` / `iterate_experience` / `iterate_defense_events` results and render real data (PASS/FAIL chip + per-dimension score bars, experience entries with adopt buttons + client-side search, defense type-count chips + filtered event stream), degrading to copy-able query instructions only when the session has none. **v3.5.3: reset actions surfaced** — F5 copies an `iterate_checkpoint clear` instruction, F8 an `iterate_quality_gate clear` instruction, F10 an `iterate_defense_events clear` instruction.
110
110
  - **TriagePanel** (`conversation.chat.turnTail`) — per-finding y/n/a triage, filtering, batch ops (incl. select-all), keyboard shortcuts, localStorage persistence, copy-YAML / apply-instruction. **v3.2: native command buttons** (approve architectural fix, trigger new round, rollback to checkpoint). **v3.3: §8 指派修复 (assign)** — one click copies an `iterate_fix` instruction carrying every in-scope finding (respecting the batch toggle).
111
111
  - **StatsCard** (`conversation.chat.turnTail`) — convergence stats, round history, trend chart and completion summary when no findings remain.
112
112
  - **iterate theme skin** (`theme.overrideTokens`) — a warm-amber 13-token `--dsw-*` override, light/dark modes, togglable in settings.
@@ -255,14 +255,14 @@ validation:
255
255
  - `iterate_fix` — apply **one atomic fix**: validates the relative path, backs up the original file, enforces atomicity via `atomic.max_lines` (skippable with `force`), writes new content, records a FixRecord and an `atomic_fix` log. The only legal file-modifying entry in normal mode
256
256
  - `iterate_diff` — view accumulated fix changes: with `file`, returns the unified diff against the first backup; without it, a per-fixed-file summary
257
257
  - `iterate_rollback` — roll back an applied fix: restore the file from backup, remove that FixRecord from the registry, append a `revert` log. Used after a failed round validation
258
- - `iterate_checkpoint` — iteration breakpoint: `save` persists progress to `.iterate/checkpoint.json`, `load` reads it back, `resume` loads + bumps the resume counter (interruption recovery), `clear` removes it. Resumable interrupted long iterations
258
+ - `iterate_checkpoint` — iteration breakpoint: `save` persists progress to `.iterate/checkpoint.json`, `load` reads it back, `resume` loads + bumps the resume counter (interruption recovery), `clear` removes it (bounded findings payload, `round ≤ maxRounds` enforced). Resumable interrupted long iterations
259
259
  - `iterate_status` — summarize current iteration state: mode, current/last round, fixes applied, remaining architectural, decision-log entry count, whether a checkpoint exists; **v3.4: also surfaces the persisted quality-gate snapshot, experience-bank summary, and defense-events summary** (`qualityGate` / `experienceBank` / `defenseEvents`)
260
260
  - `iterate_history` — read iteration history (read-only): decision-log entries (filter by `type` / `since` / `limit`, default latest 50, cap 200) + fix-registry summary (per-round fixed/failed counts). For auditing the run, tracing logs, and inventorying fixes
261
261
  - `iterate_prune` — clean runtime artifacts: stale decision-log entries (by `retainDays`, default 30), stale checkpoints, orphaned fix backups, empty rounds, stray temp files (crashed atomic writes). Dry-run by default (report-only); real cleanup with `dryRun:false`, each cleanup logged
262
262
  - `iterate_transcript` — runtime observatory: persist review transcripts, threads, fixes, and nudge directions to `.iterate/transcript.json` for the client observatory
263
263
  - `iterate_experience` — **v3.2** query the experience bank (list / search / get), `add` a new verified fix (re-adding the same pattern+dimension bumps its hit count instead of duplicating it), or **v3.5 remove a stale/incorrect entry by `id`** so bad experiences never resurface. Persists to `.iterate/experience.json`
264
264
  - `iterate_quality_gate` — **v3.2** read the quality certificate (`read`), or recompute + persist a fresh one (`compute`) from findings, validation results, `findingsByRound`, and `fixedByDimension`. Real per-dimension convergence rates. **v3.5: `clear` removes the persisted certificate to reset a stale gate before a fresh iteration**
265
- - `iterate_defense_events` — **v3.2** query defense events (list / counts), or `record` a new one. Human-readable labels follow the project language (en / zh)
265
+ - `iterate_defense_events` — **v3.2** query defense events (list / counts), `record` a new one, or **v3.5.3 `clear` the persisted stream** so a fresh iteration starts without stale defensive data. Human-readable labels follow the project language (en / zh)
266
266
 
267
267
  ---
268
268
 
package/README.zh-CN.md CHANGED
@@ -105,7 +105,7 @@ dsh plugin --profile web add iterate-plugin
105
105
  ### UI 层(客户端免构建槽位,v3.2:10 个标签页)
106
106
 
107
107
  - **收敛看板 `ConvergenceDashboard`**(`conversation.input.dock`)— 输入框上方实时显示轮次进度条、严重度统计、维度徽章、趋势迷你图;normal 模式另显示修复计数徽章;还有运行阶段芯片(当前工作流阶段 + 运行中/已结束)与 **v3.2 task_mode 指示器(code / iterate)**。
108
- - **运行时观测台 `ObservatoryPanel`**(`conversation.input.dock`)— 输入框下方**十个标签页**运行时观测台:实时活动流(支持按活动类型筛选)、审查线程(支持全部展开/全部收起)、收敛趋势、发现定位(支持按严重度/维度/关键词筛选)、修复与回滚、断点恢复、决策时间线(支持按类型/轮次筛选与关键词搜索)。**v3.2 新增标签:质量门禁(F8)、经验银行(F9)、防御事件(F10)**;支持一键导出全部观测数据为 JSON(优先下载,失败回退复制)。
108
+ - **运行时观测台 `ObservatoryPanel`**(`conversation.input.dock`)— 输入框下方**十个标签页**运行时观测台:实时活动流(支持按活动类型筛选)、审查线程(支持全部展开/全部收起)、收敛趋势、发现定位(支持按严重度/维度/关键词筛选)、修复与回滚、断点恢复、决策时间线(支持按类型/轮次筛选与关键词搜索)。**v3.2 新增标签:质量门禁(F8)、经验银行(F9)、防御事件(F10)**;支持一键导出全部观测数据为 JSON(优先下载,失败回退复制)。**v3.5.3 新增重置操作**:F5 复制 `iterate_checkpoint clear`、F8 复制 `iterate_quality_gate clear`、F10 复制 `iterate_defense_events clear` 指令。
109
109
  - **Findings 分诊面板 `TriagePanel`**(`conversation.chat.turnTail`)— 逐条 y/n/a 判定,支持筛选、批量(含一键全选所有 findings)、键盘快捷键、localStorage 持久化、复制 YAML / 应用指令。**v3.2:原生命令按钮**(批准架构修复、触发新一轮、回滚到断点)。
110
110
  - **收敛统计卡片 `StatsCard`**(`conversation.chat.turnTail`)— 无 findings 时显示收敛统计、历史轮次、趋势图、完成摘要。
111
111
  - **iterate 主题皮肤**(`theme.overrideTokens`)— 暖琥珀配色的 13 个 `--dsw-*` token 覆盖,明暗双模式,可在设置页开关。
@@ -254,14 +254,14 @@ validation:
254
254
  - `iterate_fix` — 应用**一个原子修复**:校验相对路径、备份原文件、按 `atomic.max_lines` 强制原子性(可 `force` 跳过)、写入新内容、记录 FixRecord 与 `atomic_fix` 日志。normal 模式唯一合法的改文件入口
255
255
  - `iterate_diff` — 查看修复累积变更:指定 `file` 返回相对首个备份的 unified diff;省略则返回每个已修复文件的汇总
256
256
  - `iterate_rollback` — 回滚一个已应用的修复:从备份还原文件、从注册表移除该 FixRecord、追加 `revert` 日志。用于某轮验证失败后
257
- - `iterate_checkpoint` — 迭代断点:`save` 保存当前进度到 `.iterate/checkpoint.json`,`load` 读回,`resume` 加载并累加恢复计数(中断恢复),`clear` 清除。长迭代可中断续跑
257
+ - `iterate_checkpoint` — 迭代断点:`save` 保存当前进度到 `.iterate/checkpoint.json`,`load` 读回,`resume` 加载并累加恢复计数(中断恢复),`clear` 清除(findings 载荷有上限,强制 `round ≤ maxRounds`)。长迭代可中断续跑
258
258
  - `iterate_status` — 汇总当前迭代状态:模式、当前轮/总轮、已修复数、剩余 architectural、决策日志条数、是否存在 checkpoint;**v3.4:同时返回持久化的质量门禁快照、经验银行摘要与防御事件摘要**(`qualityGate` / `experienceBank` / `defenseEvents`)
259
259
  - `iterate_history` — 读取迭代历史(只读):决策日志条目(可按 `type` / `since` / `limit` 过滤,默认取最新 50 条,上限 200 条)+ 修复注册表汇总(各轮 fixed/failed 计数)。用于审查运行过程、审计日志、盘点修复
260
260
  - `iterate_prune` — 清理运行时产物:过期决策日志条目(按 `retainDays`,默认 30 天)、陈旧断点、孤儿修复备份、空轮次、崩溃原子写入残留的临时文件。默认 dry-run 只报告不删除;`dryRun:false` 才真正清理,每次清理写入决策日志
261
261
  - `iterate_transcript` — 运行时观测台:把审查转录、线程、修复与 nudge 指令持久化到 `.iterate/transcript.json`,供客户端观测台读取
262
262
  - `iterate_experience` — **v3.2** 查询经验银行(list / search / get),或 `add` 一条新的已验证修复:重复添加同一 pattern + dimension 累加命中次数而非重复写入。**v3.5 新增 `remove`:按 `id` 删除过时或错误的经验条目**。持久化到 `.iterate/experience.json`
263
263
  - `iterate_quality_gate` — **v3.2** 读取质量凭证(`read`),或基于 findings、验证结果、`findingsByRound` 与 `fixedByDimension` 重新计算并持久化一份新凭证(`compute`)。真实的逐维度收敛率。**v3.5 新增 `clear`:删除持久化的质量凭证**,用于新一轮迭代前重置陈旧的门禁
264
- - `iterate_defense_events` — **v3.2** 查询防御事件(list / counts),或 `record` 记录一条新事件。可读标签跟随项目语言(en / zh)
264
+ - `iterate_defense_events` — **v3.2** 查询防御事件(list / counts),`record` 记录一条新事件,或 **v3.5.3 `clear` 清除持久化事件流**,让新一轮迭代不带陈旧防御数据起跑。可读标签跟随项目语言(en / zh)
265
265
 
266
266
  ---
267
267
 
@@ -19,6 +19,9 @@ import { readQualityGate } from "./quality-store.js";
19
19
  import { readExperienceBank } from "./experience-store.js";
20
20
  import { readDefenseEvents } from "./defense-store.js";
21
21
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
22
+ /** Max findings persisted in a checkpoint (a model-authored findings payload is
23
+ * bounded, mirroring MAX_FIX_CONTENT_CHARS for fix content). */
24
+ export const MAX_CHECKPOINT_FINDINGS = 1000;
22
25
  /** Read the current checkpoint from disk (missing/corrupt → null). */
23
26
  export function readCheckpoint(projectRoot) {
24
27
  const file = checkpointPath(projectRoot);
@@ -30,9 +33,28 @@ export function readCheckpoint(projectRoot) {
30
33
  return null;
31
34
  if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal')
32
35
  return null;
33
- if (typeof parsed.round !== 'number')
36
+ // Strict round gate: non-numeric (incl. NaN — `NaN <= 0` is false and
37
+ // `NaN < 0` also false) round values must not survive into the status.
38
+ const round = typeof parsed.round === 'number' && Number.isFinite(parsed.round)
39
+ ? Math.floor(parsed.round)
40
+ : null;
41
+ if (round === null || round < 0)
34
42
  return null;
35
- return parsed;
43
+ const num = (v, fallback) => typeof v === 'number' && Number.isFinite(v) ? v : fallback;
44
+ // Normalize every field so a hand-edited/corrupt checkpoint (string
45
+ // maxRounds, missing fixedCount, …) can never leak a non-numeric value
46
+ // into the status output or crash a consumer.
47
+ return {
48
+ mode: parsed.mode,
49
+ round,
50
+ maxRounds: num(parsed.maxRounds, round),
51
+ fixedCount: num(parsed.fixedCount, 0),
52
+ architecturalCount: num(parsed.architecturalCount, 0),
53
+ resumeCount: num(parsed.resumeCount, 0),
54
+ findings: Array.isArray(parsed.findings) ? parsed.findings : [],
55
+ startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString(),
56
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
57
+ };
36
58
  }
37
59
  catch {
38
60
  return null;
@@ -73,6 +95,15 @@ export function validateCheckpoint(input) {
73
95
  (typeof input.resumeCount !== 'number' || !Number.isInteger(input.resumeCount) || input.resumeCount < 0)) {
74
96
  return 'resumeCount must be a non-negative integer';
75
97
  }
98
+ // A round that claims to be past the configured cap is an inconsistent save —
99
+ // the status would render `Round X / Y` with X > Y, which is never legitimate
100
+ // for a resumed run (resume continues AT the checkpoint, it does not exceed
101
+ // the cap).
102
+ if (typeof input.round === 'number' &&
103
+ typeof input.maxRounds === 'number' &&
104
+ input.round > input.maxRounds) {
105
+ return 'round must not exceed maxRounds';
106
+ }
76
107
  return null;
77
108
  }
78
109
  /**
@@ -95,8 +126,11 @@ export function computeStatus(input) {
95
126
  }
96
127
  }
97
128
  const totalRounds = checkpoint?.maxRounds ?? currentRound;
98
- const registryFixed = registry.rounds.reduce((sum, r) => sum + r.fixedCount, 0);
99
- const failedCount = registry.rounds.reduce((sum, r) => sum + r.failedCount, 0);
129
+ // Coerce the registry's per-round counts defensively: a hand-edited registry
130
+ // round missing fixedCount/failedCount would otherwise feed NaN into the
131
+ // status integer fields.
132
+ const registryFixed = registry.rounds.reduce((sum, r) => sum + (Number(r.fixedCount) || 0), 0);
133
+ const failedCount = registry.rounds.reduce((sum, r) => sum + (Number(r.failedCount) || 0), 0);
100
134
  // When a checkpoint exists, its snapshot fields are authoritative for resume
101
135
  // (fixedCount / architecturalCount / findings); otherwise derive from the
102
136
  // live fix registry and decision log.
@@ -230,7 +264,7 @@ export function registerCheckpointTool(ctx) {
230
264
  fixedCount: args.fixedCount,
231
265
  architecturalCount: args.architecturalCount,
232
266
  resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
233
- findings: (Array.isArray(args.findings) ? args.findings : []),
267
+ findings: (Array.isArray(args.findings) ? args.findings.slice(0, MAX_CHECKPOINT_FINDINGS) : []),
234
268
  startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
235
269
  updatedAt: new Date().toISOString(),
236
270
  };
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
2
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
3
- import { loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from "../config-loader.js";
4
+ import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from "../config-loader.js";
4
5
  import { applyConfigUpdates, readRawConfig, validateConfigUpdates, writeConfigFile, } from "../config-write.js";
5
6
  /**
6
7
  * Register the `iterate_config` tool.
@@ -91,7 +92,15 @@ export function registerConfigTool(ctx) {
91
92
  }
92
93
  let base;
93
94
  try {
94
- base = readRawConfig(join(projectRoot, 'iterate.config.yaml'));
95
+ // Partial updates must be mergeable on a FRESH project (no config
96
+ // file yet): otherwise `{max_rounds: 5}` alone fails schema
97
+ // validation with a misleading "missing goal". Merge against the
98
+ // built-in defaults when there is no file to read, so the written
99
+ // config captures the effective config (defaults + update) and the
100
+ // documented "write a partial update" contract works out of the box.
101
+ base = existsSync(join(projectRoot, 'iterate.config.yaml'))
102
+ ? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
103
+ : defaultConfig();
95
104
  }
96
105
  catch (err) {
97
106
  return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` };
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { defineTool } from '@deepseek-ai/dsh-tools';
12
12
  import { resolveProjectRootForExec, loadEffectiveConfig } from "../config-loader.js";
13
- import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from "./defense-store.js";
13
+ import { readDefenseEvents, writeDefenseEvents, addDefenseEvent, clearDefenseEvents } from "./defense-store.js";
14
14
  const DEFAULT_LIMIT = 50;
15
15
  const MAX_LIMIT = 100;
16
16
  const EVENT_TYPES = [
@@ -75,16 +75,18 @@ export function registerDefenseEventsTool(ctx) {
75
75
  // List/counts never write; only `record` persists an event → read shapes
76
76
  // join a parallel dispatch group.
77
77
  isConcurrencySafe: (args) => args.operation !== 'record',
78
- description: 'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
78
+ description: 'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
79
79
  'and assumption falsifications. ' +
80
80
  'List/counts return events with descriptions, outcomes, and summary counts; ' +
81
- '"record" persists a new event to .iterate/defense-events.json. ' +
81
+ '"record" persists a new event to .iterate/defense-events.json; ' +
82
+ '"clear" resets the persisted event stream (use it to start a fresh iteration without stale ' +
83
+ 'defense data). ' +
82
84
  'Use it to review defensive actions taken, or to log one when a defense fires.',
83
85
  parameters: {
84
86
  operation: {
85
87
  type: 'string',
86
- description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
87
- enum: ['list', 'counts', 'record'],
88
+ description: 'Operation: list (browse all), counts (summary by type), record (log a new event), clear (reset the stream). Default: list.',
89
+ enum: ['list', 'counts', 'record', 'clear'],
88
90
  },
89
91
  type: {
90
92
  type: 'string',
@@ -141,6 +143,7 @@ export function registerDefenseEventsTool(ctx) {
141
143
  kind: { type: 'string' },
142
144
  operation: { type: 'string' },
143
145
  count: { type: 'integer' },
146
+ counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
144
147
  events: { type: 'json' },
145
148
  counts: { type: 'json' },
146
149
  event: { type: 'json' },
@@ -153,6 +156,16 @@ export function registerDefenseEventsTool(ctx) {
153
156
  if (!value.ok)
154
157
  return [{ type: 'text', text: `defense events query failed: ${value.error}` }];
155
158
  const language = value.language === 'zh' ? 'zh' : 'en';
159
+ if (value.operation === 'clear') {
160
+ // Fresh stream: counts are all zero and the event list is empty.
161
+ return [{ type: 'text', text: [
162
+ value.counted === true
163
+ ? 'Defense event stream cleared — a fresh iteration starts with an empty stream.'
164
+ : 'No persisted defense event stream to clear.',
165
+ 'Defense Event Summary:',
166
+ ...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: 0`),
167
+ ].join('\n') }];
168
+ }
156
169
  if (value.operation === 'counts' && value.counts) {
157
170
  const counts = value.counts;
158
171
  const lines = [
@@ -193,10 +206,32 @@ export function registerDefenseEventsTool(ctx) {
193
206
  if (!resolved.ok)
194
207
  return { ok: false, kind: 'defense_events', error: resolved.reason };
195
208
  const projectRoot = resolved.root;
196
- const configLang = loadEffectiveConfig(projectRoot).config.language;
209
+ // A hand-edited `language` value outside zh/en must not leak out of the
210
+ // declared 'zh' | 'en' enum (labelFor would return undefined for it) —
211
+ // normalize any non-'zh' config value to 'en'.
212
+ const rawConfigLang = loadEffectiveConfig(projectRoot).config.language;
213
+ const configLang = rawConfigLang === 'zh' ? 'zh' : 'en';
197
214
  const language = args.language === 'zh' || args.language === 'en' ? args.language : configLang;
198
215
  const operation = typeof args.operation === 'string' ? args.operation : 'list';
199
216
  const limit = clampLimit(args.limit);
217
+ if (operation === 'clear') {
218
+ // Reset the persisted event stream so a fresh iteration does not carry
219
+ // stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
220
+ const result = clearDefenseEvents(projectRoot);
221
+ if (!result.ok) {
222
+ return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error };
223
+ }
224
+ const empty = readDefenseEvents(projectRoot);
225
+ return {
226
+ ok: true,
227
+ kind: 'defense_events',
228
+ operation: 'clear',
229
+ counted: result.existed,
230
+ language,
231
+ counts: empty.counts,
232
+ events: empty.events,
233
+ };
234
+ }
200
235
  if (operation === 'record') {
201
236
  const errors = validateRecordInput(args);
202
237
  if (errors.length > 0) {
@@ -39,11 +39,41 @@ function emptyStream() {
39
39
  },
40
40
  };
41
41
  }
42
+ /** Valid severity values (kept in sync with DefenseEvent). */
43
+ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
44
+ /**
45
+ * Normalize one persisted event. Hand-edited files can carry events missing
46
+ * `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
47
+ * (list sort by timestamp, render label selection) must never crash or emit
48
+ * NaN for those. Returns null when the entry is not an object or has no usable
49
+ * `type`; otherwise fills every required field with a safe default.
50
+ */
51
+ function normalizeEvent(raw) {
52
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
53
+ return null;
54
+ const e = raw;
55
+ if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type))
56
+ return null;
57
+ return {
58
+ id: typeof e.id === 'string' && e.id ? e.id : `def-${Math.random().toString(36).slice(2, 8)}`,
59
+ timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
60
+ round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
61
+ type: e.type,
62
+ description: typeof e.description === 'string' ? e.description : '',
63
+ defense: typeof e.defense === 'string' ? e.defense : '',
64
+ outcome: typeof e.outcome === 'string' ? e.outcome : '',
65
+ ...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
66
+ ...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
67
+ severity: VALID_SEVERITIES.has(String(e.severity)) ? e.severity : 'low',
68
+ };
69
+ }
42
70
  /**
43
71
  * Read the defense events stream from disk.
44
72
  * Normalizes the persisted stream so a hand-edited / partial file can never
45
73
  * produce NaN counts: `counts` is recomputed from the events when missing or
46
- * malformed, and every type key is guaranteed present.
74
+ * malformed, every type key is guaranteed present, and every surviving event is
75
+ * shape-normalized so consumers (timestamp sort, render label selection) cannot
76
+ * throw on missing fields.
47
77
  */
48
78
  export function readDefenseEvents(projectRoot) {
49
79
  const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE);
@@ -51,7 +81,9 @@ export function readDefenseEvents(projectRoot) {
51
81
  const content = fs.readFileSync(filePath, 'utf-8');
52
82
  const parsed = JSON.parse(content);
53
83
  if (parsed && Array.isArray(parsed.events)) {
54
- const events = parsed.events.filter((e) => !!e && typeof e === 'object' && typeof e.type === 'string');
84
+ const events = parsed.events
85
+ .map(normalizeEvent)
86
+ .filter((e) => e !== null);
55
87
  const counts = computeCounts(events);
56
88
  return {
57
89
  events,
@@ -87,6 +119,25 @@ export function writeDefenseEvents(projectRoot, stream) {
87
119
  }
88
120
  return { ok: true };
89
121
  }
122
+ /**
123
+ * Clear the persisted defense-event stream (`.iterate/defense-events.json`).
124
+ * Mirrors `clearQualityGate` in quality-store.ts: a stale event stream from a
125
+ * previous iteration must be resettable before a fresh run. Returns whether a
126
+ * file existed and was removed, or a structured error when removal fails.
127
+ */
128
+ export function clearDefenseEvents(projectRoot) {
129
+ const filePath = path.join(iterateDir(projectRoot), DEFENSE_EVENTS_FILE);
130
+ const existed = fs.existsSync(filePath);
131
+ if (!existed)
132
+ return { ok: true, existed: false };
133
+ try {
134
+ fs.rmSync(filePath, { force: true });
135
+ }
136
+ catch (err) {
137
+ return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` };
138
+ }
139
+ return { ok: true, existed: true };
140
+ }
90
141
  /** Add a defense event to the stream. */
91
142
  export function addDefenseEvent(stream, event) {
92
143
  const id = `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -144,8 +144,8 @@ export function registerExperienceBankTool(ctx) {
144
144
  `Dimension: ${entry.dimension}`,
145
145
  `Description: ${entry.description}`,
146
146
  `Fix: ${entry.verifiedFix}`,
147
- `Files: ${entry.files.join(', ')}`,
148
- `Tags: ${entry.tags.join(', ')}`,
147
+ `Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
148
+ `Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
149
149
  ].join('\n') }];
150
150
  }
151
151
  if (value.operation === 'remove') {
@@ -158,9 +158,9 @@ export function registerExperienceBankTool(ctx) {
158
158
  `Pattern: ${entry.pattern}`,
159
159
  `Description: ${entry.description}`,
160
160
  `Fix: ${entry.verifiedFix}`,
161
- `Files: ${entry.files.join(', ')}`,
161
+ `Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
162
162
  `Hits: ${entry.hitCount}`,
163
- `Tags: ${entry.tags.join(', ')}`,
163
+ `Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
164
164
  ].join('\n') }];
165
165
  }
166
166
  const entries = value.entries ?? [];
@@ -252,7 +252,7 @@ export function registerExperienceBankTool(ctx) {
252
252
  operation: 'get',
253
253
  count: 1,
254
254
  entry: entry,
255
- totalHits: bank.totalHits,
255
+ totalHits: bank.totalHits ?? 0,
256
256
  };
257
257
  }
258
258
  if (operation === 'search' && typeof args.query === 'string') {
@@ -266,7 +266,7 @@ export function registerExperienceBankTool(ctx) {
266
266
  operation: 'search',
267
267
  count: entries.length,
268
268
  entries: entries,
269
- totalHits: bank.totalHits,
269
+ totalHits: bank.totalHits ?? 0,
270
270
  };
271
271
  }
272
272
  // Default: list with optional filters
@@ -275,7 +275,7 @@ export function registerExperienceBankTool(ctx) {
275
275
  entries = entries.filter((e) => e.dimension === args.dimension);
276
276
  }
277
277
  if (Array.isArray(args.tags) && args.tags.length > 0) {
278
- entries = entries.filter((e) => args.tags.every((t) => e.tags.includes(t)));
278
+ entries = entries.filter((e) => args.tags.every((t) => Array.isArray(e.tags) && e.tags.includes(t)));
279
279
  }
280
280
  return {
281
281
  ok: true,
@@ -283,7 +283,7 @@ export function registerExperienceBankTool(ctx) {
283
283
  operation: 'list',
284
284
  count: Math.min(entries.length, limit),
285
285
  entries: entries.slice(0, limit),
286
- totalHits: bank.totalHits,
286
+ totalHits: bank.totalHits ?? 0,
287
287
  };
288
288
  },
289
289
  }));
@@ -17,6 +17,46 @@ function emptyBank() {
17
17
  totalHits: 0,
18
18
  };
19
19
  }
20
+ /** Valid severity values (kept in sync with ExperienceEntry). */
21
+ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
22
+ /** String array guard for fields that must be arrays (`files`, `tags`). */
23
+ function stringArray(v) {
24
+ if (!Array.isArray(v))
25
+ return [];
26
+ return v.filter((x) => typeof x === 'string');
27
+ }
28
+ /**
29
+ * Normalize one persisted experience entry. A hand-edited bank entry can be
30
+ * missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
31
+ * entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
32
+ * throw or emit NaN. Returns null for non-object entries; every required field
33
+ * gets a safe default.
34
+ */
35
+ function normalizeEntry(raw) {
36
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
37
+ return null;
38
+ const e = raw;
39
+ const pattern = typeof e.pattern === 'string' ? e.pattern : '';
40
+ const dimension = typeof e.dimension === 'string' ? e.dimension : '';
41
+ const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`;
42
+ if (!pattern && !dimension)
43
+ return null;
44
+ const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0;
45
+ return {
46
+ id,
47
+ timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
48
+ dimension,
49
+ pattern,
50
+ description: typeof e.description === 'string' ? e.description : '',
51
+ verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
52
+ findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
53
+ files: stringArray(e.files),
54
+ hitCount,
55
+ ...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
56
+ tags: stringArray(e.tags),
57
+ severity: VALID_SEVERITIES.has(String(e.severity)) ? e.severity : 'low',
58
+ };
59
+ }
20
60
  /** Read the experience bank from disk. Returns empty bank if not found. */
21
61
  export function readExperienceBank(projectRoot) {
22
62
  const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE);
@@ -24,7 +64,14 @@ export function readExperienceBank(projectRoot) {
24
64
  const content = fs.readFileSync(filePath, 'utf-8');
25
65
  const parsed = JSON.parse(content);
26
66
  if (parsed && Array.isArray(parsed.entries)) {
27
- return parsed;
67
+ const entries = parsed.entries
68
+ .map(normalizeEntry)
69
+ .filter((e) => e !== null);
70
+ return {
71
+ entries,
72
+ lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
73
+ totalHits: typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
74
+ };
28
75
  }
29
76
  }
30
77
  catch {
@@ -58,17 +105,23 @@ export function writeExperienceBank(projectRoot, bank) {
58
105
  export function searchExperienceEntries(entries, query, opts = {}) {
59
106
  const lowerQuery = query.toLowerCase();
60
107
  return entries.filter((entry) => {
108
+ if (!entry || typeof entry !== 'object')
109
+ return false;
110
+ const rawEntry = entry;
111
+ const tags = stringArray(rawEntry.tags);
61
112
  // Dimension filter
62
113
  if (opts.dimension && entry.dimension !== opts.dimension) {
63
114
  return false;
64
115
  }
65
116
  // Tags filter (AND logic)
66
117
  if (opts.tags && opts.tags.length > 0) {
67
- if (!opts.tags.every((t) => entry.tags.includes(t))) {
118
+ if (!opts.tags.every((t) => tags.includes(t))) {
68
119
  return false;
69
120
  }
70
121
  }
71
- // Text search across multiple fields
122
+ // Text search across multiple fields. Guards against a hand-edited bank entry
123
+ // whose `files`/`tags` are missing or non-array (the spread below would
124
+ // otherwise throw a TypeError on a non-iterable).
72
125
  if (query) {
73
126
  const searchableText = [
74
127
  entry.pattern,
@@ -76,8 +129,8 @@ export function searchExperienceEntries(entries, query, opts = {}) {
76
129
  entry.verifiedFix,
77
130
  entry.findingSummary,
78
131
  entry.dimension,
79
- ...entry.files,
80
- ...entry.tags,
132
+ ...stringArray(entry.files),
133
+ ...stringArray(entry.tags),
81
134
  ].join(' ').toLowerCase();
82
135
  if (!searchableText.includes(lowerQuery)) {
83
136
  return false;
package/dist/tools/fix.js CHANGED
@@ -120,9 +120,22 @@ export function readRegistry(projectRoot) {
120
120
  const parsed = JSON.parse(readFileSync(file, 'utf-8'));
121
121
  if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds))
122
122
  return emptyRegistry();
123
- // Defensive: a hand-edited or partially-written registry may contain a
124
- // round without a `records` array — normalize instead of crashing readers.
125
- parsed.rounds = parsed.rounds.filter((r) => r && typeof r === 'object' && Array.isArray(r.records));
123
+ // Defensive normalization: a hand-edited or partially-written registry may
124
+ // contain a round without a `records` array, or records that are missing
125
+ // their id / finding object — all of which would make readers
126
+ // (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
127
+ parsed.rounds = parsed.rounds
128
+ .filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
129
+ .map((r) => ({
130
+ ...r,
131
+ round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
132
+ records: r.records.filter((rec) => !!rec &&
133
+ typeof rec === 'object' &&
134
+ typeof rec.id === 'string' &&
135
+ !!rec.finding &&
136
+ typeof rec.finding === 'object'),
137
+ }))
138
+ .filter((r) => typeof r.round === 'number' && Number.isFinite(r.round));
126
139
  return parsed;
127
140
  }
128
141
  catch {
@@ -611,20 +624,27 @@ export function registerDiffTool(ctx) {
611
624
  for (const r of round.records) {
612
625
  if (!r.success)
613
626
  continue;
614
- const existing = files.find((f) => f.file === r.finding.file);
627
+ const file = typeof r.finding.file === 'string' ? r.finding.file : '';
628
+ if (!file)
629
+ continue;
630
+ // Coerce defensively: a hand-edited registry record missing the
631
+ // numeric fields must not produce NaN in the accumulated summary.
632
+ const added = Number(r.linesAdded) || 0;
633
+ const removed = Number(r.linesRemoved) || 0;
634
+ const existing = files.find((f) => f.file === file);
615
635
  if (existing) {
616
- existing.linesAdded += r.linesAdded;
617
- existing.linesRemoved += r.linesRemoved;
636
+ existing.linesAdded += added;
637
+ existing.linesRemoved += removed;
618
638
  // Recompute the summary from the summed counts so a multi-fix
619
639
  // file's text does not contradict its accumulated numbers.
620
640
  existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`;
621
641
  }
622
642
  else {
623
643
  files.push({
624
- file: r.finding.file,
625
- diffSummary: r.diffSummary,
626
- linesAdded: r.linesAdded,
627
- linesRemoved: r.linesRemoved,
644
+ file,
645
+ diffSummary: typeof r.diffSummary === 'string' ? r.diffSummary : `+${added}/-${removed} lines`,
646
+ linesAdded: added,
647
+ linesRemoved: removed,
628
648
  });
629
649
  }
630
650
  }
@@ -46,8 +46,10 @@ export function filterDecisionEntries(entries, opts) {
46
46
  export function summarizeFixRegistry(registry) {
47
47
  const rounds = (registry.rounds ?? []).map((r) => ({
48
48
  round: r.round,
49
- fixedCount: r.fixedCount,
50
- failedCount: r.failedCount,
49
+ // Coerce defensively so a hand-edited registry round missing either count
50
+ // can never propagate NaN into the integer output fields.
51
+ fixedCount: Number(r.fixedCount) || 0,
52
+ failedCount: Number(r.failedCount) || 0,
51
53
  }));
52
54
  return {
53
55
  totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
@@ -74,7 +76,7 @@ export function registerHistoryTool(ctx) {
74
76
  type: {
75
77
  type: 'string',
76
78
  description: 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
77
- 'revert, validation, decision, report.',
79
+ 'revert, round_failed, validation, decision, report, resume.',
78
80
  },
79
81
  since: {
80
82
  type: 'string',