iterate-plugin 3.5.0 → 3.5.2
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/live.js +5 -0
- package/dist/skill-prompt.js +2 -2
- package/dist/tools/checkpoint.js +21 -0
- package/dist/tools/context.js +39 -5
- package/dist/tools/decision-log.js +8 -1
- package/dist/tools/experience-bank.js +39 -3
- package/dist/tools/experience-store.js +24 -0
- package/dist/tools/fix.js +28 -9
- package/dist/tools/quality-gate.js +21 -4
- package/dist/tools/quality-store.js +18 -0
- package/dist/tools/transcript.js +31 -0
- package/package.json +10 -6
- package/src/live.ts +5 -0
- package/src/skill-prompt.ts +2 -2
- package/src/tools/checkpoint.ts +27 -0
- package/src/tools/context.ts +36 -4
- package/src/tools/decision-log.ts +10 -1
- package/src/tools/experience-bank.ts +40 -3
- package/src/tools/experience-store.ts +26 -0
- package/src/tools/fix.ts +28 -9
- package/src/tools/quality-gate.ts +22 -4
- package/src/tools/quality-store.ts +19 -0
- package/src/tools/transcript.ts +32 -0
package/README.md
CHANGED
|
@@ -260,8 +260,8 @@ validation:
|
|
|
260
260
|
- `iterate_history` — read iteration history (read-only): decision-log entries (filter by `type` / `since` / `limit`, default latest 50, cap 200) + fix-registry summary (per-round fixed/failed counts). For auditing the run, tracing logs, and inventorying fixes
|
|
261
261
|
- `iterate_prune` — clean runtime artifacts: stale decision-log entries (by `retainDays`, default 30), stale checkpoints, orphaned fix backups, empty rounds, stray temp files (crashed atomic writes). Dry-run by default (report-only); real cleanup with `dryRun:false`, each cleanup logged
|
|
262
262
|
- `iterate_transcript` — runtime observatory: persist review transcripts, threads, fixes, and nudge directions to `.iterate/transcript.json` for the client observatory
|
|
263
|
-
- `iterate_experience` — **v3.2** query the experience bank (list / search / get),
|
|
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
|
|
263
|
+
- `iterate_experience` — **v3.2** query the experience bank (list / search / get), `add` a new verified fix (re-adding the same pattern+dimension bumps its hit count instead of duplicating it), or **v3.5 remove a stale/incorrect entry by `id`** so bad experiences never resurface. Persists to `.iterate/experience.json`
|
|
264
|
+
- `iterate_quality_gate` — **v3.2** read the quality certificate (`read`), or recompute + persist a fresh one (`compute`) from findings, validation results, `findingsByRound`, and `fixedByDimension`. Real per-dimension convergence rates. **v3.5: `clear` removes the persisted certificate to reset a stale gate before a fresh iteration**
|
|
265
265
|
- `iterate_defense_events` — **v3.2** query defense events (list / counts), or `record` a new one. Human-readable labels follow the project language (en / zh)
|
|
266
266
|
|
|
267
267
|
---
|
package/README.zh-CN.md
CHANGED
|
@@ -259,8 +259,8 @@ validation:
|
|
|
259
259
|
- `iterate_history` — 读取迭代历史(只读):决策日志条目(可按 `type` / `since` / `limit` 过滤,默认取最新 50 条,上限 200 条)+ 修复注册表汇总(各轮 fixed/failed 计数)。用于审查运行过程、审计日志、盘点修复
|
|
260
260
|
- `iterate_prune` — 清理运行时产物:过期决策日志条目(按 `retainDays`,默认 30 天)、陈旧断点、孤儿修复备份、空轮次、崩溃原子写入残留的临时文件。默认 dry-run 只报告不删除;`dryRun:false` 才真正清理,每次清理写入决策日志
|
|
261
261
|
- `iterate_transcript` — 运行时观测台:把审查转录、线程、修复与 nudge 指令持久化到 `.iterate/transcript.json`,供客户端观测台读取
|
|
262
|
-
- `iterate_experience` — **v3.2** 查询经验银行(list / search / get),或 `add` 一条新的已验证修复:重复添加同一 pattern + dimension
|
|
263
|
-
- `iterate_quality_gate` — **v3.2** 读取质量凭证(`read`),或基于 findings、验证结果、`findingsByRound` 与 `fixedByDimension` 重新计算并持久化一份新凭证(`compute
|
|
262
|
+
- `iterate_experience` — **v3.2** 查询经验银行(list / search / get),或 `add` 一条新的已验证修复:重复添加同一 pattern + dimension 累加命中次数而非重复写入。**v3.5 新增 `remove`:按 `id` 删除过时或错误的经验条目**。持久化到 `.iterate/experience.json`
|
|
263
|
+
- `iterate_quality_gate` — **v3.2** 读取质量凭证(`read`),或基于 findings、验证结果、`findingsByRound` 与 `fixedByDimension` 重新计算并持久化一份新凭证(`compute`)。真实的逐维度收敛率。**v3.5 新增 `clear`:删除持久化的质量凭证**,用于新一轮迭代前重置陈旧的门禁
|
|
264
264
|
- `iterate_defense_events` — **v3.2** 查询防御事件(list / counts),或 `record` 记录一条新事件。可读标签跟随项目语言(en / zh)
|
|
265
265
|
|
|
266
266
|
---
|
package/dist/live.js
CHANGED
|
@@ -69,6 +69,11 @@ export function classifyTool(name, args, projectRoot) {
|
|
|
69
69
|
iterate_status: 'info',
|
|
70
70
|
iterate_config: 'info',
|
|
71
71
|
iterate_context: 'info',
|
|
72
|
+
// v3.0 quality command-center tools — surface their activity in the live
|
|
73
|
+
// feed too (previously these three never produced a live entry).
|
|
74
|
+
iterate_experience: 'info',
|
|
75
|
+
iterate_quality_gate: 'info',
|
|
76
|
+
iterate_defense_events: 'info',
|
|
72
77
|
};
|
|
73
78
|
const type = records[name];
|
|
74
79
|
if (!type)
|
package/dist/skill-prompt.js
CHANGED
|
@@ -23,8 +23,8 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
23
23
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
24
24
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
25
25
|
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
26
|
-
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it.
|
|
27
|
-
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json
|
|
26
|
+
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it; \`remove\` deletes a stale or incorrect entry by \`id\` so bad experiences never resurface.
|
|
27
|
+
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json\`; \`clear\` removes the persisted certificate to reset a stale gate before a fresh iteration.
|
|
28
28
|
- \`iterate_defense_events\` — defense event stream (\`.iterate/defense-events.json\`): \`list\`/\`counts\` review precondition failures, rollbacks, invariant violations, and falsified assumptions; \`record\` logs a new event when a defense fires. Human-readable labels follow the project \`language\` (en/zh).
|
|
29
29
|
|
|
30
30
|
### When to use
|
package/dist/tools/checkpoint.js
CHANGED
|
@@ -302,6 +302,27 @@ export function registerStatusTool(ctx) {
|
|
|
302
302
|
`Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
|
|
303
303
|
value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
|
|
304
304
|
];
|
|
305
|
+
// v3.0 quality command-center summaries (surfaced here so a single
|
|
306
|
+
// status read reports the whole run state in text too).
|
|
307
|
+
const gate = value.qualityGate;
|
|
308
|
+
if (gate) {
|
|
309
|
+
lines.push(`Quality gate: ${gate.overallStatus.toUpperCase()} · score ${gate.overallScore} · ` +
|
|
310
|
+
`verification ${gate.passedChecks}/${gate.totalChecks} passed` +
|
|
311
|
+
(gate.failReason ? ` · ${gate.failReason}` : ''));
|
|
312
|
+
}
|
|
313
|
+
const exp = value.experienceBank;
|
|
314
|
+
if (exp) {
|
|
315
|
+
lines.push(`Experience bank: ${exp.totalEntries ?? 0} entries · ${exp.totalHits ?? 0} cumulative hits`);
|
|
316
|
+
}
|
|
317
|
+
const def = value.defenseEvents;
|
|
318
|
+
if (def) {
|
|
319
|
+
const counts = def.counts ?? {};
|
|
320
|
+
const total = Object.values(counts).reduce((s, n) => s + (n || 0), 0);
|
|
321
|
+
lines.push(`Defense events: ${def.totalEvents ?? total} recorded` +
|
|
322
|
+
(total > 0
|
|
323
|
+
? ` (precondition_failed:${counts.precondition_failed ?? 0}, rollback:${counts.rollback ?? 0}, invariant_violated:${counts.invariant_violated ?? 0}, assumption_falsified:${counts.assumption_falsified ?? 0})`
|
|
324
|
+
: ''));
|
|
325
|
+
}
|
|
305
326
|
return [{ type: 'text', text: lines.filter(Boolean).join('\n') }];
|
|
306
327
|
},
|
|
307
328
|
},
|
package/dist/tools/context.js
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
|
-
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
2
|
-
import { join, dirname, resolve } from 'node:path';
|
|
1
|
+
import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
5
5
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
6
6
|
/** How many ancestor directories we walk up looking for a SKILL.md. */
|
|
7
7
|
const MAX_SKILL_DIR_LOOKUP_DEPTH = 12;
|
|
8
|
+
/**
|
|
9
|
+
* Whether a model-supplied `skillDir` may be used as a SKILL.md search root.
|
|
10
|
+
*
|
|
11
|
+
* `skillDir` is model-controlled, so it is only honored when it is an existing
|
|
12
|
+
* directory whose REAL path (symlinks resolved) sits inside one of the allowed
|
|
13
|
+
* roots (the project root or the plugin directory). Anything else — a path
|
|
14
|
+
* outside the allowed roots, a nonexistent dir, or a symlink pointing outside
|
|
15
|
+
* (e.g. at `/etc`) — is rejected so arbitrary file contents can never be
|
|
16
|
+
* injected wholesale into the review context.
|
|
17
|
+
*/
|
|
18
|
+
export function isAllowedSkillDir(skillDir, allowedRoots) {
|
|
19
|
+
if (typeof skillDir !== 'string' || skillDir.trim().length === 0)
|
|
20
|
+
return false;
|
|
21
|
+
try {
|
|
22
|
+
const dir = resolve(skillDir);
|
|
23
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
24
|
+
return false;
|
|
25
|
+
return allowedRoots.some((root) => {
|
|
26
|
+
if (typeof root !== 'string' || root.length === 0)
|
|
27
|
+
return false;
|
|
28
|
+
const rootReal = realpathSync(root);
|
|
29
|
+
const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep;
|
|
30
|
+
const real = realpathSync(dir);
|
|
31
|
+
return real === rootReal || real.startsWith(rootPrefix);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
8
38
|
/** Maximum number of image attachments relayed into the context in one call. */
|
|
9
39
|
const MAX_ATTACHMENTS = 8;
|
|
10
40
|
/** Maximum intrinsic width/height (px) accepted for an attached image. */
|
|
@@ -271,9 +301,13 @@ export function registerContextTool(ctx) {
|
|
|
271
301
|
// the auto-detected root / project root.
|
|
272
302
|
if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
|
|
273
303
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
304
|
+
// Model-controlled path: only honor it when its REAL path stays
|
|
305
|
+
// inside the project root or the plugin directory — never let a
|
|
306
|
+
// skillDir point at arbitrary filesystem locations (e.g. /etc)
|
|
307
|
+
// whose contents would be injected wholesale into the context.
|
|
308
|
+
if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
|
|
309
|
+
candidates.push(resolve(args.skillDir));
|
|
310
|
+
}
|
|
277
311
|
}
|
|
278
312
|
catch {
|
|
279
313
|
// unreadable/invalid skillDir — skip it
|
|
@@ -94,7 +94,14 @@ export function readDecisionLogDetailed(projectRoot) {
|
|
|
94
94
|
if (trimmed.length === 0)
|
|
95
95
|
continue;
|
|
96
96
|
try {
|
|
97
|
-
|
|
97
|
+
const parsed = JSON.parse(trimmed);
|
|
98
|
+
if (parsed === null || typeof parsed !== 'object' ||
|
|
99
|
+
typeof parsed.timestamp !== 'string' ||
|
|
100
|
+
typeof parsed.type !== 'string') {
|
|
101
|
+
invalidLines += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
entries.push(parsed);
|
|
98
105
|
}
|
|
99
106
|
catch {
|
|
100
107
|
invalidLines += 1;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
9
9
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
10
|
-
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience } from "./experience-store.js";
|
|
10
|
+
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience } from "./experience-store.js";
|
|
11
11
|
const DEFAULT_LIMIT = 50;
|
|
12
12
|
const MAX_LIMIT = 100;
|
|
13
13
|
/** Clamp a caller-supplied limit to a sane range. */
|
|
@@ -75,12 +75,13 @@ export function registerExperienceBankTool(ctx) {
|
|
|
75
75
|
'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
|
|
76
76
|
'"add" upserts an experience entry into .iterate/experience.json — a repeat of the same ' +
|
|
77
77
|
'pattern+dimension increments its hit count instead of duplicating it. ' +
|
|
78
|
+
'"remove" deletes an entry by id (useful for pruning stale or incorrect experiences). ' +
|
|
78
79
|
'Use it to remember fixes that worked so future rounds apply them first.',
|
|
79
80
|
parameters: {
|
|
80
81
|
operation: {
|
|
81
82
|
type: 'string',
|
|
82
|
-
description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience). Default: list.',
|
|
83
|
-
enum: ['list', 'search', 'get', 'add'],
|
|
83
|
+
description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience), remove (delete by id). Default: list.',
|
|
84
|
+
enum: ['list', 'search', 'get', 'add', 'remove'],
|
|
84
85
|
},
|
|
85
86
|
query: {
|
|
86
87
|
type: 'string',
|
|
@@ -147,6 +148,9 @@ export function registerExperienceBankTool(ctx) {
|
|
|
147
148
|
`Tags: ${entry.tags.join(', ')}`,
|
|
148
149
|
].join('\n') }];
|
|
149
150
|
}
|
|
151
|
+
if (value.operation === 'remove') {
|
|
152
|
+
return [{ type: 'text', text: `Removed experience entry. Bank now holds ${value.count ?? 0} entries (${value.totalHits ?? 0} cumulative hits).` }];
|
|
153
|
+
}
|
|
150
154
|
if (value.operation === 'get' && value.entry) {
|
|
151
155
|
const entry = value.entry;
|
|
152
156
|
return [{ type: 'text', text: [
|
|
@@ -204,6 +208,38 @@ export function registerExperienceBankTool(ctx) {
|
|
|
204
208
|
totalHits: next.totalHits,
|
|
205
209
|
};
|
|
206
210
|
}
|
|
211
|
+
if (operation === 'remove') {
|
|
212
|
+
const id = typeof args.id === 'string' && args.id ? args.id : '';
|
|
213
|
+
if (!id) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
kind: 'experience',
|
|
217
|
+
operation: 'remove',
|
|
218
|
+
error: 'id is required for remove',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const bank = readExperienceBank(projectRoot);
|
|
222
|
+
const { bank: next, removed } = removeExperience(bank, id);
|
|
223
|
+
if (!removed) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
kind: 'experience',
|
|
227
|
+
operation: 'remove',
|
|
228
|
+
error: `Experience not found: ${id}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const write = writeExperienceBank(projectRoot, next);
|
|
232
|
+
if (!write.ok) {
|
|
233
|
+
return { ok: false, kind: 'experience', operation: 'remove', error: write.error };
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
kind: 'experience',
|
|
238
|
+
operation: 'remove',
|
|
239
|
+
count: next.entries.length,
|
|
240
|
+
totalHits: next.totalHits,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
207
243
|
const bank = readExperienceBank(projectRoot);
|
|
208
244
|
if (operation === 'get' && typeof args.id === 'string') {
|
|
209
245
|
const entry = bank.entries.find((e) => e.id === args.id);
|
|
@@ -140,3 +140,27 @@ export function upsertExperience(bank, entry) {
|
|
|
140
140
|
entryId: id,
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Remove an experience entry by id.
|
|
145
|
+
* Returns the resulting bank plus whether an entry was actually removed. An
|
|
146
|
+
* unknown id is a no-op (the caller decides how to surface it). Never mutates
|
|
147
|
+
* the input bank.
|
|
148
|
+
*/
|
|
149
|
+
export function removeExperience(bank, id) {
|
|
150
|
+
const target = typeof id === 'string' && id ? id : '';
|
|
151
|
+
if (!target)
|
|
152
|
+
return { bank, removed: false };
|
|
153
|
+
const entries = bank.entries.filter((e) => e.id !== target);
|
|
154
|
+
if (entries.length === bank.entries.length)
|
|
155
|
+
return { bank, removed: false };
|
|
156
|
+
const lastUpdated = new Date().toISOString();
|
|
157
|
+
return {
|
|
158
|
+
bank: {
|
|
159
|
+
...bank,
|
|
160
|
+
entries,
|
|
161
|
+
lastUpdated,
|
|
162
|
+
totalHits: bank.totalHits ?? 0,
|
|
163
|
+
},
|
|
164
|
+
removed: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
package/dist/tools/fix.js
CHANGED
|
@@ -18,13 +18,14 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
20
20
|
import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
|
|
21
|
-
import { join, sep } from 'node:path';
|
|
21
|
+
import { dirname, join, sep } from 'node:path';
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
23
23
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
24
24
|
import { runWithJob } from "../jobs.js";
|
|
25
25
|
import { countTouchedMethods } from "../method-scope.js";
|
|
26
26
|
import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
|
|
27
27
|
import { appendDecisionEntry } from "./decision-log.js";
|
|
28
|
+
import { markFixRolledBackInTranscript } from "./transcript.js";
|
|
28
29
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
29
30
|
/** Upper bound for a single fix `content` payload (characters). */
|
|
30
31
|
export const MAX_FIX_CONTENT_CHARS = 1_000_000;
|
|
@@ -197,15 +198,26 @@ export function resolveProjectFile(projectRoot, file) {
|
|
|
197
198
|
return { ok: false, reason: 'file resolves outside the project root' };
|
|
198
199
|
}
|
|
199
200
|
// Symlink containment: the lexical prefix check above does not resolve
|
|
200
|
-
// symlinks.
|
|
201
|
-
//
|
|
202
|
-
// a
|
|
203
|
-
|
|
201
|
+
// symlinks. Verify the REAL path of the target (when it exists) AND of its
|
|
202
|
+
// nearest existing ancestor directory (when it does not — e.g. a fix that
|
|
203
|
+
// creates a new file) stays inside the REAL project root, so a symlinked
|
|
204
|
+
// directory inside the repo can never route a fix (write/rollback/diff)
|
|
205
|
+
// outside the project. The probe stops at the project root itself: the root
|
|
206
|
+
// is harness-provided (not model-controlled), and a path with no existing
|
|
207
|
+
// entry inside the repo cannot hide a symlink.
|
|
208
|
+
let probe = resolved;
|
|
209
|
+
while (!existsSync(probe) && probe !== projectRoot) {
|
|
210
|
+
const parent = dirname(probe);
|
|
211
|
+
if (parent === probe)
|
|
212
|
+
break;
|
|
213
|
+
probe = parent;
|
|
214
|
+
}
|
|
215
|
+
if (existsSync(probe)) {
|
|
204
216
|
let rootReal;
|
|
205
217
|
let real;
|
|
206
218
|
try {
|
|
207
219
|
rootReal = realpathSync(projectRoot);
|
|
208
|
-
real = realpathSync(
|
|
220
|
+
real = realpathSync(probe);
|
|
209
221
|
}
|
|
210
222
|
catch {
|
|
211
223
|
return { ok: false, reason: 'failed to resolve real path for containment check' };
|
|
@@ -484,9 +496,9 @@ export function registerFixTool(ctx) {
|
|
|
484
496
|
// Registry write failed → the file was already modified but no record
|
|
485
497
|
// exists, so a later rollback/diff could never see it and a retry would
|
|
486
498
|
// back up the already-fixed content as "original". Restore the file
|
|
487
|
-
// from the backup to leave the tree exactly as it was.
|
|
499
|
+
// from the backup atomically to leave the tree exactly as it was.
|
|
488
500
|
try {
|
|
489
|
-
|
|
501
|
+
writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'));
|
|
490
502
|
}
|
|
491
503
|
catch (restoreErr) {
|
|
492
504
|
return {
|
|
@@ -690,7 +702,9 @@ export function registerRollbackTool(ctx) {
|
|
|
690
702
|
if (!target.ok)
|
|
691
703
|
return { ok: false, error: target.reason };
|
|
692
704
|
try {
|
|
693
|
-
|
|
705
|
+
// Atomic restore: never leave a truncated source file if we crash
|
|
706
|
+
// mid-restore (matches the writeTextAtomic guarantee used by apply).
|
|
707
|
+
writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'));
|
|
694
708
|
}
|
|
695
709
|
catch (err) {
|
|
696
710
|
return { ok: false, error: `failed to restore backup: ${String(err)}` };
|
|
@@ -708,6 +722,11 @@ export function registerRollbackTool(ctx) {
|
|
|
708
722
|
type: 'revert',
|
|
709
723
|
data: { id, file: record.finding.file, revertedDiff: record.diffSummary },
|
|
710
724
|
});
|
|
725
|
+
// Mirror the reversal into the persisted observatory transcript (F4) so
|
|
726
|
+
// the client no longer shows a rolled-back fix as "成功". Best-effort:
|
|
727
|
+
// a missing/corrupt transcript is left untouched and never breaks the
|
|
728
|
+
// rollback flow.
|
|
729
|
+
await markFixRolledBackInTranscript(projectRoot, id);
|
|
711
730
|
return { ok: true, id, file: record.finding.file };
|
|
712
731
|
},
|
|
713
732
|
}));
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
10
|
import { resolveProjectRootForExec } from "../config-loader.js";
|
|
11
|
-
import { readQualityGate, writeQualityGate, computeQualityGate } from "./quality-store.js";
|
|
11
|
+
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from "./quality-store.js";
|
|
12
12
|
/** Validate a single finding object; returns true when well-formed. */
|
|
13
13
|
function isValidFinding(raw) {
|
|
14
14
|
if (!raw || typeof raw !== 'object')
|
|
@@ -66,12 +66,14 @@ export function registerQualityGateTool(ctx) {
|
|
|
66
66
|
'and overall PASS/FAIL status. ' +
|
|
67
67
|
'Operation "read" (default) returns the persisted machine-readable quality certificate. ' +
|
|
68
68
|
'Operation "compute" computes a fresh snapshot from this round\'s findings/validation results, ' +
|
|
69
|
-
'persists it to .iterate/quality-gate.json, and returns it.'
|
|
69
|
+
'persists it to .iterate/quality-gate.json, and returns it. ' +
|
|
70
|
+
'Operation "clear" removes the persisted certificate (use it to reset a stale gate before ' +
|
|
71
|
+
'starting a fresh iteration).',
|
|
70
72
|
parameters: {
|
|
71
73
|
operation: {
|
|
72
74
|
type: 'string',
|
|
73
|
-
description: 'Operation: read (load persisted certificate)
|
|
74
|
-
enum: ['read', 'compute'],
|
|
75
|
+
description: 'Operation: read (load persisted certificate), compute (recompute + persist), or clear (remove the persisted certificate). Default: read.',
|
|
76
|
+
enum: ['read', 'compute', 'clear'],
|
|
75
77
|
},
|
|
76
78
|
dimensions: {
|
|
77
79
|
type: 'array',
|
|
@@ -137,6 +139,9 @@ export function registerQualityGateTool(ctx) {
|
|
|
137
139
|
if (operation === 'compute') {
|
|
138
140
|
lines.push('', 'Quality gate snapshot computed and persisted.');
|
|
139
141
|
}
|
|
142
|
+
if (operation === 'clear') {
|
|
143
|
+
lines.push('', 'Quality gate certificate cleared — the gate will be recomputed on the next iteration.');
|
|
144
|
+
}
|
|
140
145
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
141
146
|
},
|
|
142
147
|
},
|
|
@@ -146,6 +151,18 @@ export function registerQualityGateTool(ctx) {
|
|
|
146
151
|
return { ok: false, kind: 'quality_gate', error: resolved.reason };
|
|
147
152
|
const projectRoot = resolved.root;
|
|
148
153
|
const operation = typeof args.operation === 'string' ? args.operation : 'read';
|
|
154
|
+
if (operation === 'clear') {
|
|
155
|
+
const result = clearQualityGate(projectRoot);
|
|
156
|
+
if (!result.ok) {
|
|
157
|
+
return { ok: false, kind: 'quality_gate', operation: 'clear', error: result.error };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
kind: 'quality_gate',
|
|
162
|
+
operation: 'clear',
|
|
163
|
+
snapshot: readQualityGate(projectRoot),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
149
166
|
if (operation === 'compute') {
|
|
150
167
|
const dimensions = Array.isArray(args.dimensions)
|
|
151
168
|
? args.dimensions.filter((d) => typeof d === 'string' && d.length > 0)
|
|
@@ -104,6 +104,24 @@ export function writeQualityGate(projectRoot, snapshot) {
|
|
|
104
104
|
}
|
|
105
105
|
return { ok: true };
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Clear the persisted quality gate certificate (`.iterate/quality-gate.json`).
|
|
109
|
+
* Returns whether a file existed and was removed, or a structured error when
|
|
110
|
+
* the removal fails — a stale FAIL certificate must never silently persist.
|
|
111
|
+
*/
|
|
112
|
+
export function clearQualityGate(projectRoot) {
|
|
113
|
+
const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE);
|
|
114
|
+
const existed = fs.existsSync(filePath);
|
|
115
|
+
if (!existed)
|
|
116
|
+
return { ok: true, existed: false };
|
|
117
|
+
try {
|
|
118
|
+
fs.rmSync(filePath, { force: true });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, existed: true };
|
|
124
|
+
}
|
|
107
125
|
/**
|
|
108
126
|
* Compute the convergence rate for a dimension.
|
|
109
127
|
*
|
package/dist/tools/transcript.js
CHANGED
|
@@ -331,3 +331,34 @@ async function persist(file, manifest) {
|
|
|
331
331
|
await mkdir(dirname(file), { recursive: true });
|
|
332
332
|
await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2));
|
|
333
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Mark one applied fix as rolled back in the PERSISTED transcript, so the
|
|
336
|
+
* client observatory (F4 fix/rollback tab) reflects the reversal instead of
|
|
337
|
+
* showing the fix as still "成功" after a rollback.
|
|
338
|
+
*
|
|
339
|
+
* Best-effort and fail-safe: a missing/corrupt transcript is ignored, and a
|
|
340
|
+
* persistence failure never throws into the caller (rollback itself is the
|
|
341
|
+
* primary operation; the transcript is a secondary UI projection).
|
|
342
|
+
*
|
|
343
|
+
* @returns true when a transcript existed and was updated.
|
|
344
|
+
*/
|
|
345
|
+
export async function markFixRolledBackInTranscript(projectRoot, id) {
|
|
346
|
+
const file = transcriptPath(projectRoot);
|
|
347
|
+
if (!existsSync(file))
|
|
348
|
+
return false;
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
351
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.fixes))
|
|
352
|
+
return false;
|
|
353
|
+
const { config } = loadEffectiveConfig(projectRoot);
|
|
354
|
+
const approval = config.observatory?.approval ?? 'ask';
|
|
355
|
+
const builder = rehydrateBuilder(parsed, approval);
|
|
356
|
+
builder.markFixRolledBack(id);
|
|
357
|
+
await persist(file, builder.serialize());
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Never let the secondary transcript write break the rollback flow.
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
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",
|
|
@@ -33,7 +33,11 @@
|
|
|
33
33
|
"0.1.2-alpha.4": "compatible",
|
|
34
34
|
"0.1.2-alpha.5": "compatible",
|
|
35
35
|
"0.1.2-rc.1": "compatible",
|
|
36
|
-
"0.1.3-alpha.1": "compatible"
|
|
36
|
+
"0.1.3-alpha.1": "compatible",
|
|
37
|
+
"0.1.5-alpha.1": "compatible",
|
|
38
|
+
"0.1.5-alpha.2": "compatible",
|
|
39
|
+
"0.1.5-rc.1": "compatible",
|
|
40
|
+
"0.1.5-rc.2": "compatible"
|
|
37
41
|
}
|
|
38
42
|
},
|
|
39
43
|
"client": {
|
|
@@ -71,13 +75,13 @@
|
|
|
71
75
|
},
|
|
72
76
|
"dependencies": {
|
|
73
77
|
"@deepseek-ai/cordis": "4.0.2",
|
|
74
|
-
"@deepseek-ai/dsh-tools": "0.1.
|
|
75
|
-
"@deepseek-ai/dsh-util-values": "0.1.
|
|
78
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
|
79
|
+
"@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
|
|
76
80
|
"js-yaml": "4.3.1"
|
|
77
81
|
},
|
|
78
82
|
"devDependencies": {
|
|
79
|
-
"@deepseek-ai/dsh-jobs": "^0.1.
|
|
80
|
-
"@deepseek-ai/dsh-session": "0.1.
|
|
83
|
+
"@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-session": "0.1.5-rc.2",
|
|
81
85
|
"@types/js-yaml": "4.0.9",
|
|
82
86
|
"@types/node": "22.15.0",
|
|
83
87
|
"@types/react": "19.2.2",
|
package/src/live.ts
CHANGED
|
@@ -107,6 +107,11 @@ export function classifyTool(
|
|
|
107
107
|
iterate_status: 'info',
|
|
108
108
|
iterate_config: 'info',
|
|
109
109
|
iterate_context: 'info',
|
|
110
|
+
// v3.0 quality command-center tools — surface their activity in the live
|
|
111
|
+
// feed too (previously these three never produced a live entry).
|
|
112
|
+
iterate_experience: 'info',
|
|
113
|
+
iterate_quality_gate: 'info',
|
|
114
|
+
iterate_defense_events: 'info',
|
|
110
115
|
}
|
|
111
116
|
const type = records[name]
|
|
112
117
|
if (!type) return null
|
package/src/skill-prompt.ts
CHANGED
|
@@ -24,8 +24,8 @@ You have the iterate plugin installed, which registers these tools:
|
|
|
24
24
|
- \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
|
|
25
25
|
- \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
|
|
26
26
|
- \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
|
|
27
|
-
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it.
|
|
28
|
-
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json
|
|
27
|
+
- \`iterate_experience\` — experience bank (\`.iterate/experience.json\`): \`list\`/\`search\`/\`get\` recall verified fixes and patterns from past sessions (read the bank before fixing so proven fixes are applied first); \`add\` records a new verified fix — re-adding the same pattern+dimension bumps its hit count instead of duplicating it; \`remove\` deletes a stale or incorrect entry by \`id\` so bad experiences never resurface.
|
|
28
|
+
- \`iterate_quality_gate\` — quality certificate: \`read\` loads the persisted dimension convergence rates / verification pass rate / PASS-FAIL status; \`compute\` recomputes a fresh snapshot from this round's findings + validation results (supply \`findingsByRound\` for real convergence) and persists it to \`.iterate/quality-gate.json\`; \`clear\` removes the persisted certificate to reset a stale gate before a fresh iteration.
|
|
29
29
|
- \`iterate_defense_events\` — defense event stream (\`.iterate/defense-events.json\`): \`list\`/\`counts\` review precondition failures, rollbacks, invariant violations, and falsified assumptions; \`record\` logs a new event when a defense fires. Human-readable labels follow the project \`language\` (en/zh).
|
|
30
30
|
|
|
31
31
|
### When to use
|
package/src/tools/checkpoint.ts
CHANGED
|
@@ -335,6 +335,33 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
335
335
|
`Checkpoint: ${value.hasCheckpoint ? 'yes' : 'no'}${value.interrupted ? ' (interrupted — resumable)' : ''}${value.resumeCount ? ` · resumed ${value.resumeCount}x` : ''}`,
|
|
336
336
|
value.lastUpdated ? `Last updated: ${value.lastUpdated}` : '',
|
|
337
337
|
]
|
|
338
|
+
// v3.0 quality command-center summaries (surfaced here so a single
|
|
339
|
+
// status read reports the whole run state in text too).
|
|
340
|
+
const gate = value.qualityGate as QualityGateSnapshot | null | undefined
|
|
341
|
+
if (gate) {
|
|
342
|
+
lines.push(
|
|
343
|
+
`Quality gate: ${gate.overallStatus.toUpperCase()} · score ${gate.overallScore} · ` +
|
|
344
|
+
`verification ${gate.passedChecks}/${gate.totalChecks} passed` +
|
|
345
|
+
(gate.failReason ? ` · ${gate.failReason}` : ''),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
const exp = value.experienceBank as { totalEntries?: number; totalHits?: number } | null | undefined
|
|
349
|
+
if (exp) {
|
|
350
|
+
lines.push(
|
|
351
|
+
`Experience bank: ${exp.totalEntries ?? 0} entries · ${exp.totalHits ?? 0} cumulative hits`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
const def = value.defenseEvents as { totalEvents?: number; counts?: Record<DefenseEventType, number> } | null | undefined
|
|
355
|
+
if (def) {
|
|
356
|
+
const counts = def.counts ?? ({} as Record<DefenseEventType, number>)
|
|
357
|
+
const total = Object.values(counts).reduce((s, n) => s + (n || 0), 0)
|
|
358
|
+
lines.push(
|
|
359
|
+
`Defense events: ${def.totalEvents ?? total} recorded` +
|
|
360
|
+
(total > 0
|
|
361
|
+
? ` (precondition_failed:${counts.precondition_failed ?? 0}, rollback:${counts.rollback ?? 0}, invariant_violated:${counts.invariant_violated ?? 0}, assumption_falsified:${counts.assumption_falsified ?? 0})`
|
|
362
|
+
: ''),
|
|
363
|
+
)
|
|
364
|
+
}
|
|
338
365
|
return [{ type: 'text', text: lines.filter(Boolean).join('\n') }]
|
|
339
366
|
},
|
|
340
367
|
},
|
package/src/tools/context.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
|
-
import { join, dirname, resolve } from 'node:path'
|
|
1
|
+
import { readFileSync, existsSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, dirname, resolve, sep } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
5
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -7,6 +7,33 @@ import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
|
7
7
|
/** How many ancestor directories we walk up looking for a SKILL.md. */
|
|
8
8
|
const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Whether a model-supplied `skillDir` may be used as a SKILL.md search root.
|
|
12
|
+
*
|
|
13
|
+
* `skillDir` is model-controlled, so it is only honored when it is an existing
|
|
14
|
+
* directory whose REAL path (symlinks resolved) sits inside one of the allowed
|
|
15
|
+
* roots (the project root or the plugin directory). Anything else — a path
|
|
16
|
+
* outside the allowed roots, a nonexistent dir, or a symlink pointing outside
|
|
17
|
+
* (e.g. at `/etc`) — is rejected so arbitrary file contents can never be
|
|
18
|
+
* injected wholesale into the review context.
|
|
19
|
+
*/
|
|
20
|
+
export function isAllowedSkillDir(skillDir: string, allowedRoots: readonly string[]): boolean {
|
|
21
|
+
if (typeof skillDir !== 'string' || skillDir.trim().length === 0) return false
|
|
22
|
+
try {
|
|
23
|
+
const dir = resolve(skillDir)
|
|
24
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return false
|
|
25
|
+
return allowedRoots.some((root) => {
|
|
26
|
+
if (typeof root !== 'string' || root.length === 0) return false
|
|
27
|
+
const rootReal = realpathSync(root)
|
|
28
|
+
const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
|
|
29
|
+
const real = realpathSync(dir)
|
|
30
|
+
return real === rootReal || real.startsWith(rootPrefix)
|
|
31
|
+
})
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
10
37
|
/** Maximum number of image attachments relayed into the context in one call. */
|
|
11
38
|
const MAX_ATTACHMENTS = 8
|
|
12
39
|
|
|
@@ -304,8 +331,13 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
304
331
|
// the auto-detected root / project root.
|
|
305
332
|
if (typeof args.skillDir === 'string' && args.skillDir.trim()) {
|
|
306
333
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
334
|
+
// Model-controlled path: only honor it when its REAL path stays
|
|
335
|
+
// inside the project root or the plugin directory — never let a
|
|
336
|
+
// skillDir point at arbitrary filesystem locations (e.g. /etc)
|
|
337
|
+
// whose contents would be injected wholesale into the context.
|
|
338
|
+
if (isAllowedSkillDir(args.skillDir, [projectRoot, PLUGIN_SRC_DIR])) {
|
|
339
|
+
candidates.push(resolve(args.skillDir))
|
|
340
|
+
}
|
|
309
341
|
} catch {
|
|
310
342
|
// unreadable/invalid skillDir — skip it
|
|
311
343
|
}
|
|
@@ -106,7 +106,16 @@ export function readDecisionLogDetailed(projectRoot: string): DecisionLogRead {
|
|
|
106
106
|
const trimmed = line.trim()
|
|
107
107
|
if (trimmed.length === 0) continue
|
|
108
108
|
try {
|
|
109
|
-
|
|
109
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
110
|
+
if (
|
|
111
|
+
parsed === null || typeof parsed !== 'object' ||
|
|
112
|
+
typeof (parsed as { timestamp?: unknown }).timestamp !== 'string' ||
|
|
113
|
+
typeof (parsed as { type?: unknown }).type !== 'string'
|
|
114
|
+
) {
|
|
115
|
+
invalidLines += 1
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
entries.push(parsed as DecisionLogEntry)
|
|
110
119
|
} catch {
|
|
111
120
|
invalidLines += 1
|
|
112
121
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
10
10
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
11
11
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
12
|
-
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience } from './experience-store.ts'
|
|
12
|
+
import { readExperienceBank, writeExperienceBank, searchExperienceEntries, upsertExperience, removeExperience } from './experience-store.ts'
|
|
13
13
|
import type { ExperienceEntryInput } from './experience-store.ts'
|
|
14
14
|
import type { ExperienceEntry } from '../types.ts'
|
|
15
15
|
|
|
@@ -81,12 +81,13 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
81
81
|
'List/search/get return matching entries with hit counts, verified fixes, and related context. ' +
|
|
82
82
|
'"add" upserts an experience entry into .iterate/experience.json — a repeat of the same ' +
|
|
83
83
|
'pattern+dimension increments its hit count instead of duplicating it. ' +
|
|
84
|
+
'"remove" deletes an entry by id (useful for pruning stale or incorrect experiences). ' +
|
|
84
85
|
'Use it to remember fixes that worked so future rounds apply them first.',
|
|
85
86
|
parameters: {
|
|
86
87
|
operation: {
|
|
87
88
|
type: 'string',
|
|
88
|
-
description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience). Default: list.',
|
|
89
|
-
enum: ['list', 'search', 'get', 'add'],
|
|
89
|
+
description: 'Operation: list (browse all), search (by query), get (by id), add (add a new experience), remove (delete by id). Default: list.',
|
|
90
|
+
enum: ['list', 'search', 'get', 'add', 'remove'],
|
|
90
91
|
},
|
|
91
92
|
query: {
|
|
92
93
|
type: 'string',
|
|
@@ -154,6 +155,9 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
154
155
|
`Tags: ${entry.tags.join(', ')}`,
|
|
155
156
|
].join('\n') }]
|
|
156
157
|
}
|
|
158
|
+
if (value.operation === 'remove') {
|
|
159
|
+
return [{ type: 'text', text: `Removed experience entry. Bank now holds ${value.count ?? 0} entries (${value.totalHits ?? 0} cumulative hits).` }]
|
|
160
|
+
}
|
|
157
161
|
if (value.operation === 'get' && value.entry) {
|
|
158
162
|
const entry = value.entry as unknown as ExperienceEntry
|
|
159
163
|
return [{ type: 'text', text: [
|
|
@@ -214,6 +218,39 @@ export function registerExperienceBankTool(ctx: { tools: { register: (def: Retur
|
|
|
214
218
|
}
|
|
215
219
|
}
|
|
216
220
|
|
|
221
|
+
if (operation === 'remove') {
|
|
222
|
+
const id = typeof args.id === 'string' && args.id ? args.id : ''
|
|
223
|
+
if (!id) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
kind: 'experience',
|
|
227
|
+
operation: 'remove',
|
|
228
|
+
error: 'id is required for remove',
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const bank = readExperienceBank(projectRoot)
|
|
232
|
+
const { bank: next, removed } = removeExperience(bank, id)
|
|
233
|
+
if (!removed) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
kind: 'experience',
|
|
237
|
+
operation: 'remove',
|
|
238
|
+
error: `Experience not found: ${id}`,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const write = writeExperienceBank(projectRoot, next)
|
|
242
|
+
if (!write.ok) {
|
|
243
|
+
return { ok: false, kind: 'experience', operation: 'remove', error: write.error }
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
kind: 'experience',
|
|
248
|
+
operation: 'remove',
|
|
249
|
+
count: next.entries.length,
|
|
250
|
+
totalHits: next.totalHits,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
217
254
|
const bank = readExperienceBank(projectRoot)
|
|
218
255
|
|
|
219
256
|
if (operation === 'get' && typeof args.id === 'string') {
|
|
@@ -171,3 +171,29 @@ export function upsertExperience(
|
|
|
171
171
|
entryId: id,
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Remove an experience entry by id.
|
|
177
|
+
* Returns the resulting bank plus whether an entry was actually removed. An
|
|
178
|
+
* unknown id is a no-op (the caller decides how to surface it). Never mutates
|
|
179
|
+
* the input bank.
|
|
180
|
+
*/
|
|
181
|
+
export function removeExperience(
|
|
182
|
+
bank: ExperienceBank,
|
|
183
|
+
id: string,
|
|
184
|
+
): { bank: ExperienceBank; removed: boolean } {
|
|
185
|
+
const target = typeof id === 'string' && id ? id : ''
|
|
186
|
+
if (!target) return { bank, removed: false }
|
|
187
|
+
const entries = bank.entries.filter((e) => e.id !== target)
|
|
188
|
+
if (entries.length === bank.entries.length) return { bank, removed: false }
|
|
189
|
+
const lastUpdated = new Date().toISOString()
|
|
190
|
+
return {
|
|
191
|
+
bank: {
|
|
192
|
+
...bank,
|
|
193
|
+
entries,
|
|
194
|
+
lastUpdated,
|
|
195
|
+
totalHits: bank.totalHits ?? 0,
|
|
196
|
+
},
|
|
197
|
+
removed: true,
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/tools/fix.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
21
21
|
import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
|
|
22
|
-
import { join, sep } from 'node:path'
|
|
22
|
+
import { dirname, join, sep } from 'node:path'
|
|
23
23
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
24
24
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
25
25
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
@@ -27,6 +27,7 @@ import { runWithJob } from '../jobs.ts'
|
|
|
27
27
|
import { countTouchedMethods } from '../method-scope.ts'
|
|
28
28
|
import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
|
|
29
29
|
import { appendDecisionEntry } from './decision-log.ts'
|
|
30
|
+
import { markFixRolledBackInTranscript } from './transcript.ts'
|
|
30
31
|
import type { FileDiffHunk, FixRecord, FixRegistry, ReviewFinding } from '../types.ts'
|
|
31
32
|
|
|
32
33
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
@@ -206,15 +207,25 @@ export function resolveProjectFile(projectRoot: string, file: string): { ok: tru
|
|
|
206
207
|
return { ok: false, reason: 'file resolves outside the project root' }
|
|
207
208
|
}
|
|
208
209
|
// Symlink containment: the lexical prefix check above does not resolve
|
|
209
|
-
// symlinks.
|
|
210
|
-
//
|
|
211
|
-
// a
|
|
212
|
-
|
|
210
|
+
// symlinks. Verify the REAL path of the target (when it exists) AND of its
|
|
211
|
+
// nearest existing ancestor directory (when it does not — e.g. a fix that
|
|
212
|
+
// creates a new file) stays inside the REAL project root, so a symlinked
|
|
213
|
+
// directory inside the repo can never route a fix (write/rollback/diff)
|
|
214
|
+
// outside the project. The probe stops at the project root itself: the root
|
|
215
|
+
// is harness-provided (not model-controlled), and a path with no existing
|
|
216
|
+
// entry inside the repo cannot hide a symlink.
|
|
217
|
+
let probe = resolved
|
|
218
|
+
while (!existsSync(probe) && probe !== projectRoot) {
|
|
219
|
+
const parent = dirname(probe)
|
|
220
|
+
if (parent === probe) break
|
|
221
|
+
probe = parent
|
|
222
|
+
}
|
|
223
|
+
if (existsSync(probe)) {
|
|
213
224
|
let rootReal: string
|
|
214
225
|
let real: string
|
|
215
226
|
try {
|
|
216
227
|
rootReal = realpathSync(projectRoot)
|
|
217
|
-
real = realpathSync(
|
|
228
|
+
real = realpathSync(probe)
|
|
218
229
|
} catch {
|
|
219
230
|
return { ok: false, reason: 'failed to resolve real path for containment check' }
|
|
220
231
|
}
|
|
@@ -491,9 +502,9 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
491
502
|
// Registry write failed → the file was already modified but no record
|
|
492
503
|
// exists, so a later rollback/diff could never see it and a retry would
|
|
493
504
|
// back up the already-fixed content as "original". Restore the file
|
|
494
|
-
// from the backup to leave the tree exactly as it was.
|
|
505
|
+
// from the backup atomically to leave the tree exactly as it was.
|
|
495
506
|
try {
|
|
496
|
-
|
|
507
|
+
writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'))
|
|
497
508
|
} catch (restoreErr) {
|
|
498
509
|
return {
|
|
499
510
|
ok: false,
|
|
@@ -704,7 +715,9 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
704
715
|
const target = resolveProjectFile(projectRoot, record.finding.file)
|
|
705
716
|
if (!target.ok) return { ok: false, error: target.reason }
|
|
706
717
|
try {
|
|
707
|
-
|
|
718
|
+
// Atomic restore: never leave a truncated source file if we crash
|
|
719
|
+
// mid-restore (matches the writeTextAtomic guarantee used by apply).
|
|
720
|
+
writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'))
|
|
708
721
|
} catch (err) {
|
|
709
722
|
return { ok: false, error: `failed to restore backup: ${String(err)}` }
|
|
710
723
|
}
|
|
@@ -723,6 +736,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
723
736
|
data: { id, file: record.finding.file, revertedDiff: record.diffSummary },
|
|
724
737
|
})
|
|
725
738
|
|
|
739
|
+
// Mirror the reversal into the persisted observatory transcript (F4) so
|
|
740
|
+
// the client no longer shows a rolled-back fix as "成功". Best-effort:
|
|
741
|
+
// a missing/corrupt transcript is left untouched and never breaks the
|
|
742
|
+
// rollback flow.
|
|
743
|
+
await markFixRolledBackInTranscript(projectRoot, id)
|
|
744
|
+
|
|
726
745
|
return { ok: true, id, file: record.finding.file }
|
|
727
746
|
},
|
|
728
747
|
}),
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
11
11
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
12
12
|
import { resolveProjectRootForExec } from '../config-loader.ts'
|
|
13
|
-
import { readQualityGate, writeQualityGate, computeQualityGate } from './quality-store.ts'
|
|
13
|
+
import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from './quality-store.ts'
|
|
14
14
|
import type { QualityGateSnapshot } from '../types.ts'
|
|
15
15
|
|
|
16
16
|
/** Validate a single finding object; returns true when well-formed. */
|
|
@@ -72,12 +72,14 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
72
72
|
'and overall PASS/FAIL status. ' +
|
|
73
73
|
'Operation "read" (default) returns the persisted machine-readable quality certificate. ' +
|
|
74
74
|
'Operation "compute" computes a fresh snapshot from this round\'s findings/validation results, ' +
|
|
75
|
-
'persists it to .iterate/quality-gate.json, and returns it.'
|
|
75
|
+
'persists it to .iterate/quality-gate.json, and returns it. ' +
|
|
76
|
+
'Operation "clear" removes the persisted certificate (use it to reset a stale gate before ' +
|
|
77
|
+
'starting a fresh iteration).',
|
|
76
78
|
parameters: {
|
|
77
79
|
operation: {
|
|
78
80
|
type: 'string',
|
|
79
|
-
description: 'Operation: read (load persisted certificate)
|
|
80
|
-
enum: ['read', 'compute'],
|
|
81
|
+
description: 'Operation: read (load persisted certificate), compute (recompute + persist), or clear (remove the persisted certificate). Default: read.',
|
|
82
|
+
enum: ['read', 'compute', 'clear'],
|
|
81
83
|
},
|
|
82
84
|
dimensions: {
|
|
83
85
|
type: 'array',
|
|
@@ -145,6 +147,9 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
145
147
|
if (operation === 'compute') {
|
|
146
148
|
lines.push('', 'Quality gate snapshot computed and persisted.')
|
|
147
149
|
}
|
|
150
|
+
if (operation === 'clear') {
|
|
151
|
+
lines.push('', 'Quality gate certificate cleared — the gate will be recomputed on the next iteration.')
|
|
152
|
+
}
|
|
148
153
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
149
154
|
},
|
|
150
155
|
},
|
|
@@ -156,6 +161,19 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
156
161
|
|
|
157
162
|
const operation = typeof args.operation === 'string' ? args.operation : 'read'
|
|
158
163
|
|
|
164
|
+
if (operation === 'clear') {
|
|
165
|
+
const result = clearQualityGate(projectRoot)
|
|
166
|
+
if (!result.ok) {
|
|
167
|
+
return { ok: false, kind: 'quality_gate', operation: 'clear', error: result.error }
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
kind: 'quality_gate',
|
|
172
|
+
operation: 'clear',
|
|
173
|
+
snapshot: readQualityGate(projectRoot) as unknown as JsonValue,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
159
177
|
if (operation === 'compute') {
|
|
160
178
|
const dimensions = Array.isArray(args.dimensions)
|
|
161
179
|
? args.dimensions.filter((d): d is string => typeof d === 'string' && d.length > 0)
|
|
@@ -116,6 +116,25 @@ export function writeQualityGate(
|
|
|
116
116
|
return { ok: true }
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Clear the persisted quality gate certificate (`.iterate/quality-gate.json`).
|
|
121
|
+
* Returns whether a file existed and was removed, or a structured error when
|
|
122
|
+
* the removal fails — a stale FAIL certificate must never silently persist.
|
|
123
|
+
*/
|
|
124
|
+
export function clearQualityGate(
|
|
125
|
+
projectRoot: string,
|
|
126
|
+
): { ok: true; existed: boolean } | { ok: false; error: string } {
|
|
127
|
+
const filePath = path.join(projectRoot, '.iterate', QUALITY_GATE_FILE)
|
|
128
|
+
const existed = fs.existsSync(filePath)
|
|
129
|
+
if (!existed) return { ok: true, existed: false }
|
|
130
|
+
try {
|
|
131
|
+
fs.rmSync(filePath, { force: true })
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return { ok: false, error: `unable to remove ${filePath}: ${String(err)}` }
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, existed: true }
|
|
136
|
+
}
|
|
137
|
+
|
|
119
138
|
/**
|
|
120
139
|
* Compute the convergence rate for a dimension.
|
|
121
140
|
*
|
package/src/tools/transcript.ts
CHANGED
|
@@ -340,4 +340,36 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
340
340
|
async function persist(file: string, manifest: TranscriptManifest): Promise<void> {
|
|
341
341
|
await mkdir(dirname(file), { recursive: true })
|
|
342
342
|
await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Mark one applied fix as rolled back in the PERSISTED transcript, so the
|
|
347
|
+
* client observatory (F4 fix/rollback tab) reflects the reversal instead of
|
|
348
|
+
* showing the fix as still "成功" after a rollback.
|
|
349
|
+
*
|
|
350
|
+
* Best-effort and fail-safe: a missing/corrupt transcript is ignored, and a
|
|
351
|
+
* persistence failure never throws into the caller (rollback itself is the
|
|
352
|
+
* primary operation; the transcript is a secondary UI projection).
|
|
353
|
+
*
|
|
354
|
+
* @returns true when a transcript existed and was updated.
|
|
355
|
+
*/
|
|
356
|
+
export async function markFixRolledBackInTranscript(
|
|
357
|
+
projectRoot: string,
|
|
358
|
+
id: string,
|
|
359
|
+
): Promise<boolean> {
|
|
360
|
+
const file = transcriptPath(projectRoot)
|
|
361
|
+
if (!existsSync(file)) return false
|
|
362
|
+
try {
|
|
363
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
|
|
364
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.fixes)) return false
|
|
365
|
+
const { config } = loadEffectiveConfig(projectRoot)
|
|
366
|
+
const approval = config.observatory?.approval ?? 'ask'
|
|
367
|
+
const builder = rehydrateBuilder(parsed, approval)
|
|
368
|
+
builder.markFixRolledBack(id)
|
|
369
|
+
await persist(file, builder.serialize())
|
|
370
|
+
return true
|
|
371
|
+
} catch {
|
|
372
|
+
// Never let the secondary transcript write break the rollback flow.
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
343
375
|
}
|