iterate-plugin 3.4.1 → 3.5.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 +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/atomic-fs.js +77 -0
- package/dist/config-write.js +3 -2
- package/dist/live.js +5 -4
- package/dist/tools/checkpoint.js +10 -11
- package/dist/tools/config.js +14 -0
- package/dist/tools/context.js +3 -0
- package/dist/tools/decision-log.js +25 -10
- package/dist/tools/defense-events.js +3 -0
- package/dist/tools/defense-store.js +6 -2
- package/dist/tools/experience-bank.js +3 -0
- package/dist/tools/experience-store.js +6 -2
- package/dist/tools/fix.js +34 -4
- package/dist/tools/history.js +3 -0
- package/dist/tools/prune.js +50 -6
- package/dist/tools/quality-gate.js +3 -0
- package/dist/tools/quality-store.js +2 -1
- package/dist/tools/transcript.js +7 -5
- package/dist/tools/triage.js +6 -2
- package/dist/tools/validate.js +32 -3
- package/package.json +1 -1
- package/src/atomic-fs.ts +83 -0
- package/src/config-write.ts +3 -2
- package/src/live.ts +5 -4
- package/src/tools/checkpoint.ts +10 -11
- package/src/tools/config.ts +14 -0
- package/src/tools/context.ts +3 -0
- package/src/tools/decision-log.ts +35 -10
- package/src/tools/defense-events.ts +3 -0
- package/src/tools/defense-store.ts +6 -2
- package/src/tools/experience-bank.ts +3 -0
- package/src/tools/experience-store.ts +6 -2
- package/src/tools/fix.ts +32 -4
- package/src/tools/history.ts +3 -0
- package/src/tools/prune.ts +51 -6
- package/src/tools/quality-gate.ts +3 -0
- package/src/tools/quality-store.ts +2 -1
- package/src/tools/transcript.ts +7 -5
- package/src/tools/triage.ts +6 -2
- package/src/tools/validate.ts +32 -3
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ Beyond the 17 registered tools (full reference further down), the plugin closes
|
|
|
123
123
|
- **Structured fix system** — each fix backs up first, writes a registry entry, records the diff; a failed validation can be reverted with `iterate_rollback`
|
|
124
124
|
- **Breakpoint resume** — checkpoints saved at the start of each round; interrupted long iterations can resume
|
|
125
125
|
- **History audit** — `iterate_history` reads the decision log (filtered by type / time / count) and the fix-registry summary to audit run process and fix details
|
|
126
|
-
- **Runtime cleanup** — `iterate_prune` removes stale decision-log entries, stale checkpoints, orphaned fix backups and
|
|
126
|
+
- **Runtime cleanup** — `iterate_prune` removes stale decision-log entries, stale checkpoints, orphaned fix backups, empty rounds and stray temp files left by crashed atomic writes; dry-run by default (report-only), real cleanup requires `dryRun:false`, and every cleanup is logged
|
|
127
127
|
- **Config read / write** — `iterate_config` supports validated, backed-up, rollback-capable partial writes
|
|
128
128
|
- **v3.2 Experience Bank** — `iterate_experience` queries historical fixes and patterns with search / filter / adopt, and can persist new verified fixes (`add`) — re-adding the same pattern+dimension bumps its hit count instead of duplicating it
|
|
129
129
|
- **v3.2 Quality Gate** — `iterate_quality_gate` reads quality gate status with per-dimension convergence rates and PASS/FAIL, and can recompute + persist a fresh certificate (`compute`) from this round's findings / validation results (real convergence from `findingsByRound`)
|
|
@@ -258,7 +258,7 @@ validation:
|
|
|
258
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
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
|
-
- `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
|
|
261
|
+
- `iterate_prune` — clean runtime artifacts: stale decision-log entries (by `retainDays`, default 30), stale checkpoints, orphaned fix backups, empty rounds, stray temp files (crashed atomic writes). Dry-run by default (report-only); real cleanup with `dryRun:false`, each cleanup logged
|
|
262
262
|
- `iterate_transcript` — runtime observatory: persist review transcripts, threads, fixes, and nudge directions to `.iterate/transcript.json` for the client observatory
|
|
263
263
|
- `iterate_experience` — **v3.2** query the experience bank (list / search / get), or `add` a new verified fix: re-adding the same pattern+dimension bumps its hit count instead of duplicating it. Persists to `.iterate/experience.json`
|
|
264
264
|
- `iterate_quality_gate` — **v3.2** read the quality certificate (`read`), or recompute + persist a fresh one (`compute`) from findings, validation results, `findingsByRound`, and `fixedByDimension`. Real per-dimension convergence rates
|
package/README.zh-CN.md
CHANGED
|
@@ -122,7 +122,7 @@ UI 层为**防御式设计**:`slots` / `theme` / `React` 任一不可用时自
|
|
|
122
122
|
- **结构化修复系统** — 每次修复先备份、写注册表、记录 diff,验证失败可 `iterate_rollback` 还原
|
|
123
123
|
- **断点续跑** — 长迭代在每轮开头保存 checkpoint,中断后可恢复进度
|
|
124
124
|
- **历史审计** — `iterate_history` 读取决策日志(按类型/时间/数量过滤)与修复注册表汇总,审查运行过程与修复明细
|
|
125
|
-
- **运行时清理** — `iterate_prune`
|
|
125
|
+
- **运行时清理** — `iterate_prune` 清理过期的决策日志条目、陈旧断点、孤儿修复备份、空轮次与原子写入崩溃残留的临时文件;默认 dry-run 只报告不删除,显式 `dryRun:false` 才真正清理,每次清理写入决策日志
|
|
126
126
|
- **配置读写** — `iterate_config` 支持带校验、备份、回滚的局部写入
|
|
127
127
|
- **v3.2 经验银行** — `iterate_experience` 以检索 / 过滤 / 采纳查阅历史修复与模式,并可 `add` 持久化新的已验证修复——重复添加同一 pattern + dimension 时累加命中次数而非重复写入
|
|
128
128
|
- **v3.2 质量门禁** — `iterate_quality_gate` 读取质量门禁状态(各维度收敛率 + PASS/FAIL),并可基于本轮 findings / 验证结果 `compute` 重新计算并持久化一份新的质量凭证(收敛率来自 `findingsByRound` 的真实收敛序列)
|
|
@@ -257,7 +257,7 @@ validation:
|
|
|
257
257
|
- `iterate_checkpoint` — 迭代断点:`save` 保存当前进度到 `.iterate/checkpoint.json`,`load` 读回,`resume` 加载并累加恢复计数(中断恢复),`clear` 清除。长迭代可中断续跑
|
|
258
258
|
- `iterate_status` — 汇总当前迭代状态:模式、当前轮/总轮、已修复数、剩余 architectural、决策日志条数、是否存在 checkpoint;**v3.4:同时返回持久化的质量门禁快照、经验银行摘要与防御事件摘要**(`qualityGate` / `experienceBank` / `defenseEvents`)
|
|
259
259
|
- `iterate_history` — 读取迭代历史(只读):决策日志条目(可按 `type` / `since` / `limit` 过滤,默认取最新 50 条,上限 200 条)+ 修复注册表汇总(各轮 fixed/failed 计数)。用于审查运行过程、审计日志、盘点修复
|
|
260
|
-
- `iterate_prune` — 清理运行时产物:过期决策日志条目(按 `retainDays`,默认 30
|
|
260
|
+
- `iterate_prune` — 清理运行时产物:过期决策日志条目(按 `retainDays`,默认 30 天)、陈旧断点、孤儿修复备份、空轮次、崩溃原子写入残留的临时文件。默认 dry-run 只报告不删除;`dryRun:false` 才真正清理,每次清理写入决策日志
|
|
261
261
|
- `iterate_transcript` — 运行时观测台:把审查转录、线程、修复与 nudge 指令持久化到 `.iterate/transcript.json`,供客户端观测台读取
|
|
262
262
|
- `iterate_experience` — **v3.2** 查询经验银行(list / search / get),或 `add` 一条新的已验证修复:重复添加同一 pattern + dimension 累加命中次数而非重复写入。持久化到 `.iterate/experience.json`
|
|
263
263
|
- `iterate_quality_gate` — **v3.2** 读取质量凭证(`read`),或基于 findings、验证结果、`findingsByRound` 与 `fixedByDimension` 重新计算并持久化一份新凭证(`compute`)。真实的逐维度收敛率
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/atomic-fs.ts — crash-safe file writes for all plugin state.
|
|
3
|
+
*
|
|
4
|
+
* Every JSON/YAML artifact the plugin persists (.iterate/*.json,
|
|
5
|
+
* iterate.config.yaml) is written through here: content goes to a
|
|
6
|
+
* uniquely-named temp file in the SAME directory as the target (rename is
|
|
7
|
+
* only atomic within one filesystem), then is `renameSync`ed over the
|
|
8
|
+
* destination. A crash mid-write can therefore leave a stale temp file
|
|
9
|
+
* behind, but never a truncated / half-written state file — readers
|
|
10
|
+
* (which all tolerate missing/corrupt files) see either the previous or
|
|
11
|
+
* the new content, never a mixture.
|
|
12
|
+
*/
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { rename, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
/** Best-effort unlink of a leftover temp file (never throws). */
|
|
17
|
+
function cleanupTemp(tmp) {
|
|
18
|
+
try {
|
|
19
|
+
rmSync(tmp, { force: true });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Best-effort async unlink of a leftover temp file (never throws). */
|
|
26
|
+
async function cleanupTempAsync(tmp) {
|
|
27
|
+
try {
|
|
28
|
+
await rm(tmp, { force: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* ignore — temp files follow the prunable `.tmp-<pid>-<rand>` convention */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Unique temp file path for `filePath`, in the same directory, following the
|
|
36
|
+
* prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
|
|
37
|
+
* `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
|
|
38
|
+
*/
|
|
39
|
+
function tempPathFor(filePath) {
|
|
40
|
+
return join(dirname(filePath), `.${filePath.split('/').pop() ?? 'file'}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Write `data` to `filePath` atomically (temp file + rename, same dir).
|
|
44
|
+
* Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
|
|
45
|
+
* prune.ts / checkpoint.ts so they are recognizable and prunable.
|
|
46
|
+
* Throws on failure (callers own error reporting — they map failures to
|
|
47
|
+
* structured tool results).
|
|
48
|
+
*/
|
|
49
|
+
export function writeTextAtomic(filePath, data) {
|
|
50
|
+
const tmp = tempPathFor(filePath);
|
|
51
|
+
writeFileSync(tmp, data, 'utf-8');
|
|
52
|
+
try {
|
|
53
|
+
renameSync(tmp, filePath);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Best-effort temp cleanup so a failed write never litters the directory.
|
|
57
|
+
cleanupTemp(tmp);
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Async variant of {@link writeTextAtomic} (same temp naming convention). */
|
|
62
|
+
export async function writeTextAtomicAsync(filePath, data) {
|
|
63
|
+
const tmp = tempPathFor(filePath);
|
|
64
|
+
await writeFile(tmp, data, 'utf-8');
|
|
65
|
+
try {
|
|
66
|
+
await rename(tmp, filePath);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// Best-effort temp cleanup so a failed write never litters the directory.
|
|
70
|
+
await cleanupTempAsync(tmp);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** JSON.stringify (2-space indent) + atomic write in one call. */
|
|
75
|
+
export function writeJsonAtomic(filePath, value) {
|
|
76
|
+
writeTextAtomic(filePath, JSON.stringify(value, null, 2));
|
|
77
|
+
}
|
package/dist/config-write.js
CHANGED
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* The security posture mirrors the triage tool: never overwrite a malformed
|
|
10
10
|
* config, always back up before writing, roll back on failure.
|
|
11
11
|
*/
|
|
12
|
-
import { copyFileSync, existsSync, readFileSync, rmSync
|
|
12
|
+
import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import yaml from 'js-yaml';
|
|
15
|
+
import { writeTextAtomic } from "./atomic-fs.js";
|
|
15
16
|
/** Config file name (must match config-loader). */
|
|
16
17
|
export const CONFIG_FILE = 'iterate.config.yaml';
|
|
17
18
|
/** Backup suffix helper (filesystem-safe timestamp). */
|
|
@@ -185,7 +186,7 @@ export function writeConfigFile(projectRoot, config) {
|
|
|
185
186
|
}
|
|
186
187
|
}
|
|
187
188
|
try {
|
|
188
|
-
|
|
189
|
+
writeTextAtomic(configPath, yaml.dump(config, { noRefs: true }));
|
|
189
190
|
}
|
|
190
191
|
catch (err) {
|
|
191
192
|
let rollbackError = '';
|
package/dist/live.js
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* - Any capture failure is swallowed (fire-and-forget) so it can never block
|
|
24
24
|
* or crash a tool call.
|
|
25
25
|
*/
|
|
26
|
-
import { mkdir, readFile,
|
|
26
|
+
import { mkdir, readFile, stat, appendFile } from 'node:fs/promises';
|
|
27
27
|
import { existsSync } from 'node:fs';
|
|
28
|
+
import { writeTextAtomicAsync } from "./atomic-fs.js";
|
|
28
29
|
import { join } from 'node:path';
|
|
29
30
|
import { resolveProjectRoot } from "./config-loader.js";
|
|
30
31
|
/** Keep at most this many live activity entries. */
|
|
@@ -101,9 +102,9 @@ export async function appendLive(projectRoot, entry) {
|
|
|
101
102
|
const raw = await readFile(file, 'utf-8');
|
|
102
103
|
const lines = raw.split('\n').filter(Boolean);
|
|
103
104
|
const tail = lines.slice(-LIVE_MAX_ENTRIES);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
await
|
|
105
|
+
// Atomic rewrite (unique temp + rename) so a crash mid-trim never
|
|
106
|
+
// truncates the live feed; the temp file is prunable by iterate_prune.
|
|
107
|
+
await writeTextAtomicAsync(file, tail.join('\n') + '\n');
|
|
107
108
|
}
|
|
108
109
|
await appendFile(file, line, 'utf-8');
|
|
109
110
|
}
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Checkpoint layout: `.iterate/checkpoint.json`.
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
12
12
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
13
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
14
|
+
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
14
15
|
import { checkpointPath, iterateDir, transcriptPath } from "../paths.js";
|
|
15
16
|
import { readRegistry } from "./fix.js";
|
|
16
17
|
import { readDecisionEntries } from "./decision-log.js";
|
|
@@ -192,10 +193,7 @@ export function registerCheckpointTool(ctx) {
|
|
|
192
193
|
};
|
|
193
194
|
try {
|
|
194
195
|
mkdirSync(iterateDir(projectRoot), { recursive: true });
|
|
195
|
-
|
|
196
|
-
const tmpPath = `${cpPath}.tmp-${Date.now()}`;
|
|
197
|
-
writeFileSync(tmpPath, JSON.stringify(resumed, null, 2), 'utf-8');
|
|
198
|
-
renameSync(tmpPath, cpPath);
|
|
196
|
+
writeJsonAtomic(checkpointPath(projectRoot), resumed);
|
|
199
197
|
}
|
|
200
198
|
catch (err) {
|
|
201
199
|
return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` };
|
|
@@ -238,12 +236,10 @@ export function registerCheckpointTool(ctx) {
|
|
|
238
236
|
};
|
|
239
237
|
try {
|
|
240
238
|
mkdirSync(iterateDir(projectRoot), { recursive: true });
|
|
241
|
-
// Atomic write (temp + rename): a crash
|
|
242
|
-
// the checkpoint and silently lose the
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
writeFileSync(tmpPath, JSON.stringify(checkpoint, null, 2), 'utf-8');
|
|
246
|
-
renameSync(tmpPath, cpPath);
|
|
239
|
+
// Atomic write (temp + rename, via writeJsonAtomic): a crash
|
|
240
|
+
// mid-write must not corrupt the checkpoint and silently lose the
|
|
241
|
+
// interruption state.
|
|
242
|
+
writeJsonAtomic(checkpointPath(projectRoot), checkpoint);
|
|
247
243
|
}
|
|
248
244
|
catch (err) {
|
|
249
245
|
return { operation: 'save', ok: false, error: `failed to write checkpoint: ${String(err)}` };
|
|
@@ -262,6 +258,9 @@ export function registerCheckpointTool(ctx) {
|
|
|
262
258
|
export function registerStatusTool(ctx) {
|
|
263
259
|
ctx.tools.register(defineTool({
|
|
264
260
|
name: 'iterate_status',
|
|
261
|
+
// Read-only aggregation of on-disk state → safe to join a parallel
|
|
262
|
+
// dispatch group alongside other read-only sibling calls.
|
|
263
|
+
isConcurrencySafe: () => true,
|
|
265
264
|
description: 'Summarize the current iterate run: mode, current round vs total, fixes applied, architectural ' +
|
|
266
265
|
'findings remaining, decision-log size, and whether a resume checkpoint exists.',
|
|
267
266
|
parameters: {
|
package/dist/tools/config.js
CHANGED
|
@@ -11,6 +11,20 @@ import { applyConfigUpdates, readRawConfig, validateConfigUpdates, writeConfigFi
|
|
|
11
11
|
export function registerConfigTool(ctx) {
|
|
12
12
|
ctx.tools.register(defineTool({
|
|
13
13
|
name: 'iterate_config',
|
|
14
|
+
// `read` never writes; `write` persists a config update → only read
|
|
15
|
+
// joins a parallel dispatch group.
|
|
16
|
+
isConcurrencySafe: (args) => args.operation !== 'write',
|
|
17
|
+
// Pending-call card: a config read vs a validated write.
|
|
18
|
+
presentCall: (args) => {
|
|
19
|
+
const a = args;
|
|
20
|
+
const isWrite = a.operation === 'write';
|
|
21
|
+
return {
|
|
22
|
+
card: 'generic',
|
|
23
|
+
title: isWrite ? 'Update iterate.config.yaml' : 'Read iterate.config.yaml',
|
|
24
|
+
kind: isWrite ? 'edit' : 'read',
|
|
25
|
+
...(typeof a.section === 'string' && a.section ? { rawInput: { section: a.section } } : {}),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
14
28
|
description: 'Read or update the iterate.config.yaml configuration from the project root. ' +
|
|
15
29
|
'Returns the full parsed config, a specific section, or validation errors. ' +
|
|
16
30
|
'Use this to discover available dimensions, validation commands, git settings, and personalization rules, ' +
|
package/dist/tools/context.js
CHANGED
|
@@ -161,6 +161,9 @@ export { findSkillMd };
|
|
|
161
161
|
export function registerContextTool(ctx) {
|
|
162
162
|
ctx.tools.register(defineTool({
|
|
163
163
|
name: 'iterate_context',
|
|
164
|
+
// Read-only (reads SKILL.md / ITERATE.md) → safe to join a parallel
|
|
165
|
+
// dispatch group alongside other read-only sibling calls.
|
|
166
|
+
isConcurrencySafe: () => true,
|
|
164
167
|
description: 'Read project context files (SKILL.md and/or ITERATE.md). ' +
|
|
165
168
|
'SKILL.md contains the original iterate skill instructions; it is searched in ' +
|
|
166
169
|
'the skill directory (auto-detected), the project root, or an explicit `skillDir`. ' +
|
|
@@ -71,34 +71,44 @@ export function appendDecisionEntry(projectRoot, entry) {
|
|
|
71
71
|
return { count, path: filePath };
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
|
-
* Read all entries from the decision log.
|
|
74
|
+
* Read all entries from the decision log, plus the count of corrupt lines.
|
|
75
75
|
* A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
|
|
76
|
-
* one bad line must never empty the whole history for every reader
|
|
76
|
+
* one bad line must never empty the whole history for every reader — but it
|
|
77
|
+
* is counted in `invalidLines` so it never disappears silently.
|
|
77
78
|
*/
|
|
78
|
-
export function
|
|
79
|
+
export function readDecisionLogDetailed(projectRoot) {
|
|
79
80
|
const filePath = join(projectRoot, LOG_DIR, LOG_FILE);
|
|
80
81
|
if (!existsSync(filePath))
|
|
81
|
-
return [];
|
|
82
|
+
return { entries: [], invalidLines: 0 };
|
|
82
83
|
let content;
|
|
83
84
|
try {
|
|
84
85
|
content = readFileSync(filePath, 'utf-8');
|
|
85
86
|
}
|
|
86
87
|
catch {
|
|
87
|
-
return [];
|
|
88
|
+
return { entries: [], invalidLines: 0 };
|
|
88
89
|
}
|
|
89
|
-
const
|
|
90
|
+
const entries = [];
|
|
91
|
+
let invalidLines = 0;
|
|
90
92
|
for (const line of content.split('\n')) {
|
|
91
93
|
const trimmed = line.trim();
|
|
92
94
|
if (trimmed.length === 0)
|
|
93
95
|
continue;
|
|
94
96
|
try {
|
|
95
|
-
|
|
97
|
+
entries.push(JSON.parse(trimmed));
|
|
96
98
|
}
|
|
97
99
|
catch {
|
|
98
|
-
|
|
100
|
+
invalidLines += 1;
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
|
-
return
|
|
103
|
+
return { entries, invalidLines };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read all entries from the decision log.
|
|
107
|
+
* A single corrupt line (partial write, hand-edit) is SKIPPED, not fatal —
|
|
108
|
+
* one bad line must never empty the whole history for every reader.
|
|
109
|
+
*/
|
|
110
|
+
export function readDecisionEntries(projectRoot) {
|
|
111
|
+
return readDecisionLogDetailed(projectRoot).entries;
|
|
102
112
|
}
|
|
103
113
|
/**
|
|
104
114
|
* Register the `iterate_decision_log` tool.
|
|
@@ -108,6 +118,9 @@ export function readDecisionEntries(projectRoot) {
|
|
|
108
118
|
export function registerDecisionLogTool(ctx) {
|
|
109
119
|
ctx.tools.register(defineTool({
|
|
110
120
|
name: 'iterate_decision_log',
|
|
121
|
+
// `read` never writes; `append` extends the audit log → only read joins
|
|
122
|
+
// a parallel dispatch group.
|
|
123
|
+
isConcurrencySafe: (args) => args.operation === 'read',
|
|
111
124
|
description: 'Append-only decision log for the iterate loop. ' +
|
|
112
125
|
'Use `append` to record a round start, review finding, fix, validation result, or decision. ' +
|
|
113
126
|
'Use `read` to retrieve all entries for review. ' +
|
|
@@ -156,6 +169,7 @@ export function registerDecisionLogTool(ctx) {
|
|
|
156
169
|
properties: {
|
|
157
170
|
operation: { type: 'string', required: true },
|
|
158
171
|
entryCount: { type: 'integer' },
|
|
172
|
+
invalidLines: { type: 'integer' },
|
|
159
173
|
logPath: { type: 'string' },
|
|
160
174
|
entries: { type: 'json' },
|
|
161
175
|
success: { type: 'boolean' },
|
|
@@ -174,10 +188,11 @@ export function registerDecisionLogTool(ctx) {
|
|
|
174
188
|
}
|
|
175
189
|
const projectRoot = resolved.root;
|
|
176
190
|
if (args.operation === 'read') {
|
|
177
|
-
const entries =
|
|
191
|
+
const { entries, invalidLines } = readDecisionLogDetailed(projectRoot);
|
|
178
192
|
return {
|
|
179
193
|
operation: 'read',
|
|
180
194
|
entryCount: entries.length,
|
|
195
|
+
invalidLines,
|
|
181
196
|
logPath: join(projectRoot, LOG_DIR, LOG_FILE),
|
|
182
197
|
entries: entries,
|
|
183
198
|
};
|
|
@@ -72,6 +72,9 @@ function validateRecordInput(args) {
|
|
|
72
72
|
export function registerDefenseEventsTool(ctx) {
|
|
73
73
|
ctx.tools.register(defineTool({
|
|
74
74
|
name: 'iterate_defense_events',
|
|
75
|
+
// List/counts never write; only `record` persists an event → read shapes
|
|
76
|
+
// join a parallel dispatch group.
|
|
77
|
+
isConcurrencySafe: (args) => args.operation !== 'record',
|
|
75
78
|
description: 'Query or record defense events: precondition failures, rollbacks, invariant violations, ' +
|
|
76
79
|
'and assumption falsifications. ' +
|
|
77
80
|
'List/counts return events with descriptions, outcomes, and summary counts; ' +
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
+
import { iterateDir } from "../paths.js";
|
|
10
|
+
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
9
11
|
const DEFENSE_EVENTS_FILE = 'defense-events.json';
|
|
10
12
|
/** Valid defense event types (must stay in sync with DefenseEventType). */
|
|
11
13
|
const VALID_EVENT_TYPES = new Set([
|
|
@@ -70,13 +72,15 @@ export function readDefenseEvents(projectRoot) {
|
|
|
70
72
|
* an event that was never persisted.
|
|
71
73
|
*/
|
|
72
74
|
export function writeDefenseEvents(projectRoot, stream) {
|
|
73
|
-
const dirPath =
|
|
75
|
+
const dirPath = iterateDir(projectRoot);
|
|
74
76
|
const filePath = path.join(dirPath, DEFENSE_EVENTS_FILE);
|
|
75
77
|
try {
|
|
76
78
|
if (!fs.existsSync(dirPath)) {
|
|
77
79
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
78
80
|
}
|
|
79
|
-
|
|
81
|
+
// Atomic (temp + rename): a crash mid-write can never leave a truncated
|
|
82
|
+
// defense event stream behind.
|
|
83
|
+
writeJsonAtomic(filePath, stream);
|
|
80
84
|
}
|
|
81
85
|
catch (err) {
|
|
82
86
|
return { ok: false, error: `unable to write ${filePath}: ${String(err)}` };
|
|
@@ -67,6 +67,9 @@ function normalizeExperienceInput(raw) {
|
|
|
67
67
|
export function registerExperienceBankTool(ctx) {
|
|
68
68
|
ctx.tools.register(defineTool({
|
|
69
69
|
name: 'iterate_experience',
|
|
70
|
+
// List/search/get never write; only `add` upserts the bank → read shapes
|
|
71
|
+
// join a parallel dispatch group.
|
|
72
|
+
isConcurrencySafe: (args) => args.operation !== 'add',
|
|
70
73
|
description: 'Query or extend the experience bank: browse/search historical fixes and patterns, ' +
|
|
71
74
|
'or record a new verified fix (operation:"add"). ' +
|
|
72
75
|
'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
+
import { iterateDir } from "../paths.js";
|
|
10
|
+
import { writeJsonAtomic } from "../atomic-fs.js";
|
|
9
11
|
const EXPERIENCE_FILE = 'experience.json';
|
|
10
12
|
/** Default empty experience bank. */
|
|
11
13
|
function emptyBank() {
|
|
@@ -37,13 +39,15 @@ export function readExperienceBank(projectRoot) {
|
|
|
37
39
|
* an entry that was never persisted.
|
|
38
40
|
*/
|
|
39
41
|
export function writeExperienceBank(projectRoot, bank) {
|
|
40
|
-
const dirPath =
|
|
42
|
+
const dirPath = iterateDir(projectRoot);
|
|
41
43
|
const filePath = path.join(dirPath, EXPERIENCE_FILE);
|
|
42
44
|
try {
|
|
43
45
|
if (!fs.existsSync(dirPath)) {
|
|
44
46
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
45
47
|
}
|
|
46
|
-
|
|
48
|
+
// Atomic (temp + rename): a crash mid-write can never leave a truncated
|
|
49
|
+
// experience bank behind.
|
|
50
|
+
writeJsonAtomic(filePath, bank);
|
|
47
51
|
}
|
|
48
52
|
catch (err) {
|
|
49
53
|
return { ok: false, error: `unable to write ${filePath}: ${String(err)}` };
|
package/dist/tools/fix.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* - Backups are written before any write, so a failure never destroys data.
|
|
17
17
|
* - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
|
|
18
18
|
*/
|
|
19
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
20
|
+
import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
|
|
20
21
|
import { join, sep } from 'node:path';
|
|
21
22
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
22
23
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
@@ -276,6 +277,20 @@ function readProjectFile(projectRoot, file) {
|
|
|
276
277
|
export function registerFixTool(ctx) {
|
|
277
278
|
ctx.tools.register(defineTool({
|
|
278
279
|
name: 'iterate_fix',
|
|
280
|
+
// Pending-call card: surface the file about to change as an inline diff.
|
|
281
|
+
// oldText is null — a call-time presenter has no access to the file's
|
|
282
|
+
// prior content (an overwrite, per the presentation contract).
|
|
283
|
+
presentCall: (args) => {
|
|
284
|
+
const a = args;
|
|
285
|
+
if (typeof a.file !== 'string' || typeof a.content !== 'string')
|
|
286
|
+
return undefined;
|
|
287
|
+
return {
|
|
288
|
+
card: 'diff',
|
|
289
|
+
title: `Fix ${a.file}`,
|
|
290
|
+
diffs: [{ path: a.file, oldText: null, newText: a.content }],
|
|
291
|
+
locations: [{ path: a.file }],
|
|
292
|
+
};
|
|
293
|
+
},
|
|
279
294
|
description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
|
|
280
295
|
'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
|
|
281
296
|
'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
|
|
@@ -445,7 +460,7 @@ export function registerFixTool(ctx) {
|
|
|
445
460
|
return { ok: false, error: `failed to create backup: ${String(err)}` };
|
|
446
461
|
}
|
|
447
462
|
try {
|
|
448
|
-
|
|
463
|
+
writeTextAtomic(target.resolved, args.content);
|
|
449
464
|
}
|
|
450
465
|
catch (err) {
|
|
451
466
|
return { ok: false, error: `failed to write file: ${String(err)}` };
|
|
@@ -463,7 +478,7 @@ export function registerFixTool(ctx) {
|
|
|
463
478
|
};
|
|
464
479
|
const nextRegistry = upsertRecord(registry, record);
|
|
465
480
|
try {
|
|
466
|
-
|
|
481
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
|
|
467
482
|
}
|
|
468
483
|
catch (err) {
|
|
469
484
|
// Registry write failed → the file was already modified but no record
|
|
@@ -511,6 +526,9 @@ export function registerFixTool(ctx) {
|
|
|
511
526
|
export function registerDiffTool(ctx) {
|
|
512
527
|
ctx.tools.register(defineTool({
|
|
513
528
|
name: 'iterate_diff',
|
|
529
|
+
// Read-only (never writes project files or plugin state) → safe to join
|
|
530
|
+
// a parallel dispatch group alongside other read-only sibling calls.
|
|
531
|
+
isConcurrencySafe: () => true,
|
|
514
532
|
description: 'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
|
|
515
533
|
'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
|
|
516
534
|
parameters: {
|
|
@@ -612,6 +630,18 @@ export function registerDiffTool(ctx) {
|
|
|
612
630
|
export function registerRollbackTool(ctx) {
|
|
613
631
|
ctx.tools.register(defineTool({
|
|
614
632
|
name: 'iterate_rollback',
|
|
633
|
+
// Pending-call card: which fix is about to be reverted.
|
|
634
|
+
presentCall: (args) => {
|
|
635
|
+
const a = args;
|
|
636
|
+
if (typeof a.id !== 'string' || a.id.length === 0)
|
|
637
|
+
return undefined;
|
|
638
|
+
return {
|
|
639
|
+
card: 'generic',
|
|
640
|
+
title: `Rollback fix ${a.id}`,
|
|
641
|
+
kind: 'edit',
|
|
642
|
+
rawInput: { id: a.id },
|
|
643
|
+
};
|
|
644
|
+
},
|
|
615
645
|
description: 'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
|
|
616
646
|
'The file is restored from the fix backup, the fix is removed from the registry, ' +
|
|
617
647
|
'and a `revert` entry is appended to the decision log. Use when a round\'s validation fails.',
|
|
@@ -667,7 +697,7 @@ export function registerRollbackTool(ctx) {
|
|
|
667
697
|
}
|
|
668
698
|
const nextRegistry = removeRecord(registry, id);
|
|
669
699
|
try {
|
|
670
|
-
|
|
700
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
|
|
671
701
|
}
|
|
672
702
|
catch (err) {
|
|
673
703
|
return { ok: false, error: `failed to update fix registry: ${String(err)}` };
|
package/dist/tools/history.js
CHANGED
|
@@ -64,6 +64,9 @@ export function summarizeFixRegistry(registry) {
|
|
|
64
64
|
export function registerHistoryTool(ctx) {
|
|
65
65
|
ctx.tools.register(defineTool({
|
|
66
66
|
name: 'iterate_history',
|
|
67
|
+
// Read-only audit view over on-disk state → safe to join a parallel
|
|
68
|
+
// dispatch group alongside other read-only sibling calls.
|
|
69
|
+
isConcurrencySafe: () => true,
|
|
67
70
|
description: 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
|
|
68
71
|
'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
|
|
69
72
|
'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
|
package/dist/tools/prune.js
CHANGED
|
@@ -11,16 +11,19 @@
|
|
|
11
11
|
* - Fix backups left over from old rounds (backups whose fix-id no longer
|
|
12
12
|
* appears in the registry).
|
|
13
13
|
* - Empty fix rounds (rounds with 0 records).
|
|
14
|
+
* - Stray temp files left behind by a crashed atomic write (see
|
|
15
|
+
* `isPrunableTemp` for the recognized naming conventions).
|
|
14
16
|
*
|
|
15
17
|
* Security model:
|
|
16
18
|
* - Only operates under the resolved project `.iterate/` directory.
|
|
17
19
|
* - dryRun=true by default — the caller must explicitly opt into deletion.
|
|
18
20
|
* - Each deletion is logged to the decision log (when not dry-run).
|
|
19
21
|
*/
|
|
20
|
-
import { existsSync, readdirSync,
|
|
22
|
+
import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
21
23
|
import { join } from 'node:path';
|
|
22
24
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
23
25
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
26
|
+
import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
|
|
24
27
|
import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
|
|
25
28
|
import { readRegistry, recomputeRoundCounts } from "./fix.js";
|
|
26
29
|
import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
|
|
@@ -41,6 +44,28 @@ export function cutoffTimestamp(retainDays) {
|
|
|
41
44
|
d.setDate(d.getDate() - retainDays);
|
|
42
45
|
return d.toISOString();
|
|
43
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether a file name inside `.iterate/` is a prunable leftover temp file.
|
|
49
|
+
*
|
|
50
|
+
* Recognized conventions (all written to the SAME directory as their target,
|
|
51
|
+
* so they can never be confused with real state files):
|
|
52
|
+
* - `.<name>.tmp-<pid>-<rand>` — current atomic-fs.ts temp prefix;
|
|
53
|
+
* - `.tmp-<pid>-<rand>` — legacy temp prefix (older plugin builds);
|
|
54
|
+
* - `<name>.tmp` — legacy transcript.ts / live.ts temp suffix.
|
|
55
|
+
*
|
|
56
|
+
* Pure and exported for unit tests. A live writer's temp file is only visible
|
|
57
|
+
* to it (unique name), so deleting any match is always safe.
|
|
58
|
+
*/
|
|
59
|
+
export function isPrunableTemp(name) {
|
|
60
|
+
if (typeof name !== 'string' || name.length === 0 || name === '.' || name === '..')
|
|
61
|
+
return false;
|
|
62
|
+
if (name.startsWith('.tmp-'))
|
|
63
|
+
return true;
|
|
64
|
+
if (name.endsWith('.tmp'))
|
|
65
|
+
return true;
|
|
66
|
+
// .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
|
|
67
|
+
return name.startsWith('.') && name.includes('.tmp-');
|
|
68
|
+
}
|
|
44
69
|
/**
|
|
45
70
|
* Inspect the runtime state and report what would be pruned.
|
|
46
71
|
* Pure (no deletions). Returns a structured report.
|
|
@@ -79,10 +104,17 @@ export function inspectPrune(projectRoot, retainDays) {
|
|
|
79
104
|
const emptyRounds = registry.rounds
|
|
80
105
|
.filter((r) => r.records.length === 0)
|
|
81
106
|
.map((r) => r.round);
|
|
107
|
+
// 5. Stray atomic-write temp files left behind by a crashed writer. The
|
|
108
|
+
// unique prefix makes them recognizable and safe to delete — no live writer
|
|
109
|
+
// ever reads another writer's temp file.
|
|
110
|
+
const staleTemps = existsSync(iterateDir(projectRoot))
|
|
111
|
+
? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
|
|
112
|
+
: [];
|
|
82
113
|
return {
|
|
83
114
|
oldLogEntries,
|
|
84
115
|
hasCheckpoint,
|
|
85
116
|
staleBackups,
|
|
117
|
+
staleTemps,
|
|
86
118
|
emptyRounds,
|
|
87
119
|
totalLogEntries: entries.length,
|
|
88
120
|
registryRounds: registry.rounds.length,
|
|
@@ -98,6 +130,7 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
98
130
|
deletedLogEntries: 0,
|
|
99
131
|
deletedCheckpoint: false,
|
|
100
132
|
deletedBackups: [],
|
|
133
|
+
deletedTemps: [],
|
|
101
134
|
trimmedEmptyRounds: 0,
|
|
102
135
|
errors: [],
|
|
103
136
|
};
|
|
@@ -109,9 +142,7 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
109
142
|
result.deletedLogEntries = entries.length - kept.length;
|
|
110
143
|
if (result.deletedLogEntries > 0) {
|
|
111
144
|
const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
|
|
112
|
-
|
|
113
|
-
writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
|
114
|
-
renameSync(tmpPath, logPath);
|
|
145
|
+
writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
115
146
|
}
|
|
116
147
|
}
|
|
117
148
|
catch (err) {
|
|
@@ -151,13 +182,23 @@ export function executePrune(projectRoot, retainDays, report) {
|
|
|
151
182
|
rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
|
|
152
183
|
};
|
|
153
184
|
registry = recomputeRoundCounts(registry);
|
|
154
|
-
|
|
185
|
+
writeJsonAtomic(fixRegistryPath(projectRoot), registry);
|
|
155
186
|
result.trimmedEmptyRounds = report.emptyRounds.length;
|
|
156
187
|
}
|
|
157
188
|
catch (err) {
|
|
158
189
|
result.errors.push(`failed to trim empty rounds: ${String(err)}`);
|
|
159
190
|
}
|
|
160
191
|
}
|
|
192
|
+
// 5. Remove stray atomic-write temp files.
|
|
193
|
+
for (const tmp of report.staleTemps) {
|
|
194
|
+
try {
|
|
195
|
+
rmSync(join(iterateDir(projectRoot), tmp), { force: true });
|
|
196
|
+
result.deletedTemps.push(tmp);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
161
202
|
return result;
|
|
162
203
|
}
|
|
163
204
|
/**
|
|
@@ -170,7 +211,8 @@ export function registerPruneTool(ctx) {
|
|
|
170
211
|
name: 'iterate_prune',
|
|
171
212
|
description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
|
|
172
213
|
'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
|
|
173
|
-
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds
|
|
214
|
+
'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
|
|
215
|
+
'and stray temp files left by crashed atomic writes. ' +
|
|
174
216
|
'Each deletion is logged to the decision log.',
|
|
175
217
|
parameters: {
|
|
176
218
|
dryRun: {
|
|
@@ -210,6 +252,7 @@ export function registerPruneTool(ctx) {
|
|
|
210
252
|
` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
|
|
211
253
|
` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
|
|
212
254
|
` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
|
|
255
|
+
` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
|
|
213
256
|
` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
|
|
214
257
|
'',
|
|
215
258
|
'Pass dryRun:false to execute the prune.',
|
|
@@ -221,6 +264,7 @@ export function registerPruneTool(ctx) {
|
|
|
221
264
|
` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
|
|
222
265
|
` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
|
|
223
266
|
` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
|
|
267
|
+
` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
|
|
224
268
|
` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
|
|
225
269
|
];
|
|
226
270
|
const errs = result?.errors ?? [];
|