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 +24 -9
- package/README.zh-CN.md +19 -9
- package/dist/config-write.js +27 -0
- package/dist/git-scope.js +3 -2
- package/dist/review-scope.js +11 -3
- package/dist/review.js +13 -3
- package/dist/skill-prompt.js +16 -5
- package/dist/tools/checkpoint.js +63 -5
- package/dist/tools/defense-events.js +3 -0
- package/dist/tools/defense-store.js +17 -3
- package/dist/tools/history.js +1 -1
- package/dist/tools/quality-store.js +44 -2
- package/dist/transcript.js +6 -0
- package/lib/client.js +81 -11
- package/lib/parse.js +53 -6
- package/package.json +4 -2
- package/src/client/index.ts +49 -7
- package/src/config-write.ts +27 -0
- package/src/git-scope.ts +3 -2
- package/src/review-scope.ts +10 -3
- package/src/review.ts +18 -10
- package/src/skill-prompt.ts +16 -5
- package/src/tools/checkpoint.ts +71 -6
- package/src/tools/defense-events.ts +4 -0
- package/src/tools/defense-store.ts +21 -4
- package/src/tools/history.ts +1 -1
- package/src/tools/quality-store.ts +48 -3
- package/src/transcript.ts +7 -0
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`,
|
|
296
|
-
against `@deepseek-ai/dsh-tools` /
|
|
297
|
-
and uses only public contracts
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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.
|
|
294
|
-
`0.1.2-alpha.5`、`0.1.2-rc.1`
|
|
295
|
-
`@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values`
|
|
296
|
-
|
|
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)** —
|
|
308
|
-
|
|
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;它们是独立包,本插件从不加载它们。
|
package/dist/config-write.js
CHANGED
|
@@ -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(
|
|
120
|
+
if (!candidate.startsWith(rootPrefix) && candidate !== root)
|
|
120
121
|
continue;
|
|
121
122
|
try {
|
|
122
123
|
if (existsSync(candidate) && statSync(candidate).isFile())
|
package/dist/review-scope.js
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
package/dist/skill-prompt.js
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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({
|
|
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
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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],
|
package/dist/tools/history.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 {
|
package/dist/transcript.js
CHANGED
|
@@ -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). */
|