iterate-plugin 3.3.1 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -292,9 +292,20 @@ All runtime state lives under `.iterate/` at the project root (can be excluded v
292
292
 
293
293
  - **Node.js**: `>=20` (`package.json` `engines.node`).
294
294
  - **DSH**: declares `dsh.compatibility.dshReleases` — `compatible` for the official
295
- `0.1.2-alpha.4`, `0.1.2-alpha.5`, and `0.1.2-rc.1` releases. The plugin is built
296
- against `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1`
297
- and uses only public contracts (tool registration, client slots + theme, bundle patch).
295
+ `0.1.1-rc.1`, `0.1.2-alpha.4`, `0.1.2-alpha.5`, `0.1.2-rc.1`, and `0.1.3-alpha.1`
296
+ releases. The plugin is built against `@deepseek-ai/dsh-tools` /
297
+ `@deepseek-ai/dsh-util-values` `0.1.2-rc.1` and uses only public contracts
298
+ (tool registration, client slots + theme, bundle patch). `0.1.3-alpha.1` is a
299
+ source declaration matching the current DSH release window; it does not use any
300
+ API changed by that release.
301
+ - **Disposable-Profile evidence (real, `dsh` CLI `0.1.1-rc.1`)**: on a temp
302
+ `$DSH_HOME`, `dsh plugin --profile <p> add <this-repo>` installed the bundle in
303
+ ~449 ms; `dsh --profile <p> --dump-config` composed the `iterate-plugin` bundle
304
+ patch into the profile; `dsh plugin --profile <p> remove iterate-plugin`
305
+ uninstalled in ~602 ms and left zero references in the composed config. A full
306
+ runtime boot was **not** exercised here (no model provider in the disposable
307
+ profile), so `dshOperations` start/rollback stay `unknown`/`partial` — see
308
+ `CHANGELOG.md` for the exact commands and timings.
298
309
 
299
310
  ### Runtime permissions (conservative disclosure)
300
311
 
@@ -308,10 +319,14 @@ following capabilities; elevated capability means a DSH Profile install stays
308
319
  in-place edits to the user's source files (path-traversal protected to the
309
320
  resolved project root, backups taken before every fix, rollback on failure).
310
321
  The client half persists triage verdicts to `localStorage`.
311
- - **Commands** — the plugin registers model-facing tools; any shell execution that
312
- the model performs (build / test / `git`) is run by the **host**, not by this
313
- plugin. The client "command buttons" only copy paste-able instruction text; they
314
- do not spawn processes.
322
+ - **Commands** — the plugin's own tools execute a small, strictly bounded command
323
+ surface inside the dsh host process, only when the model calls them: `iterate_validate`
324
+ runs one of the **exact-match whitelisted** commands configured in
325
+ `iterate.config.yaml` `validation.commands` (yes, it shells out via
326
+ `node:child_process`, timeout-capped to ≤600 s); `iterate_review` (changed-only
327
+ scope) runs `git diff --name-only -z <branch>` in the project root. Nothing else
328
+ spawns a process. The client "command buttons" only copy paste-able instruction
329
+ text; they do not spawn processes.
315
330
  - **Credentials** — this plugin does **not** read or transmit credentials at
316
331
  runtime. The sibling **iterate-skill / iterate-harness** components may read git
317
332
  credentials / GitHub tokens when the user runs their own Git/API operations; those
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`,供客户端观测台读取
@@ -290,10 +290,17 @@ validation:
290
290
  ### Node.js / DSH 兼容范围
291
291
 
292
292
  - **Node.js**:`>=20`(`package.json` 的 `engines.node`)。
293
- - **DSH**:在 `dsh.compatibility.dshReleases` 中声明官方 `0.1.2-alpha.4`、
294
- `0.1.2-alpha.5`、`0.1.2-rc.1` 三个版本为 `compatible`。插件基于
295
- `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1` 构建,
296
- 只使用公开契约(工具注册、客户端 slots + theme、bundle patch)。
293
+ - **DSH**:在 `dsh.compatibility.dshReleases` 中声明官方 `0.1.1-rc.1`、
294
+ `0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1` 五个版本为
295
+ `compatible`。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values`
296
+ `0.1.2-rc.1` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch);
297
+ `0.1.3-alpha.1` 为匹配当前 DSH 发布窗口的源码声明,插件不使用该发布变更的任何 API。
298
+ - **一次性 Profile 证据(真实,`dsh` CLI `0.1.1-rc.1`)**:在临时 `$DSH_HOME` 下
299
+ `dsh plugin --profile <p> add <本仓库>` 约 449ms 完成安装;`dsh --profile <p>
300
+ --dump-config` 正确合成 `iterate-plugin` bundle patch;`dsh plugin --profile <p>
301
+ remove iterate-plugin` 约 602ms 卸载完成、合成配置中不存在残留引用。完整运行时
302
+ 启动未在此环境演练(一次性 Profile 无模型提供方),故 `dshOperations` 的
303
+ start/rollback 证据为 `unknown`/`partial`——确切命令与耗时见 `CHANGELOG.md`。
297
304
 
298
305
  ### 运行时权限(保守披露)
299
306
 
@@ -304,9 +311,12 @@ validation:
304
311
  检查点、transcript、修复备份/注册表、质量门禁、经验银行、防御事件)。
305
312
  `iterate_fix` / `iterate_triage` 还会就地修改用户源码(路径遍历防护限定在解析后
306
313
  的项目根目录内,每次修复前备份、失败回滚)。客户端侧把分诊判定持久化到 `localStorage`。
307
- - **命令(commands)** — 插件只注册模型可调用工具;模型要执行的任何 shell 操作
308
- (build / test / `git`)都由**宿主**执行,不是本插件。客户端"指挥按钮"只复制可粘贴
309
- 的指令文本,不启动任何进程。
314
+ - **命令(commands)** — 插件的工具本身只执行一个严格受限的命令面、且仅在模型调用时
315
+ 于 dsh 宿主进程内运行:`iterate_validate` 运行 `iterate.config.yaml`
316
+ `validation.commands` 中**逐条精确匹配白名单**的命令(是的,经由
317
+ `node:child_process` 执行,超时上限 600 秒);`iterate_review`(changed-only 范围)
318
+ 在项目根目录运行 `git diff --name-only -z <branch>`。除此之外不启动任何进程。
319
+ 客户端"指挥按钮"只复制可粘贴的指令文本,不启动任何进程。
310
320
  - **凭据(credentials)** — 本插件运行时**不**读取或传输凭据。同生态的
311
321
  **iterate-skill / iterate-harness** 组件在用户自行执行 Git/API 操作时可能读取
312
322
  git 凭据 / GitHub token;它们是独立包,本插件从不加载它们。
@@ -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' &&
package/dist/git-scope.js CHANGED
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { execFile } from 'node:child_process';
24
24
  import { existsSync, statSync } from 'node:fs';
25
- import { join } from 'node:path';
25
+ import { join, sep } from 'node:path';
26
26
  /**
27
27
  * Parse `git diff --name-only -z` stdout into a list of relative paths.
28
28
  * Pure. NUL-delimited mode is machine-safe (handles any filename); when no
@@ -112,11 +112,12 @@ export function parseChangedFiles(stdout) {
112
112
  */
113
113
  export function filterExistingFiles(root, files) {
114
114
  const out = [];
115
+ const rootPrefix = root.endsWith(sep) ? root : root + sep;
115
116
  for (const rel of files) {
116
117
  if (rel.startsWith('/') || rel.includes('\0'))
117
118
  continue;
118
119
  const candidate = join(root, rel);
119
- if (!candidate.startsWith(root + '/') && candidate !== root)
120
+ if (!candidate.startsWith(rootPrefix) && candidate !== root)
120
121
  continue;
121
122
  try {
122
123
  if (existsSync(candidate) && statSync(candidate).isFile())
@@ -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).
@@ -142,9 +142,20 @@ for (let r = 1; r <= maxRounds; r++) {
142
142
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
143
143
  // list stays bounded and reviewers never see the same issue twice.
144
144
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
145
- if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
146
- log('round ' + r + ' found 0 new findings — converged')
147
- break
145
+ // A round counts as "converged" ONLY when the aggregate accepted it
146
+ // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
147
+ // failed or their output stayed schema-invalid after retries, the round is
148
+ // INCONCLUSIVE — never report an empty-but-broken round as converged (an
149
+ // invalid/failed round must fail the run, not masquerade as a clean pass).
150
+ if (!schemaInvalid) {
151
+ const newCount =
152
+ agg && agg.report && agg.report.convergence
153
+ ? agg.report.convergence.findingsByRound[r - 1]
154
+ : thisRound.findings.length
155
+ if (newCount === 0) {
156
+ log('round ' + r + ' found 0 new findings — converged')
157
+ break
158
+ }
148
159
  }
149
160
  }
150
161
 
@@ -237,7 +248,7 @@ if (checkpoint) {
237
248
 
238
249
  phase('plan')
239
250
  const configRes = await agent(
240
- 'Call iterate_config({ validate: true }) and return the config JSON.',
251
+ 'Call iterate_config({}) and return the config JSON.',
241
252
  Object.assign({ label: 'config:read' }, backend)
242
253
  )
243
254
  const cfg = (configRes && configRes.config) ? configRes.config : null
@@ -420,7 +431,7 @@ for (let r = startRound; r <= maxRounds; r++) {
420
431
  Object.assign({ label: 'checkpoint:save:r' + r }, backend)
421
432
  )
422
433
 
423
- if (atomic.length === 0 && remaining.length === 0) {
434
+ if (atomic.length === 0 && remaining.length === 0 && !schemaInvalid) {
424
435
  log('round ' + r + ' found nothing to fix — converged')
425
436
  converged = true
426
437
  break
@@ -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). */