iterate-plugin 3.5.2 → 3.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/dist/session-hooks.js +48 -4
  4. package/dist/skill-prompt.js +63 -29
  5. package/dist/tools/checkpoint.js +39 -5
  6. package/dist/tools/config.js +11 -2
  7. package/dist/tools/context.js +5 -1
  8. package/dist/tools/defense-events.js +48 -9
  9. package/dist/tools/defense-store.js +63 -5
  10. package/dist/tools/experience-bank.js +14 -11
  11. package/dist/tools/experience-store.js +79 -14
  12. package/dist/tools/fix.js +30 -10
  13. package/dist/tools/history.js +5 -3
  14. package/dist/tools/prune.js +197 -29
  15. package/dist/tools/quality-gate.js +3 -0
  16. package/dist/tools/quality-store.js +8 -2
  17. package/dist/tools/review.js +17 -3
  18. package/dist/tools/transcript.js +72 -15
  19. package/dist/tools/triage.js +35 -2
  20. package/dist/transcript.js +30 -7
  21. package/lib/client.js +89 -21
  22. package/lib/parse.js +27 -2
  23. package/package.json +9 -7
  24. package/src/client/index.ts +53 -8
  25. package/src/session-hooks.ts +50 -4
  26. package/src/skill-prompt.ts +63 -29
  27. package/src/tools/checkpoint.ts +45 -6
  28. package/src/tools/config.ts +11 -2
  29. package/src/tools/context.ts +5 -1
  30. package/src/tools/defense-events.ts +50 -9
  31. package/src/tools/defense-store.ts +64 -8
  32. package/src/tools/experience-bank.ts +14 -11
  33. package/src/tools/experience-store.ts +88 -23
  34. package/src/tools/fix.ts +32 -12
  35. package/src/tools/history.ts +5 -3
  36. package/src/tools/prune.ts +204 -28
  37. package/src/tools/quality-gate.ts +4 -0
  38. package/src/tools/quality-store.ts +7 -2
  39. package/src/tools/review.ts +16 -3
  40. package/src/tools/transcript.ts +71 -14
  41. package/src/tools/triage.ts +36 -2
  42. package/src/transcript.ts +32 -7
  43. package/src/types.ts +2 -0
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
 
@@ -7,12 +7,22 @@
7
7
  *
8
8
  * - `allow` policy → the call runs.
9
9
  * - `deny` policy → the call is refused (fail-closed), surfaced as an
10
- * error to the model.
10
+ * error to the model. Since dsh 0.1.6-alpha.1 the
11
+ * denial carries a structured `info` (name
12
+ * `iterate-approval-gate`, code `APPROVAL_DENIED`,
13
+ * plus the human-readable reason) so durable
14
+ * projections can route it distinctly from a normal
15
+ * tool failure.
11
16
  * - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
12
17
  * scheduler routes it through the `approval` service
13
18
  * (see `@deepseek-ai/dsh-user-approval`), which
14
19
  * prompts the human and audits an approve/deny pair
15
20
  * on the session.
21
+ * - canceled → if the caller aborted the invocation before
22
+ * dispatch, return `{ kind: 'cancel' }` (the
23
+ * canonical 0.1.6-alpha.1 cancellation result) so a
24
+ * destructive call never runs (or asks) for a dead
25
+ * request.
16
26
  *
17
27
  * We deliberately do NOT also add `approved` flags inside the tool bodies:
18
28
  * the pre-execute waterfall consumes the human decision before the tool runs,
@@ -27,6 +37,13 @@
27
37
  */
28
38
  import { loadEffectiveConfig, resolveProjectRoot } from "./config-loader.js";
29
39
  import { decideApproval, isDestructiveIterateTool } from "./approval-gate.js";
40
+ /** Structured error identity attached to policy denials (dsh 0.1.6-alpha.1:
41
+ * `deny.info` lets durable projections / retry / replay distinguish an
42
+ * approval-policy denial from a tool-thrown failure). */
43
+ const DENY_INFO = {
44
+ name: 'iterate-approval-gate',
45
+ code: 'APPROVAL_DENIED',
46
+ };
30
47
  /**
31
48
  * Build the per-call approval decision for a tool execution.
32
49
  * Returns a dsh `PreToolDecision` so the caller can short-circuit the caller.
@@ -46,6 +63,20 @@ export function gateDecision(exec) {
46
63
  }
47
64
  if (!isDestructiveIterateTool(name))
48
65
  return { kind: 'allow' };
66
+ // dsh 0.1.6-alpha.1: if the caller already aborted this invocation before
67
+ // dispatch, there is nothing to gate — select the canonical cancellation
68
+ // result (`cancel`) rather than prompting a human for a dead call or running
69
+ // `next()` (which would allow) on an aborted one.
70
+ let signal;
71
+ try {
72
+ signal = exec.signal;
73
+ }
74
+ catch {
75
+ signal = undefined;
76
+ }
77
+ if (signal && typeof signal.aborted === 'boolean' && signal.aborted) {
78
+ return { kind: 'cancel' };
79
+ }
49
80
  // Resolve the project root (use the call's own `path` arg, else the agent's
50
81
  // session cwd) to read the effective observatory policy.
51
82
  let argPath;
@@ -74,8 +105,18 @@ export function gateDecision(exec) {
74
105
  // anything else (including a corrupt/missing `ask`) → 'ask'
75
106
  }
76
107
  const decision = decideApproval(exec, policy);
77
- if (decision.kind === 'deny')
78
- return { kind: 'deny', reason: decision.reason };
108
+ if (decision.kind === 'deny') {
109
+ return {
110
+ kind: 'deny',
111
+ reason: decision.reason,
112
+ info: {
113
+ ...DENY_INFO,
114
+ // ToolErrorInfo.reason (0.1.6-alpha.1): raw user-facing detail kept in
115
+ // durable state; the model-facing text still only sees `reason` above.
116
+ reason: decision.reason,
117
+ },
118
+ };
119
+ }
79
120
  if (decision.kind === 'ask')
80
121
  return { kind: 'ask', reason: decision.reason };
81
122
  return { kind: 'allow' };
@@ -106,7 +147,10 @@ export function registerSessionHooks(ctx) {
106
147
  // to allow, which would bypass consent — so return our ask decision.
107
148
  return Promise.resolve(decision);
108
149
  }
109
- if (decision.kind === 'deny') {
150
+ if (decision.kind === 'deny' || decision.kind === 'cancel') {
151
+ // deny: refuse the call. cancel: the caller already aborted this
152
+ // invocation — selecting the canonical cancellation result instead of
153
+ // letting `next()` allow it or prompting consent for a dead request.
110
154
  return Promise.resolve(decision);
111
155
  }
112
156
  return next();
@@ -94,10 +94,11 @@ for (let r = 1; r <= maxRounds; r++) {
94
94
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
95
95
  let agg = null
96
96
  let schemaInvalid = false
97
- let retries = 0
97
+ let attempts = 0
98
98
  do {
99
+ attempts += 1
99
100
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
100
- const nudge = retries > 0
101
+ const nudge = attempts > 1
101
102
  ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
102
103
  : ''
103
104
  const raw = await parallel(dims.map(dim => () => {
@@ -134,11 +135,15 @@ for (let r = 1; r <= maxRounds; r++) {
134
135
  schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
135
136
  ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
136
137
  : false
137
- if (schemaInvalid && retries < 2) {
138
- retries += 1
139
- log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
138
+ if (schemaInvalid && attempts < 3) {
139
+ log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
140
140
  }
141
- } while (schemaInvalid && retries <= 2)
141
+ } while (schemaInvalid && attempts < 3)
142
+ if (schemaInvalid) {
143
+ // Bounded exit: never loop forever. Surface the failure so the run's
144
+ // final summary can explain WHY the round was inconclusive.
145
+ log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
146
+ }
142
147
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
143
148
  // list stays bounded and reviewers never see the same issue twice.
144
149
  if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
@@ -269,6 +274,7 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
269
274
  let converged = false
270
275
  let abortedByValidation = false
271
276
  let failedCommands = []
277
+ let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
272
278
  const fixRecords = [] // observatory fix records collected round by round
273
279
 
274
280
  // Read any steering nudge intended for this run's reviewers.
@@ -288,10 +294,11 @@ for (let r = startRound; r <= maxRounds; r++) {
288
294
  )
289
295
  let agg = null
290
296
  let schemaInvalid = false
291
- let retries = 0
297
+ let attempts = 0
292
298
  do {
299
+ attempts += 1
293
300
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
294
- const nudge = retries > 0
301
+ const nudge = attempts > 1
295
302
  ? '\\nSTRICT JSON REQUIRED: your previous output failed schema validation. Return ONLY a JSON object {"findings":[...]} where EVERY finding has dimension, file, line (non-negative integer; 0 = whole-file), severity (critical|high|medium|low), summary, failure_scenario, suggested_fix, is_atomic (boolean).'
296
303
  : ''
297
304
  const raw = await parallel(dims.map(dim => () => {
@@ -327,11 +334,15 @@ for (let r = startRound; r <= maxRounds; r++) {
327
334
  schemaInvalid = agg && agg.schemaValidation && agg.schemaValidation.length > 0
328
335
  ? agg.schemaValidation[agg.schemaValidation.length - 1].valid === false
329
336
  : false
330
- if (schemaInvalid && retries < 2) {
331
- retries += 1
332
- log('retry ' + retries + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
337
+ if (schemaInvalid && attempts < 3) {
338
+ log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
333
339
  }
334
- } while (schemaInvalid && retries <= 2)
340
+ } while (schemaInvalid && attempts < 3)
341
+ if (schemaInvalid) {
342
+ // Bounded exit: never loop forever. Surface the failure so the run's
343
+ // final summary can explain WHY the round was inconclusive.
344
+ log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
345
+ }
335
346
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
336
347
  const atomic = findings.filter(f => f.is_atomic === true)
337
348
  const remaining = findings.filter(f => f.is_atomic !== true)
@@ -386,18 +397,33 @@ for (let r = startRound; r <= maxRounds; r++) {
386
397
  if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
387
398
  }
388
399
 
389
- // Validate every configured command; on ANY failure roll back this round's fixes.
400
+ // Validate every configured command; on ANY *executed* failure roll back this
401
+ // round's fixes. iterate_validate returns \`allowed:false\` + \`rejectReason\`
402
+ // when a command is NOT in validation.commands (a config gap, NOT a code
403
+ // failure) — those abort the run WITHOUT rolling back (the code changes are
404
+ // fine; the trust list just needs fixing).
390
405
  const valRes = await agent(
391
406
  'Read iterate.config.yaml validation.commands, then call iterate_validate({ command: <cmd> }) for EACH configured command ' +
392
- '(one tool call per command). Return all results as {command, exitCode} entries.',
407
+ '(one tool call per command). Return all results as {command, exitCode, allowed, rejectReason} entries — include \`allowed\` and \`rejectReason\` exactly as returned by iterate_validate (allowed:false means the command is not in validation.commands).',
393
408
  Object.assign({ label: 'validate:r' + r, phase: 'validate', schema: {
394
409
  type: 'object', additionalProperties: false,
395
410
  properties: {
396
- results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' } }, required: ['command', 'exitCode'] } }
411
+ results: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { command: { type: 'string' }, exitCode: { type: 'integer' }, allowed: { type: 'boolean' }, rejectReason: { type: 'string' } }, required: ['command', 'exitCode'] } }
397
412
  },
398
413
  required: ['results'] } }, backend)
399
414
  )
400
- failedCommands = (valRes && Array.isArray(valRes.results)) ? valRes.results.filter(v => v.exitCode !== 0).map(v => v.command) : []
415
+ const valResults = (valRes && Array.isArray(valRes.results)) ? valRes.results : []
416
+ configErrors = valResults.filter(v => v.allowed === false).map(v => v.rejectReason || (v.command + ' was rejected'))
417
+ failedCommands = valResults.filter(v => !(v.allowed === false) && v.exitCode !== 0).map(v => v.command)
418
+ if (configErrors.length > 0) {
419
+ log('round ' + r + ' validation CONFIG error (not in validation.commands): ' + configErrors.join(' | ') + ' — aborting WITHOUT rollback (code changes are kept; the command trust list needs updating)')
420
+ abortedByValidation = true
421
+ await agent(
422
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{configErrors:' + JSON.stringify(configErrors) + ', rolledBack:0, reason:"command not in validation.commands"}})',
423
+ Object.assign({ label: 'log:configError:r' + r }, backend)
424
+ )
425
+ break
426
+ }
401
427
  if (failedCommands.length > 0) {
402
428
  log('round ' + r + ' validation FAILED on: ' + failedCommands.join(', ') + ' — rolling back this round')
403
429
  abortedByValidation = true
@@ -458,17 +484,23 @@ if (!abortedByValidation) {
458
484
  }
459
485
  // Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
460
486
  // so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
461
- const obsCheckpoint = abortedByValidation ? null : {
462
- mode: 'normal',
463
- round: rounds.length,
464
- maxRounds: maxRounds,
465
- fixedCount: fixedCount,
466
- resumeCount: effectiveResumeCount,
467
- }
468
- await agent(
469
- 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
470
- { label: 'transcript:capture' }
471
- )
487
+ // The checkpoint survives on disk for aborted runs (that is how resumption
488
+ // works), so the transcript must mirror that reality — a null checkpoint here
489
+ // would hide the very thing F5 is meant to resume. Reflect what is on disk.
490
+ const obsCheckpoint = {
491
+ mode: 'normal',
492
+ round: rounds.length,
493
+ maxRounds: maxRounds,
494
+ fixedCount: fixedCount,
495
+ resumeCount: effectiveResumeCount,
496
+ }
497
+ const stopReason = abortedByValidation
498
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
499
+ : (converged ? 'converged' : 'max_rounds_reached')
500
+ await agent(
501
+ 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', stoppedReason:"' + stopReason + '", fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
502
+ { label: 'transcript:capture' }
503
+ )
472
504
  return {
473
505
  mode: 'normal',
474
506
  goal: plan.goal,
@@ -477,6 +509,8 @@ return {
477
509
  converged: converged,
478
510
  abortedByValidation: abortedByValidation,
479
511
  failedCommands: failedCommands,
512
+ configErrors: configErrors,
513
+ stoppedReason: stopReason,
480
514
  findingsFixed: fixedCount,
481
515
  remainingArchitecturalCount: architectural.length,
482
516
  remainingArchitectural: architectural,
@@ -494,10 +528,10 @@ return {
494
528
  Key rules for normal mode:
495
529
  - Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
496
530
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
497
- - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`.
531
+ - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt, mark the round inconclusive (never report it as a clean convergence).
498
532
  - Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
499
533
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
500
- - **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed).
534
+ - **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed). EXCEPTION: \`iterate_validate\` returning \`allowed:false\` means the command is simply not in \`validation.commands\` — a config gap, not broken code. In that case abort WITHOUT rolling back the round's fixes and surface the missing command + file so the user can fix the trust list.
501
535
  - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
502
536
  - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
503
537
  - Every round, every rollback, and the final report go to the append-only decision log.
@@ -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)}` };
@@ -306,7 +306,11 @@ export function registerContextTool(ctx) {
306
306
  // skillDir point at arbitrary filesystem locations (e.g. /etc)
307
307
  // whose contents would be injected wholesale into the context.
308
308
  if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
309
- candidates.push(resolve(args.skillDir));
309
+ // Read from the REAL (symlink-resolved) path that was actually
310
+ // validated — never the raw path. Validating one path and then
311
+ // reading through a different (swappable) symlink would leave a
312
+ // check-vs-read TOCTOU gap; using the realpath here closes it.
313
+ candidates.push(realpathSync(resolve(args.skillDir)));
310
314
  }
311
315
  }
312
316
  catch {
@@ -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 = [
@@ -72,19 +72,25 @@ function validateRecordInput(args) {
72
72
  export function registerDefenseEventsTool(ctx) {
73
73
  ctx.tools.register(defineTool({
74
74
  name: 'iterate_defense_events',
75
- // List/counts never write; only `record` persists an event → read shapes
76
- // join a parallel dispatch group.
77
- isConcurrencySafe: (args) => args.operation !== 'record',
78
- description: 'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
75
+ // List/counts never write; `record` persists an event and `clear` removes
76
+ // the persisted stream, so only the read shapes may join a parallel
77
+ // dispatch group.
78
+ isConcurrencySafe: (args) => {
79
+ const op = args.operation;
80
+ return op !== 'record' && op !== 'clear';
81
+ },
82
+ description: 'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
79
83
  'and assumption falsifications. ' +
80
84
  'List/counts return events with descriptions, outcomes, and summary counts; ' +
81
- '"record" persists a new event to .iterate/defense-events.json. ' +
85
+ '"record" persists a new event to .iterate/defense-events.json; ' +
86
+ '"clear" resets the persisted event stream (use it to start a fresh iteration without stale ' +
87
+ 'defense data). ' +
82
88
  'Use it to review defensive actions taken, or to log one when a defense fires.',
83
89
  parameters: {
84
90
  operation: {
85
91
  type: 'string',
86
- description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
87
- enum: ['list', 'counts', 'record'],
92
+ description: 'Operation: list (browse all), counts (summary by type), record (log a new event), clear (reset the stream). Default: list.',
93
+ enum: ['list', 'counts', 'record', 'clear'],
88
94
  },
89
95
  type: {
90
96
  type: 'string',
@@ -141,6 +147,7 @@ export function registerDefenseEventsTool(ctx) {
141
147
  kind: { type: 'string' },
142
148
  operation: { type: 'string' },
143
149
  count: { type: 'integer' },
150
+ counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
144
151
  events: { type: 'json' },
145
152
  counts: { type: 'json' },
146
153
  event: { type: 'json' },
@@ -153,6 +160,16 @@ export function registerDefenseEventsTool(ctx) {
153
160
  if (!value.ok)
154
161
  return [{ type: 'text', text: `defense events query failed: ${value.error}` }];
155
162
  const language = value.language === 'zh' ? 'zh' : 'en';
163
+ if (value.operation === 'clear') {
164
+ // Fresh stream: counts are all zero and the event list is empty.
165
+ return [{ type: 'text', text: [
166
+ value.counted === true
167
+ ? 'Defense event stream cleared — a fresh iteration starts with an empty stream.'
168
+ : 'No persisted defense event stream to clear.',
169
+ 'Defense Event Summary:',
170
+ ...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: 0`),
171
+ ].join('\n') }];
172
+ }
156
173
  if (value.operation === 'counts' && value.counts) {
157
174
  const counts = value.counts;
158
175
  const lines = [
@@ -193,10 +210,32 @@ export function registerDefenseEventsTool(ctx) {
193
210
  if (!resolved.ok)
194
211
  return { ok: false, kind: 'defense_events', error: resolved.reason };
195
212
  const projectRoot = resolved.root;
196
- const configLang = loadEffectiveConfig(projectRoot).config.language;
213
+ // A hand-edited `language` value outside zh/en must not leak out of the
214
+ // declared 'zh' | 'en' enum (labelFor would return undefined for it) —
215
+ // normalize any non-'zh' config value to 'en'.
216
+ const rawConfigLang = loadEffectiveConfig(projectRoot).config.language;
217
+ const configLang = rawConfigLang === 'zh' ? 'zh' : 'en';
197
218
  const language = args.language === 'zh' || args.language === 'en' ? args.language : configLang;
198
219
  const operation = typeof args.operation === 'string' ? args.operation : 'list';
199
220
  const limit = clampLimit(args.limit);
221
+ if (operation === 'clear') {
222
+ // Reset the persisted event stream so a fresh iteration does not carry
223
+ // stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
224
+ const result = clearDefenseEvents(projectRoot);
225
+ if (!result.ok) {
226
+ return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error };
227
+ }
228
+ const empty = readDefenseEvents(projectRoot);
229
+ return {
230
+ ok: true,
231
+ kind: 'defense_events',
232
+ operation: 'clear',
233
+ counted: result.existed,
234
+ language,
235
+ counts: empty.counts,
236
+ events: empty.events,
237
+ };
238
+ }
200
239
  if (operation === 'record') {
201
240
  const errors = validateRecordInput(args);
202
241
  if (errors.length > 0) {