iterate-plugin 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -2
- package/README.zh-CN.md +45 -2
- package/dist/config-write.js +27 -0
- package/dist/review-scope.js +11 -3
- package/dist/review.js +13 -3
- 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 +29 -2
- package/package.json +12 -4
- package/src/client/index.ts +37 -3
- package/src/config-write.ts +27 -0
- package/src/review-scope.ts +10 -3
- package/src/review.ts +18 -10
- 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
|
|
@@ -286,6 +286,56 @@ All runtime state lives under `.iterate/` at the project root (can be excluded v
|
|
|
286
286
|
|
|
287
287
|
---
|
|
288
288
|
|
|
289
|
+
## 🔐 Permissions, dependencies & compatibility
|
|
290
|
+
|
|
291
|
+
### Node.js / DSH compatibility
|
|
292
|
+
|
|
293
|
+
- **Node.js**: `>=20` (`package.json` `engines.node`).
|
|
294
|
+
- **DSH**: declares `dsh.compatibility.dshReleases` — `compatible` for the official
|
|
295
|
+
`0.1.2-alpha.4`, `0.1.2-alpha.5`, and `0.1.2-rc.1` releases. The plugin is built
|
|
296
|
+
against `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1`
|
|
297
|
+
and uses only public contracts (tool registration, client slots + theme, bundle patch).
|
|
298
|
+
|
|
299
|
+
### Runtime permissions (conservative disclosure)
|
|
300
|
+
|
|
301
|
+
The plugin runs with the **same process capabilities as the DSH host** and touches the
|
|
302
|
+
following capabilities; elevated capability means a DSH Profile install stays
|
|
303
|
+
`user-reviewed`/guarded rather than auto-approved:
|
|
304
|
+
|
|
305
|
+
- **Files** — reads and writes project-local state under `<projectRoot>/.iterate/`
|
|
306
|
+
(decision log, checkpoint, transcript, fix backups/registry, quality gate,
|
|
307
|
+
experience bank, defense events). `iterate_fix` / `iterate_triage` also apply
|
|
308
|
+
in-place edits to the user's source files (path-traversal protected to the
|
|
309
|
+
resolved project root, backups taken before every fix, rollback on failure).
|
|
310
|
+
The client half persists triage verdicts to `localStorage`.
|
|
311
|
+
- **Commands** — the plugin registers model-facing tools; any shell execution that
|
|
312
|
+
the model performs (build / test / `git`) is run by the **host**, not by this
|
|
313
|
+
plugin. The client "command buttons" only copy paste-able instruction text; they
|
|
314
|
+
do not spawn processes.
|
|
315
|
+
- **Credentials** — this plugin does **not** read or transmit credentials at
|
|
316
|
+
runtime. The sibling **iterate-skill / iterate-harness** components may read git
|
|
317
|
+
credentials / GitHub tokens when the user runs their own Git/API operations; those
|
|
318
|
+
are separate packages and are never loaded by this plugin.
|
|
319
|
+
- **Network** — no network access at runtime (everything is local to the project).
|
|
320
|
+
|
|
321
|
+
### Dependencies
|
|
322
|
+
|
|
323
|
+
Runtime dependencies: `@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`,
|
|
324
|
+
`@deepseek-ai/dsh-util-values`, `js-yaml` — all pinned exact versions. No
|
|
325
|
+
install-time lifecycle scripts (`preinstall`/`install`/`postinstall`/`prepare`);
|
|
326
|
+
only `prepublishOnly` (build) runs on publish.
|
|
327
|
+
|
|
328
|
+
### Failure bounds
|
|
329
|
+
|
|
330
|
+
- All writes are atomic (temp + rename) and confined under `.iterate/`; a crash
|
|
331
|
+
mid-write cannot corrupt the checkpoint/transcript.
|
|
332
|
+
- Persistence failures surface as structured `{ ok: false, error }` results instead
|
|
333
|
+
of silent success.
|
|
334
|
+
- Fix edits are applied to backups first and rolled back on validation failure.
|
|
335
|
+
- The UI degrades gracefully when a slot/service is unavailable.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
289
339
|
## 🎨 Design
|
|
290
340
|
|
|
291
341
|
The plugin follows dsh's "everything-is-a-plugin" architecture:
|
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`,供客户端观测台读取
|
|
@@ -285,6 +285,49 @@ validation:
|
|
|
285
285
|
|
|
286
286
|
---
|
|
287
287
|
|
|
288
|
+
## 🔐 权限、依赖与兼容性
|
|
289
|
+
|
|
290
|
+
### Node.js / DSH 兼容范围
|
|
291
|
+
|
|
292
|
+
- **Node.js**:`>=20`(`package.json` 的 `engines.node`)。
|
|
293
|
+
- **DSH**:在 `dsh.compatibility.dshReleases` 中声明官方 `0.1.2-alpha.4`、
|
|
294
|
+
`0.1.2-alpha.5`、`0.1.2-rc.1` 三个版本为 `compatible`。插件基于
|
|
295
|
+
`@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.1.2-rc.1` 构建,
|
|
296
|
+
只使用公开契约(工具注册、客户端 slots + theme、bundle patch)。
|
|
297
|
+
|
|
298
|
+
### 运行时权限(保守披露)
|
|
299
|
+
|
|
300
|
+
插件与 DSH 宿主共享同一进程能力,涉及以下能力;具备高权限意味着 DSH Profile
|
|
301
|
+
安装仍需 `user-reviewed`/守卫,不会自动放行:
|
|
302
|
+
|
|
303
|
+
- **文件(files)** — 读写 `<projectRoot>/.iterate/` 下的项目本地状态(决策日志、
|
|
304
|
+
检查点、transcript、修复备份/注册表、质量门禁、经验银行、防御事件)。
|
|
305
|
+
`iterate_fix` / `iterate_triage` 还会就地修改用户源码(路径遍历防护限定在解析后
|
|
306
|
+
的项目根目录内,每次修复前备份、失败回滚)。客户端侧把分诊判定持久化到 `localStorage`。
|
|
307
|
+
- **命令(commands)** — 插件只注册模型可调用工具;模型要执行的任何 shell 操作
|
|
308
|
+
(build / test / `git`)都由**宿主**执行,不是本插件。客户端"指挥按钮"只复制可粘贴
|
|
309
|
+
的指令文本,不启动任何进程。
|
|
310
|
+
- **凭据(credentials)** — 本插件运行时**不**读取或传输凭据。同生态的
|
|
311
|
+
**iterate-skill / iterate-harness** 组件在用户自行执行 Git/API 操作时可能读取
|
|
312
|
+
git 凭据 / GitHub token;它们是独立包,本插件从不加载它们。
|
|
313
|
+
- **网络(network)** — 运行时无任何网络访问(全部本地)。
|
|
314
|
+
|
|
315
|
+
### 依赖
|
|
316
|
+
|
|
317
|
+
运行时依赖:`@deepseek-ai/cordis`、`@deepseek-ai/dsh-tools`、
|
|
318
|
+
`@deepseek-ai/dsh-util-values`、`js-yaml`——全部精确锁版。无安装期生命周期脚本
|
|
319
|
+
(`preinstall`/`install`/`postinstall`/`prepare`);仅 `prepublishOnly`(构建)在发布时运行。
|
|
320
|
+
|
|
321
|
+
### 失败边界
|
|
322
|
+
|
|
323
|
+
- 所有写入都是原子的(临时文件 + rename),且限定在 `.iterate/` 下;写入中途崩溃
|
|
324
|
+
不会损坏 checkpoint / transcript。
|
|
325
|
+
- 持久化失败以结构化 `{ ok: false, error }` 返回,而不是静默成功。
|
|
326
|
+
- 修复编辑先备份再应用,验证失败自动回滚。
|
|
327
|
+
- 某个 slot / 服务不可用时 UI 优雅降级。
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
288
331
|
## 🎨 设计
|
|
289
332
|
|
|
290
333
|
插件遵循 dsh "everything-is-a-plugin" 架构:
|
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/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/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). */
|
package/lib/client.js
CHANGED
|
@@ -1525,6 +1525,7 @@ var ITERATE_CSS = `
|
|
|
1525
1525
|
/* Dashboard empty/onboarding state */
|
|
1526
1526
|
.iterate-dashboard-empty { opacity: 0.75; }
|
|
1527
1527
|
.iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
1528
|
+
.iterate-dashboard-launch { display: inline-flex; align-items: center; gap: 6px; }
|
|
1528
1529
|
|
|
1529
1530
|
/* Convergence-completed progress fill */
|
|
1530
1531
|
.iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
|
|
@@ -1748,6 +1749,31 @@ function TrendChart({ points }) {
|
|
|
1748
1749
|
"aria-label": `\u5404\u8F6E\u53D1\u73B0\u6570\u91CF\u8D8B\u52BF\uFF1A${summary}`
|
|
1749
1750
|
}, ...bars);
|
|
1750
1751
|
}
|
|
1752
|
+
function StartIterationButton() {
|
|
1753
|
+
const [copied, setCopied] = React.useState(null);
|
|
1754
|
+
const copy = (key, text) => {
|
|
1755
|
+
copyText(text).then((ok) => {
|
|
1756
|
+
if (ok) {
|
|
1757
|
+
setCopied(key);
|
|
1758
|
+
setTimeout(() => setCopied((cur) => cur === key ? null : cur), 1600);
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
};
|
|
1762
|
+
const btn = (key, label, command, primary, title) => React.createElement("button", {
|
|
1763
|
+
key,
|
|
1764
|
+
className: "iterate-cmd",
|
|
1765
|
+
"data-primary": primary ? "" : void 0,
|
|
1766
|
+
"data-copied": copied === key ? "" : void 0,
|
|
1767
|
+
onClick: () => copy(key, command),
|
|
1768
|
+
title
|
|
1769
|
+
}, copied === key ? "\u5DF2\u590D\u5236" : label);
|
|
1770
|
+
return React.createElement(
|
|
1771
|
+
"span",
|
|
1772
|
+
{ className: "iterate-dashboard-launch" },
|
|
1773
|
+
btn("start-full", "\u5B8C\u6574\u8FED\u4EE3", "/iterate", true, "\u590D\u5236\u542F\u52A8\u547D\u4EE4\uFF1A\u5B8C\u6574\u300C\u5BA1\u67E5 \u2192 \u4FEE\u590D \u2192 \u9A8C\u8BC1\u300D\u95ED\u73AF"),
|
|
1774
|
+
btn("start-review", "\u4EC5\u8BC4\u5BA1", "/iterate review-only", false, "\u590D\u5236\u542F\u52A8\u547D\u4EE4\uFF1A\u53EA\u5BA1\u67E5\u4E0D\u4FEE\u6539\uFF08dry-run\uFF09")
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1751
1777
|
function ConvergenceDashboard(props) {
|
|
1752
1778
|
const [pulseKey, setPulseKey] = React.useState(0);
|
|
1753
1779
|
const session = props && props.session ? props.session : null;
|
|
@@ -1764,7 +1790,8 @@ function ConvergenceDashboard(props) {
|
|
|
1764
1790
|
"div",
|
|
1765
1791
|
{ "data-iterate-root": "", "data-iterate": "dashboard", className: "iterate-dashboard iterate-dashboard-empty" },
|
|
1766
1792
|
React.createElement("span", { className: "iterate-round-badge" }, "iterate"),
|
|
1767
|
-
React.createElement("span", { className: "iterate-empty-hint" }, "\u8FD0\u884C\u4E00\u6B21\u8BC4\u5BA1\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u6536\u655B\u8FDB\u5EA6\u4E0E\u53D1\u73B0\u7EDF\u8BA1\u3002
|
|
1793
|
+
React.createElement("span", { className: "iterate-empty-hint" }, "\u8FD0\u884C\u4E00\u6B21\u8BC4\u5BA1\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u6536\u655B\u8FDB\u5EA6\u4E0E\u53D1\u73B0\u7EDF\u8BA1\u3002"),
|
|
1794
|
+
React.createElement(StartIterationButton, null)
|
|
1768
1795
|
);
|
|
1769
1796
|
}
|
|
1770
1797
|
const round = getCurrentRound(report);
|
|
@@ -3060,7 +3087,7 @@ ${JSON.stringify({
|
|
|
3060
3087
|
"data-primary": "",
|
|
3061
3088
|
"data-copied": copiedKey === "cp-resume" ? "" : void 0,
|
|
3062
3089
|
onClick: () => copyInstruction("cp-resume", resumeText),
|
|
3063
|
-
title: "\u590D\u5236 iterate_checkpoint \
|
|
3090
|
+
title: "\u590D\u5236 iterate_checkpoint resume \u6307\u4EE4\u6587\u672C\uFF08\u52A0\u8F7D\u65AD\u70B9\u5E76\u8BA1\u6570\u4E00\u6B21\u6062\u590D\uFF09"
|
|
3064
3091
|
}, copiedKey === "cp-resume" ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u6062\u590D\u6307\u4EE4")
|
|
3065
3092
|
)
|
|
3066
3093
|
);
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/jingzhao-l/iterate-
|
|
9
|
+
"url": "https://github.com/jingzhao-l/iterate-skill.git",
|
|
10
|
+
"directory": "harness/iterate-plugin"
|
|
10
11
|
},
|
|
11
|
-
"homepage": "https://github.com/jingzhao-l/iterate-plugin
|
|
12
|
+
"homepage": "https://github.com/jingzhao-l/iterate-skill/tree/main/harness/iterate-plugin",
|
|
12
13
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/jingzhao-l/iterate-
|
|
14
|
+
"url": "https://github.com/jingzhao-l/iterate-skill/issues"
|
|
14
15
|
},
|
|
15
16
|
"keywords": [
|
|
16
17
|
"dsh-plugin",
|
|
@@ -26,6 +27,13 @@
|
|
|
26
27
|
"bundle": {
|
|
27
28
|
"patch": "./cordis.patch.yml"
|
|
28
29
|
},
|
|
30
|
+
"compatibility": {
|
|
31
|
+
"dshReleases": {
|
|
32
|
+
"0.1.2-alpha.4": "compatible",
|
|
33
|
+
"0.1.2-alpha.5": "compatible",
|
|
34
|
+
"0.1.2-rc.1": "compatible"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
29
37
|
"client": {
|
|
30
38
|
"platform": "web",
|
|
31
39
|
"inject": [
|
package/src/client/index.ts
CHANGED
|
@@ -567,6 +567,7 @@ const ITERATE_CSS = `
|
|
|
567
567
|
/* Dashboard empty/onboarding state */
|
|
568
568
|
.iterate-dashboard-empty { opacity: 0.75; }
|
|
569
569
|
.iterate-empty-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
570
|
+
.iterate-dashboard-launch { display: inline-flex; align-items: center; gap: 6px; }
|
|
570
571
|
|
|
571
572
|
/* Convergence-completed progress fill */
|
|
572
573
|
.iterate-progress-fill-done { background: var(--dsw-alias-state-success-primary); }
|
|
@@ -834,6 +835,36 @@ function TrendChart({ points }: { points: Array<{ round: number; count: number }
|
|
|
834
835
|
}, ...bars)
|
|
835
836
|
}
|
|
836
837
|
|
|
838
|
+
/** Start-iteration launcher shown on the empty dashboard: copies a runnable
|
|
839
|
+
* command (the browser cannot invoke harness tools directly, so every action
|
|
840
|
+
* follows the panel's copy-to-command pattern). */
|
|
841
|
+
function StartIterationButton() {
|
|
842
|
+
const [copied, setCopied] = React.useState<string | null>(null)
|
|
843
|
+
const copy = (key: string, text: string) => {
|
|
844
|
+
copyText(text).then((ok) => {
|
|
845
|
+
if (ok) {
|
|
846
|
+
setCopied(key)
|
|
847
|
+
setTimeout(() => setCopied((cur) => (cur === key ? null : cur)), 1600)
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
const btn = (key: string, label: string, command: string, primary: boolean, title: string) =>
|
|
852
|
+
React.createElement('button', {
|
|
853
|
+
key,
|
|
854
|
+
className: 'iterate-cmd',
|
|
855
|
+
'data-primary': primary ? '' : undefined,
|
|
856
|
+
'data-copied': copied === key ? '' : undefined,
|
|
857
|
+
onClick: () => copy(key, command),
|
|
858
|
+
title,
|
|
859
|
+
}, copied === key ? '已复制' : label)
|
|
860
|
+
return React.createElement(
|
|
861
|
+
'span',
|
|
862
|
+
{ className: 'iterate-dashboard-launch' },
|
|
863
|
+
btn('start-full', '完整迭代', '/iterate', true, '复制启动命令:完整「审查 → 修复 → 验证」闭环'),
|
|
864
|
+
btn('start-review', '仅评审', '/iterate review-only', false, '复制启动命令:只审查不修改(dry-run)'),
|
|
865
|
+
)
|
|
866
|
+
}
|
|
867
|
+
|
|
837
868
|
/** Dashboard: live convergence strip above the composer.
|
|
838
869
|
*
|
|
839
870
|
* The `conversation.input.dock` slot's owner share is `InputZone`, which
|
|
@@ -859,12 +890,15 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
859
890
|
|
|
860
891
|
if (!report) {
|
|
861
892
|
// Empty/onboarding state: first-time users otherwise see nothing and have
|
|
862
|
-
// no idea the plugin exists or how to start.
|
|
893
|
+
// no idea the plugin exists or how to start. Provide a one-click command
|
|
894
|
+
// that launches the loop (copied for paste — the browser cannot call
|
|
895
|
+
// harness tools directly).
|
|
863
896
|
return React.createElement(
|
|
864
897
|
'div',
|
|
865
898
|
{ 'data-iterate-root': '', 'data-iterate': 'dashboard', className: 'iterate-dashboard iterate-dashboard-empty' },
|
|
866
899
|
React.createElement('span', { className: 'iterate-round-badge' }, 'iterate'),
|
|
867
|
-
React.createElement('span', { className: 'iterate-empty-hint' }, '
|
|
900
|
+
React.createElement('span', { className: 'iterate-empty-hint' }, '运行一次评审后,这里会显示收敛进度与发现统计。'),
|
|
901
|
+
React.createElement(StartIterationButton, null),
|
|
868
902
|
)
|
|
869
903
|
}
|
|
870
904
|
|
|
@@ -2151,7 +2185,7 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2151
2185
|
React.createElement('button', {
|
|
2152
2186
|
className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
|
|
2153
2187
|
onClick: () => copyInstruction('cp-resume', resumeText),
|
|
2154
|
-
title: '复制 iterate_checkpoint
|
|
2188
|
+
title: '复制 iterate_checkpoint resume 指令文本(加载断点并计数一次恢复)',
|
|
2155
2189
|
}, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
|
|
2156
2190
|
),
|
|
2157
2191
|
)
|
package/src/config-write.ts
CHANGED
|
@@ -92,6 +92,28 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
|
|
|
92
92
|
errors.push('updates.validation.commands must be an object of command arrays')
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
if ('observatory' in updates) {
|
|
96
|
+
const o = updates.observatory as Record<string, unknown> | undefined
|
|
97
|
+
if (!o || typeof o !== 'object') {
|
|
98
|
+
errors.push('updates.observatory must be an object')
|
|
99
|
+
} else {
|
|
100
|
+
if (o.capture !== undefined && typeof o.capture !== 'boolean') {
|
|
101
|
+
errors.push('updates.observatory.capture must be a boolean')
|
|
102
|
+
}
|
|
103
|
+
// The approval policy is the AUTHORITATIVE human-consent seam for
|
|
104
|
+
// destructive iterate tools (session-hooks.ts reads it at call time).
|
|
105
|
+
// Letting the model flip it to `allow` via a config write would bypass
|
|
106
|
+
// the gate entirely, so model-driven approval changes are refused
|
|
107
|
+
// fail-closed — the value can only be set by editing the config file
|
|
108
|
+
// directly (a human action the approval seam can trust).
|
|
109
|
+
if ('approval' in o) {
|
|
110
|
+
errors.push(
|
|
111
|
+
'updates.observatory.approval cannot be changed through iterate_config — ' +
|
|
112
|
+
'edit iterate.config.yaml directly (the approval gate is human-controlled)',
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
95
117
|
if ('personalization' in updates && (!updates.personalization || typeof updates.personalization !== 'object')) {
|
|
96
118
|
errors.push('updates.personalization must be an object')
|
|
97
119
|
}
|
|
@@ -109,6 +131,11 @@ export function applyConfigUpdates(
|
|
|
109
131
|
const out: Record<string, unknown> = { ...base }
|
|
110
132
|
for (const [key, value] of Object.entries(updates)) {
|
|
111
133
|
if (value === undefined) continue
|
|
134
|
+
// Prototype-pollution guard (mirrors config-loader.mergeConfig): a
|
|
135
|
+
// caller-supplied `__proto__`/`constructor`/`prototype` key must never be
|
|
136
|
+
// plain-assigned — on a plain object `out['__proto__'] = value` would set
|
|
137
|
+
// the object's prototype instead of an own property.
|
|
138
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue
|
|
112
139
|
const baseValue = out[key]
|
|
113
140
|
if (
|
|
114
141
|
baseValue &&
|
package/src/review-scope.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { readdirSync } from 'node:fs'
|
|
25
|
-
import { join } from 'node:path'
|
|
25
|
+
import { join, relative } from 'node:path'
|
|
26
26
|
|
|
27
27
|
export interface CoverageResult {
|
|
28
28
|
assigned: string[]
|
|
@@ -135,8 +135,15 @@ function collectFull(root: string): string[] {
|
|
|
135
135
|
}
|
|
136
136
|
if (!entry.isFile()) continue
|
|
137
137
|
if (!sourceExt(entry.name)) continue
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// Derive the inventory path relative to the root via path.relative (then
|
|
139
|
+
// canonicalize separators). The old `abs.slice(root.length + 1)` string
|
|
140
|
+
// prefix was only correct on POSIX: on Windows path.join emits '\\' while
|
|
141
|
+
// this module's SEP is '/', so the prefix never matched and the ABSOLUTE
|
|
142
|
+
// path leaked into the inventory. path.relative is separator-agnostic and
|
|
143
|
+
// always yields a path strictly inside `root`.
|
|
144
|
+
const rel = normalizePath(relative(root, abs))
|
|
145
|
+
if (rel === '' || rel === '.' || rel.startsWith('../') || rel === '..') continue
|
|
146
|
+
out.push(rel)
|
|
140
147
|
}
|
|
141
148
|
}
|
|
142
149
|
return out.sort()
|
package/src/review.ts
CHANGED
|
@@ -247,14 +247,20 @@ export function buildReviewReport(input: {
|
|
|
247
247
|
fixedCount?: number
|
|
248
248
|
}): ReviewReport {
|
|
249
249
|
// 1. Filter known-intentional per round (before cross-round dedupe).
|
|
250
|
-
const filteredRounds = input.rounds
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
250
|
+
const filteredRounds = input.rounds
|
|
251
|
+
.map((r) => ({
|
|
252
|
+
round: typeof r?.round === 'number' ? r.round : 0,
|
|
253
|
+
findings: filterKnownIntentional(
|
|
254
|
+
Array.isArray(r?.findings) ? r.findings : [],
|
|
255
|
+
input.knownIntentional,
|
|
256
|
+
),
|
|
257
|
+
readFiles: Array.isArray(r?.readFiles) ? r.readFiles : [],
|
|
258
|
+
}))
|
|
259
|
+
// Sort by round number so convergence math below (and the meta-review
|
|
260
|
+
// audit, which reads the LAST element as the highest round) never depends
|
|
261
|
+
// on the caller's array order. A resumed run that only passes [round 5]
|
|
262
|
+
// must be audited as round 5, not as "one round with no number".
|
|
263
|
+
.sort((a, b) => a.round - b.round)
|
|
258
264
|
|
|
259
265
|
// 2. Cross-round dedupe + per-round "first seen" tracking.
|
|
260
266
|
const { findings, findingsByRound } = aggregateRounds(
|
|
@@ -271,8 +277,10 @@ export function buildReviewReport(input: {
|
|
|
271
277
|
// using its reported round number — NOT `filteredRounds.length - 1`, which
|
|
272
278
|
// is only valid for contiguous 1..N round numbers (resumed iterations and
|
|
273
279
|
// non-contiguous round sets would otherwise read the wrong count).
|
|
274
|
-
|
|
275
|
-
|
|
280
|
+
let lastRound = 0
|
|
281
|
+
for (const r of filteredRounds) {
|
|
282
|
+
if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
|
|
283
|
+
}
|
|
276
284
|
const lastRoundCount =
|
|
277
285
|
lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
|
|
278
286
|
const converged = filteredRounds.length > 0 && lastRoundCount === 0
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -16,7 +16,10 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
|
16
16
|
import { checkpointPath, iterateDir, transcriptPath } from '../paths.ts'
|
|
17
17
|
import { readRegistry } from './fix.ts'
|
|
18
18
|
import { readDecisionEntries } from './decision-log.ts'
|
|
19
|
-
import
|
|
19
|
+
import { readQualityGate } from './quality-store.ts'
|
|
20
|
+
import { readExperienceBank } from './experience-store.ts'
|
|
21
|
+
import { readDefenseEvents } from './defense-store.ts'
|
|
22
|
+
import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGateSnapshot } from '../types.ts'
|
|
20
23
|
|
|
21
24
|
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
22
25
|
|
|
@@ -90,6 +93,12 @@ export function computeStatus(input: {
|
|
|
90
93
|
taskMode?: 'code' | 'iterate' | null
|
|
91
94
|
decisionEntries: { timestamp: string; type: string; round?: number; data?: Record<string, unknown> }[]
|
|
92
95
|
fixRegistry: { rounds: { round: number; fixedCount: number; failedCount: number }[] }
|
|
96
|
+
/** v3.0: persisted quality-gate snapshot (optional; absent → omitted). */
|
|
97
|
+
qualityGate?: QualityGateSnapshot | null
|
|
98
|
+
/** v3.0: experience bank summary (optional; absent → omitted). */
|
|
99
|
+
experienceBank?: { totalEntries: number; totalHits: number } | null
|
|
100
|
+
/** v3.0: defense events summary (optional; absent → omitted). */
|
|
101
|
+
defenseEvents?: { totalEvents: number; counts: Record<DefenseEventType, number> } | null
|
|
93
102
|
}): IterationStatus {
|
|
94
103
|
const checkpoint = input.checkpoint
|
|
95
104
|
const taskMode = input.taskMode ?? null
|
|
@@ -134,6 +143,12 @@ export function computeStatus(input: {
|
|
|
134
143
|
resumeCount: checkpoint?.resumeCount ?? 0,
|
|
135
144
|
checkpoint,
|
|
136
145
|
lastUpdated,
|
|
146
|
+
// v3.0: quality command-center snapshots (present only when the caller
|
|
147
|
+
// supplied a real snapshot — the status never fabricates one that is not
|
|
148
|
+
// on disk, and never emits null for an absent optional field).
|
|
149
|
+
...(input.qualityGate != null ? { qualityGate: input.qualityGate } : {}),
|
|
150
|
+
...(input.experienceBank != null ? { experienceBank: input.experienceBank } : {}),
|
|
151
|
+
...(input.defenseEvents != null ? { defenseEvents: input.defenseEvents } : {}),
|
|
137
152
|
}
|
|
138
153
|
}
|
|
139
154
|
|
|
@@ -148,14 +163,16 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
148
163
|
defineTool({
|
|
149
164
|
name: 'iterate_checkpoint',
|
|
150
165
|
description:
|
|
151
|
-
'Save / load / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
|
|
152
|
-
'each round (so a long run can resume) and clears it when the iteration completes.'
|
|
166
|
+
'Save / load / resume / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
|
|
167
|
+
'each round (so a long run can resume) and clears it when the iteration completes. ' +
|
|
168
|
+
'`resume` loads an existing checkpoint, bumps its resumeCount, and persists it back — ' +
|
|
169
|
+
'call it when continuing an interrupted run so the resume counter stays accurate.',
|
|
153
170
|
parameters: {
|
|
154
171
|
operation: {
|
|
155
172
|
type: 'string',
|
|
156
173
|
required: true,
|
|
157
|
-
description: '"save" to persist the current progress, "load" to read it back, "clear" to remove it.',
|
|
158
|
-
enum: ['save', 'load', 'clear'],
|
|
174
|
+
description: '"save" to persist the current progress, "load" to read it back, "resume" to load + count a resumption, "clear" to remove it.',
|
|
175
|
+
enum: ['save', 'load', 'resume', 'clear'],
|
|
159
176
|
},
|
|
160
177
|
mode: { type: 'string', description: 'Required for save: "dry-run" or "normal".' },
|
|
161
178
|
round: { type: 'integer', description: 'Required for save: current round number (0 = none started).' },
|
|
@@ -194,6 +211,28 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
194
211
|
return { operation: 'load', ok: true, checkpoint: checkpoint as unknown as JsonValue | null }
|
|
195
212
|
}
|
|
196
213
|
|
|
214
|
+
if (args.operation === 'resume') {
|
|
215
|
+
const current = readCheckpoint(projectRoot)
|
|
216
|
+
if (!current) {
|
|
217
|
+
return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' }
|
|
218
|
+
}
|
|
219
|
+
const resumed: IterationCheckpoint = {
|
|
220
|
+
...current,
|
|
221
|
+
resumeCount: (current.resumeCount ?? 0) + 1,
|
|
222
|
+
updatedAt: new Date().toISOString(),
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(iterateDir(projectRoot), { recursive: true })
|
|
226
|
+
const cpPath = checkpointPath(projectRoot)
|
|
227
|
+
const tmpPath = `${cpPath}.tmp-${Date.now()}`
|
|
228
|
+
writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8')
|
|
229
|
+
renameSync(tmpPath, cpPath)
|
|
230
|
+
} catch (err) {
|
|
231
|
+
return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
|
|
232
|
+
}
|
|
233
|
+
return { operation: 'resume', ok: true, checkpoint: resumed as unknown as JsonValue }
|
|
234
|
+
}
|
|
235
|
+
|
|
197
236
|
if (args.operation === 'clear') {
|
|
198
237
|
const existed = existsSync(checkpointPath(projectRoot))
|
|
199
238
|
if (existed) {
|
|
@@ -239,7 +278,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
|
|
|
239
278
|
return { operation: 'save', ok: true, checkpoint: checkpoint as unknown as JsonValue }
|
|
240
279
|
}
|
|
241
280
|
|
|
242
|
-
return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", or "clear".' }
|
|
281
|
+
return { operation: args.operation, ok: false, error: 'unknown operation. Use "save", "load", "resume", or "clear".' }
|
|
243
282
|
},
|
|
244
283
|
}),
|
|
245
284
|
)
|
|
@@ -280,6 +319,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
280
319
|
interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
|
|
281
320
|
resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
|
|
282
321
|
lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
322
|
+
qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
|
|
323
|
+
experienceBank: { type: 'json', description: 'v3.0: experience bank summary (.iterate/experience.json), when present.' },
|
|
324
|
+
defenseEvents: { type: 'json', description: 'v3.0: defense events summary (.iterate/defense-events.json), when present.' },
|
|
283
325
|
error: { type: 'string' },
|
|
284
326
|
},
|
|
285
327
|
},
|
|
@@ -302,11 +344,31 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
302
344
|
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
303
345
|
if (!resolved.ok) return { ok: false, error: resolved.reason }
|
|
304
346
|
const projectRoot = resolved.root
|
|
347
|
+
// v3.0: surface the persisted quality command-center snapshots so a
|
|
348
|
+
// single `iterate_status` call reports the whole run state — the gate,
|
|
349
|
+
// the experience bank, and the defense event stream. Each read is
|
|
350
|
+
// defensive (missing/malformed files yield an empty snapshot), so the
|
|
351
|
+
// status never crashes on absent artifacts.
|
|
352
|
+
const qualityGate = readQualityGate(projectRoot)
|
|
353
|
+
const experienceBank = readExperienceBank(projectRoot)
|
|
354
|
+
const defenseEvents = readDefenseEvents(projectRoot)
|
|
305
355
|
const status = computeStatus({
|
|
306
356
|
checkpoint: readCheckpoint(projectRoot),
|
|
307
357
|
taskMode: readTranscriptTaskMode(projectRoot),
|
|
308
358
|
decisionEntries: readDecisionEntries(projectRoot),
|
|
309
359
|
fixRegistry: readRegistry(projectRoot),
|
|
360
|
+
qualityGate:
|
|
361
|
+
qualityGate.dimensions.length > 0 || qualityGate.overallStatus === 'pass' || qualityGate.overallStatus === 'fail'
|
|
362
|
+
? qualityGate
|
|
363
|
+
: null,
|
|
364
|
+
experienceBank: {
|
|
365
|
+
totalEntries: experienceBank.entries.length,
|
|
366
|
+
totalHits: experienceBank.totalHits ?? 0,
|
|
367
|
+
},
|
|
368
|
+
defenseEvents: {
|
|
369
|
+
totalEvents: defenseEvents.events.length,
|
|
370
|
+
counts: defenseEvents.counts,
|
|
371
|
+
},
|
|
310
372
|
})
|
|
311
373
|
return {
|
|
312
374
|
ok: true,
|
|
@@ -322,6 +384,9 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
322
384
|
interrupted: status.interrupted,
|
|
323
385
|
resumeCount: status.resumeCount,
|
|
324
386
|
lastUpdated: status.lastUpdated ?? null,
|
|
387
|
+
qualityGate: status.qualityGate ? (status.qualityGate as unknown as JsonValue) : null,
|
|
388
|
+
experienceBank: status.experienceBank ? (status.experienceBank as unknown as JsonValue) : null,
|
|
389
|
+
defenseEvents: status.defenseEvents ? (status.defenseEvents as unknown as JsonValue) : null,
|
|
325
390
|
}
|
|
326
391
|
},
|
|
327
392
|
}),
|
|
@@ -55,6 +55,7 @@ function validateRecordInput(args: {
|
|
|
55
55
|
defense?: unknown
|
|
56
56
|
outcome?: unknown
|
|
57
57
|
severity?: unknown
|
|
58
|
+
line?: unknown
|
|
58
59
|
}): string[] {
|
|
59
60
|
const errors: string[] = []
|
|
60
61
|
if (typeof args.type !== 'string' || !EVENT_TYPES.includes(args.type as DefenseEventType)) {
|
|
@@ -72,6 +73,9 @@ function validateRecordInput(args: {
|
|
|
72
73
|
if (typeof args.outcome !== 'string' || !args.outcome.trim()) {
|
|
73
74
|
errors.push('outcome is required')
|
|
74
75
|
}
|
|
76
|
+
if (args.line !== undefined && (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 0)) {
|
|
77
|
+
errors.push('line must be a non-negative integer when present')
|
|
78
|
+
}
|
|
75
79
|
const severity = args.severity
|
|
76
80
|
if (severity !== 'critical' && severity !== 'high' && severity !== 'medium' && severity !== 'low') {
|
|
77
81
|
errors.push('severity must be one of critical, high, medium, low')
|
|
@@ -44,14 +44,28 @@ function emptyStream(): DefenseEventStream {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Read the defense events stream from disk.
|
|
49
|
+
* Normalizes the persisted stream so a hand-edited / partial file can never
|
|
50
|
+
* produce NaN counts: `counts` is recomputed from the events when missing or
|
|
51
|
+
* malformed, and every type key is guaranteed present.
|
|
52
|
+
*/
|
|
48
53
|
export function readDefenseEvents(projectRoot: string): DefenseEventStream {
|
|
49
54
|
const filePath = path.join(projectRoot, '.iterate', DEFENSE_EVENTS_FILE)
|
|
50
55
|
try {
|
|
51
56
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
52
|
-
const parsed = JSON.parse(content) as DefenseEventStream
|
|
57
|
+
const parsed = JSON.parse(content) as Partial<DefenseEventStream>
|
|
53
58
|
if (parsed && Array.isArray(parsed.events)) {
|
|
54
|
-
|
|
59
|
+
const events = parsed.events.filter(
|
|
60
|
+
(e): e is DefenseEvent =>
|
|
61
|
+
!!e && typeof e === 'object' && typeof (e as DefenseEvent).type === 'string',
|
|
62
|
+
)
|
|
63
|
+
const counts = computeCounts(events)
|
|
64
|
+
return {
|
|
65
|
+
events,
|
|
66
|
+
lastUpdated: typeof parsed.lastUpdated === 'string' ? parsed.lastUpdated : emptyStream().lastUpdated,
|
|
67
|
+
counts,
|
|
68
|
+
}
|
|
55
69
|
}
|
|
56
70
|
} catch {
|
|
57
71
|
// File not found or invalid JSON
|
|
@@ -95,7 +109,10 @@ export function addDefenseEvent(
|
|
|
95
109
|
...event,
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
|
|
112
|
+
// Always recompute from the events array instead of mutating a possibly
|
|
113
|
+
// stale/malformed persisted `counts` object — guarantees the stream counts
|
|
114
|
+
// can never drift from (or NaN out against) its events.
|
|
115
|
+
const newCounts = computeCounts(stream.events)
|
|
99
116
|
bumpCount(newCounts, event.type)
|
|
100
117
|
|
|
101
118
|
return {
|
package/src/tools/history.ts
CHANGED
|
@@ -130,7 +130,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
130
130
|
? `Fixes: ${fixes.totalFixed} applied · ${fixes.totalFailed} failed · across ${fixes.roundCount} round(s)`
|
|
131
131
|
: 'Fixes: none',
|
|
132
132
|
'',
|
|
133
|
-
...log.map((e) => `[${e.timestamp}] r${e.round} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
|
|
133
|
+
...log.map((e) => `[${e.timestamp}] r${e.round ?? '?'} ${e.type}: ${JSON.stringify(e.data ?? {})}`),
|
|
134
134
|
]
|
|
135
135
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
136
136
|
},
|
|
@@ -31,14 +31,59 @@ function emptySnapshot(): QualityGateSnapshot {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Read the quality gate snapshot from disk.
|
|
36
|
+
* Normalizes a hand-edited / partial file so readers and the tool's `render`
|
|
37
|
+
* never crash on missing arrays or non-numeric fields: `dimensions` is
|
|
38
|
+
* guaranteed to be an array and every numeric field degrades to 0.
|
|
39
|
+
*/
|
|
35
40
|
export function readQualityGate(projectRoot: string): QualityGateSnapshot {
|
|
36
41
|
const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE)
|
|
37
42
|
try {
|
|
38
43
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
39
|
-
const parsed = JSON.parse(content) as QualityGateSnapshot
|
|
44
|
+
const parsed = JSON.parse(content) as Partial<QualityGateSnapshot> | null
|
|
40
45
|
if (parsed && typeof parsed === 'object') {
|
|
41
|
-
|
|
46
|
+
const num = (v: unknown): number =>
|
|
47
|
+
typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
48
|
+
const status: QualityGateSnapshot['overallStatus'] =
|
|
49
|
+
parsed.overallStatus === 'pass' || parsed.overallStatus === 'fail' || parsed.overallStatus === 'pending'
|
|
50
|
+
? parsed.overallStatus
|
|
51
|
+
: 'pending'
|
|
52
|
+
const dimensions = Array.isArray(parsed.dimensions)
|
|
53
|
+
? (parsed.dimensions as unknown as Array<Record<string, unknown>>)
|
|
54
|
+
.filter((d): d is Record<string, unknown> => !!d && typeof d === 'object' && typeof d.dimension === 'string')
|
|
55
|
+
.map((d) => {
|
|
56
|
+
const dimStatus: QualityGateDimension['status'] =
|
|
57
|
+
d.status === 'pass' || d.status === 'fail' || d.status === 'warn' ? d.status : 'warn'
|
|
58
|
+
return {
|
|
59
|
+
dimension: d.dimension as string,
|
|
60
|
+
convergenceRate: num(d.convergenceRate),
|
|
61
|
+
findingsCount: num(d.findingsCount),
|
|
62
|
+
fixedCount: num(d.fixedCount),
|
|
63
|
+
score: num(d.score),
|
|
64
|
+
status: dimStatus,
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
: []
|
|
68
|
+
return {
|
|
69
|
+
timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : emptySnapshot().timestamp,
|
|
70
|
+
overallStatus: status,
|
|
71
|
+
overallScore: num(parsed.overallScore),
|
|
72
|
+
dimensions,
|
|
73
|
+
verificationPassRate: num(parsed.verificationPassRate),
|
|
74
|
+
totalChecks: num(parsed.totalChecks),
|
|
75
|
+
passedChecks: num(parsed.passedChecks),
|
|
76
|
+
failedChecks: num(parsed.failedChecks),
|
|
77
|
+
totalFindings: num(parsed.totalFindings),
|
|
78
|
+
criticalCount: num(parsed.criticalCount),
|
|
79
|
+
highCount: num(parsed.highCount),
|
|
80
|
+
mediumCount: num(parsed.mediumCount),
|
|
81
|
+
lowCount: num(parsed.lowCount),
|
|
82
|
+
// Only carry an own failReason when it is a real string — an absent
|
|
83
|
+
// persisted reason must not surface as `failReason: undefined` (which
|
|
84
|
+
// deep-equals differently than the JSON round-trip of computeQualityGate).
|
|
85
|
+
...(typeof parsed.failReason === 'string' ? { failReason: parsed.failReason } : {}),
|
|
86
|
+
}
|
|
42
87
|
}
|
|
43
88
|
} catch {
|
|
44
89
|
// File not found or invalid JSON
|
package/src/transcript.ts
CHANGED
|
@@ -49,6 +49,10 @@ const MAX_FINDINGS_TOTAL = 2000
|
|
|
49
49
|
/** Max timeline entries kept (newest wins). */
|
|
50
50
|
const MAX_TIMELINE = 500
|
|
51
51
|
|
|
52
|
+
/** Max applied-fix records kept (newest wins; bounded so a long run cannot
|
|
53
|
+
* grow the manifest payload without limit). */
|
|
54
|
+
const MAX_FIXES = 200
|
|
55
|
+
|
|
52
56
|
/** Thresholds applied when reducing a string list under a cap. */
|
|
53
57
|
function clampStringList(source: string[], cap: number): string[] {
|
|
54
58
|
const out: string[] = []
|
|
@@ -317,6 +321,9 @@ export class ReviewTranscriptBuilder {
|
|
|
317
321
|
typeof record.linesRemoved === 'number' ? Math.floor(record.linesRemoved) : 0,
|
|
318
322
|
success: record.success !== false,
|
|
319
323
|
})
|
|
324
|
+
if (this.fixes.length > MAX_FIXES) {
|
|
325
|
+
this.fixes.splice(0, this.fixes.length - MAX_FIXES)
|
|
326
|
+
}
|
|
320
327
|
this.touch()
|
|
321
328
|
}
|
|
322
329
|
|