iterate-plugin 3.5.2 → 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.
@@ -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.2",
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",
@@ -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) {
@@ -24,16 +24,41 @@ import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGat
24
24
 
25
25
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
26
26
 
27
+ /** Max findings persisted in a checkpoint (a model-authored findings payload is
28
+ * bounded, mirroring MAX_FIX_CONTENT_CHARS for fix content). */
29
+ export const MAX_CHECKPOINT_FINDINGS = 1000
30
+
27
31
  /** Read the current checkpoint from disk (missing/corrupt → null). */
28
32
  export function readCheckpoint(projectRoot: string): IterationCheckpoint | null {
29
33
  const file = checkpointPath(projectRoot)
30
34
  if (!existsSync(file)) return null
31
35
  try {
32
- const parsed = JSON.parse(readFileSync(file, 'utf-8')) as IterationCheckpoint
36
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<IterationCheckpoint>
33
37
  if (!parsed || typeof parsed !== 'object') return null
34
38
  if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal') return null
35
- if (typeof parsed.round !== 'number') return null
36
- return parsed
39
+ // Strict round gate: non-numeric (incl. NaN — `NaN <= 0` is false and
40
+ // `NaN < 0` also false) round values must not survive into the status.
41
+ const round =
42
+ typeof parsed.round === 'number' && Number.isFinite(parsed.round)
43
+ ? Math.floor(parsed.round)
44
+ : null
45
+ if (round === null || round < 0) return null
46
+ const num = (v: unknown, fallback: number): number =>
47
+ typeof v === 'number' && Number.isFinite(v) ? v : fallback
48
+ // Normalize every field so a hand-edited/corrupt checkpoint (string
49
+ // maxRounds, missing fixedCount, …) can never leak a non-numeric value
50
+ // into the status output or crash a consumer.
51
+ return {
52
+ mode: parsed.mode,
53
+ round,
54
+ maxRounds: num(parsed.maxRounds, round),
55
+ fixedCount: num(parsed.fixedCount, 0),
56
+ architecturalCount: num(parsed.architecturalCount, 0),
57
+ resumeCount: num(parsed.resumeCount, 0),
58
+ findings: Array.isArray(parsed.findings) ? (parsed.findings as IterationCheckpoint['findings']) : [],
59
+ startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString(),
60
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
61
+ }
37
62
  } catch {
38
63
  return null
39
64
  }
@@ -82,6 +107,17 @@ export function validateCheckpoint(input: {
82
107
  ) {
83
108
  return 'resumeCount must be a non-negative integer'
84
109
  }
110
+ // A round that claims to be past the configured cap is an inconsistent save —
111
+ // the status would render `Round X / Y` with X > Y, which is never legitimate
112
+ // for a resumed run (resume continues AT the checkpoint, it does not exceed
113
+ // the cap).
114
+ if (
115
+ typeof input.round === 'number' &&
116
+ typeof input.maxRounds === 'number' &&
117
+ input.round > input.maxRounds
118
+ ) {
119
+ return 'round must not exceed maxRounds'
120
+ }
85
121
  return null
86
122
  }
87
123
 
@@ -118,8 +154,11 @@ export function computeStatus(input: {
118
154
  }
119
155
 
120
156
  const totalRounds = checkpoint?.maxRounds ?? currentRound
121
- const registryFixed = registry.rounds.reduce((sum, r) => sum + r.fixedCount, 0)
122
- const failedCount = registry.rounds.reduce((sum, r) => sum + r.failedCount, 0)
157
+ // Coerce the registry's per-round counts defensively: a hand-edited registry
158
+ // round missing fixedCount/failedCount would otherwise feed NaN into the
159
+ // status integer fields.
160
+ const registryFixed = registry.rounds.reduce((sum, r) => sum + (Number(r.fixedCount) || 0), 0)
161
+ const failedCount = registry.rounds.reduce((sum, r) => sum + (Number(r.failedCount) || 0), 0)
123
162
  // When a checkpoint exists, its snapshot fields are authoritative for resume
124
163
  // (fixedCount / architecturalCount / findings); otherwise derive from the
125
164
  // live fix registry and decision log.
@@ -258,7 +297,7 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
258
297
  fixedCount: args.fixedCount as number,
259
298
  architecturalCount: args.architecturalCount as number,
260
299
  resumeCount: (typeof args.resumeCount === 'number' ? args.resumeCount : 0),
261
- findings: (Array.isArray(args.findings) ? args.findings : []) as unknown as IterationCheckpoint['findings'],
300
+ findings: (Array.isArray(args.findings) ? args.findings.slice(0, MAX_CHECKPOINT_FINDINGS) : []) as unknown as IterationCheckpoint['findings'],
262
301
  startedAt: readCheckpoint(projectRoot)?.startedAt ?? new Date().toISOString(),
263
302
  updatedAt: new Date().toISOString(),
264
303
  }
@@ -1,7 +1,8 @@
1
1
  import { join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
2
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
4
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
4
- import { loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
5
+ import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
5
6
  import {
6
7
  applyConfigUpdates,
7
8
  readRawConfig,
@@ -106,7 +107,15 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
106
107
  }
107
108
  let base: Record<string, unknown>
108
109
  try {
109
- base = readRawConfig(join(projectRoot, 'iterate.config.yaml'))
110
+ // Partial updates must be mergeable on a FRESH project (no config
111
+ // file yet): otherwise `{max_rounds: 5}` alone fails schema
112
+ // validation with a misleading "missing goal". Merge against the
113
+ // built-in defaults when there is no file to read, so the written
114
+ // config captures the effective config (defaults + update) and the
115
+ // documented "write a partial update" contract works out of the box.
116
+ base = existsSync(join(projectRoot, 'iterate.config.yaml'))
117
+ ? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
118
+ : (defaultConfig() as unknown as Record<string, unknown>)
110
119
  } catch (err) {
111
120
  return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
112
121
  }