iterate-plugin 3.5.2 → 3.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/tools/checkpoint.js +39 -5
- package/dist/tools/config.js +11 -2
- package/dist/tools/defense-events.js +41 -6
- package/dist/tools/defense-store.js +53 -2
- package/dist/tools/experience-bank.js +8 -8
- package/dist/tools/experience-store.js +58 -5
- package/dist/tools/fix.js +30 -10
- package/dist/tools/history.js +5 -3
- package/dist/tools/prune.js +29 -15
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +8 -2
- package/dist/tools/transcript.js +46 -11
- package/dist/transcript.js +20 -5
- package/lib/client.js +52 -10
- package/lib/parse.js +3 -0
- package/package.json +1 -1
- package/src/client/index.ts +30 -5
- package/src/tools/checkpoint.ts +45 -6
- package/src/tools/config.ts +11 -2
- package/src/tools/defense-events.ts +43 -6
- package/src/tools/defense-store.ts +54 -5
- package/src/tools/experience-bank.ts +8 -8
- package/src/tools/experience-store.ts +67 -14
- package/src/tools/fix.ts +32 -12
- package/src/tools/history.ts +5 -3
- package/src/tools/prune.ts +25 -14
- package/src/tools/quality-gate.ts +4 -0
- package/src/tools/quality-store.ts +7 -2
- package/src/tools/transcript.ts +47 -11
- package/src/transcript.ts +23 -5
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
|
|
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),
|
|
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
|
|
264
|
+
- `iterate_defense_events` — **v3.2** 查询防御事件(list / counts),`record` 记录一条新事件,或 **v3.5.3 `clear` 清除持久化事件流**,让新一轮迭代不带陈旧防御数据起跑。可读标签跟随项目语言(en / zh)
|
|
265
265
|
|
|
266
266
|
---
|
|
267
267
|
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
};
|
package/dist/tools/config.js
CHANGED
|
@@ -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
|
-
|
|
95
|
+
// Partial updates must be mergeable on a FRESH project (no config
|
|
96
|
+
// file yet): otherwise `{max_rounds: 5}` alone fails schema
|
|
97
|
+
// validation with a misleading "missing goal". Merge against the
|
|
98
|
+
// built-in defaults when there is no file to read, so the written
|
|
99
|
+
// config captures the effective config (defaults + update) and the
|
|
100
|
+
// documented "write a partial update" contract works out of the box.
|
|
101
|
+
base = existsSync(join(projectRoot, 'iterate.config.yaml'))
|
|
102
|
+
? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
|
|
103
|
+
: defaultConfig();
|
|
95
104
|
}
|
|
96
105
|
catch (err) {
|
|
97
106
|
return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` };
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
12
12
|
import { resolveProjectRootForExec, loadEffectiveConfig } from "../config-loader.js";
|
|
13
|
-
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent } from "./defense-store.js";
|
|
13
|
+
import { readDefenseEvents, writeDefenseEvents, addDefenseEvent, clearDefenseEvents } from "./defense-store.js";
|
|
14
14
|
const DEFAULT_LIMIT = 50;
|
|
15
15
|
const MAX_LIMIT = 100;
|
|
16
16
|
const EVENT_TYPES = [
|
|
@@ -75,16 +75,18 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
75
75
|
// List/counts never write; only `record` persists an event → read shapes
|
|
76
76
|
// join a parallel dispatch group.
|
|
77
77
|
isConcurrencySafe: (args) => args.operation !== 'record',
|
|
78
|
-
description: 'Query or
|
|
78
|
+
description: 'Query, record, or clear defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
79
79
|
'and assumption falsifications. ' +
|
|
80
80
|
'List/counts return events with descriptions, outcomes, and summary counts; ' +
|
|
81
|
-
'"record" persists a new event to .iterate/defense-events.json
|
|
81
|
+
'"record" persists a new event to .iterate/defense-events.json; ' +
|
|
82
|
+
'"clear" resets the persisted event stream (use it to start a fresh iteration without stale ' +
|
|
83
|
+
'defense data). ' +
|
|
82
84
|
'Use it to review defensive actions taken, or to log one when a defense fires.',
|
|
83
85
|
parameters: {
|
|
84
86
|
operation: {
|
|
85
87
|
type: 'string',
|
|
86
|
-
description: 'Operation: list (browse all), counts (summary by type), record (log a new event). Default: list.',
|
|
87
|
-
enum: ['list', 'counts', 'record'],
|
|
88
|
+
description: 'Operation: list (browse all), counts (summary by type), record (log a new event), clear (reset the stream). Default: list.',
|
|
89
|
+
enum: ['list', 'counts', 'record', 'clear'],
|
|
88
90
|
},
|
|
89
91
|
type: {
|
|
90
92
|
type: 'string',
|
|
@@ -141,6 +143,7 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
141
143
|
kind: { type: 'string' },
|
|
142
144
|
operation: { type: 'string' },
|
|
143
145
|
count: { type: 'integer' },
|
|
146
|
+
counted: { type: 'boolean', description: 'clear only: true when a persisted stream was removed.' },
|
|
144
147
|
events: { type: 'json' },
|
|
145
148
|
counts: { type: 'json' },
|
|
146
149
|
event: { type: 'json' },
|
|
@@ -153,6 +156,16 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
153
156
|
if (!value.ok)
|
|
154
157
|
return [{ type: 'text', text: `defense events query failed: ${value.error}` }];
|
|
155
158
|
const language = value.language === 'zh' ? 'zh' : 'en';
|
|
159
|
+
if (value.operation === 'clear') {
|
|
160
|
+
// Fresh stream: counts are all zero and the event list is empty.
|
|
161
|
+
return [{ type: 'text', text: [
|
|
162
|
+
value.counted === true
|
|
163
|
+
? 'Defense event stream cleared — a fresh iteration starts with an empty stream.'
|
|
164
|
+
: 'No persisted defense event stream to clear.',
|
|
165
|
+
'Defense Event Summary:',
|
|
166
|
+
...EVENT_TYPES.map((type) => ` ${labelFor(type, language)}: 0`),
|
|
167
|
+
].join('\n') }];
|
|
168
|
+
}
|
|
156
169
|
if (value.operation === 'counts' && value.counts) {
|
|
157
170
|
const counts = value.counts;
|
|
158
171
|
const lines = [
|
|
@@ -193,10 +206,32 @@ export function registerDefenseEventsTool(ctx) {
|
|
|
193
206
|
if (!resolved.ok)
|
|
194
207
|
return { ok: false, kind: 'defense_events', error: resolved.reason };
|
|
195
208
|
const projectRoot = resolved.root;
|
|
196
|
-
|
|
209
|
+
// A hand-edited `language` value outside zh/en must not leak out of the
|
|
210
|
+
// declared 'zh' | 'en' enum (labelFor would return undefined for it) —
|
|
211
|
+
// normalize any non-'zh' config value to 'en'.
|
|
212
|
+
const rawConfigLang = loadEffectiveConfig(projectRoot).config.language;
|
|
213
|
+
const configLang = rawConfigLang === 'zh' ? 'zh' : 'en';
|
|
197
214
|
const language = args.language === 'zh' || args.language === 'en' ? args.language : configLang;
|
|
198
215
|
const operation = typeof args.operation === 'string' ? args.operation : 'list';
|
|
199
216
|
const limit = clampLimit(args.limit);
|
|
217
|
+
if (operation === 'clear') {
|
|
218
|
+
// Reset the persisted event stream so a fresh iteration does not carry
|
|
219
|
+
// stale defensive data (mirrors iterate_quality_gate clear / clearQualityGate).
|
|
220
|
+
const result = clearDefenseEvents(projectRoot);
|
|
221
|
+
if (!result.ok) {
|
|
222
|
+
return { ok: false, kind: 'defense_events', operation: 'clear', error: result.error };
|
|
223
|
+
}
|
|
224
|
+
const empty = readDefenseEvents(projectRoot);
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
kind: 'defense_events',
|
|
228
|
+
operation: 'clear',
|
|
229
|
+
counted: result.existed,
|
|
230
|
+
language,
|
|
231
|
+
counts: empty.counts,
|
|
232
|
+
events: empty.events,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
200
235
|
if (operation === 'record') {
|
|
201
236
|
const errors = validateRecordInput(args);
|
|
202
237
|
if (errors.length > 0) {
|
|
@@ -39,11 +39,41 @@ function emptyStream() {
|
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
/** Valid severity values (kept in sync with DefenseEvent). */
|
|
43
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
44
|
+
/**
|
|
45
|
+
* Normalize one persisted event. Hand-edited files can carry events missing
|
|
46
|
+
* `timestamp`/`round`/`description`/`defense`/`outcome`/`severity` — readers
|
|
47
|
+
* (list sort by timestamp, render label selection) must never crash or emit
|
|
48
|
+
* NaN for those. Returns null when the entry is not an object or has no usable
|
|
49
|
+
* `type`; otherwise fills every required field with a safe default.
|
|
50
|
+
*/
|
|
51
|
+
function normalizeEvent(raw) {
|
|
52
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
53
|
+
return null;
|
|
54
|
+
const e = raw;
|
|
55
|
+
if (typeof e.type !== 'string' || !VALID_EVENT_TYPES.has(e.type))
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
id: typeof e.id === 'string' && e.id ? e.id : `def-${Math.random().toString(36).slice(2, 8)}`,
|
|
59
|
+
timestamp: typeof e.timestamp === 'string' && e.timestamp ? e.timestamp : new Date().toISOString(),
|
|
60
|
+
round: typeof e.round === 'number' && Number.isFinite(e.round) ? Math.floor(e.round) : 0,
|
|
61
|
+
type: e.type,
|
|
62
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
63
|
+
defense: typeof e.defense === 'string' ? e.defense : '',
|
|
64
|
+
outcome: typeof e.outcome === 'string' ? e.outcome : '',
|
|
65
|
+
...(typeof e.file === 'string' && e.file.length > 0 ? { file: e.file } : {}),
|
|
66
|
+
...(typeof e.line === 'number' && Number.isFinite(e.line) && e.line >= 0 ? { line: e.line } : {}),
|
|
67
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? e.severity : 'low',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
42
70
|
/**
|
|
43
71
|
* Read the defense events stream from disk.
|
|
44
72
|
* Normalizes the persisted stream so a hand-edited / partial file can never
|
|
45
73
|
* produce NaN counts: `counts` is recomputed from the events when missing or
|
|
46
|
-
* malformed,
|
|
74
|
+
* malformed, every type key is guaranteed present, and every surviving event is
|
|
75
|
+
* shape-normalized so consumers (timestamp sort, render label selection) cannot
|
|
76
|
+
* throw on missing fields.
|
|
47
77
|
*/
|
|
48
78
|
export function readDefenseEvents(projectRoot) {
|
|
49
79
|
const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE);
|
|
@@ -51,7 +81,9 @@ export function readDefenseEvents(projectRoot) {
|
|
|
51
81
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
52
82
|
const parsed = JSON.parse(content);
|
|
53
83
|
if (parsed && Array.isArray(parsed.events)) {
|
|
54
|
-
const events = parsed.events
|
|
84
|
+
const events = parsed.events
|
|
85
|
+
.map(normalizeEvent)
|
|
86
|
+
.filter((e) => e !== null);
|
|
55
87
|
const counts = computeCounts(events);
|
|
56
88
|
return {
|
|
57
89
|
events,
|
|
@@ -87,6 +119,25 @@ export function writeDefenseEvents(projectRoot, stream) {
|
|
|
87
119
|
}
|
|
88
120
|
return { ok: true };
|
|
89
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Clear the persisted defense-event stream (`.iterate/defense-events.json`).
|
|
124
|
+
* Mirrors `clearQualityGate` in quality-store.ts: a stale event stream from a
|
|
125
|
+
* previous iteration must be resettable before a fresh run. Returns whether a
|
|
126
|
+
* file existed and was removed, or a structured error when removal fails.
|
|
127
|
+
*/
|
|
128
|
+
export function clearDefenseEvents(projectRoot) {
|
|
129
|
+
const filePath = path.join(iterateDir(projectRoot), DEFENSE_EVENTS_FILE);
|
|
130
|
+
const existed = fs.existsSync(filePath);
|
|
131
|
+
if (!existed)
|
|
132
|
+
return { ok: true, existed: false };
|
|
133
|
+
try {
|
|
134
|
+
fs.rmSync(filePath, { force: true });
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, existed: true };
|
|
140
|
+
}
|
|
90
141
|
/** Add a defense event to the stream. */
|
|
91
142
|
export function addDefenseEvent(stream, event) {
|
|
92
143
|
const id = `def-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -144,8 +144,8 @@ export function registerExperienceBankTool(ctx) {
|
|
|
144
144
|
`Dimension: ${entry.dimension}`,
|
|
145
145
|
`Description: ${entry.description}`,
|
|
146
146
|
`Fix: ${entry.verifiedFix}`,
|
|
147
|
-
`Files: ${entry.files.join(', ')}`,
|
|
148
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
147
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
148
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
149
149
|
].join('\n') }];
|
|
150
150
|
}
|
|
151
151
|
if (value.operation === 'remove') {
|
|
@@ -158,9 +158,9 @@ export function registerExperienceBankTool(ctx) {
|
|
|
158
158
|
`Pattern: ${entry.pattern}`,
|
|
159
159
|
`Description: ${entry.description}`,
|
|
160
160
|
`Fix: ${entry.verifiedFix}`,
|
|
161
|
-
`Files: ${entry.files.join(', ')}`,
|
|
161
|
+
`Files: ${Array.isArray(entry.files) ? entry.files.join(', ') : ''}`,
|
|
162
162
|
`Hits: ${entry.hitCount}`,
|
|
163
|
-
`Tags: ${entry.tags.join(', ')}`,
|
|
163
|
+
`Tags: ${Array.isArray(entry.tags) ? entry.tags.join(', ') : ''}`,
|
|
164
164
|
].join('\n') }];
|
|
165
165
|
}
|
|
166
166
|
const entries = value.entries ?? [];
|
|
@@ -252,7 +252,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
252
252
|
operation: 'get',
|
|
253
253
|
count: 1,
|
|
254
254
|
entry: entry,
|
|
255
|
-
totalHits: bank.totalHits,
|
|
255
|
+
totalHits: bank.totalHits ?? 0,
|
|
256
256
|
};
|
|
257
257
|
}
|
|
258
258
|
if (operation === 'search' && typeof args.query === 'string') {
|
|
@@ -266,7 +266,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
266
266
|
operation: 'search',
|
|
267
267
|
count: entries.length,
|
|
268
268
|
entries: entries,
|
|
269
|
-
totalHits: bank.totalHits,
|
|
269
|
+
totalHits: bank.totalHits ?? 0,
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
272
|
// Default: list with optional filters
|
|
@@ -275,7 +275,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
275
275
|
entries = entries.filter((e) => e.dimension === args.dimension);
|
|
276
276
|
}
|
|
277
277
|
if (Array.isArray(args.tags) && args.tags.length > 0) {
|
|
278
|
-
entries = entries.filter((e) => args.tags.every((t) => e.tags.includes(t)));
|
|
278
|
+
entries = entries.filter((e) => args.tags.every((t) => Array.isArray(e.tags) && e.tags.includes(t)));
|
|
279
279
|
}
|
|
280
280
|
return {
|
|
281
281
|
ok: true,
|
|
@@ -283,7 +283,7 @@ export function registerExperienceBankTool(ctx) {
|
|
|
283
283
|
operation: 'list',
|
|
284
284
|
count: Math.min(entries.length, limit),
|
|
285
285
|
entries: entries.slice(0, limit),
|
|
286
|
-
totalHits: bank.totalHits,
|
|
286
|
+
totalHits: bank.totalHits ?? 0,
|
|
287
287
|
};
|
|
288
288
|
},
|
|
289
289
|
}));
|
|
@@ -17,6 +17,46 @@ function emptyBank() {
|
|
|
17
17
|
totalHits: 0,
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
/** Valid severity values (kept in sync with ExperienceEntry). */
|
|
21
|
+
const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
|
|
22
|
+
/** String array guard for fields that must be arrays (`files`, `tags`). */
|
|
23
|
+
function stringArray(v) {
|
|
24
|
+
if (!Array.isArray(v))
|
|
25
|
+
return [];
|
|
26
|
+
return v.filter((x) => typeof x === 'string');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Normalize one persisted experience entry. A hand-edited bank entry can be
|
|
30
|
+
* missing `files`/`tags` arrays (or `hitCount`) — consumers rendering/searching
|
|
31
|
+
* entries (`render` `.join(', ')`, `searchExperienceEntries` spread) must never
|
|
32
|
+
* throw or emit NaN. Returns null for non-object entries; every required field
|
|
33
|
+
* gets a safe default.
|
|
34
|
+
*/
|
|
35
|
+
function normalizeEntry(raw) {
|
|
36
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
37
|
+
return null;
|
|
38
|
+
const e = raw;
|
|
39
|
+
const pattern = typeof e.pattern === 'string' ? e.pattern : '';
|
|
40
|
+
const dimension = typeof e.dimension === 'string' ? e.dimension : '';
|
|
41
|
+
const id = typeof e.id === 'string' && e.id ? e.id : `exp-${Math.random().toString(36).slice(2, 8)}`;
|
|
42
|
+
if (!pattern && !dimension)
|
|
43
|
+
return null;
|
|
44
|
+
const hitCount = typeof e.hitCount === 'number' && Number.isFinite(e.hitCount) ? e.hitCount : 0;
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
timestamp: typeof e.timestamp === 'string' ? e.timestamp : new Date().toISOString(),
|
|
48
|
+
dimension,
|
|
49
|
+
pattern,
|
|
50
|
+
description: typeof e.description === 'string' ? e.description : '',
|
|
51
|
+
verifiedFix: typeof e.verifiedFix === 'string' ? e.verifiedFix : '',
|
|
52
|
+
findingSummary: typeof e.findingSummary === 'string' ? e.findingSummary : '',
|
|
53
|
+
files: stringArray(e.files),
|
|
54
|
+
hitCount,
|
|
55
|
+
...(typeof e.lastHitAt === 'string' ? { lastHitAt: e.lastHitAt } : {}),
|
|
56
|
+
tags: stringArray(e.tags),
|
|
57
|
+
severity: VALID_SEVERITIES.has(String(e.severity)) ? e.severity : 'low',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
20
60
|
/** Read the experience bank from disk. Returns empty bank if not found. */
|
|
21
61
|
export function readExperienceBank(projectRoot) {
|
|
22
62
|
const filePath = path.join(projectRoot, '.iterate', EXPERIENCE_FILE);
|
|
@@ -24,7 +64,14 @@ export function readExperienceBank(projectRoot) {
|
|
|
24
64
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
25
65
|
const parsed = JSON.parse(content);
|
|
26
66
|
if (parsed && Array.isArray(parsed.entries)) {
|
|
27
|
-
|
|
67
|
+
const entries = parsed.entries
|
|
68
|
+
.map(normalizeEntry)
|
|
69
|
+
.filter((e) => e !== null);
|
|
70
|
+
return {
|
|
71
|
+
entries,
|
|
72
|
+
lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyBank().lastUpdated,
|
|
73
|
+
totalHits: typeof parsed.totalHits === 'number' && Number.isFinite(parsed.totalHits) ? parsed.totalHits : 0,
|
|
74
|
+
};
|
|
28
75
|
}
|
|
29
76
|
}
|
|
30
77
|
catch {
|
|
@@ -58,17 +105,23 @@ export function writeExperienceBank(projectRoot, bank) {
|
|
|
58
105
|
export function searchExperienceEntries(entries, query, opts = {}) {
|
|
59
106
|
const lowerQuery = query.toLowerCase();
|
|
60
107
|
return entries.filter((entry) => {
|
|
108
|
+
if (!entry || typeof entry !== 'object')
|
|
109
|
+
return false;
|
|
110
|
+
const rawEntry = entry;
|
|
111
|
+
const tags = stringArray(rawEntry.tags);
|
|
61
112
|
// Dimension filter
|
|
62
113
|
if (opts.dimension && entry.dimension !== opts.dimension) {
|
|
63
114
|
return false;
|
|
64
115
|
}
|
|
65
116
|
// Tags filter (AND logic)
|
|
66
117
|
if (opts.tags && opts.tags.length > 0) {
|
|
67
|
-
if (!opts.tags.every((t) =>
|
|
118
|
+
if (!opts.tags.every((t) => tags.includes(t))) {
|
|
68
119
|
return false;
|
|
69
120
|
}
|
|
70
121
|
}
|
|
71
|
-
// Text search across multiple fields
|
|
122
|
+
// Text search across multiple fields. Guards against a hand-edited bank entry
|
|
123
|
+
// whose `files`/`tags` are missing or non-array (the spread below would
|
|
124
|
+
// otherwise throw a TypeError on a non-iterable).
|
|
72
125
|
if (query) {
|
|
73
126
|
const searchableText = [
|
|
74
127
|
entry.pattern,
|
|
@@ -76,8 +129,8 @@ export function searchExperienceEntries(entries, query, opts = {}) {
|
|
|
76
129
|
entry.verifiedFix,
|
|
77
130
|
entry.findingSummary,
|
|
78
131
|
entry.dimension,
|
|
79
|
-
...entry.files,
|
|
80
|
-
...entry.tags,
|
|
132
|
+
...stringArray(entry.files),
|
|
133
|
+
...stringArray(entry.tags),
|
|
81
134
|
].join(' ').toLowerCase();
|
|
82
135
|
if (!searchableText.includes(lowerQuery)) {
|
|
83
136
|
return false;
|
package/dist/tools/fix.js
CHANGED
|
@@ -120,9 +120,22 @@ export function readRegistry(projectRoot) {
|
|
|
120
120
|
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
121
121
|
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds))
|
|
122
122
|
return emptyRegistry();
|
|
123
|
-
// Defensive: a hand-edited or partially-written registry may
|
|
124
|
-
// round without a `records` array
|
|
125
|
-
|
|
123
|
+
// Defensive normalization: a hand-edited or partially-written registry may
|
|
124
|
+
// contain a round without a `records` array, or records that are missing
|
|
125
|
+
// their id / finding object — all of which would make readers
|
|
126
|
+
// (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
|
|
127
|
+
parsed.rounds = parsed.rounds
|
|
128
|
+
.filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
|
|
129
|
+
.map((r) => ({
|
|
130
|
+
...r,
|
|
131
|
+
round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
|
|
132
|
+
records: r.records.filter((rec) => !!rec &&
|
|
133
|
+
typeof rec === 'object' &&
|
|
134
|
+
typeof rec.id === 'string' &&
|
|
135
|
+
!!rec.finding &&
|
|
136
|
+
typeof rec.finding === 'object'),
|
|
137
|
+
}))
|
|
138
|
+
.filter((r) => typeof r.round === 'number' && Number.isFinite(r.round));
|
|
126
139
|
return parsed;
|
|
127
140
|
}
|
|
128
141
|
catch {
|
|
@@ -611,20 +624,27 @@ export function registerDiffTool(ctx) {
|
|
|
611
624
|
for (const r of round.records) {
|
|
612
625
|
if (!r.success)
|
|
613
626
|
continue;
|
|
614
|
-
const
|
|
627
|
+
const file = typeof r.finding.file === 'string' ? r.finding.file : '';
|
|
628
|
+
if (!file)
|
|
629
|
+
continue;
|
|
630
|
+
// Coerce defensively: a hand-edited registry record missing the
|
|
631
|
+
// numeric fields must not produce NaN in the accumulated summary.
|
|
632
|
+
const added = Number(r.linesAdded) || 0;
|
|
633
|
+
const removed = Number(r.linesRemoved) || 0;
|
|
634
|
+
const existing = files.find((f) => f.file === file);
|
|
615
635
|
if (existing) {
|
|
616
|
-
existing.linesAdded +=
|
|
617
|
-
existing.linesRemoved +=
|
|
636
|
+
existing.linesAdded += added;
|
|
637
|
+
existing.linesRemoved += removed;
|
|
618
638
|
// Recompute the summary from the summed counts so a multi-fix
|
|
619
639
|
// file's text does not contradict its accumulated numbers.
|
|
620
640
|
existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`;
|
|
621
641
|
}
|
|
622
642
|
else {
|
|
623
643
|
files.push({
|
|
624
|
-
file
|
|
625
|
-
diffSummary: r.diffSummary
|
|
626
|
-
linesAdded:
|
|
627
|
-
linesRemoved:
|
|
644
|
+
file,
|
|
645
|
+
diffSummary: typeof r.diffSummary === 'string' ? r.diffSummary : `+${added}/-${removed} lines`,
|
|
646
|
+
linesAdded: added,
|
|
647
|
+
linesRemoved: removed,
|
|
628
648
|
});
|
|
629
649
|
}
|
|
630
650
|
}
|
package/dist/tools/history.js
CHANGED
|
@@ -46,8 +46,10 @@ export function filterDecisionEntries(entries, opts) {
|
|
|
46
46
|
export function summarizeFixRegistry(registry) {
|
|
47
47
|
const rounds = (registry.rounds ?? []).map((r) => ({
|
|
48
48
|
round: r.round,
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Coerce defensively so a hand-edited registry round missing either count
|
|
50
|
+
// can never propagate NaN into the integer output fields.
|
|
51
|
+
fixedCount: Number(r.fixedCount) || 0,
|
|
52
|
+
failedCount: Number(r.failedCount) || 0,
|
|
51
53
|
}));
|
|
52
54
|
return {
|
|
53
55
|
totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
|
|
@@ -74,7 +76,7 @@ export function registerHistoryTool(ctx) {
|
|
|
74
76
|
type: {
|
|
75
77
|
type: 'string',
|
|
76
78
|
description: 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
|
|
77
|
-
'revert, validation, decision, report.',
|
|
79
|
+
'revert, round_failed, validation, decision, report, resume.',
|
|
78
80
|
},
|
|
79
81
|
since: {
|
|
80
82
|
type: 'string',
|