iterate-plugin 3.5.2 → 3.5.4

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.
Files changed (43) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/dist/session-hooks.js +48 -4
  4. package/dist/skill-prompt.js +63 -29
  5. package/dist/tools/checkpoint.js +39 -5
  6. package/dist/tools/config.js +11 -2
  7. package/dist/tools/context.js +5 -1
  8. package/dist/tools/defense-events.js +48 -9
  9. package/dist/tools/defense-store.js +63 -5
  10. package/dist/tools/experience-bank.js +14 -11
  11. package/dist/tools/experience-store.js +79 -14
  12. package/dist/tools/fix.js +30 -10
  13. package/dist/tools/history.js +5 -3
  14. package/dist/tools/prune.js +197 -29
  15. package/dist/tools/quality-gate.js +3 -0
  16. package/dist/tools/quality-store.js +8 -2
  17. package/dist/tools/review.js +17 -3
  18. package/dist/tools/transcript.js +72 -15
  19. package/dist/tools/triage.js +35 -2
  20. package/dist/transcript.js +30 -7
  21. package/lib/client.js +89 -21
  22. package/lib/parse.js +27 -2
  23. package/package.json +9 -7
  24. package/src/client/index.ts +53 -8
  25. package/src/session-hooks.ts +50 -4
  26. package/src/skill-prompt.ts +63 -29
  27. package/src/tools/checkpoint.ts +45 -6
  28. package/src/tools/config.ts +11 -2
  29. package/src/tools/context.ts +5 -1
  30. package/src/tools/defense-events.ts +50 -9
  31. package/src/tools/defense-store.ts +64 -8
  32. package/src/tools/experience-bank.ts +14 -11
  33. package/src/tools/experience-store.ts +88 -23
  34. package/src/tools/fix.ts +32 -12
  35. package/src/tools/history.ts +5 -3
  36. package/src/tools/prune.ts +204 -28
  37. package/src/tools/quality-gate.ts +4 -0
  38. package/src/tools/quality-store.ts +7 -2
  39. package/src/tools/review.ts +16 -3
  40. package/src/tools/transcript.ts +71 -14
  41. package/src/tools/triage.ts +36 -2
  42. package/src/transcript.ts +32 -7
  43. package/src/types.ts +2 -0
@@ -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 = [];
@@ -103,6 +107,7 @@ export class ReviewTranscriptBuilder {
103
107
  round = 0;
104
108
  maxRounds = 0;
105
109
  active = true;
110
+ stoppedReason = null;
106
111
  rounds = [];
107
112
  convergence = [];
108
113
  globalFindings = [];
@@ -154,17 +159,27 @@ export class ReviewTranscriptBuilder {
154
159
  this.phases.push(n);
155
160
  this.touch();
156
161
  }
157
- /** End the run (stops the "active" pulsing in the UI). */
158
- finish() {
162
+ /**
163
+ * End the run (stops the "active" pulsing in the UI). `reason` records WHY
164
+ * it ended, so a run stopped by max-rounds or validation is never mistaken
165
+ * for a live run NOR for a clean convergence.
166
+ */
167
+ finish(reason) {
159
168
  this.active = false;
169
+ if (typeof reason === 'string' && reason.trim())
170
+ this.stoppedReason = reason.trim();
160
171
  this.touch();
161
172
  }
162
173
  /** Open a review round, capturing the current round index. */
163
174
  roundStart(round, maxRounds) {
164
175
  const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
165
- this.round = r > 0 ? r : 1;
176
+ // Clamp to MAX_ROUNDS: `this.rounds.length < this.round` below preallocates
177
+ // an array of size `round` — an unbounded model-controlled value (e.g. 1e9)
178
+ // would OOM the host. Values above the cap are folded into the cap so the
179
+ // real rounds are never silently dropped.
180
+ this.round = Math.min(r > 0 ? r : 1, MAX_ROUNDS);
166
181
  if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
167
- this.maxRounds = Math.floor(maxRounds);
182
+ this.maxRounds = Math.min(Math.floor(maxRounds), MAX_ROUNDS);
168
183
  }
169
184
  while (this.rounds.length < this.round) {
170
185
  this.rounds.push({ round: this.rounds.length + 1, threads: [] });
@@ -248,7 +263,10 @@ export class ReviewTranscriptBuilder {
248
263
  // ─── Convergence (F2) ───────────────────────────────────────────────────
249
264
  /** Record a round's new-finding count for the convergence series. */
250
265
  snapshotConvergence(round, newCount) {
251
- const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
266
+ const rawR = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
267
+ // Clamp (mirrors roundStart): the loop below preallocates `r` slots, so an
268
+ // unbounded model-controlled round number would OOM the host.
269
+ const r = Math.min(rawR > 0 ? rawR : 1, MAX_ROUNDS);
252
270
  const n = typeof newCount === 'number' && Number.isFinite(newCount) ? newCount : 0;
253
271
  while (this.convergence.length < r)
254
272
  this.convergence.push(-1);
@@ -267,7 +285,9 @@ export class ReviewTranscriptBuilder {
267
285
  this.fixes.push({
268
286
  id,
269
287
  timestamp: typeof record.timestamp === 'string' ? record.timestamp : isoNow(),
270
- round: typeof record.round === 'number' ? record.round : this.round,
288
+ round: typeof record.round === 'number' && Number.isFinite(record.round)
289
+ ? Math.floor(record.round)
290
+ : this.round,
271
291
  file,
272
292
  summary: typeof record.summary === 'string' ? record.summary : '',
273
293
  linesAdded: typeof record.linesAdded === 'number' ? Math.floor(record.linesAdded) : 0,
@@ -313,7 +333,9 @@ export class ReviewTranscriptBuilder {
313
333
  const type = typeof entry.type === 'string' ? entry.type : 'decision';
314
334
  this.timeline.push({
315
335
  timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : isoNow(),
316
- round: typeof entry.round === 'number' ? entry.round : this.round,
336
+ round: typeof entry.round === 'number' && Number.isFinite(entry.round)
337
+ ? Math.floor(entry.round)
338
+ : this.round,
317
339
  type,
318
340
  data: entry.data && typeof entry.data === 'object'
319
341
  ? entry.data
@@ -370,6 +392,7 @@ export class ReviewTranscriptBuilder {
370
392
  checkpoint: this.checkpoint,
371
393
  timeline: this.timeline,
372
394
  nudge: this.nudge,
395
+ stoppedReason: this.stoppedReason,
373
396
  approval: {
374
397
  active: this.approval !== 'allow',
375
398
  policy: this.approval,
package/lib/client.js CHANGED
@@ -941,18 +941,25 @@ function computeConvergenceProgress(report) {
941
941
  report.convergence ?? {}
942
942
  );
943
943
  const totalRounds = typeof convergence.totalRounds === "number" ? convergence.totalRounds : 1;
944
- const currentRounds = (
945
- /** @type {Array<unknown>} */
946
- (report.rounds ?? []).length
947
- );
944
+ const currentRounds = currentRoundNumber(report);
948
945
  if (!(totalRounds > 0)) return 0;
949
946
  return Math.min(100, Math.round(currentRounds / totalRounds * 100));
950
947
  }
951
- function getCurrentRound(report) {
952
- return (
953
- /** @type {Array<unknown>} */
954
- (report.rounds ?? []).length
948
+ function currentRoundNumber(report) {
949
+ const rounds = (
950
+ /** @type {Array<Record<string, unknown>>} */
951
+ report.rounds ?? []
955
952
  );
953
+ let max = 0;
954
+ for (const r of rounds) {
955
+ if (!r || typeof r !== "object") continue;
956
+ const n = Number(r.round);
957
+ if (Number.isFinite(n) && n > max) max = n;
958
+ }
959
+ return rounds.length > 0 && max === 0 ? rounds.length : max;
960
+ }
961
+ function getCurrentRound(report) {
962
+ return currentRoundNumber(report);
956
963
  }
957
964
  function getTotalRounds(report) {
958
965
  const convergence = (
@@ -1283,7 +1290,10 @@ function buildRuntimeStatusGuide() {
1283
1290
  ...RUNTIME_ARTIFACTS.map((a) => `- ${a.key}\uFF08${a.label}\uFF09\uFF1A${a.hint}`),
1284
1291
  "",
1285
1292
  "\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"
1293
+ "\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",
1294
+ '\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',
1295
+ ' iterate_quality_gate(operation:"clear")\u3001iterate_defense_events(operation:"clear")',
1296
+ " \u6E05\u9664\u9648\u65E7\u65AD\u70B9\u3001\u8D28\u91CF\u95E8\u7981\u8BC1\u4E66\u4E0E\u9632\u5FA1\u4E8B\u4EF6\u6D41\u3002"
1287
1297
  ];
1288
1298
  return lines.join("\n");
1289
1299
  }
@@ -1710,6 +1720,25 @@ function severityColor(severity) {
1710
1720
  function severityLabel(severity) {
1711
1721
  return SEVERITY_LABEL[coerceSeverity(severity)];
1712
1722
  }
1723
+ function stoppedReasonLabel(reason) {
1724
+ switch (reason) {
1725
+ case "converged":
1726
+ return "\u5DF2\u7ED3\u675F \xB7 \u5DF2\u6536\u655B\uFF08\u65E0\u65B0\u53D1\u73B0\uFF09";
1727
+ case "max_rounds_reached":
1728
+ return "\u5DF2\u7ED3\u675F \xB7 \u8FBE\u5230\u8F6E\u6570\u4E0A\u9650";
1729
+ case "aborted_by_validation":
1730
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u5931\u8D25\u540E\u56DE\u6EDA\u505C\u6B62";
1731
+ case "aborted_by_config":
1732
+ return "\u5DF2\u7ED3\u675F \xB7 \u9A8C\u8BC1\u547D\u4EE4\u4E0D\u5728\u767D\u540D\u5355\uFF08\u914D\u7F6E\u9700\u4FEE\u590D\uFF09";
1733
+ default:
1734
+ return reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F";
1735
+ }
1736
+ }
1737
+ function runStatusText(live, manifest) {
1738
+ if (live) return "\u8FD0\u884C\u4E2D";
1739
+ const reason = manifest && typeof manifest.stoppedReason === "string" ? manifest.stoppedReason : "";
1740
+ return reason ? stoppedReasonLabel(reason) : "\u5DF2\u7ED3\u675F";
1741
+ }
1713
1742
  function readSlots(ctx) {
1714
1743
  if (ctx && ctx.slots && typeof ctx.slots.inject === "function" && typeof ctx.slots.register === "function") {
1715
1744
  return ctx.slots;
@@ -1855,7 +1884,7 @@ function ConvergenceDashboard(props) {
1855
1884
  key: "phase",
1856
1885
  "data-live": transcript.active === true ? "" : void 0,
1857
1886
  title: transcript.active === true ? `\u5F53\u524D\u5904\u4E8E\u300C${phase}\u300D\u9636\u6BB5\uFF08\u8FD0\u884C\u4E2D\uFF09` : `\u6700\u8FD1\u4E00\u6B21\u6267\u884C\u505C\u7559\u5728\u300C${phase}\u300D\u9636\u6BB5\uFF08\u5DF2\u7ED3\u675F\uFF09`
1858
- }, `${phase} \xB7 ${transcript.active === true ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"}`) : null;
1887
+ }, `${phase} \xB7 ${runStatusText(transcript.active === true, transcript)}`) : null;
1859
1888
  const taskModeTranscript = latestTranscript(session);
1860
1889
  const taskMode = taskModeTranscript?.taskMode;
1861
1890
  const taskModeChip = taskMode ? React.createElement("span", {
@@ -3095,6 +3124,11 @@ ${JSON.stringify({
3095
3124
  mode: String(cp.mode || "normal"),
3096
3125
  maxRounds: typeof cp.maxRounds === "number" ? cp.maxRounds : null
3097
3126
  }, null, 2)}
3127
+ \`\`\``;
3128
+ const clearText = `\u8BF7\u8C03\u7528 \`iterate_checkpoint\` \u6E05\u9664\u5F53\u524D\u65AD\u70B9\uFF1A
3129
+
3130
+ \`\`\`json
3131
+ ${JSON.stringify({ operation: "clear" }, null, 2)}
3098
3132
  \`\`\``;
3099
3133
  const item = (label, value) => React.createElement("span", { className: "iterate-obs-chip" }, `${label} ${String(value ?? "?")}`);
3100
3134
  return React.createElement(
@@ -3118,13 +3152,24 @@ ${JSON.stringify({
3118
3152
  item("fixed", cp.fixedCount),
3119
3153
  item("resume", cp.resumeCount)
3120
3154
  ),
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")
3155
+ React.createElement(
3156
+ "div",
3157
+ { className: "iterate-obs-bar", style: { marginBottom: 4 } },
3158
+ React.createElement("button", {
3159
+ className: "iterate-btn",
3160
+ "data-primary": "",
3161
+ "data-copied": copiedKey === "cp-resume" ? "" : void 0,
3162
+ onClick: () => copyInstruction("cp-resume", resumeText),
3163
+ title: "\u590D\u5236 iterate_checkpoint resume \u6307\u4EE4\u6587\u672C\uFF08\u52A0\u8F7D\u65AD\u70B9\u5E76\u8BA1\u6570\u4E00\u6B21\u6062\u590D\uFF09"
3164
+ }, copiedKey === "cp-resume" ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u6062\u590D\u6307\u4EE4"),
3165
+ React.createElement("button", {
3166
+ className: "iterate-btn",
3167
+ "data-danger": "",
3168
+ "data-copied": copiedKey === "cp-clear" ? "" : void 0,
3169
+ onClick: () => copyInstruction("cp-clear", clearText),
3170
+ title: "\u590D\u5236 iterate_checkpoint clear \u6307\u4EE4\u6587\u672C\uFF08\u6E05\u9664\u5F53\u524D\u65AD\u70B9\uFF0C\u4F9B\u91CD\u65B0\u5F00\u59CB\uFF09"
3171
+ }, copiedKey === "cp-clear" ? "\u5DF2\u590D\u5236" : "\u6E05\u9664\u65AD\u70B9")
3172
+ )
3128
3173
  )
3129
3174
  );
3130
3175
  };
@@ -3152,7 +3197,7 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3152
3197
  "div",
3153
3198
  { className: "iterate-obs-block-head" },
3154
3199
  React.createElement("span", {}, "\u8FD0\u884C\u63A7\u5236\u53F0"),
3155
- React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"),
3200
+ React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
3156
3201
  React.createElement("span", { className: "iterate-obs-head-meta" }, `\u6279\u51C6\u7B56\u7565\uFF1A${policyLabel}`)
3157
3202
  ),
3158
3203
  React.createElement(
@@ -3293,6 +3338,11 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3293
3338
  };
3294
3339
  const renderQualityGate = () => {
3295
3340
  const gateInstruction = "\u8BF7\u8C03\u7528 `iterate_quality_gate` \u67E5\u8BE2\u5F53\u524D\u8D28\u91CF\u95E8\u7981\u72B6\u6001";
3341
+ const gateClearInstruction = `\u8BF7\u8C03\u7528 \`iterate_quality_gate\` \u6E05\u9664\u5F53\u524D\u8D28\u91CF\u95E8\u7981\u8BC1\u4E66\uFF1A
3342
+
3343
+ \`\`\`json
3344
+ ${JSON.stringify({ operation: "clear" }, null, 2)}
3345
+ \`\`\``;
3296
3346
  const gate = qualityGate;
3297
3347
  const dims = gate && gate.dimensions ? gate.dimensions : [];
3298
3348
  const hasGate = gate !== null && Boolean(gate.overallStatus || gate.overallScore != null || dims.length > 0);
@@ -3309,7 +3359,14 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3309
3359
  "data-copied": copiedKey === "qgate" ? "" : void 0,
3310
3360
  onClick: () => copyInstruction("qgate", gateInstruction),
3311
3361
  title: "\u590D\u5236 iterate_quality_gate \u67E5\u8BE2\u6307\u4EE4"
3312
- }, copiedKey === "qgate" ? "\u5DF2\u590D\u5236" : "\u67E5\u8BE2\u95E8\u7981")
3362
+ }, copiedKey === "qgate" ? "\u5DF2\u590D\u5236" : "\u67E5\u8BE2\u95E8\u7981"),
3363
+ React.createElement("button", {
3364
+ className: "iterate-btn",
3365
+ "data-danger": "",
3366
+ "data-copied": copiedKey === "qgate-clear" ? "" : void 0,
3367
+ onClick: () => copyInstruction("qgate-clear", gateClearInstruction),
3368
+ title: "\u590D\u5236 iterate_quality_gate clear \u6307\u4EE4\u6587\u672C\uFF08\u91CD\u7F6E\u9648\u65E7\u7684 FAIL \u8BC1\u4E66\uFF09"
3369
+ }, copiedKey === "qgate-clear" ? "\u5DF2\u590D\u5236" : "\u6E05\u9664\u95E8\u7981")
3313
3370
  );
3314
3371
  if (!hasGate) {
3315
3372
  return React.createElement(
@@ -3524,7 +3581,18 @@ ${JSON.stringify({ operation: "list", type: defenseFilter || void 0 }, null, 2)}
3524
3581
  "data-copied": copiedKey === "defense-counts" ? "" : void 0,
3525
3582
  onClick: () => copyInstruction("defense-counts", "\u8BF7\u8C03\u7528 `iterate_defense_events` \u67E5\u8BE2\u4E8B\u4EF6\u7EDF\u8BA1"),
3526
3583
  title: "\u590D\u5236\u7EDF\u8BA1\u6307\u4EE4"
3527
- }, copiedKey === "defense-counts" ? "\u5DF2\u590D\u5236" : "\u7EDF\u8BA1")
3584
+ }, copiedKey === "defense-counts" ? "\u5DF2\u590D\u5236" : "\u7EDF\u8BA1"),
3585
+ React.createElement("button", {
3586
+ className: "iterate-btn",
3587
+ "data-danger": "",
3588
+ "data-copied": copiedKey === "defense-clear" ? "" : void 0,
3589
+ onClick: () => copyInstruction("defense-clear", `\u8BF7\u8C03\u7528 \`iterate_defense_events\` \u6E05\u9664\u9632\u5FA1\u4E8B\u4EF6\u6D41\uFF1A
3590
+
3591
+ \`\`\`json
3592
+ ${JSON.stringify({ operation: "clear" }, null, 2)}
3593
+ \`\`\``),
3594
+ title: "\u590D\u5236 iterate_defense_events clear \u6307\u4EE4\u6587\u672C\uFF08\u91CD\u7F6E\u9648\u65E7\u4E8B\u4EF6\u6D41\uFF09"
3595
+ }, copiedKey === "defense-clear" ? "\u5DF2\u590D\u5236" : "\u6E05\u9664\u4E8B\u4EF6")
3528
3596
  );
3529
3597
  if (allEvents.length === 0 && totalCount === 0) {
3530
3598
  return React.createElement(
@@ -3725,7 +3793,7 @@ ${JSON.stringify({ operation: "list", type: defenseFilter || void 0 }, null, 2)}
3725
3793
  "div",
3726
3794
  { className: "iterate-obs-head", "data-closed": open ? void 0 : "", onClick: () => setOpen((v) => !v) },
3727
3795
  React.createElement("span", { className: "iterate-obs-title" }, "iterate \u89C2\u6D4B\u53F0"),
3728
- React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, live ? "\u8FD0\u884C\u4E2D" : "\u5DF2\u7ED3\u675F"),
3796
+ React.createElement("span", { className: "iterate-obs-badge", "data-live": live ? "" : void 0 }, runStatusText(live, manifest)),
3729
3797
  React.createElement("span", { className: "iterate-obs-head-meta" }, headMeta || "runtime"),
3730
3798
  React.createElement("button", {
3731
3799
  className: "iterate-btn",
package/lib/parse.js CHANGED
@@ -1217,12 +1217,34 @@ export function computeConvergenceProgress(report) {
1217
1217
  const totalRounds = typeof convergence.totalRounds === 'number'
1218
1218
  ? convergence.totalRounds
1219
1219
  : 1
1220
- const currentRounds = /** @type {Array<unknown>} */ (report.rounds ?? []).length
1220
+ const currentRounds = currentRoundNumber(report)
1221
1221
  // Guard against an empty report (totalRounds <= 0) producing NaN.
1222
1222
  if (!(totalRounds > 0)) return 0
1223
1223
  return Math.min(100, Math.round((currentRounds / totalRounds) * 100))
1224
1224
  }
1225
1225
 
1226
+ /**
1227
+ * The actual current round (1-indexed) carried by a report. Uses the highest
1228
+ * per-round `round` field: a normal-mode aggregate ships ONLY the live round
1229
+ * (`rounds: [{round: r, ...}]`), so `rounds.length` would be stuck at 1 while
1230
+ * the run is on round 3. Dry-run aggregates ship the cumulative list, where
1231
+ * the round numbers and the array length agree anyway.
1232
+ *
1233
+ * @param {Record<string, unknown>} report
1234
+ * @returns {number}
1235
+ */
1236
+ function currentRoundNumber(report) {
1237
+ const rounds = /** @type {Array<Record<string, unknown>>} */ (report.rounds ?? [])
1238
+ let max = 0
1239
+ for (const r of rounds) {
1240
+ if (!r || typeof r !== 'object') continue
1241
+ const n = Number(r.round)
1242
+ if (Number.isFinite(n) && n > max) max = n
1243
+ }
1244
+ // Fallback: no usable round numbers → array length (covers all-0 rounds).
1245
+ return rounds.length > 0 && max === 0 ? rounds.length : max
1246
+ }
1247
+
1226
1248
  /**
1227
1249
  * Get the current round number (1-indexed) from a report.
1228
1250
  *
@@ -1230,7 +1252,7 @@ export function computeConvergenceProgress(report) {
1230
1252
  * @returns {number}
1231
1253
  */
1232
1254
  export function getCurrentRound(report) {
1233
- return (/** @type {Array<unknown>} */ (report.rounds ?? [])).length
1255
+ return currentRoundNumber(report)
1234
1256
  }
1235
1257
 
1236
1258
  /**
@@ -1816,6 +1838,9 @@ export function buildRuntimeStatusGuide() {
1816
1838
  '',
1817
1839
  '查看状态:让模型调用 iterate_status(汇总)或 iterate_history(明细)。',
1818
1840
  '清理状态:让模型调用 iterate_prune(默认 dry-run,只报告不删除,显式 dryRun:false 才真正清理)。',
1841
+ '重置状态:中断后重新开始前,可分别让模型调用 iterate_checkpoint(operation:"clear")、',
1842
+ ' iterate_quality_gate(operation:"clear")、iterate_defense_events(operation:"clear")',
1843
+ ' 清除陈旧断点、质量门禁证书与防御事件流。',
1819
1844
  ]
1820
1845
  return lines.join('\n')
1821
1846
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.2",
3
+ "version": "3.5.4",
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",
@@ -37,7 +37,8 @@
37
37
  "0.1.5-alpha.1": "compatible",
38
38
  "0.1.5-alpha.2": "compatible",
39
39
  "0.1.5-rc.1": "compatible",
40
- "0.1.5-rc.2": "compatible"
40
+ "0.1.5-rc.2": "compatible",
41
+ "0.1.6-alpha.1": "compatible"
41
42
  }
42
43
  },
43
44
  "client": {
@@ -75,13 +76,14 @@
75
76
  },
76
77
  "dependencies": {
77
78
  "@deepseek-ai/cordis": "4.0.2",
78
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
79
- "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
80
- "js-yaml": "4.3.1"
79
+ "@deepseek-ai/dsh-tools": "0.1.6-alpha.1",
80
+ "@deepseek-ai/dsh-util-values": "0.1.6-alpha.1",
81
+ "js-yaml": "4.3.2"
81
82
  },
82
83
  "devDependencies": {
83
- "@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
84
- "@deepseek-ai/dsh-session": "0.1.5-rc.2",
84
+ "@deepseek-ai/dsh-agent": "0.1.6-alpha.1",
85
+ "@deepseek-ai/dsh-jobs": "^0.1.6-alpha.1",
86
+ "@deepseek-ai/dsh-session": "0.1.6-alpha.1",
85
87
  "@types/js-yaml": "4.0.9",
86
88
  "@types/node": "22.15.0",
87
89
  "@types/react": "19.2.2",
@@ -244,6 +244,8 @@ interface ObsManifest {
244
244
  timeline?: ObsEntry[]
245
245
  nudge?: ObsNudge | null
246
246
  approval?: ObsApproval
247
+ /** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
248
+ stoppedReason?: string | null
247
249
  }
248
250
 
249
251
  // ─── Quality command center data types (v3.1+; mirrors src/types.ts) ────────
@@ -735,6 +737,24 @@ function severityLabel(severity: string | undefined): string {
735
737
  return SEVERITY_LABEL[coerceSeverity(severity)]
736
738
  }
737
739
 
740
+ /** Human label for WHY a finished run stopped (transcript manifest). */
741
+ function stoppedReasonLabel(reason: string | null | undefined): string {
742
+ switch (reason) {
743
+ case 'converged': return '已结束 · 已收敛(无新发现)'
744
+ case 'max_rounds_reached': return '已结束 · 达到轮数上限'
745
+ case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
746
+ case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
747
+ default: return reason ? `已结束 · ${reason}` : '已结束'
748
+ }
749
+ }
750
+
751
+ /** Badge text for the run's live/finished state, annotated with the stop reason. */
752
+ function runStatusText(live: boolean, manifest: ObsManifest | null | undefined): string {
753
+ if (live) return '运行中'
754
+ const reason = manifest && typeof manifest.stoppedReason === 'string' ? manifest.stoppedReason : ''
755
+ return reason ? stoppedReasonLabel(reason) : '已结束'
756
+ }
757
+
738
758
  /** Resolve the slots service: prefer the typed `ctx.slots`, fall back to `ctx.get`. */
739
759
  function readSlots(ctx: ClientContext): SlotsService | undefined {
740
760
  if (ctx && ctx.slots && typeof ctx.slots.inject === 'function' && typeof ctx.slots.register === 'function') {
@@ -940,7 +960,7 @@ function ConvergenceDashboard(props: SlotProps) {
940
960
  title: transcript.active === true
941
961
  ? `当前处于「${phase}」阶段(运行中)`
942
962
  : `最近一次执行停留在「${phase}」阶段(已结束)`,
943
- }, `${phase} · ${transcript.active === true ? '运行中' : '已结束'}`)
963
+ }, `${phase} · ${runStatusText(transcript.active === true, transcript)}`)
944
964
  : null
945
965
 
946
966
  // v3.0: task_mode indicator (code/iterate)
@@ -2172,6 +2192,11 @@ function ObservatoryPanel(props: SlotProps) {
2172
2192
  mode: String(cp.mode || 'normal'),
2173
2193
  maxRounds: typeof cp.maxRounds === 'number' ? cp.maxRounds : null,
2174
2194
  }, null, 2)}\n\`\`\``
2195
+ // Clearing a stale checkpoint after an interrupted run — iterate_checkpoint
2196
+ // exposes `clear`, but before this button it was only reachable by typing
2197
+ // the command manually (an interrupted run otherwise shows a permanent
2198
+ // "可恢复" chip with no companion reset action).
2199
+ const clearText = `请调用 \`iterate_checkpoint\` 清除当前断点:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``
2175
2200
  const item = (label: string, value: unknown) =>
2176
2201
  React.createElement('span', { className: 'iterate-obs-chip' }, `${label} ${String(value ?? '?')}`)
2177
2202
  return React.createElement('div', { className: 'iterate-obs-block' },
@@ -2187,11 +2212,18 @@ function ObservatoryPanel(props: SlotProps) {
2187
2212
  item('fixed', cp.fixedCount),
2188
2213
  item('resume', cp.resumeCount),
2189
2214
  ),
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' ? '已复制' : '复制恢复指令'),
2215
+ React.createElement('div', { className: 'iterate-obs-bar', style: { marginBottom: 4 } },
2216
+ React.createElement('button', {
2217
+ className: 'iterate-btn', 'data-primary': '', 'data-copied': copiedKey === 'cp-resume' ? '' : undefined,
2218
+ onClick: () => copyInstruction('cp-resume', resumeText),
2219
+ title: '复制 iterate_checkpoint resume 指令文本(加载断点并计数一次恢复)',
2220
+ }, copiedKey === 'cp-resume' ? '已复制' : '复制恢复指令'),
2221
+ React.createElement('button', {
2222
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'cp-clear' ? '' : undefined,
2223
+ onClick: () => copyInstruction('cp-clear', clearText),
2224
+ title: '复制 iterate_checkpoint clear 指令文本(清除当前断点,供重新开始)',
2225
+ }, copiedKey === 'cp-clear' ? '已复制' : '清除断点'),
2226
+ ),
2195
2227
  ),
2196
2228
  )
2197
2229
  }
@@ -2212,7 +2244,7 @@ function ObservatoryPanel(props: SlotProps) {
2212
2244
  return React.createElement('div', { className: 'iterate-obs-block' },
2213
2245
  React.createElement('div', { className: 'iterate-obs-block-head' },
2214
2246
  React.createElement('span', {}, '运行控制台'),
2215
- React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
2247
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, runStatusText(live, manifest)),
2216
2248
  React.createElement('span', { className: 'iterate-obs-head-meta' }, `批准策略:${policyLabel}`),
2217
2249
  ),
2218
2250
  React.createElement('div', { className: 'iterate-obs-block-body' },
@@ -2338,6 +2370,9 @@ function ObservatoryPanel(props: SlotProps) {
2338
2370
  // falls back to copy-able query instructions when no snapshot exists yet.
2339
2371
  const renderQualityGate = () => {
2340
2372
  const gateInstruction = '请调用 `iterate_quality_gate` 查询当前质量门禁状态'
2373
+ // Reset a stale FAIL certificate before a fresh iteration — the tool's
2374
+ // `clear` operation existed since v3.5.1 but had no client entry point.
2375
+ const gateClearInstruction = `请调用 \`iterate_quality_gate\` 清除当前质量门禁证书:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``
2341
2376
  const gate = qualityGate
2342
2377
  const dims = gate && gate.dimensions ? gate.dimensions : []
2343
2378
  const hasGate = gate !== null && Boolean(gate.overallStatus || gate.overallScore != null || dims.length > 0)
@@ -2352,6 +2387,11 @@ function ObservatoryPanel(props: SlotProps) {
2352
2387
  onClick: () => copyInstruction('qgate', gateInstruction),
2353
2388
  title: '复制 iterate_quality_gate 查询指令',
2354
2389
  }, copiedKey === 'qgate' ? '已复制' : '查询门禁'),
2390
+ React.createElement('button', {
2391
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'qgate-clear' ? '' : undefined,
2392
+ onClick: () => copyInstruction('qgate-clear', gateClearInstruction),
2393
+ title: '复制 iterate_quality_gate clear 指令文本(重置陈旧的 FAIL 证书)',
2394
+ }, copiedKey === 'qgate-clear' ? '已复制' : '清除门禁'),
2355
2395
  )
2356
2396
 
2357
2397
  if (!hasGate) {
@@ -2538,6 +2578,11 @@ function ObservatoryPanel(props: SlotProps) {
2538
2578
  onClick: () => copyInstruction('defense-counts', '请调用 `iterate_defense_events` 查询事件统计'),
2539
2579
  title: '复制统计指令',
2540
2580
  }, copiedKey === 'defense-counts' ? '已复制' : '统计'),
2581
+ React.createElement('button', {
2582
+ className: 'iterate-btn', 'data-danger': '', 'data-copied': copiedKey === 'defense-clear' ? '' : undefined,
2583
+ onClick: () => copyInstruction('defense-clear', `请调用 \`iterate_defense_events\` 清除防御事件流:\n\n\`\`\`json\n${JSON.stringify({ operation: 'clear' }, null, 2)}\n\`\`\``),
2584
+ title: '复制 iterate_defense_events clear 指令文本(重置陈旧事件流)',
2585
+ }, copiedKey === 'defense-clear' ? '已复制' : '清除事件'),
2541
2586
  )
2542
2587
 
2543
2588
  if (allEvents.length === 0 && totalCount === 0) {
@@ -2706,7 +2751,7 @@ function ObservatoryPanel(props: SlotProps) {
2706
2751
  return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'obs', className: 'iterate-obs' },
2707
2752
  React.createElement('div', { className: 'iterate-obs-head', 'data-closed': open ? undefined : '', onClick: () => setOpen((v) => !v) },
2708
2753
  React.createElement('span', { className: 'iterate-obs-title' }, 'iterate 观测台'),
2709
- React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, live ? '运行中' : '已结束'),
2754
+ React.createElement('span', { className: 'iterate-obs-badge', 'data-live': live ? '' : undefined }, runStatusText(live, manifest)),
2710
2755
  React.createElement('span', { className: 'iterate-obs-head-meta' }, headMeta || 'runtime'),
2711
2756
  React.createElement('button', {
2712
2757
  className: 'iterate-btn', 'data-ghost': '',
@@ -7,12 +7,22 @@
7
7
  *
8
8
  * - `allow` policy → the call runs.
9
9
  * - `deny` policy → the call is refused (fail-closed), surfaced as an
10
- * error to the model.
10
+ * error to the model. Since dsh 0.1.6-alpha.1 the
11
+ * denial carries a structured `info` (name
12
+ * `iterate-approval-gate`, code `APPROVAL_DENIED`,
13
+ * plus the human-readable reason) so durable
14
+ * projections can route it distinctly from a normal
15
+ * tool failure.
11
16
  * - `ask` policy → return `{ kind: 'ask', reason }`; dsh's own
12
17
  * scheduler routes it through the `approval` service
13
18
  * (see `@deepseek-ai/dsh-user-approval`), which
14
19
  * prompts the human and audits an approve/deny pair
15
20
  * on the session.
21
+ * - canceled → if the caller aborted the invocation before
22
+ * dispatch, return `{ kind: 'cancel' }` (the
23
+ * canonical 0.1.6-alpha.1 cancellation result) so a
24
+ * destructive call never runs (or asks) for a dead
25
+ * request.
16
26
  *
17
27
  * We deliberately do NOT also add `approved` flags inside the tool bodies:
18
28
  * the pre-execute waterfall consumes the human decision before the tool runs,
@@ -29,7 +39,15 @@
29
39
  import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
30
40
  import { decideApproval, isDestructiveIterateTool } from './approval-gate.ts'
31
41
  import type { Context } from '@deepseek-ai/cordis'
32
- import type { ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
42
+ import type { ToolErrorInfo, ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
43
+
44
+ /** Structured error identity attached to policy denials (dsh 0.1.6-alpha.1:
45
+ * `deny.info` lets durable projections / retry / replay distinguish an
46
+ * approval-policy denial from a tool-thrown failure). */
47
+ const DENY_INFO: ToolErrorInfo = {
48
+ name: 'iterate-approval-gate',
49
+ code: 'APPROVAL_DENIED',
50
+ }
33
51
 
34
52
  /**
35
53
  * Build the per-call approval decision for a tool execution.
@@ -49,6 +67,20 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
49
67
  }
50
68
  if (!isDestructiveIterateTool(name)) return { kind: 'allow' }
51
69
 
70
+ // dsh 0.1.6-alpha.1: if the caller already aborted this invocation before
71
+ // dispatch, there is nothing to gate — select the canonical cancellation
72
+ // result (`cancel`) rather than prompting a human for a dead call or running
73
+ // `next()` (which would allow) on an aborted one.
74
+ let signal: Readonly<AbortSignal> | undefined
75
+ try {
76
+ signal = exec.signal
77
+ } catch {
78
+ signal = undefined
79
+ }
80
+ if (signal && typeof signal.aborted === 'boolean' && signal.aborted) {
81
+ return { kind: 'cancel' }
82
+ }
83
+
52
84
  // Resolve the project root (use the call's own `path` arg, else the agent's
53
85
  // session cwd) to read the effective observatory policy.
54
86
  let argPath: string | undefined
@@ -74,7 +106,18 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
74
106
  }
75
107
 
76
108
  const decision = decideApproval(exec, policy)
77
- if (decision.kind === 'deny') return { kind: 'deny', reason: decision.reason }
109
+ if (decision.kind === 'deny') {
110
+ return {
111
+ kind: 'deny',
112
+ reason: decision.reason,
113
+ info: {
114
+ ...DENY_INFO,
115
+ // ToolErrorInfo.reason (0.1.6-alpha.1): raw user-facing detail kept in
116
+ // durable state; the model-facing text still only sees `reason` above.
117
+ reason: decision.reason,
118
+ },
119
+ }
120
+ }
78
121
  if (decision.kind === 'ask') return { kind: 'ask', reason: decision.reason }
79
122
  return { kind: 'allow' }
80
123
  }
@@ -104,7 +147,10 @@ export function registerSessionHooks(ctx: Context): void {
104
147
  // to allow, which would bypass consent — so return our ask decision.
105
148
  return Promise.resolve(decision)
106
149
  }
107
- if (decision.kind === 'deny') {
150
+ if (decision.kind === 'deny' || decision.kind === 'cancel') {
151
+ // deny: refuse the call. cancel: the caller already aborted this
152
+ // invocation — selecting the canonical cancellation result instead of
153
+ // letting `next()` allow it or prompting consent for a dead request.
108
154
  return Promise.resolve(decision)
109
155
  }
110
156
  return next()