iterate-plugin 3.3.1 → 3.4.0

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
@@ -255,8 +255,8 @@ 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, `clear` removes it. Resumable interrupted long iterations
259
- - `iterate_status` — summarize current iteration state: mode, current/last round, fixes applied, remaining architectural, decision-log entry count, whether a checkpoint exists
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
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. 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
package/README.zh-CN.md CHANGED
@@ -254,8 +254,8 @@ 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` 读回,`clear` 清除。长迭代可中断续跑
258
- - `iterate_status` — 汇总当前迭代状态:模式、当前轮/总轮、已修复数、剩余 architectural、决策日志条数、是否存在 checkpoint
257
+ - `iterate_checkpoint` — 迭代断点:`save` 保存当前进度到 `.iterate/checkpoint.json`,`load` 读回,`resume` 加载并累加恢复计数(中断恢复),`clear` 清除。长迭代可中断续跑
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`,供客户端观测台读取
@@ -91,6 +91,27 @@ export function validateConfigUpdates(updates) {
91
91
  errors.push('updates.validation.commands must be an object of command arrays');
92
92
  }
93
93
  }
94
+ if ('observatory' in updates) {
95
+ const o = updates.observatory;
96
+ if (!o || typeof o !== 'object') {
97
+ errors.push('updates.observatory must be an object');
98
+ }
99
+ else {
100
+ if (o.capture !== undefined && typeof o.capture !== 'boolean') {
101
+ errors.push('updates.observatory.capture must be a boolean');
102
+ }
103
+ // The approval policy is the AUTHORITATIVE human-consent seam for
104
+ // destructive iterate tools (session-hooks.ts reads it at call time).
105
+ // Letting the model flip it to `allow` via a config write would bypass
106
+ // the gate entirely, so model-driven approval changes are refused
107
+ // fail-closed — the value can only be set by editing the config file
108
+ // directly (a human action the approval seam can trust).
109
+ if ('approval' in o) {
110
+ errors.push('updates.observatory.approval cannot be changed through iterate_config — ' +
111
+ 'edit iterate.config.yaml directly (the approval gate is human-controlled)');
112
+ }
113
+ }
114
+ }
94
115
  if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
95
116
  errors.push('updates.personalization must be an object');
96
117
  }
@@ -105,6 +126,12 @@ export function applyConfigUpdates(base, updates) {
105
126
  for (const [key, value] of Object.entries(updates)) {
106
127
  if (value === undefined)
107
128
  continue;
129
+ // Prototype-pollution guard (mirrors config-loader.mergeConfig): a
130
+ // caller-supplied `__proto__`/`constructor`/`prototype` key must never be
131
+ // plain-assigned — on a plain object `out['__proto__'] = value` would set
132
+ // the object's prototype instead of an own property.
133
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
134
+ continue;
108
135
  const baseValue = out[key];
109
136
  if (baseValue &&
110
137
  typeof baseValue === 'object' &&
@@ -21,7 +21,7 @@
21
21
  * cleanly; collectScopeFiles walks the filesystem.
22
22
  */
23
23
  import { readdirSync } from 'node:fs';
24
- import { join } from 'node:path';
24
+ import { join, relative } from 'node:path';
25
25
  /** Relative-scope sentinel for whole-module findings. */
26
26
  export const WHOLE_FILE_LINE = 0;
27
27
  /** Source extensions a full-scope walk includes. */
@@ -124,8 +124,16 @@ function collectFull(root) {
124
124
  continue;
125
125
  if (!sourceExt(entry.name))
126
126
  continue;
127
- const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs;
128
- out.push(rel.split(SEP).join(SEP));
127
+ // Derive the inventory path relative to the root via path.relative (then
128
+ // canonicalize separators). The old `abs.slice(root.length + 1)` string
129
+ // prefix was only correct on POSIX: on Windows path.join emits '\\' while
130
+ // this module's SEP is '/', so the prefix never matched and the ABSOLUTE
131
+ // path leaked into the inventory. path.relative is separator-agnostic and
132
+ // always yields a path strictly inside `root`.
133
+ const rel = normalizePath(relative(root, abs));
134
+ if (rel === '' || rel === '.' || rel.startsWith('../') || rel === '..')
135
+ continue;
136
+ out.push(rel);
129
137
  }
130
138
  }
131
139
  return out.sort();
package/dist/review.js CHANGED
@@ -219,11 +219,17 @@ function summarize(findings) {
219
219
  */
220
220
  export function buildReviewReport(input) {
221
221
  // 1. Filter known-intentional per round (before cross-round dedupe).
222
- const filteredRounds = input.rounds.map((r) => ({
222
+ const filteredRounds = input.rounds
223
+ .map((r) => ({
223
224
  round: typeof r?.round === 'number' ? r.round : 0,
224
225
  findings: filterKnownIntentional(Array.isArray(r?.findings) ? r.findings : [], input.knownIntentional),
225
226
  readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
226
- }));
227
+ }))
228
+ // Sort by round number so convergence math below (and the meta-review
229
+ // audit, which reads the LAST element as the highest round) never depends
230
+ // on the caller's array order. A resumed run that only passes [round 5]
231
+ // must be audited as round 5, not as "one round with no number".
232
+ .sort((a, b) => a.round - b.round);
227
233
  // 2. Cross-round dedupe + per-round "first seen" tracking.
228
234
  const { findings, findingsByRound } = aggregateRounds(filteredRounds, input.maxReviewRounds);
229
235
  // 3. Severity sort the global result.
@@ -234,7 +240,11 @@ export function buildReviewReport(input) {
234
240
  // using its reported round number — NOT `filteredRounds.length - 1`, which
235
241
  // is only valid for contiguous 1..N round numbers (resumed iterations and
236
242
  // non-contiguous round sets would otherwise read the wrong count).
237
- const lastRound = filteredRounds.length > 0 ? filteredRounds[filteredRounds.length - 1].round : 0;
243
+ let lastRound = 0;
244
+ for (const r of filteredRounds) {
245
+ if (typeof r.round === 'number' && r.round > lastRound)
246
+ lastRound = r.round;
247
+ }
238
248
  const lastRoundCount = lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0;
239
249
  const converged = filteredRounds.length > 0 && lastRoundCount === 0;
240
250
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
@@ -14,6 +14,9 @@ import { resolveProjectRootForExec } from "../config-loader.js";
14
14
  import { checkpointPath, iterateDir, transcriptPath } from "../paths.js";
15
15
  import { readRegistry } from "./fix.js";
16
16
  import { readDecisionEntries } from "./decision-log.js";
17
+ import { readQualityGate } from "./quality-store.js";
18
+ import { readExperienceBank } from "./experience-store.js";
19
+ import { readDefenseEvents } from "./defense-store.js";
17
20
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
18
21
  /** Read the current checkpoint from disk (missing/corrupt → null). */
19
22
  export function readCheckpoint(projectRoot) {
@@ -116,6 +119,12 @@ export function computeStatus(input) {
116
119
  resumeCount: checkpoint?.resumeCount ?? 0,
117
120
  checkpoint,
118
121
  lastUpdated,
122
+ // v3.0: quality command-center snapshots (present only when the caller
123
+ // supplied a real snapshot — the status never fabricates one that is not
124
+ // on disk, and never emits null for an absent optional field).
125
+ ...(input.qualityGate != null ? { qualityGate: input.qualityGate } : {}),
126
+ ...(input.experienceBank != null ? { experienceBank: input.experienceBank } : {}),
127
+ ...(input.defenseEvents != null ? { defenseEvents: input.defenseEvents } : {}),
119
128
  };
120
129
  }
121
130
  // ─── iterate_checkpoint ──────────────────────────────────────────────────────
@@ -126,14 +135,16 @@ export function computeStatus(input) {
126
135
  export function registerCheckpointTool(ctx) {
127
136
  ctx.tools.register(defineTool({
128
137
  name: 'iterate_checkpoint',
129
- description: 'Save / load / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
130
- 'each round (so a long run can resume) and clears it when the iteration completes.',
138
+ description: 'Save / load / resume / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
139
+ 'each round (so a long run can resume) and clears it when the iteration completes. ' +
140
+ '`resume` loads an existing checkpoint, bumps its resumeCount, and persists it back — ' +
141
+ 'call it when continuing an interrupted run so the resume counter stays accurate.',
131
142
  parameters: {
132
143
  operation: {
133
144
  type: 'string',
134
145
  required: true,
135
- description: '"save" to persist the current progress, "load" to read it back, "clear" to remove it.',
136
- enum: ['save', 'load', 'clear'],
146
+ description: '"save" to persist the current progress, "load" to read it back, "resume" to load + count a resumption, "clear" to remove it.',
147
+ enum: ['save', 'load', 'resume', 'clear'],
137
148
  },
138
149
  mode: { type: 'string', description: 'Required for save: "dry-run" or "normal".' },
139
150
  round: { type: 'integer', description: 'Required for save: current round number (0 = none started).' },
@@ -169,6 +180,28 @@ export function registerCheckpointTool(ctx) {
169
180
  const checkpoint = readCheckpoint(projectRoot);
170
181
  return { operation: 'load', ok: true, checkpoint: checkpoint };
171
182
  }
183
+ if (args.operation === 'resume') {
184
+ const current = readCheckpoint(projectRoot);
185
+ if (!current) {
186
+ return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' };
187
+ }
188
+ const resumed = {
189
+ ...current,
190
+ resumeCount: (current.resumeCount ?? 0) + 1,
191
+ updatedAt: new Date().toISOString(),
192
+ };
193
+ try {
194
+ mkdirSync(iterateDir(projectRoot), { recursive: true });
195
+ const cpPath = checkpointPath(projectRoot);
196
+ const tmpPath = `${cpPath}.tmp-${Date.now()}`;
197
+ writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8');
198
+ renameSync(tmpPath, cpPath);
199
+ }
200
+ catch (err) {
201
+ return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` };
202
+ }
203
+ return { operation: 'resume', ok: true, checkpoint: resumed };
204
+ }
172
205
  if (args.operation === 'clear') {
173
206
  const existed = existsSync(checkpointPath(projectRoot));
174
207
  if (existed) {
@@ -217,7 +250,7 @@ export function registerCheckpointTool(ctx) {
217
250
  }
218
251
  return { operation: 'save', ok: true, checkpoint: checkpoint };
219
252
  }
220
- return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", or "clear".' };
253
+ return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", "resume", or "clear".' };
221
254
  },
222
255
  }));
223
256
  }
@@ -252,6 +285,9 @@ export function registerStatusTool(ctx) {
252
285
  interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
253
286
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
254
287
  lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
288
+ qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
289
+ experienceBank: { type: 'json', description: 'v3.0: experience bank summary (.iterate/experience.json), when present.' },
290
+ defenseEvents: { type: 'json', description: 'v3.0: defense events summary (.iterate/defense-events.json), when present.' },
255
291
  error: { type: 'string' },
256
292
  },
257
293
  },
@@ -275,11 +311,30 @@ export function registerStatusTool(ctx) {
275
311
  if (!resolved.ok)
276
312
  return { ok: false, error: resolved.reason };
277
313
  const projectRoot = resolved.root;
314
+ // v3.0: surface the persisted quality command-center snapshots so a
315
+ // single `iterate_status` call reports the whole run state — the gate,
316
+ // the experience bank, and the defense event stream. Each read is
317
+ // defensive (missing/malformed files yield an empty snapshot), so the
318
+ // status never crashes on absent artifacts.
319
+ const qualityGate = readQualityGate(projectRoot);
320
+ const experienceBank = readExperienceBank(projectRoot);
321
+ const defenseEvents = readDefenseEvents(projectRoot);
278
322
  const status = computeStatus({
279
323
  checkpoint: readCheckpoint(projectRoot),
280
324
  taskMode: readTranscriptTaskMode(projectRoot),
281
325
  decisionEntries: readDecisionEntries(projectRoot),
282
326
  fixRegistry: readRegistry(projectRoot),
327
+ qualityGate: qualityGate.dimensions.length > 0 || qualityGate.overallStatus === 'pass' || qualityGate.overallStatus === 'fail'
328
+ ? qualityGate
329
+ : null,
330
+ experienceBank: {
331
+ totalEntries: experienceBank.entries.length,
332
+ totalHits: experienceBank.totalHits ?? 0,
333
+ },
334
+ defenseEvents: {
335
+ totalEvents: defenseEvents.events.length,
336
+ counts: defenseEvents.counts,
337
+ },
283
338
  });
284
339
  return {
285
340
  ok: true,
@@ -295,6 +350,9 @@ export function registerStatusTool(ctx) {
295
350
  interrupted: status.interrupted,
296
351
  resumeCount: status.resumeCount,
297
352
  lastUpdated: status.lastUpdated ?? null,
353
+ qualityGate: status.qualityGate ? status.qualityGate : null,
354
+ experienceBank: status.experienceBank ? status.experienceBank : null,
355
+ defenseEvents: status.defenseEvents ? status.defenseEvents : null,
298
356
  };
299
357
  },
300
358
  }));
@@ -56,6 +56,9 @@ function validateRecordInput(args) {
56
56
  if (typeof args.outcome !== 'string' || !args.outcome.trim()) {
57
57
  errors.push('outcome is required');
58
58
  }
59
+ if (args.line !== undefined && (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 0)) {
60
+ errors.push('line must be a non-negative integer when present');
61
+ }
59
62
  const severity = args.severity;
60
63
  if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
61
64
  errors.push('severity must be one of critical, high, medium, low');
@@ -37,14 +37,25 @@ function emptyStream() {
37
37
  },
38
38
  };
39
39
  }
40
- /** Read the defense events stream from disk. */
40
+ /**
41
+ * Read the defense events stream from disk.
42
+ * Normalizes the persisted stream so a hand-edited / partial file can never
43
+ * produce NaN counts: `counts` is recomputed from the events when missing or
44
+ * malformed, and every type key is guaranteed present.
45
+ */
41
46
  export function readDefenseEvents(projectRoot) {
42
47
  const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE);
43
48
  try {
44
49
  const content = fs.readFileSync(filePath, 'utf-8');
45
50
  const parsed = JSON.parse(content);
46
51
  if (parsed && Array.isArray(parsed.events)) {
47
- return parsed;
52
+ const events = parsed.events.filter((e) => !!e && typeof e === 'object' && typeof e.type === 'string');
53
+ const counts = computeCounts(events);
54
+ return {
55
+ events,
56
+ lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyStream().lastUpdated,
57
+ counts,
58
+ };
48
59
  }
49
60
  }
50
61
  catch {
@@ -80,7 +91,10 @@ export function addDefenseEvent(stream, event) {
80
91
  timestamp: new Date().toISOString(),
81
92
  ...event,
82
93
  };
83
- const newCounts = { ...stream.counts };
94
+ // Always recompute from the events array instead of mutating a possibly
95
+ // stale/malformed persisted `counts` object — guarantees the stream counts
96
+ // can never drift from (or NaN out against) its events.
97
+ const newCounts = computeCounts(stream.events);
84
98
  bumpCount(newCounts, event.type);
85
99
  return {
86
100
  events: [...stream.events, newEvent],
@@ -112,7 +112,7 @@ export function registerHistoryTool(ctx) {
112
112
  ? `Fixes: ${fixes.totalFixed} applied · ${fixes.totalFailed} failed · across ${fixes.roundCount} round(s)`
113
113
  : 'Fixes: none',
114
114
  '',
115
- ...log.map((e) => `[${e.timestamp}] r${e.round} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
115
+ ...log.map((e) => `[${e.timestamp}] r${e.round ?? '?'} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
116
116
  ];
117
117
  return [{ type: 'text', text: lines.join('\n') }];
118
118
  },
@@ -26,14 +26,56 @@ function emptySnapshot() {
26
26
  lowCount: 0,
27
27
  };
28
28
  }
29
- /** Read the quality gate snapshot from disk. */
29
+ /**
30
+ * Read the quality gate snapshot from disk.
31
+ * Normalizes a hand-edited / partial file so readers and the tool's `render`
32
+ * never crash on missing arrays or non-numeric fields: `dimensions` is
33
+ * guaranteed to be an array and every numeric field degrades to 0.
34
+ */
30
35
  export function readQualityGate(projectRoot) {
31
36
  const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE);
32
37
  try {
33
38
  const content = fs.readFileSync(filePath, 'utf-8');
34
39
  const parsed = JSON.parse(content);
35
40
  if (parsed && typeof parsed === 'object') {
36
- return parsed;
41
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : 0;
42
+ const status = parsed.overallStatus === 'pass' || parsed.overallStatus === 'fail' || parsed.overallStatus === 'pending'
43
+ ? parsed.overallStatus
44
+ : 'pending';
45
+ const dimensions = Array.isArray(parsed.dimensions)
46
+ ? parsed.dimensions
47
+ .filter((d) => !!d && typeof d === 'object' && typeof d.dimension === 'string')
48
+ .map((d) => {
49
+ const dimStatus = d.status === 'pass' || d.status === 'fail' || d.status === 'warn' ? d.status : 'warn';
50
+ return {
51
+ dimension: d.dimension,
52
+ convergenceRate: num(d.convergenceRate),
53
+ findingsCount: num(d.findingsCount),
54
+ fixedCount: num(d.fixedCount),
55
+ score: num(d.score),
56
+ status: dimStatus,
57
+ };
58
+ })
59
+ : [];
60
+ return {
61
+ timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
62
+ overallStatus: status,
63
+ overallScore: num(parsed.overallScore),
64
+ dimensions,
65
+ verificationPassRate: num(parsed.verificationPassRate),
66
+ totalChecks: num(parsed.totalChecks),
67
+ passedChecks: num(parsed.passedChecks),
68
+ failedChecks: num(parsed.failedChecks),
69
+ totalFindings: num(parsed.totalFindings),
70
+ criticalCount: num(parsed.criticalCount),
71
+ highCount: num(parsed.highCount),
72
+ mediumCount: num(parsed.mediumCount),
73
+ lowCount: num(parsed.lowCount),
74
+ // Only carry an own failReason when it is a real string — an absent
75
+ // persisted reason must not surface as `failReason: undefined` (which
76
+ // deep-equals differently than the JSON round-trip of computeQualityGate).
77
+ ...(typeof parsed.failReason === 'string' ? { failReason: parsed.failReason } : {}),
78
+ };
37
79
  }
38
80
  }
39
81
  catch {
@@ -32,6 +32,9 @@ const MAX_FINDINGS_PER_THREAD = 100;
32
32
  const MAX_FINDINGS_TOTAL = 2000;
33
33
  /** Max timeline entries kept (newest wins). */
34
34
  const MAX_TIMELINE = 500;
35
+ /** Max applied-fix records kept (newest wins; bounded so a long run cannot
36
+ * grow the manifest payload without limit). */
37
+ const MAX_FIXES = 200;
35
38
  /** Thresholds applied when reducing a string list under a cap. */
36
39
  function clampStringList(source, cap) {
37
40
  const out = [];
@@ -271,6 +274,9 @@ export class ReviewTranscriptBuilder {
271
274
  linesRemoved: typeof record.linesRemoved === 'number' ? Math.floor(record.linesRemoved) : 0,
272
275
  success: record.success !== false,
273
276
  });
277
+ if (this.fixes.length > MAX_FIXES) {
278
+ this.fixes.splice(0, this.fixes.length - MAX_FIXES);
279
+ }
274
280
  this.touch();
275
281
  }
276
282
  /** Flag a fix as rolled back (kept in the list so the UI shows the reversal). */
package/lib/client.js CHANGED
@@ -1525,6 +1525,7 @@ var ITERATE_CSS = `
1525
1525
  /* Dashboard empty/onboarding state */
1526
1526
  .iterate-dashboard-empty { opacity: 0.75; }
1527
1527
  .iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
1528
+ .iterate-dashboard-launch { display: inline-flex; align-items: center; gap: 6px; }
1528
1529
 
1529
1530
  /* Convergence-completed progress fill */
1530
1531
  .iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
@@ -1748,6 +1749,31 @@ function TrendChart({ points }) {
1748
1749
  "aria-label": `\u5404\u8F6E\u53D1\u73B0\u6570\u91CF\u8D8B\u52BF\uFF1A${summary}`
1749
1750
  }, ...bars);
1750
1751
  }
1752
+ function StartIterationButton() {
1753
+ const [copied, setCopied] = React.useState(null);
1754
+ const copy = (key, text) => {
1755
+ copyText(text).then((ok) => {
1756
+ if (ok) {
1757
+ setCopied(key);
1758
+ setTimeout(() => setCopied((cur) => cur === key ? null : cur), 1600);
1759
+ }
1760
+ });
1761
+ };
1762
+ const btn = (key, label, command, primary, title) => React.createElement("button", {
1763
+ key,
1764
+ className: "iterate-cmd",
1765
+ "data-primary": primary ? "" : void 0,
1766
+ "data-copied": copied === key ? "" : void 0,
1767
+ onClick: () => copy(key, command),
1768
+ title
1769
+ }, copied === key ? "\u5DF2\u590D\u5236" : label);
1770
+ return React.createElement(
1771
+ "span",
1772
+ { className: "iterate-dashboard-launch" },
1773
+ btn("start-full", "\u5B8C\u6574\u8FED\u4EE3", "/iterate", true, "\u590D\u5236\u542F\u52A8\u547D\u4EE4\uFF1A\u5B8C\u6574\u300C\u5BA1\u67E5 \u2192 \u4FEE\u590D \u2192 \u9A8C\u8BC1\u300D\u95ED\u73AF"),
1774
+ btn("start-review", "\u4EC5\u8BC4\u5BA1", "/iterate review-only", false, "\u590D\u5236\u542F\u52A8\u547D\u4EE4\uFF1A\u53EA\u5BA1\u67E5\u4E0D\u4FEE\u6539\uFF08dry-run\uFF09")
1775
+ );
1776
+ }
1751
1777
  function ConvergenceDashboard(props) {
1752
1778
  const [pulseKey, setPulseKey] = React.useState(0);
1753
1779
  const session = props && props.session ? props.session : null;
@@ -1764,7 +1790,8 @@ function ConvergenceDashboard(props) {
1764
1790
  "div",
1765
1791
  { "data-iterate-root": "", "data-iterate": "dashboard", className: "iterate-dashboard iterate-dashboard-empty" },
1766
1792
  React.createElement("span", { className: "iterate-round-badge" }, "iterate"),
1767
- React.createElement("span", { className: "iterate-empty-hint" }, "\u8FD0\u884C\u4E00\u6B21\u8BC4\u5BA1\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u6536\u655B\u8FDB\u5EA6\u4E0E\u53D1\u73B0\u7EDF\u8BA1\u3002\u8BD5\u8BD5\u300Creview this project\u300D\u6216\u300C/iterate review-only\u300D")
1793
+ React.createElement("span", { className: "iterate-empty-hint" }, "\u8FD0\u884C\u4E00\u6B21\u8BC4\u5BA1\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u6536\u655B\u8FDB\u5EA6\u4E0E\u53D1\u73B0\u7EDF\u8BA1\u3002"),
1794
+ React.createElement(StartIterationButton, null)
1768
1795
  );
1769
1796
  }
1770
1797
  const round = getCurrentRound(report);
@@ -3060,7 +3087,7 @@ ${JSON.stringify({
3060
3087
  "data-primary": "",
3061
3088
  "data-copied": copiedKey === "cp-resume" ? "" : void 0,
3062
3089
  onClick: () => copyInstruction("cp-resume", resumeText),
3063
- title: "\u590D\u5236 iterate_checkpoint \u7EFC/\u6062\u590D\u6307\u4EE4\u6587\u672C"
3090
+ title: "\u590D\u5236 iterate_checkpoint resume \u6307\u4EE4\u6587\u672C\uFF08\u52A0\u8F7D\u65AD\u70B9\u5E76\u8BA1\u6570\u4E00\u6B21\u6062\u590D\uFF09"
3064
3091
  }, copiedKey === "cp-resume" ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u6062\u590D\u6307\u4EE4")
3065
3092
  )
3066
3093
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -567,6 +567,7 @@ const ITERATE_CSS = `
567
567
  /* Dashboard empty/onboarding state */
568
568
  .iterate-dashboard-empty { opacity: 0.75; }
569
569
  .iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
570
+ .iterate-dashboard-launch { display: inline-flex; align-items: center; gap: 6px; }
570
571
 
571
572
  /* Convergence-completed progress fill */
572
573
  .iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
@@ -834,6 +835,36 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
834
835
  }, ...bars)
835
836
  }
836
837
 
838
+ /** Start-iteration launcher shown on the empty dashboard: copies a runnable
839
+ * command (the browser cannot invoke harness tools directly, so every action
840
+ * follows the panel's copy-to-command pattern). */
841
+ function StartIterationButton() {
842
+ const [copied, setCopied] = React.useState<string | null>(null)
843
+ const copy = (key: string, text: string) => {
844
+ copyText(text).then((ok) => {
845
+ if (ok) {
846
+ setCopied(key)
847
+ setTimeout(() => setCopied((cur) => (cur === key ? null : cur)), 1600)
848
+ }
849
+ })
850
+ }
851
+ const btn = (key: string, label: string, command: string, primary: boolean, title: string) =>
852
+ React.createElement('button', {
853
+ key,
854
+ className: 'iterate-cmd',
855
+ 'data-primary': primary ? '' : undefined,
856
+ 'data-copied': copied === key ? '' : undefined,
857
+ onClick: () => copy(key, command),
858
+ title,
859
+ }, copied === key ? '已复制' : label)
860
+ return React.createElement(
861
+ 'span',
862
+ { className: 'iterate-dashboard-launch' },
863
+ btn('start-full', '完整迭代', '/iterate', true, '复制启动命令:完整「审查 → 修复 → 验证」闭环'),
864
+ btn('start-review', '仅评审', '/iterate review-only', false, '复制启动命令:只审查不修改(dry-run)'),
865
+ )
866
+ }
867
+
837
868
  /** Dashboard: live convergence strip above the composer.
838
869
  *
839
870
  * The `conversation.input.dock` slot's owner share is `InputZone`, which
@@ -859,12 +890,15 @@ function ConvergenceDashboard(props: SlotProps) {
859
890
 
860
891
  if (!report) {
861
892
  // Empty/onboarding state: first-time users otherwise see nothing and have
862
- // no idea the plugin exists or how to start.
893
+ // no idea the plugin exists or how to start. Provide a one-click command
894
+ // that launches the loop (copied for paste — the browser cannot call
895
+ // harness tools directly).
863
896
  return React.createElement(
864
897
  'div',
865
898
  { 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard iterate-dashboard-empty' },
866
899
  React.createElement('span', { className: 'iterate-round-badge' }, 'iterate'),
867
- React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。试试「review this project」或「/iterate review-only」'),
900
+ React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。'),
901
+ React.createElement(StartIterationButton, null),
868
902
  )
869
903
  }
870
904
 
@@ -2151,7 +2185,7 @@ function ObservatoryPanel(props: SlotProps) {
2151
2185
  React.createElement('button', {
2152
2186
  className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
2153
2187
  onClick: () => copyInstruction('cp-resume', resumeText),
2154
- title: '复制 iterate_checkpoint 综/恢复指令文本',
2188
+ title: '复制 iterate_checkpoint resume 指令文本(加载断点并计数一次恢复)',
2155
2189
  }, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
2156
2190
  ),
2157
2191
  )
@@ -92,6 +92,28 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
92
92
  errors.push('updates.validation.commands must be an object of command arrays')
93
93
  }
94
94
  }
95
+ if ('observatory' in updates) {
96
+ const o = updates.observatory as Record<string, unknown> | undefined
97
+ if (!o || typeof o !== 'object') {
98
+ errors.push('updates.observatory must be an object')
99
+ } else {
100
+ if (o.capture !== undefined && typeof o.capture !== 'boolean') {
101
+ errors.push('updates.observatory.capture must be a boolean')
102
+ }
103
+ // The approval policy is the AUTHORITATIVE human-consent seam for
104
+ // destructive iterate tools (session-hooks.ts reads it at call time).
105
+ // Letting the model flip it to `allow` via a config write would bypass
106
+ // the gate entirely, so model-driven approval changes are refused
107
+ // fail-closed — the value can only be set by editing the config file
108
+ // directly (a human action the approval seam can trust).
109
+ if ('approval' in o) {
110
+ errors.push(
111
+ 'updates.observatory.approval cannot be changed through iterate_config — ' +
112
+ 'edit iterate.config.yaml directly (the approval gate is human-controlled)',
113
+ )
114
+ }
115
+ }
116
+ }
95
117
  if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
96
118
  errors.push('updates.personalization must be an object')
97
119
  }
@@ -109,6 +131,11 @@ export function applyConfigUpdates(
109
131
  const out: Record<string, unknown> = { ...base }
110
132
  for (const [key, value] of Object.entries(updates)) {
111
133
  if (value === undefined) continue
134
+ // Prototype-pollution guard (mirrors config-loader.mergeConfig): a
135
+ // caller-supplied `__proto__`/`constructor`/`prototype` key must never be
136
+ // plain-assigned — on a plain object `out['__proto__'] = value` would set
137
+ // the object's prototype instead of an own property.
138
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
112
139
  const baseValue = out[key]
113
140
  if (
114
141
  baseValue &&
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { readdirSync } from 'node:fs'
25
- import { join } from 'node:path'
25
+ import { join, relative } from 'node:path'
26
26
 
27
27
  export interface CoverageResult {
28
28
  assigned: string[]
@@ -135,8 +135,15 @@ function collectFull(root: string): string[] {
135
135
  }
136
136
  if (!entry.isFile()) continue
137
137
  if (!sourceExt(entry.name)) continue
138
- const rel = abs.startsWith(root + SEP) ? abs.slice(root.length + 1) : abs
139
- out.push(rel.split(SEP).join(SEP))
138
+ // Derive the inventory path relative to the root via path.relative (then
139
+ // canonicalize separators). The old `abs.slice(root.length + 1)` string
140
+ // prefix was only correct on POSIX: on Windows path.join emits '\\' while
141
+ // this module's SEP is '/', so the prefix never matched and the ABSOLUTE
142
+ // path leaked into the inventory. path.relative is separator-agnostic and
143
+ // always yields a path strictly inside `root`.
144
+ const rel = normalizePath(relative(root, abs))
145
+ if (rel === '' || rel === '.' || rel.startsWith('../') || rel === '..') continue
146
+ out.push(rel)
140
147
  }
141
148
  }
142
149
  return out.sort()
package/src/review.ts CHANGED
@@ -247,14 +247,20 @@ export function buildReviewReport(input: {
247
247
  fixedCount?: number
248
248
  }): ReviewReport {
249
249
  // 1. Filter known-intentional per round (before cross-round dedupe).
250
- const filteredRounds = input.rounds.map((r) => ({
251
- round: typeof r?.round === 'number' ? r.round : 0,
252
- findings: filterKnownIntentional(
253
- Array.isArray(r?.findings) ? r.findings : [],
254
- input.knownIntentional,
255
- ),
256
- readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
257
- }))
250
+ const filteredRounds = input.rounds
251
+ .map((r) => ({
252
+ round: typeof r?.round === 'number' ? r.round : 0,
253
+ findings: filterKnownIntentional(
254
+ Array.isArray(r?.findings) ? r.findings : [],
255
+ input.knownIntentional,
256
+ ),
257
+ readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
258
+ }))
259
+ // Sort by round number so convergence math below (and the meta-review
260
+ // audit, which reads the LAST element as the highest round) never depends
261
+ // on the caller's array order. A resumed run that only passes [round 5]
262
+ // must be audited as round 5, not as "one round with no number".
263
+ .sort((a, b) => a.round - b.round)
258
264
 
259
265
  // 2. Cross-round dedupe + per-round "first seen" tracking.
260
266
  const { findings, findingsByRound } = aggregateRounds(
@@ -271,8 +277,10 @@ export function buildReviewReport(input: {
271
277
  // using its reported round number — NOT `filteredRounds.length - 1`, which
272
278
  // is only valid for contiguous 1..N round numbers (resumed iterations and
273
279
  // non-contiguous round sets would otherwise read the wrong count).
274
- const lastRound =
275
- filteredRounds.length > 0 ? filteredRounds[filteredRounds.length - 1]!.round : 0
280
+ let lastRound = 0
281
+ for (const r of filteredRounds) {
282
+ if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
283
+ }
276
284
  const lastRoundCount =
277
285
  lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
278
286
  const converged = filteredRounds.length > 0 && lastRoundCount === 0
@@ -16,7 +16,10 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
16
16
  import { checkpointPath, iterateDir, transcriptPath } from '../paths.ts'
17
17
  import { readRegistry } from './fix.ts'
18
18
  import { readDecisionEntries } from './decision-log.ts'
19
- import type { IterationCheckpoint, IterationStatus } from '../types.ts'
19
+ import { readQualityGate } from './quality-store.ts'
20
+ import { readExperienceBank } from './experience-store.ts'
21
+ import { readDefenseEvents } from './defense-store.ts'
22
+ import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGateSnapshot } from '../types.ts'
20
23
 
21
24
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
22
25
 
@@ -90,6 +93,12 @@ export function computeStatus(input: {
90
93
  taskMode?: 'code' | 'iterate' | null
91
94
  decisionEntries: { timestamp: string; type: string; round?: number; data?: Record<string, unknown> }[]
92
95
  fixRegistry: { rounds: { round: number; fixedCount: number; failedCount: number }[] }
96
+ /** v3.0: persisted quality-gate snapshot (optional; absent → omitted). */
97
+ qualityGate?: QualityGateSnapshot | null
98
+ /** v3.0: experience bank summary (optional; absent → omitted). */
99
+ experienceBank?: { totalEntries: number; totalHits: number } | null
100
+ /** v3.0: defense events summary (optional; absent → omitted). */
101
+ defenseEvents?: { totalEvents: number; counts: Record<DefenseEventType, number> } | null
93
102
  }): IterationStatus {
94
103
  const checkpoint = input.checkpoint
95
104
  const taskMode = input.taskMode ?? null
@@ -134,6 +143,12 @@ export function computeStatus(input: {
134
143
  resumeCount: checkpoint?.resumeCount ?? 0,
135
144
  checkpoint,
136
145
  lastUpdated,
146
+ // v3.0: quality command-center snapshots (present only when the caller
147
+ // supplied a real snapshot — the status never fabricates one that is not
148
+ // on disk, and never emits null for an absent optional field).
149
+ ...(input.qualityGate != null ? { qualityGate: input.qualityGate } : {}),
150
+ ...(input.experienceBank != null ? { experienceBank: input.experienceBank } : {}),
151
+ ...(input.defenseEvents != null ? { defenseEvents: input.defenseEvents } : {}),
137
152
  }
138
153
  }
139
154
 
@@ -148,14 +163,16 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
148
163
  defineTool({
149
164
  name: 'iterate_checkpoint',
150
165
  description:
151
- 'Save / load / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
152
- 'each round (so a long run can resume) and clears it when the iteration completes.',
166
+ 'Save / load / resume / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
167
+ 'each round (so a long run can resume) and clears it when the iteration completes. ' +
168
+ '`resume` loads an existing checkpoint, bumps its resumeCount, and persists it back — ' +
169
+ 'call it when continuing an interrupted run so the resume counter stays accurate.',
153
170
  parameters: {
154
171
  operation: {
155
172
  type: 'string',
156
173
  required: true,
157
- description: '"save" to persist the current progress, "load" to read it back, "clear" to remove it.',
158
- enum: ['save', 'load', 'clear'],
174
+ description: '"save" to persist the current progress, "load" to read it back, "resume" to load + count a resumption, "clear" to remove it.',
175
+ enum: ['save', 'load', 'resume', 'clear'],
159
176
  },
160
177
  mode: { type: 'string', description: 'Required for save: "dry-run" or "normal".' },
161
178
  round: { type: 'integer', description: 'Required for save: current round number (0 = none started).' },
@@ -194,6 +211,28 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
194
211
  return { operation: 'load', ok: true, checkpoint: checkpoint as unknown as JsonValue | null }
195
212
  }
196
213
 
214
+ if (args.operation === 'resume') {
215
+ const current = readCheckpoint(projectRoot)
216
+ if (!current) {
217
+ return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' }
218
+ }
219
+ const resumed: IterationCheckpoint = {
220
+ ...current,
221
+ resumeCount: (current.resumeCount ?? 0) + 1,
222
+ updatedAt: new Date().toISOString(),
223
+ }
224
+ try {
225
+ mkdirSync(iterateDir(projectRoot), { recursive: true })
226
+ const cpPath = checkpointPath(projectRoot)
227
+ const tmpPath = `${cpPath}.tmp-${Date.now()}`
228
+ writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8')
229
+ renameSync(tmpPath, cpPath)
230
+ } catch (err) {
231
+ return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
232
+ }
233
+ return { operation: 'resume', ok: true, checkpoint: resumed as unknown as JsonValue }
234
+ }
235
+
197
236
  if (args.operation === 'clear') {
198
237
  const existed = existsSync(checkpointPath(projectRoot))
199
238
  if (existed) {
@@ -239,7 +278,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
239
278
  return { operation: 'save', ok: true, checkpoint: checkpoint as unknown as JsonValue }
240
279
  }
241
280
 
242
- return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", or "clear".' }
281
+ return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", "resume", or "clear".' }
243
282
  },
244
283
  }),
245
284
  )
@@ -280,6 +319,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
280
319
  interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
281
320
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
282
321
  lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
322
+ qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
323
+ experienceBank: { type: 'json', description: 'v3.0: experience bank summary (.iterate/experience.json), when present.' },
324
+ defenseEvents: { type: 'json', description: 'v3.0: defense events summary (.iterate/defense-events.json), when present.' },
283
325
  error: { type: 'string' },
284
326
  },
285
327
  },
@@ -302,11 +344,31 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
302
344
  const resolved = resolveProjectRootForExec(exec, args.path)
303
345
  if (!resolved.ok) return { ok: false, error: resolved.reason }
304
346
  const projectRoot = resolved.root
347
+ // v3.0: surface the persisted quality command-center snapshots so a
348
+ // single `iterate_status` call reports the whole run state — the gate,
349
+ // the experience bank, and the defense event stream. Each read is
350
+ // defensive (missing/malformed files yield an empty snapshot), so the
351
+ // status never crashes on absent artifacts.
352
+ const qualityGate = readQualityGate(projectRoot)
353
+ const experienceBank = readExperienceBank(projectRoot)
354
+ const defenseEvents = readDefenseEvents(projectRoot)
305
355
  const status = computeStatus({
306
356
  checkpoint: readCheckpoint(projectRoot),
307
357
  taskMode: readTranscriptTaskMode(projectRoot),
308
358
  decisionEntries: readDecisionEntries(projectRoot),
309
359
  fixRegistry: readRegistry(projectRoot),
360
+ qualityGate:
361
+ qualityGate.dimensions.length > 0 || qualityGate.overallStatus === 'pass' || qualityGate.overallStatus === 'fail'
362
+ ? qualityGate
363
+ : null,
364
+ experienceBank: {
365
+ totalEntries: experienceBank.entries.length,
366
+ totalHits: experienceBank.totalHits ?? 0,
367
+ },
368
+ defenseEvents: {
369
+ totalEvents: defenseEvents.events.length,
370
+ counts: defenseEvents.counts,
371
+ },
310
372
  })
311
373
  return {
312
374
  ok: true,
@@ -322,6 +384,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
322
384
  interrupted: status.interrupted,
323
385
  resumeCount: status.resumeCount,
324
386
  lastUpdated: status.lastUpdated ?? null,
387
+ qualityGate: status.qualityGate ? (status.qualityGate as unknown as JsonValue) : null,
388
+ experienceBank: status.experienceBank ? (status.experienceBank as unknown as JsonValue) : null,
389
+ defenseEvents: status.defenseEvents ? (status.defenseEvents as unknown as JsonValue) : null,
325
390
  }
326
391
  },
327
392
  }),
@@ -55,6 +55,7 @@ function validateRecordInput(args: {
55
55
  defense?: unknown
56
56
  outcome?: unknown
57
57
  severity?: unknown
58
+ line?: unknown
58
59
  }): string[] {
59
60
  const errors: string[] = []
60
61
  if (typeof args.type !== 'string' || !EVENT_TYPES.includes(args.type as DefenseEventType)) {
@@ -72,6 +73,9 @@ function validateRecordInput(args: {
72
73
  if (typeof args.outcome !== 'string' || !args.outcome.trim()) {
73
74
  errors.push('outcome is required')
74
75
  }
76
+ if (args.line !== undefined && (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 0)) {
77
+ errors.push('line must be a non-negative integer when present')
78
+ }
75
79
  const severity = args.severity
76
80
  if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
77
81
  errors.push('severity must be one of critical, high, medium, low')
@@ -44,14 +44,28 @@ function emptyStream(): DefenseEventStream {
44
44
  }
45
45
  }
46
46
 
47
- /** Read the defense events stream from disk. */
47
+ /**
48
+ * Read the defense events stream from disk.
49
+ * Normalizes the persisted stream so a hand-edited / partial file can never
50
+ * produce NaN counts: `counts` is recomputed from the events when missing or
51
+ * malformed, and every type key is guaranteed present.
52
+ */
48
53
  export function readDefenseEvents(projectRoot: string): DefenseEventStream {
49
54
  const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE)
50
55
  try {
51
56
  const content = fs.readFileSync(filePath, 'utf-8')
52
- const parsed = JSON.parse(content) as DefenseEventStream
57
+ const parsed = JSON.parse(content) as Partial<DefenseEventStream>
53
58
  if (parsed && Array.isArray(parsed.events)) {
54
- return parsed
59
+ const events = parsed.events.filter(
60
+ (e): e is DefenseEvent =>
61
+ !!e && typeof e === 'object' && typeof (e as DefenseEvent).type === 'string',
62
+ )
63
+ const counts = computeCounts(events)
64
+ return {
65
+ events,
66
+ lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyStream().lastUpdated,
67
+ counts,
68
+ }
55
69
  }
56
70
  } catch {
57
71
  // File not found or invalid JSON
@@ -95,7 +109,10 @@ export function addDefenseEvent(
95
109
  ...event,
96
110
  }
97
111
 
98
- const newCounts = { ...stream.counts }
112
+ // Always recompute from the events array instead of mutating a possibly
113
+ // stale/malformed persisted `counts` object — guarantees the stream counts
114
+ // can never drift from (or NaN out against) its events.
115
+ const newCounts = computeCounts(stream.events)
99
116
  bumpCount(newCounts, event.type)
100
117
 
101
118
  return {
@@ -130,7 +130,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
130
130
  ? `Fixes: ${fixes.totalFixed} applied · ${fixes.totalFailed} failed · across ${fixes.roundCount} round(s)`
131
131
  : 'Fixes: none',
132
132
  '',
133
- ...log.map((e) => `[${e.timestamp}] r${e.round} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
133
+ ...log.map((e) => `[${e.timestamp}] r${e.round ?? '?'} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
134
134
  ]
135
135
  return [{ type: 'text', text: lines.join('\n') }]
136
136
  },
@@ -31,14 +31,59 @@ function emptySnapshot(): QualityGateSnapshot {
31
31
  }
32
32
  }
33
33
 
34
- /** Read the quality gate snapshot from disk. */
34
+ /**
35
+ * Read the quality gate snapshot from disk.
36
+ * Normalizes a hand-edited / partial file so readers and the tool's `render`
37
+ * never crash on missing arrays or non-numeric fields: `dimensions` is
38
+ * guaranteed to be an array and every numeric field degrades to 0.
39
+ */
35
40
  export function readQualityGate(projectRoot: string): QualityGateSnapshot {
36
41
  const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE)
37
42
  try {
38
43
  const content = fs.readFileSync(filePath, 'utf-8')
39
- const parsed = JSON.parse(content) as QualityGateSnapshot
44
+ const parsed = JSON.parse(content) as Partial<QualityGateSnapshot> | null
40
45
  if (parsed && typeof parsed === 'object') {
41
- return parsed
46
+ const num = (v: unknown): number =>
47
+ typeof v === 'number' && Number.isFinite(v) ? v : 0
48
+ const status: QualityGateSnapshot['overallStatus'] =
49
+ parsed.overallStatus === 'pass' || parsed.overallStatus === 'fail' || parsed.overallStatus === 'pending'
50
+ ? parsed.overallStatus
51
+ : 'pending'
52
+ const dimensions = Array.isArray(parsed.dimensions)
53
+ ? (parsed.dimensions as unknown as Array<Record<string, unknown>>)
54
+ .filter((d): d is Record<string, unknown> => !!d && typeof d === 'object' && typeof d.dimension === 'string')
55
+ .map((d) => {
56
+ const dimStatus: QualityGateDimension['status'] =
57
+ d.status === 'pass' || d.status === 'fail' || d.status === 'warn' ? d.status : 'warn'
58
+ return {
59
+ dimension: d.dimension as string,
60
+ convergenceRate: num(d.convergenceRate),
61
+ findingsCount: num(d.findingsCount),
62
+ fixedCount: num(d.fixedCount),
63
+ score: num(d.score),
64
+ status: dimStatus,
65
+ }
66
+ })
67
+ : []
68
+ return {
69
+ timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
70
+ overallStatus: status,
71
+ overallScore: num(parsed.overallScore),
72
+ dimensions,
73
+ verificationPassRate: num(parsed.verificationPassRate),
74
+ totalChecks: num(parsed.totalChecks),
75
+ passedChecks: num(parsed.passedChecks),
76
+ failedChecks: num(parsed.failedChecks),
77
+ totalFindings: num(parsed.totalFindings),
78
+ criticalCount: num(parsed.criticalCount),
79
+ highCount: num(parsed.highCount),
80
+ mediumCount: num(parsed.mediumCount),
81
+ lowCount: num(parsed.lowCount),
82
+ // Only carry an own failReason when it is a real string — an absent
83
+ // persisted reason must not surface as `failReason: undefined` (which
84
+ // deep-equals differently than the JSON round-trip of computeQualityGate).
85
+ ...(typeof parsed.failReason === 'string' ? { failReason: parsed.failReason } : {}),
86
+ }
42
87
  }
43
88
  } catch {
44
89
  // File not found or invalid JSON
package/src/transcript.ts CHANGED
@@ -49,6 +49,10 @@ const MAX_FINDINGS_TOTAL = 2000
49
49
  /** Max timeline entries kept (newest wins). */
50
50
  const MAX_TIMELINE = 500
51
51
 
52
+ /** Max applied-fix records kept (newest wins; bounded so a long run cannot
53
+ * grow the manifest payload without limit). */
54
+ const MAX_FIXES = 200
55
+
52
56
  /** Thresholds applied when reducing a string list under a cap. */
53
57
  function clampStringList(source: string[], cap: number): string[] {
54
58
  const out: string[] = []
@@ -317,6 +321,9 @@ export class ReviewTranscriptBuilder {
317
321
  typeof record.linesRemoved === 'number' ? Math.floor(record.linesRemoved) : 0,
318
322
  success: record.success !== false,
319
323
  })
324
+ if (this.fixes.length > MAX_FIXES) {
325
+ this.fixes.splice(0, this.fixes.length - MAX_FIXES)
326
+ }
320
327
  this.touch()
321
328
  }
322
329