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 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), 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
- - `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 累加命中次数而非重复写入。持久化到 `.iterate/experience.json`
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)
@@ -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
@@ -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
  },
@@ -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
- const dir = resolve(args.skillDir);
275
- if (existsSync(dir) && statSync(dir).isDirectory())
276
- candidates.push(dir);
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
- entries.push(JSON.parse(trimmed));
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. If the target exists, verify its REAL path stays inside the REAL
201
- // project root so a symlinked directory/file inside the repo can never route
202
- // a fix (write/rollback/diff) outside the project.
203
- if (existsSync(resolved)) {
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(resolved);
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
- copyFileSync(backupPath, target.resolved);
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
- copyFileSync(record.backupPath, target.resolved);
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) or compute (recompute + persist). Default: read.',
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
  *
@@ -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.0",
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.2-rc.1",
75
- "@deepseek-ai/dsh-util-values": "0.1.2-rc.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.2-rc.1",
80
- "@deepseek-ai/dsh-session": "0.1.2-rc.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
@@ -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
@@ -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
  },
@@ -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
- const dir = resolve(args.skillDir)
308
- if (existsSync(dir) && statSync(dir).isDirectory()) candidates.push(dir)
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
- entries.push(JSON.parse(trimmed) as DecisionLogEntry)
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. If the target exists, verify its REAL path stays inside the REAL
210
- // project root so a symlinked directory/file inside the repo can never route
211
- // a fix (write/rollback/diff) outside the project.
212
- if (existsSync(resolved)) {
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(resolved)
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
- copyFileSync(backupPath, target.resolved)
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
- copyFileSync(record.backupPath, target.resolved)
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) or compute (recompute + persist). Default: read.',
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
  *
@@ -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
  }