iterate-plugin 3.5.1 → 3.5.3

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/dist/tools/fix.js CHANGED
@@ -18,7 +18,7 @@
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";
@@ -120,9 +120,22 @@ export function readRegistry(projectRoot) {
120
120
  const parsed = JSON.parse(readFileSync(file, 'utf-8'));
121
121
  if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rounds))
122
122
  return emptyRegistry();
123
- // Defensive: a hand-edited or partially-written registry may contain a
124
- // round without a `records` array — normalize instead of crashing readers.
125
- parsed.rounds = parsed.rounds.filter((r) => r && typeof r === 'object' && Array.isArray(r.records));
123
+ // Defensive normalization: a hand-edited or partially-written registry may
124
+ // contain a round without a `records` array, or records that are missing
125
+ // their id / finding object — all of which would make readers
126
+ // (findFixRecord / recordsForFile / iterate_diff) throw or sum NaN.
127
+ parsed.rounds = parsed.rounds
128
+ .filter((r) => r && typeof r === 'object' && Array.isArray(r.records))
129
+ .map((r) => ({
130
+ ...r,
131
+ round: typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0,
132
+ records: r.records.filter((rec) => !!rec &&
133
+ typeof rec === 'object' &&
134
+ typeof rec.id === 'string' &&
135
+ !!rec.finding &&
136
+ typeof rec.finding === 'object'),
137
+ }))
138
+ .filter((r) => typeof r.round === 'number' && Number.isFinite(r.round));
126
139
  return parsed;
127
140
  }
128
141
  catch {
@@ -198,15 +211,26 @@ export function resolveProjectFile(projectRoot, file) {
198
211
  return { ok: false, reason: 'file resolves outside the project root' };
199
212
  }
200
213
  // Symlink containment: the lexical prefix check above does not resolve
201
- // symlinks. If the target exists, verify its REAL path stays inside the REAL
202
- // project root so a symlinked directory/file inside the repo can never route
203
- // a fix (write/rollback/diff) outside the project.
204
- if (existsSync(resolved)) {
214
+ // symlinks. Verify the REAL path of the target (when it exists) AND of its
215
+ // nearest existing ancestor directory (when it does not — e.g. a fix that
216
+ // creates a new file) stays inside the REAL project root, so a symlinked
217
+ // directory inside the repo can never route a fix (write/rollback/diff)
218
+ // outside the project. The probe stops at the project root itself: the root
219
+ // is harness-provided (not model-controlled), and a path with no existing
220
+ // entry inside the repo cannot hide a symlink.
221
+ let probe = resolved;
222
+ while (!existsSync(probe) && probe !== projectRoot) {
223
+ const parent = dirname(probe);
224
+ if (parent === probe)
225
+ break;
226
+ probe = parent;
227
+ }
228
+ if (existsSync(probe)) {
205
229
  let rootReal;
206
230
  let real;
207
231
  try {
208
232
  rootReal = realpathSync(projectRoot);
209
- real = realpathSync(resolved);
233
+ real = realpathSync(probe);
210
234
  }
211
235
  catch {
212
236
  return { ok: false, reason: 'failed to resolve real path for containment check' };
@@ -485,9 +509,9 @@ export function registerFixTool(ctx) {
485
509
  // Registry write failed → the file was already modified but no record
486
510
  // exists, so a later rollback/diff could never see it and a retry would
487
511
  // back up the already-fixed content as "original". Restore the file
488
- // from the backup to leave the tree exactly as it was.
512
+ // from the backup atomically to leave the tree exactly as it was.
489
513
  try {
490
- copyFileSync(backupPath, target.resolved);
514
+ writeTextAtomic(target.resolved, readFileSync(backupPath, 'utf-8'));
491
515
  }
492
516
  catch (restoreErr) {
493
517
  return {
@@ -600,20 +624,27 @@ export function registerDiffTool(ctx) {
600
624
  for (const r of round.records) {
601
625
  if (!r.success)
602
626
  continue;
603
- const existing = files.find((f) => f.file === r.finding.file);
627
+ const file = typeof r.finding.file === 'string' ? r.finding.file : '';
628
+ if (!file)
629
+ continue;
630
+ // Coerce defensively: a hand-edited registry record missing the
631
+ // numeric fields must not produce NaN in the accumulated summary.
632
+ const added = Number(r.linesAdded) || 0;
633
+ const removed = Number(r.linesRemoved) || 0;
634
+ const existing = files.find((f) => f.file === file);
604
635
  if (existing) {
605
- existing.linesAdded += r.linesAdded;
606
- existing.linesRemoved += r.linesRemoved;
636
+ existing.linesAdded += added;
637
+ existing.linesRemoved += removed;
607
638
  // Recompute the summary from the summed counts so a multi-fix
608
639
  // file's text does not contradict its accumulated numbers.
609
640
  existing.diffSummary = `+${existing.linesAdded}/-${existing.linesRemoved} lines`;
610
641
  }
611
642
  else {
612
643
  files.push({
613
- file: r.finding.file,
614
- diffSummary: r.diffSummary,
615
- linesAdded: r.linesAdded,
616
- linesRemoved: r.linesRemoved,
644
+ file,
645
+ diffSummary: typeof r.diffSummary === 'string' ? r.diffSummary : `+${added}/-${removed} lines`,
646
+ linesAdded: added,
647
+ linesRemoved: removed,
617
648
  });
618
649
  }
619
650
  }
@@ -691,7 +722,9 @@ export function registerRollbackTool(ctx) {
691
722
  if (!target.ok)
692
723
  return { ok: false, error: target.reason };
693
724
  try {
694
- copyFileSync(record.backupPath, target.resolved);
725
+ // Atomic restore: never leave a truncated source file if we crash
726
+ // mid-restore (matches the writeTextAtomic guarantee used by apply).
727
+ writeTextAtomic(target.resolved, readFileSync(record.backupPath, 'utf-8'));
695
728
  }
696
729
  catch (err) {
697
730
  return { ok: false, error: `failed to restore backup: ${String(err)}` };
@@ -46,8 +46,10 @@ export function filterDecisionEntries(entries, opts) {
46
46
  export function summarizeFixRegistry(registry) {
47
47
  const rounds = (registry.rounds ?? []).map((r) => ({
48
48
  round: r.round,
49
- fixedCount: r.fixedCount,
50
- failedCount: r.failedCount,
49
+ // Coerce defensively so a hand-edited registry round missing either count
50
+ // can never propagate NaN into the integer output fields.
51
+ fixedCount: Number(r.fixedCount) || 0,
52
+ failedCount: Number(r.failedCount) || 0,
51
53
  }));
52
54
  return {
53
55
  totalFixed: rounds.reduce((s, r) => s + r.fixedCount, 0),
@@ -74,7 +76,7 @@ export function registerHistoryTool(ctx) {
74
76
  type: {
75
77
  type: 'string',
76
78
  description: 'Optional entry-type filter: round_start, review_result, atomic_fix, architectural_fix, ' +
77
- 'revert, validation, decision, report.',
79
+ 'revert, round_failed, validation, decision, report, resume.',
78
80
  },
79
81
  since: {
80
82
  type: 'string',
@@ -87,17 +87,25 @@ export function inspectPrune(projectRoot, retainDays) {
87
87
  }
88
88
  const staleBackups = [];
89
89
  const fixDir = fixesDir(projectRoot);
90
- if (existsSync(fixDir)) {
91
- for (const entry of readdirSync(fixDir)) {
92
- if (!entry.endsWith('.bak'))
93
- continue;
94
- // Extract the fix-id prefix (up to the first underscore after the id).
95
- // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
96
- const match = entry.match(/^(fix-[a-z0-9]+)_/);
97
- const id = match?.[1];
98
- if (id && !activeIds.has(id)) {
99
- staleBackups.push(entry);
100
- }
90
+ // existsSync does not prove readability — a permissions failure must degrade
91
+ // to "nothing listable" (report skipped) instead of throwing out of execute.
92
+ let fixEntries = [];
93
+ try {
94
+ if (existsSync(fixDir))
95
+ fixEntries = readdirSync(fixDir);
96
+ }
97
+ catch {
98
+ fixEntries = [];
99
+ }
100
+ for (const entry of fixEntries) {
101
+ if (!entry.endsWith('.bak'))
102
+ continue;
103
+ // Extract the fix-id prefix (up to the first underscore after the id).
104
+ // e.g. "fix-abc123_2026-08-17T00-00-00-000Z.bak" → "fix-abc123"
105
+ const match = entry.match(/^(fix-[a-z0-9]+)_/);
106
+ const id = match?.[1];
107
+ if (id && !activeIds.has(id)) {
108
+ staleBackups.push(entry);
101
109
  }
102
110
  }
103
111
  // 4. Empty rounds (rounds with 0 records).
@@ -106,10 +114,16 @@ export function inspectPrune(projectRoot, retainDays) {
106
114
  .map((r) => r.round);
107
115
  // 5. Stray atomic-write temp files left behind by a crashed writer. The
108
116
  // unique prefix makes them recognizable and safe to delete — no live writer
109
- // ever reads another writer's temp file.
110
- const staleTemps = existsSync(iterateDir(projectRoot))
111
- ? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
112
- : [];
117
+ // ever reads another writer's temp file. A permissions failure listing the
118
+ // dir degrades to "none found" (report-only) rather than throwing.
119
+ let iterateEntries = [];
120
+ try {
121
+ iterateEntries = existsSync(iterateDir(projectRoot)) ? readdirSync(iterateDir(projectRoot)) : [];
122
+ }
123
+ catch {
124
+ iterateEntries = [];
125
+ }
126
+ const staleTemps = iterateEntries.filter((f) => isPrunableTemp(f)).sort();
113
127
  return {
114
128
  oldLogEntries,
115
129
  hasCheckpoint,
@@ -9,6 +9,8 @@
9
9
  import { defineTool } from '@deepseek-ai/dsh-tools';
10
10
  import { resolveProjectRootForExec } from "../config-loader.js";
11
11
  import { readQualityGate, writeQualityGate, computeQualityGate, clearQualityGate } from "./quality-store.js";
12
+ /** Valid severity values (kept in sync with QualityGateSnapshot counting). */
13
+ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low']);
12
14
  /** Validate a single finding object; returns true when well-formed. */
13
15
  function isValidFinding(raw) {
14
16
  if (!raw || typeof raw !== 'object')
@@ -16,6 +18,7 @@ function isValidFinding(raw) {
16
18
  const f = raw;
17
19
  return (typeof f.dimension === 'string' &&
18
20
  typeof f.severity === 'string' &&
21
+ VALID_SEVERITIES.has(f.severity) &&
19
22
  typeof f.file === 'string' &&
20
23
  (f.line === undefined || typeof f.line === 'number'));
21
24
  }
@@ -200,13 +200,19 @@ export function computeQualityGate(opts) {
200
200
  const overallScore = dimensionGates.length > 0
201
201
  ? Math.round(dimensionGates.reduce((sum, d) => sum + d.score, 0) / dimensionGates.length)
202
202
  : 0;
203
- // Determine overall status
203
+ // Determine overall status. An empty review (no dimensions, no findings, no
204
+ // validation results) is NOT a failure — there is simply nothing to gate —
205
+ // so it reports `pending` (not-applicable) instead of a fabricated FAIL.
204
206
  const hasCritical = criticalCount > 0;
205
207
  const hasHighFail = dimensionGates.some((d) => d.status === 'fail');
206
208
  const verificationFails = totalChecks > 0 && failedChecks > 0;
209
+ const noData = dimensionGates.length === 0 && totalFindings === 0 && totalChecks === 0;
207
210
  let overallStatus = 'pass';
208
211
  let failReason;
209
- if (hasCritical) {
212
+ if (noData) {
213
+ overallStatus = 'pending';
214
+ }
215
+ else if (hasCritical) {
210
216
  overallStatus = 'fail';
211
217
  failReason = `${criticalCount} critical findings present`;
212
218
  }
@@ -31,7 +31,10 @@ function captureRound(builder, round) {
31
31
  if (!round || typeof round !== 'object')
32
32
  return;
33
33
  const r = round;
34
- const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0;
34
+ const roundNo = typeof r.round === 'number' && Number.isFinite(r.round) ? Math.floor(r.round) : 0;
35
+ // NaN round values (`Math.floor(NaN) === NaN`, and `NaN <= 0` is false) would
36
+ // fall through the guard below; a finite check rejects them outright so a
37
+ // malformed round can never collapse into round 1 via the builder's coercion.
35
38
  if (roundNo <= 0)
36
39
  return;
37
40
  builder.roundStart(roundNo);
@@ -61,15 +64,17 @@ function normalizeCheckpoint(input) {
61
64
  if (!input || typeof input !== 'object')
62
65
  return null;
63
66
  const c = input;
64
- const round = typeof c.round === 'number' ? c.round : 0;
67
+ // Number.isFinite: a NaN round must not pass `round <= 0` (which is false for
68
+ // NaN) and later coerce into a phantom round 1 row.
69
+ const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0;
65
70
  if (round <= 0)
66
71
  return null;
67
72
  return {
68
73
  mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
69
74
  round,
70
- maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
71
- fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
72
- resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
75
+ maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
76
+ fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
77
+ resumeCount: typeof c.resumeCount === 'number' && Number.isFinite(c.resumeCount) ? c.resumeCount : 0,
73
78
  updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
74
79
  };
75
80
  }
@@ -85,7 +90,7 @@ function normalizeFix(input) {
85
90
  return {
86
91
  id,
87
92
  timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
88
- round: typeof f.round === 'number' ? f.round : 0,
93
+ round: typeof f.round === 'number' && Number.isFinite(f.round) ? f.round : 0,
89
94
  file,
90
95
  summary: typeof f.summary === 'string' ? f.summary : '',
91
96
  linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
@@ -211,11 +216,26 @@ export function registerTranscriptTool(ctx) {
211
216
  manifest = null;
212
217
  }
213
218
  }
214
- const builder = manifest
215
- ? rehydrateBuilder(manifest, approval)
216
- : new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
219
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
220
+ // or a non-object) must never crash the nudge — degrade to a fresh
221
+ // builder so the steering text still lands.
222
+ let builder;
223
+ if (manifest) {
224
+ try {
225
+ builder = rehydrateBuilder(manifest, approval);
226
+ }
227
+ catch {
228
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
229
+ }
230
+ }
231
+ else {
232
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
233
+ }
217
234
  builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
218
- await persist(file, builder.serialize());
235
+ const persisted = await persistChecked(file, builder.serialize());
236
+ if (!persisted.ok) {
237
+ return { operation: 'nudge', updated: false, error: persisted.error };
238
+ }
219
239
  return {
220
240
  operation: 'nudge',
221
241
  updated: true,
@@ -276,7 +296,10 @@ export function registerTranscriptTool(ctx) {
276
296
  const last = convergence[convergence.length - 1];
277
297
  if (convergence.length > 0 && last === 0)
278
298
  builder.finish();
279
- await persist(file, builder.serialize());
299
+ const persisted = await persistChecked(file, builder.serialize());
300
+ if (!persisted.ok) {
301
+ return { operation: 'capture', found: true, updated: false, error: persisted.error };
302
+ }
280
303
  const live = await readLive(projectRoot);
281
304
  return {
282
305
  operation: 'capture',
@@ -331,6 +354,18 @@ async function persist(file, manifest) {
331
354
  await mkdir(dirname(file), { recursive: true });
332
355
  await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2));
333
356
  }
357
+ /** persist wrapped with a structured error channel so disk failures surface
358
+ * as a structured tool result instead of rejecting the whole execute (matches
359
+ * every other write path in this codebase). */
360
+ async function persistChecked(file, manifest) {
361
+ try {
362
+ await persist(file, manifest);
363
+ return { ok: true };
364
+ }
365
+ catch (err) {
366
+ return { ok: false, error: `failed to persist transcript: ${err instanceof Error ? err.message : String(err)}` };
367
+ }
368
+ }
334
369
  /**
335
370
  * Mark one applied fix as rolled back in the PERSISTED transcript, so the
336
371
  * client observatory (F4 fix/rollback tab) reflects the reversal instead of
@@ -35,6 +35,10 @@ const MAX_TIMELINE = 500;
35
35
  /** Max applied-fix records kept (newest wins; bounded so a long run cannot
36
36
  * grow the manifest payload without limit). */
37
37
  const MAX_FIXES = 200;
38
+ /** Max round number accepted by the builder. A model-authored/manifest-backed
39
+ * round value is attacker-influenced JSON: `roundStart(1e9)` / `snapshotConvergence`
40
+ * would otherwise preallocate arrays of that size and OOM the host. */
41
+ const MAX_ROUNDS = 1000;
38
42
  /** Thresholds applied when reducing a string list under a cap. */
39
43
  function clampStringList(source, cap) {
40
44
  const out = [];
@@ -162,9 +166,13 @@ export class ReviewTranscriptBuilder {
162
166
  /** Open a review round, capturing the current round index. */
163
167
  roundStart(round, maxRounds) {
164
168
  const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
165
- this.round = r > 0 ? r : 1;
169
+ // Clamp to MAX_ROUNDS: `this.rounds.length < this.round` below preallocates
170
+ // an array of size `round` — an unbounded model-controlled value (e.g. 1e9)
171
+ // would OOM the host. Values above the cap are folded into the cap so the
172
+ // real rounds are never silently dropped.
173
+ this.round = Math.min(r > 0 ? r : 1, MAX_ROUNDS);
166
174
  if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
167
- this.maxRounds = Math.floor(maxRounds);
175
+ this.maxRounds = Math.min(Math.floor(maxRounds), MAX_ROUNDS);
168
176
  }
169
177
  while (this.rounds.length < this.round) {
170
178
  this.rounds.push({ round: this.rounds.length + 1, threads: [] });
@@ -248,7 +256,10 @@ export class ReviewTranscriptBuilder {
248
256
  // ─── Convergence (F2) ───────────────────────────────────────────────────
249
257
  /** Record a round's new-finding count for the convergence series. */
250
258
  snapshotConvergence(round, newCount) {
251
- const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
259
+ const rawR = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
260
+ // Clamp (mirrors roundStart): the loop below preallocates `r` slots, so an
261
+ // unbounded model-controlled round number would OOM the host.
262
+ const r = Math.min(rawR > 0 ? rawR : 1, MAX_ROUNDS);
252
263
  const n = typeof newCount === 'number' && Number.isFinite(newCount) ? newCount : 0;
253
264
  while (this.convergence.length < r)
254
265
  this.convergence.push(-1);
@@ -267,7 +278,9 @@ export class ReviewTranscriptBuilder {
267
278
  this.fixes.push({
268
279
  id,
269
280
  timestamp: typeof record.timestamp === 'string' ? record.timestamp : isoNow(),
270
- round: typeof record.round === 'number' ? record.round : this.round,
281
+ round: typeof record.round === 'number' && Number.isFinite(record.round)
282
+ ? Math.floor(record.round)
283
+ : this.round,
271
284
  file,
272
285
  summary: typeof record.summary === 'string' ? record.summary : '',
273
286
  linesAdded: typeof record.linesAdded === 'number' ? Math.floor(record.linesAdded) : 0,
@@ -313,7 +326,9 @@ export class ReviewTranscriptBuilder {
313
326
  const type = typeof entry.type === 'string' ? entry.type : 'decision';
314
327
  this.timeline.push({
315
328
  timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : isoNow(),
316
- round: typeof entry.round === 'number' ? entry.round : this.round,
329
+ round: typeof entry.round === 'number' && Number.isFinite(entry.round)
330
+ ? Math.floor(entry.round)
331
+ : this.round,
317
332
  type,
318
333
  data: entry.data && typeof entry.data === 'object'
319
334
  ? entry.data
package/lib/client.js CHANGED
@@ -1283,7 +1283,10 @@ function buildRuntimeStatusGuide() {
1283
1283
  ...RUNTIME_ARTIFACTS.map((a) => `- ${a.key}\uFF08${a.label}\uFF09\uFF1A${a.hint}`),
1284
1284
  "",
1285
1285
  "\u67E5\u770B\u72B6\u6001\uFF1A\u8BA9\u6A21\u578B\u8C03\u7528 iterate_status\uFF08\u6C47\u603B\uFF09\u6216 iterate_history\uFF08\u660E\u7EC6\uFF09\u3002",
1286
- "\u6E05\u7406\u72B6\u6001\uFF1A\u8BA9\u6A21\u578B\u8C03\u7528 iterate_prune\uFF08\u9ED8\u8BA4 dry-run\uFF0C\u53EA\u62A5\u544A\u4E0D\u5220\u9664\uFF0C\u663E\u5F0F dryRun:false \u624D\u771F\u6B63\u6E05\u7406\uFF09\u3002"
1286
+ "\u6E05\u7406\u72B6\u6001\uFF1A\u8BA9\u6A21\u578B\u8C03\u7528 iterate_prune\uFF08\u9ED8\u8BA4 dry-run\uFF0C\u53EA\u62A5\u544A\u4E0D\u5220\u9664\uFF0C\u663E\u5F0F dryRun:false \u624D\u771F\u6B63\u6E05\u7406\uFF09\u3002",
1287
+ '\u91CD\u7F6E\u72B6\u6001\uFF1A\u4E2D\u65AD\u540E\u91CD\u65B0\u5F00\u59CB\u524D\uFF0C\u53EF\u5206\u522B\u8BA9\u6A21\u578B\u8C03\u7528 iterate_checkpoint(operation:"clear")\u3001',
1288
+ ' iterate_quality_gate(operation:"clear")\u3001iterate_defense_events(operation:"clear")',
1289
+ " \u6E05\u9664\u9648\u65E7\u65AD\u70B9\u3001\u8D28\u91CF\u95E8\u7981\u8BC1\u4E66\u4E0E\u9632\u5FA1\u4E8B\u4EF6\u6D41\u3002"
1287
1290
  ];
1288
1291
  return lines.join("\n");
1289
1292
  }
@@ -3095,6 +3098,11 @@ ${JSON.stringify({
3095
3098
  mode: String(cp.mode || "normal"),
3096
3099
  maxRounds: typeof cp.maxRounds === "number" ? cp.maxRounds : null
3097
3100
  }, null, 2)}
3101
+ \`\`\``;
3102
+ const clearText = `\u8BF7\u8C03\u7528 \`iterate_checkpoint\` \u6E05\u9664\u5F53\u524D\u65AD\u70B9\uFF1A
3103
+
3104
+ \`\`\`json
3105
+ ${JSON.stringify({ operation: "clear" }, null, 2)}
3098
3106
  \`\`\``;
3099
3107
  const item = (label, value) => React.createElement("span", { className: "iterate-obs-chip" }, `${label} ${String(value ?? "?")}`);
3100
3108
  return React.createElement(
@@ -3118,13 +3126,24 @@ ${JSON.stringify({
3118
3126
  item("fixed", cp.fixedCount),
3119
3127
  item("resume", cp.resumeCount)
3120
3128
  ),
3121
- React.createElement("button", {
3122
- className: "iterate-btn",
3123
- "data-primary": "",
3124
- "data-copied": copiedKey === "cp-resume" ? "" : void 0,
3125
- onClick: () => copyInstruction("cp-resume", resumeText),
3126
- title: "\u590D\u5236 iterate_checkpoint resume \u6307\u4EE4\u6587\u672C\uFF08\u52A0\u8F7D\u65AD\u70B9\u5E76\u8BA1\u6570\u4E00\u6B21\u6062\u590D\uFF09"
3127
- }, copiedKey === "cp-resume" ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u6062\u590D\u6307\u4EE4")
3129
+ React.createElement(
3130
+ "div",
3131
+ { className: "iterate-obs-bar", style: { marginBottom: 4 } },
3132
+ React.createElement("button", {
3133
+ className: "iterate-btn",
3134
+ "data-primary": "",
3135
+ "data-copied": copiedKey === "cp-resume" ? "" : void 0,
3136
+ onClick: () => copyInstruction("cp-resume", resumeText),
3137
+ title: "\u590D\u5236 iterate_checkpoint resume \u6307\u4EE4\u6587\u672C\uFF08\u52A0\u8F7D\u65AD\u70B9\u5E76\u8BA1\u6570\u4E00\u6B21\u6062\u590D\uFF09"
3138
+ }, copiedKey === "cp-resume" ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u6062\u590D\u6307\u4EE4"),
3139
+ React.createElement("button", {
3140
+ className: "iterate-btn",
3141
+ "data-danger": "",
3142
+ "data-copied": copiedKey === "cp-clear" ? "" : void 0,
3143
+ onClick: () => copyInstruction("cp-clear", clearText),
3144
+ title: "\u590D\u5236 iterate_checkpoint clear \u6307\u4EE4\u6587\u672C\uFF08\u6E05\u9664\u5F53\u524D\u65AD\u70B9\uFF0C\u4F9B\u91CD\u65B0\u5F00\u59CB\uFF09"
3145
+ }, copiedKey === "cp-clear" ? "\u5DF2\u590D\u5236" : "\u6E05\u9664\u65AD\u70B9")
3146
+ )
3128
3147
  )
3129
3148
  );
3130
3149
  };
@@ -3293,6 +3312,11 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3293
3312
  };
3294
3313
  const renderQualityGate = () => {
3295
3314
  const gateInstruction = "\u8BF7\u8C03\u7528 `iterate_quality_gate` \u67E5\u8BE2\u5F53\u524D\u8D28\u91CF\u95E8\u7981\u72B6\u6001";
3315
+ const gateClearInstruction = `\u8BF7\u8C03\u7528 \`iterate_quality_gate\` \u6E05\u9664\u5F53\u524D\u8D28\u91CF\u95E8\u7981\u8BC1\u4E66\uFF1A
3316
+
3317
+ \`\`\`json
3318
+ ${JSON.stringify({ operation: "clear" }, null, 2)}
3319
+ \`\`\``;
3296
3320
  const gate = qualityGate;
3297
3321
  const dims = gate && gate.dimensions ? gate.dimensions : [];
3298
3322
  const hasGate = gate !== null && Boolean(gate.overallStatus || gate.overallScore != null || dims.length > 0);
@@ -3309,7 +3333,14 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3309
3333
  "data-copied": copiedKey === "qgate" ? "" : void 0,
3310
3334
  onClick: () => copyInstruction("qgate", gateInstruction),
3311
3335
  title: "\u590D\u5236 iterate_quality_gate \u67E5\u8BE2\u6307\u4EE4"
3312
- }, copiedKey === "qgate" ? "\u5DF2\u590D\u5236" : "\u67E5\u8BE2\u95E8\u7981")
3336
+ }, copiedKey === "qgate" ? "\u5DF2\u590D\u5236" : "\u67E5\u8BE2\u95E8\u7981"),
3337
+ React.createElement("button", {
3338
+ className: "iterate-btn",
3339
+ "data-danger": "",
3340
+ "data-copied": copiedKey === "qgate-clear" ? "" : void 0,
3341
+ onClick: () => copyInstruction("qgate-clear", gateClearInstruction),
3342
+ title: "\u590D\u5236 iterate_quality_gate clear \u6307\u4EE4\u6587\u672C\uFF08\u91CD\u7F6E\u9648\u65E7\u7684 FAIL \u8BC1\u4E66\uFF09"
3343
+ }, copiedKey === "qgate-clear" ? "\u5DF2\u590D\u5236" : "\u6E05\u9664\u95E8\u7981")
3313
3344
  );
3314
3345
  if (!hasGate) {
3315
3346
  return React.createElement(
@@ -3524,7 +3555,18 @@ ${JSON.stringify({ operation: "list", type: defenseFilter || void 0 }, null, 2)}
3524
3555
  "data-copied": copiedKey === "defense-counts" ? "" : void 0,
3525
3556
  onClick: () => copyInstruction("defense-counts", "\u8BF7\u8C03\u7528 `iterate_defense_events` \u67E5\u8BE2\u4E8B\u4EF6\u7EDF\u8BA1"),
3526
3557
  title: "\u590D\u5236\u7EDF\u8BA1\u6307\u4EE4"
3527
- }, copiedKey === "defense-counts" ? "\u5DF2\u590D\u5236" : "\u7EDF\u8BA1")
3558
+ }, copiedKey === "defense-counts" ? "\u5DF2\u590D\u5236" : "\u7EDF\u8BA1"),
3559
+ React.createElement("button", {
3560
+ className: "iterate-btn",
3561
+ "data-danger": "",
3562
+ "data-copied": copiedKey === "defense-clear" ? "" : void 0,
3563
+ onClick: () => copyInstruction("defense-clear", `\u8BF7\u8C03\u7528 \`iterate_defense_events\` \u6E05\u9664\u9632\u5FA1\u4E8B\u4EF6\u6D41\uFF1A
3564
+
3565
+ \`\`\`json
3566
+ ${JSON.stringify({ operation: "clear" }, null, 2)}
3567
+ \`\`\``),
3568
+ title: "\u590D\u5236 iterate_defense_events clear \u6307\u4EE4\u6587\u672C\uFF08\u91CD\u7F6E\u9648\u65E7\u4E8B\u4EF6\u6D41\uFF09"
3569
+ }, copiedKey === "defense-clear" ? "\u5DF2\u590D\u5236" : "\u6E05\u9664\u4E8B\u4EF6")
3528
3570
  );
3529
3571
  if (allEvents.length === 0 && totalCount === 0) {
3530
3572
  return React.createElement(
package/lib/parse.js CHANGED
@@ -1816,6 +1816,9 @@ export function buildRuntimeStatusGuide() {
1816
1816
  '',
1817
1817
  '查看状态:让模型调用 iterate_status(汇总)或 iterate_history(明细)。',
1818
1818
  '清理状态:让模型调用 iterate_prune(默认 dry-run,只报告不删除,显式 dryRun:false 才真正清理)。',
1819
+ '重置状态:中断后重新开始前,可分别让模型调用 iterate_checkpoint(operation:"clear")、',
1820
+ ' iterate_quality_gate(operation:"clear")、iterate_defense_events(operation:"clear")',
1821
+ ' 清除陈旧断点、质量门禁证书与防御事件流。',
1819
1822
  ]
1820
1823
  return lines.join('\n')
1821
1824
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
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",
@@ -36,7 +36,8 @@
36
36
  "0.1.3-alpha.1": "compatible",
37
37
  "0.1.5-alpha.1": "compatible",
38
38
  "0.1.5-alpha.2": "compatible",
39
- "0.1.5-rc.1": "compatible"
39
+ "0.1.5-rc.1": "compatible",
40
+ "0.1.5-rc.2": "compatible"
40
41
  }
41
42
  },
42
43
  "client": {
@@ -74,13 +75,13 @@
74
75
  },
75
76
  "dependencies": {
76
77
  "@deepseek-ai/cordis": "4.0.2",
77
- "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
78
- "@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",
79
80
  "js-yaml": "4.3.1"
80
81
  },
81
82
  "devDependencies": {
82
- "@deepseek-ai/dsh-jobs": "^0.1.2-rc.1",
83
- "@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",
84
85
  "@types/js-yaml": "4.0.9",
85
86
  "@types/node": "22.15.0",
86
87
  "@types/react": "19.2.2",
@@ -2172,6 +2172,11 @@ function ObservatoryPanel(props: SlotProps) {
2172
2172
  mode: String(cp.mode || 'normal'),
2173
2173
  maxRounds: typeof cp.maxRounds === 'number' ? cp.maxRounds : null,
2174
2174
  }, null, 2)}\n\`\`\``
2175
+ // Clearing a stale checkpoint after an interrupted run — iterate_checkpoint
2176
+ // exposes `clear`, but before this button it was only reachable by typing
2177
+ // the command manually (an interrupted run otherwise shows a permanent
2178
+ // "可恢复" chip with no companion reset action).
2179
+ const clearText = `请调用 \`iterate_checkpoint\` 清除当前断点:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``
2175
2180
  const item = (label: string, value: unknown) =>
2176
2181
  React.createElement('span', { className: 'iterate-obs-chip' }, `${label} ${String(value ?? '?')}`)
2177
2182
  return React.createElement('div', { className: 'iterate-obs-block' },
@@ -2187,11 +2192,18 @@ function ObservatoryPanel(props: SlotProps) {
2187
2192
  item('fixed', cp.fixedCount),
2188
2193
  item('resume', cp.resumeCount),
2189
2194
  ),
2190
- React.createElement('button', {
2191
- className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
2192
- onClick: () => copyInstruction('cp-resume', resumeText),
2193
- title: '复制 iterate_checkpoint resume 指令文本(加载断点并计数一次恢复)',
2194
- }, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
2195
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 4 } },
2196
+ React.createElement('button', {
2197
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
2198
+ onClick: () => copyInstruction('cp-resume', resumeText),
2199
+ title: '复制 iterate_checkpoint resume 指令文本(加载断点并计数一次恢复)',
2200
+ }, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
2201
+ React.createElement('button', {
2202
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'cp-clear' ? '' : undefined,
2203
+ onClick: () => copyInstruction('cp-clear', clearText),
2204
+ title: '复制 iterate_checkpoint clear 指令文本(清除当前断点,供重新开始)',
2205
+ }, copiedKey === 'cp-clear' ? '已复制' : '清除断点'),
2206
+ ),
2195
2207
  ),
2196
2208
  )
2197
2209
  }
@@ -2338,6 +2350,9 @@ function ObservatoryPanel(props: SlotProps) {
2338
2350
  // falls back to copy-able query instructions when no snapshot exists yet.
2339
2351
  const renderQualityGate = () => {
2340
2352
  const gateInstruction = '请调用 `iterate_quality_gate` 查询当前质量门禁状态'
2353
+ // Reset a stale FAIL certificate before a fresh iteration — the tool's
2354
+ // `clear` operation existed since v3.5.1 but had no client entry point.
2355
+ const gateClearInstruction = `请调用 \`iterate_quality_gate\` 清除当前质量门禁证书:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``
2341
2356
  const gate = qualityGate
2342
2357
  const dims = gate && gate.dimensions ? gate.dimensions : []
2343
2358
  const hasGate = gate !== null && Boolean(gate.overallStatus || gate.overallScore != null || dims.length > 0)
@@ -2352,6 +2367,11 @@ function ObservatoryPanel(props: SlotProps) {
2352
2367
  onClick: () => copyInstruction('qgate', gateInstruction),
2353
2368
  title: '复制 iterate_quality_gate 查询指令',
2354
2369
  }, copiedKey === 'qgate' ? '已复制' : '查询门禁'),
2370
+ React.createElement('button', {
2371
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'qgate-clear' ? '' : undefined,
2372
+ onClick: () => copyInstruction('qgate-clear', gateClearInstruction),
2373
+ title: '复制 iterate_quality_gate clear 指令文本(重置陈旧的 FAIL 证书)',
2374
+ }, copiedKey === 'qgate-clear' ? '已复制' : '清除门禁'),
2355
2375
  )
2356
2376
 
2357
2377
  if (!hasGate) {
@@ -2538,6 +2558,11 @@ function ObservatoryPanel(props: SlotProps) {
2538
2558
  onClick: () => copyInstruction('defense-counts', '请调用 `iterate_defense_events` 查询事件统计'),
2539
2559
  title: '复制统计指令',
2540
2560
  }, copiedKey === 'defense-counts' ? '已复制' : '统计'),
2561
+ React.createElement('button', {
2562
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'defense-clear' ? '' : undefined,
2563
+ onClick: () => copyInstruction('defense-clear', `请调用 \`iterate_defense_events\` 清除防御事件流:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``),
2564
+ title: '复制 iterate_defense_events clear 指令文本(重置陈旧事件流)',
2565
+ }, copiedKey === 'defense-clear' ? '已复制' : '清除事件'),
2541
2566
  )
2542
2567
 
2543
2568
  if (allEvents.length === 0 && totalCount === 0) {