iterate-plugin 3.7.0 → 3.8.0

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.
@@ -23,6 +23,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
23
23
  import { mkdir, readFile } from 'node:fs/promises';
24
24
  import { existsSync } from 'node:fs';
25
25
  import { writeTextAtomicAsync } from "../atomic-fs.js";
26
+ import { withProjectLock } from "../file-lock.js";
26
27
  import { dirname } from 'node:path';
27
28
  import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
28
29
  import { transcriptPath } from "../paths.js";
@@ -73,7 +74,7 @@ function normalizeCheckpoint(input) {
73
74
  if (round <= 0)
74
75
  return null;
75
76
  return {
76
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
77
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
77
78
  round,
78
79
  maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
79
80
  fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
@@ -198,7 +199,23 @@ export function registerTranscriptTool(ctx) {
198
199
  'the run console can show which commands ran and whether they passed.',
199
200
  },
200
201
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
201
- stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
202
+ stoppedReason: {
203
+ type: 'string',
204
+ // Enum guard: the label renderer keys off this value, so a stray or
205
+ // prototype-chain string (e.g. "__proto__") must not enter the tool.
206
+ // The render path additionally uses Object.hasOwn (defense for
207
+ // hand-edited manifests that bypass this schema).
208
+ enum: [
209
+ 'converged',
210
+ 'max_rounds_reached',
211
+ 'aborted_by_validation',
212
+ 'aborted_by_config',
213
+ 'schema_invalid',
214
+ 'no_usable_reviewer_output',
215
+ 'inconclusive',
216
+ ],
217
+ description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).'
218
+ },
202
219
  // `string | null` (NOT plain `type: 'string'`): dsh validates arguments
203
220
  // against the compiled schema BEFORE execute, and the docs — plus the
204
221
  // client's clear button — advertise `{"operation":"nudge","text":null}`.
@@ -288,52 +305,65 @@ export function registerTranscriptTool(ctx) {
288
305
  };
289
306
  }
290
307
  if (args.operation === 'nudge') {
291
- let manifest = null;
292
- if (existsSync(file)) {
293
- try {
294
- const parsed = JSON.parse(await readFile(file, 'utf-8'));
295
- manifest = parsed;
308
+ // Read-modify-write across processes: a concurrent nudge in a sibling
309
+ // process would otherwise read the same manifest and write back over
310
+ // each other's steering text. Serialize on the shared transcript lock
311
+ // (same pattern as the config/triage writers). The body still awaits,
312
+ // so the lock is held across the read and write; a same-process
313
+ // re-entry degrades to unlocked (the advisory lock is per-pid), which
314
+ // is the documented best-effort fallback for a low-value projection.
315
+ return withProjectLock(projectRoot, 'transcript', async () => {
316
+ let manifest = null;
317
+ if (existsSync(file)) {
318
+ try {
319
+ const parsed = JSON.parse(await readFile(file, 'utf-8'));
320
+ manifest = parsed;
321
+ }
322
+ catch {
323
+ manifest = null;
324
+ }
296
325
  }
297
- catch {
298
- manifest = null;
326
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
327
+ // or a non-object) must never crash the nudge — degrade to a fresh
328
+ // builder so the steering text still lands. The fallback must keep
329
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
330
+ // stray malformed field can never silently rewrite a dry-run run
331
+ // into a `normal` one.
332
+ const fallbackOpts = {
333
+ project: projectRoot,
334
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
335
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
336
+ approval,
337
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
338
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
339
+ };
340
+ let builder;
341
+ if (manifest) {
342
+ try {
343
+ builder = rehydrateBuilder(manifest, approval);
344
+ }
345
+ catch {
346
+ builder = new ReviewTranscriptBuilder(fallbackOpts);
347
+ }
299
348
  }
300
- }
301
- // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
302
- // or a non-object) must never crash the nudge — degrade to a fresh
303
- // builder so the steering text still lands. The fallback must keep
304
- // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
305
- // stray malformed field can never silently rewrite a dry-run run
306
- // into a `normal` one.
307
- const fallbackOpts = {
308
- project: projectRoot,
309
- mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
310
- taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
311
- approval,
312
- goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
313
- maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
314
- };
315
- let builder;
316
- if (manifest) {
317
- try {
318
- builder = rehydrateBuilder(manifest, approval);
349
+ else {
350
+ // No manifest yet: the run's mode is genuinely unknown at nudge
351
+ // time (capture sets it). Recording `mode: 'normal'` here would
352
+ // rewrite a dry-run run into a `normal` one in the status/badge
353
+ // until capture lands — `null` honestly says "not captured yet".
354
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval });
319
355
  }
320
- catch {
321
- builder = new ReviewTranscriptBuilder(fallbackOpts);
356
+ builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
357
+ const persisted = await persistChecked(file, builder.serialize());
358
+ if (!persisted.ok) {
359
+ return { operation: 'nudge', updated: false, error: persisted.error };
322
360
  }
323
- }
324
- else {
325
- builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
326
- }
327
- builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
328
- const persisted = await persistChecked(file, builder.serialize());
329
- if (!persisted.ok) {
330
- return { operation: 'nudge', updated: false, error: persisted.error };
331
- }
332
- return {
333
- operation: 'nudge',
334
- updated: true,
335
- transcript: builder.serialize(),
336
- };
361
+ return {
362
+ operation: 'nudge',
363
+ updated: true,
364
+ transcript: builder.serialize(),
365
+ };
366
+ });
337
367
  }
338
368
  // capture
339
369
  // `observatory.capture: false` opts the project out of transcript
@@ -5,6 +5,7 @@ import yaml from 'js-yaml';
5
5
  import { resolveProjectRootForExec } from "../config-loader.js";
6
6
  import { writeTextAtomic } from "../atomic-fs.js";
7
7
  import { configBackupSuffix } from "../config-write.js";
8
+ import { withProjectLock } from "../file-lock.js";
8
9
  import { WHOLE_FILE_LINE } from "../evidence.js";
9
10
  const CONFIG_FILE = 'iterate.config.yaml';
10
11
  /** Personalization key that holds the known-intentional list. */
@@ -227,58 +228,64 @@ function readConfigFile(configPath) {
227
228
  /** Apply the triage entries: backup, merge, write, rollback on failure. */
228
229
  function applyEntries(projectRoot, incoming) {
229
230
  const configPath = join(projectRoot, CONFIG_FILE);
230
- let config;
231
- try {
232
- config = readConfigFile(configPath);
233
- }
234
- catch (err) {
235
- // The file exists but is malformed — refuse to overwrite user data.
236
- return { ok: false, error: `Failed to read config: ${String(err)}` };
237
- }
238
- const existing = readKnownIntentional(config);
239
- const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming);
240
- const nextConfig = buildConfigWithKnownIntentional(config, merged);
241
- const hadFile = existsSync(configPath);
242
- const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null;
243
- if (backupPath) {
231
+ // Serialize against `iterate_config write` (SAME lock name) so a triage apply
232
+ // and a config write on one project cannot interleave their read-modify-write
233
+ // and silently drop each other's updates. The lock spans read → merge →
234
+ // backup → write → backup-prune so the whole cycle is atomic.
235
+ return withProjectLock(projectRoot, 'config-write', () => {
236
+ let config;
244
237
  try {
245
- copyFileSync(configPath, backupPath);
238
+ config = readConfigFile(configPath);
246
239
  }
247
240
  catch (err) {
248
- return {
249
- ok: false,
250
- error: `Failed to create backup: ${String(err)}`,
251
- };
241
+ // The file exists but is malformed — refuse to overwrite user data.
242
+ return { ok: false, error: `Failed to read config: ${String(err)}` };
252
243
  }
253
- }
254
- const yamlText = yaml.dump(nextConfig, { noRefs: true });
255
- try {
256
- writeTextAtomic(configPath, yamlText);
257
- }
258
- catch (err) {
259
- // Rollback: restore the backup, or REMOVE the file we just created when
260
- // there was no prior config — an empty file left behind would poison all
261
- // future config reads (empty YAML is not a valid mapping).
262
- let rollbackError = '';
244
+ const existing = readKnownIntentional(config);
245
+ const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming);
246
+ const nextConfig = buildConfigWithKnownIntentional(config, merged);
247
+ const hadFile = existsSync(configPath);
248
+ const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null;
249
+ if (backupPath) {
250
+ try {
251
+ copyFileSync(configPath, backupPath);
252
+ }
253
+ catch (err) {
254
+ return {
255
+ ok: false,
256
+ error: `Failed to create backup: ${String(err)}`,
257
+ };
258
+ }
259
+ }
260
+ const yamlText = yaml.dump(nextConfig, { noRefs: true });
263
261
  try {
264
- if (backupPath)
265
- copyFileSync(backupPath, configPath);
266
- else if (existsSync(configPath))
267
- rmSync(configPath, { force: true });
262
+ writeTextAtomic(configPath, yamlText);
268
263
  }
269
- catch (rbErr) {
270
- rollbackError = `; rollback also failed: ${String(rbErr)}`;
264
+ catch (err) {
265
+ // Rollback: restore the backup, or REMOVE the file we just created when
266
+ // there was no prior config — an empty file left behind would poison all
267
+ // future config reads (empty YAML is not a valid mapping).
268
+ let rollbackError = '';
269
+ try {
270
+ if (backupPath)
271
+ copyFileSync(backupPath, configPath);
272
+ else if (existsSync(configPath))
273
+ rmSync(configPath, { force: true });
274
+ }
275
+ catch (rbErr) {
276
+ rollbackError = `; rollback also failed: ${String(rbErr)}`;
277
+ }
278
+ return {
279
+ ok: false,
280
+ error: `Failed to write config: ${String(err)}${rollbackError}`,
281
+ };
271
282
  }
272
- return {
273
- ok: false,
274
- error: `Failed to write config: ${String(err)}${rollbackError}`,
275
- };
276
- }
277
- // Success: bound the accumulation of timestamped backups so a long-lived
278
- // project never collects an unbounded pile of config snapshots.
279
- if (backupPath)
280
- pruneOldConfigBackups(configPath);
281
- return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath };
283
+ // Success: bound the accumulation of timestamped backups so a long-lived
284
+ // project never collects an unbounded pile of config snapshots.
285
+ if (backupPath)
286
+ pruneOldConfigBackups(configPath);
287
+ return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath };
288
+ });
282
289
  }
283
290
  /**
284
291
  * Register the `iterate_triage` tool.
@@ -501,7 +501,7 @@ export class ReviewTranscriptBuilder {
501
501
  return;
502
502
  }
503
503
  this.checkpoint = {
504
- mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : 'normal',
504
+ mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : null,
505
505
  round: typeof state.round === 'number' ? state.round : 0,
506
506
  maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
507
507
  fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
@@ -725,7 +725,7 @@ function normalizeCheckpointRow(input) {
725
725
  if (round <= 0)
726
726
  return null;
727
727
  return {
728
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
728
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
729
729
  round: clampRoundValue(c.round),
730
730
  maxRounds: clampCountValue(c.maxRounds),
731
731
  fixedCount: clampCountValue(c.fixedCount),
package/lib/client.js CHANGED
@@ -1,4 +1,4 @@
1
- /* iterate-plugin client source-digest: 7da1ef5af84458a763f4b0ad8cd75ba2de3fb16b9831e00c0a9767bc16fece65 */
1
+ /* iterate-plugin client source-digest: fc3f99c3c9a74c891ce54564ac49e7a336441432dfafe0cb21775ee4afe8f411 */
2
2
  window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {
3
3
  var module = { exports: {} };
4
4
  var exports = module.exports;
@@ -1539,8 +1539,11 @@ function stoppedReasonLabel(reason, lang = "zh") {
1539
1539
  schema_invalid: "Done \xB7 repeated schema validation failures in review output",
1540
1540
  no_usable_reviewer_output: "Done \xB7 no usable review output"
1541
1541
  };
1542
- if (lang === "en") return en[reason] ?? (reason ? `Done \xB7 ${reason}` : "Done");
1543
- return zh[reason] ?? (reason ? `\u5DF2\u7ED3\u675F \xB7 ${reason}` : "\u5DF2\u7ED3\u675F");
1542
+ const table = lang === "en" ? en : zh;
1543
+ if (typeof reason === "string" && Object.hasOwn(table, reason)) return table[reason];
1544
+ const text = typeof reason === "string" && reason !== "" ? reason : "";
1545
+ if (lang === "en") return text ? `Done \xB7 ${text}` : "Done";
1546
+ return text ? `\u5DF2\u7ED3\u675F \xB7 ${text}` : "\u5DF2\u7ED3\u675F";
1544
1547
  }
1545
1548
  function buildCompletionSummary(report, lang = "zh") {
1546
1549
  const conv = (
@@ -1626,8 +1629,9 @@ function buildConfigEditGuide() {
1626
1629
  "---------------------",
1627
1630
  "\u914D\u7F6E\u6587\u4EF6\uFF1A\u9879\u76EE\u6839\u76EE\u5F55 iterate.config.yaml\u3002",
1628
1631
  "",
1629
- "\u26A0 \u672A\u914D\u7F6E validation.commands \u65F6\u8FED\u4EE3\u4E0D\u88AB\u4FDD\u62A4\uFF1A\u6BCF\u8F6E validate \u62FF\u5230\u7A7A\u7ED3\u679C\u4F1A\u88AB\u5F53\u4F5C",
1630
- " \u201C\u9A8C\u8BC1\u901A\u8FC7\u201D\uFF0C\u7B49\u4E8E\u5728\u6CA1\u6709\u4EFB\u4F55\u6D4B\u8BD5\u4FDD\u62A4\u7684\u60C5\u51B5\u4E0B\u7EE7\u7EED\u6536\u655B\u3002\u7B2C\u4E00\u6B21\u542F\u52A8\u8FED\u4EE3\u524D\uFF0C",
1632
+ "\u26A0 \u672A\u914D\u7F6E validation.commands \u65F6\u8FED\u4EE3\u4E0D\u88AB\u4FDD\u62A4\uFF1A\u8BE5\u8F6E\u4F1A\u76F4\u63A5\u4E2D\u6B62\uFF08fail-closed\uFF09\uFF0C",
1633
+ " \u7A7A\u9A8C\u8BC1\u7ED3\u679C/\u9A8C\u8BC1\u672A\u6267\u884C\u7EDD\u4E0D\u5F53\u4F5C\u201C\u9A8C\u8BC1\u901A\u8FC7\u201D\uFF0C\u8FD0\u884C\u4EE5 converged_unverified /",
1634
+ " aborted_by_config \u6536\u5C3E\u800C\u4E0D\u662F plain converged\u3002\u7B2C\u4E00\u6B21\u542F\u52A8\u8FED\u4EE3\u524D\uFF0C",
1631
1635
  " \u8BF7\u5148\u7ED9 validation.commands \u914D\u4E0A\u81F3\u5C11\u4E00\u6761\u771F\u5B9E\u547D\u4EE4\uFF08\u5982 npm test\uFF09\u3002",
1632
1636
  "",
1633
1637
  "\u53EF\u7F16\u8F91\u5B57\u6BB5\uFF1A",
@@ -1844,7 +1848,7 @@ function pickExportExtras(extra) {
1844
1848
  }
1845
1849
  return out;
1846
1850
  }
1847
- function buildRoundComparison(report, validations) {
1851
+ function buildRoundComparison(report, validations, fixes) {
1848
1852
  const rounds = /* @__PURE__ */ new Map();
1849
1853
  const rowsFor = (round) => {
1850
1854
  const n = Number(round);
@@ -1861,7 +1865,18 @@ function buildRoundComparison(report, validations) {
1861
1865
  row.findings += 1;
1862
1866
  const sev = typeof f.severity === "string" && f.severity ? f.severity : "unknown";
1863
1867
  row.severities[sev] = (row.severities[sev] || 0) + 1;
1864
- if (f.status === "fixed") row.fixed += 1;
1868
+ }
1869
+ const fixList = Array.isArray(fixes) ? fixes : [];
1870
+ for (const fx of fixList) {
1871
+ if (!fx || typeof fx !== "object") continue;
1872
+ if (
1873
+ /** @type {Record<string, unknown>} */
1874
+ fx.success !== true
1875
+ ) continue;
1876
+ rowsFor(
1877
+ /** @type {Record<string, unknown>} */
1878
+ fx.round
1879
+ ).fixed += 1;
1865
1880
  }
1866
1881
  const vals = Array.isArray(validations) ? validations : [];
1867
1882
  for (const v of vals) {
@@ -2841,9 +2856,17 @@ function ConvergenceDashboard(props) {
2841
2856
  noteSession(session);
2842
2857
  }, [session]);
2843
2858
  const report = latestReport(session);
2859
+ const pulseSeenRef = React.useRef(null);
2844
2860
  React.useEffect(() => {
2845
2861
  if (!report) return;
2846
2862
  const cur = getCurrentRound(report);
2863
+ const key = hashReport(report) + ":" + String(cur);
2864
+ if (pulseSeenRef.current === null) {
2865
+ pulseSeenRef.current = key;
2866
+ return;
2867
+ }
2868
+ if (pulseSeenRef.current === key) return;
2869
+ pulseSeenRef.current = key;
2847
2870
  const conv = report.convergence;
2848
2871
  emitRoundPulse(cur, conv?.converged === true);
2849
2872
  setPulseKey((k) => k + 1);
@@ -3146,6 +3169,7 @@ function TriagePanel(props) {
3146
3169
  setVerdicts((prev) => setAllVerdicts(prev, "keep"));
3147
3170
  setSelectAll(false);
3148
3171
  };
3172
+ const rootRef = React.useRef(null);
3149
3173
  React.useEffect(() => {
3150
3174
  const doc = typeof document !== "undefined" ? document : null;
3151
3175
  if (!doc) return;
@@ -3154,7 +3178,7 @@ function TriagePanel(props) {
3154
3178
  const t2 = ev.target;
3155
3179
  if (t2 && (t2.tagName === "INPUT" || t2.tagName === "TEXTAREA" || t2.tagName === "SELECT")) return;
3156
3180
  if (t2 && typeof t2.isContentEditable === "boolean" && t2.isContentEditable) return;
3157
- const rootEl = doc.querySelector('[data-iterate="triage"]');
3181
+ const rootEl = rootRef.current;
3158
3182
  if (!rootEl) return;
3159
3183
  const activeEl = doc.activeElement;
3160
3184
  if (!activeEl) return;
@@ -3292,7 +3316,7 @@ function TriagePanel(props) {
3292
3316
  });
3293
3317
  return React.createElement(
3294
3318
  "div",
3295
- { "data-iterate-root": "", "data-iterate": "triage", className: "iterate-triage" },
3319
+ { ref: rootRef, "data-iterate-root": "", "data-iterate": "triage", className: "iterate-triage" },
3296
3320
  React.createElement(
3297
3321
  "div",
3298
3322
  { className: "iterate-triage-head" },
@@ -4196,7 +4220,7 @@ function ObservatoryPanel(props) {
4196
4220
  }
4197
4221
  const points = conv.map((n, i) => ({ round: i + 1, count: n }));
4198
4222
  const report = latestReport(session);
4199
- const comparison = buildRoundComparison(report, manifest.validations || []);
4223
+ const comparison = buildRoundComparison(report, manifest.validations || [], manifest.fixes || []);
4200
4224
  const comparisonTable = comparison.length === 0 ? null : React.createElement(
4201
4225
  "table",
4202
4226
  { className: "iterate-obs-table" },
package/lib/parse.js CHANGED
@@ -2281,8 +2281,17 @@ export function stoppedReasonLabel(reason, lang = 'zh') {
2281
2281
  schema_invalid: 'Done \u00b7 repeated schema validation failures in review output',
2282
2282
  no_usable_reviewer_output: 'Done \u00b7 no usable review output',
2283
2283
  }
2284
- if (lang === 'en') return en[reason] ?? (reason ? `Done \u00b7 ${reason}` : 'Done')
2285
- return zh[reason] ?? (reason ? `已结束 · ${reason}` : '已结束')
2284
+ // Object.hasOwn, NOT `zh[reason]`: a model/hand-edited `stoppedReason` of
2285
+ // `__proto__` / `constructor` / `toString` would otherwise resolve through
2286
+ // the PROTOTYPE CHAIN to Object.prototype or a function (never a string) and
2287
+ // crash React ("Objects are not valid as a React child") when the badge
2288
+ // renders it. An own-key hit is the only way to return a table value; any
2289
+ // other value degrades to a plain text label.
2290
+ const table = lang === 'en' ? en : zh
2291
+ if (typeof reason === 'string' && Object.hasOwn(table, reason)) return table[reason]
2292
+ const text = typeof reason === 'string' && reason !== '' ? reason : ''
2293
+ if (lang === 'en') return text ? `Done \u00b7 ${text}` : 'Done'
2294
+ return text ? `已结束 · ${text}` : '已结束'
2286
2295
  }
2287
2296
 
2288
2297
  // ─── Completion notification ────────────────────────────────────────────────
@@ -2391,8 +2400,9 @@ export function buildConfigEditGuide() {
2391
2400
  '---------------------',
2392
2401
  '配置文件:项目根目录 iterate.config.yaml。',
2393
2402
  '',
2394
- '⚠ 未配置 validation.commands 时迭代不被保护:每轮 validate 拿到空结果会被当作',
2395
- ' “验证通过”,等于在没有任何测试保护的情况下继续收敛。第一次启动迭代前,',
2403
+ '⚠ 未配置 validation.commands 时迭代不被保护:该轮会直接中止(fail-closed),',
2404
+ ' 空验证结果/验证未执行绝不当作“验证通过”,运行以 converged_unverified /',
2405
+ ' aborted_by_config 收尾而不是 plain converged。第一次启动迭代前,',
2396
2406
  ' 请先给 validation.commands 配上至少一条真实命令(如 npm test)。',
2397
2407
  '',
2398
2408
  '可编辑字段:',
@@ -2789,14 +2799,19 @@ function pickExportExtras(extra) {
2789
2799
  * One row per round: findings raised, findings fixed, severity mix and the
2790
2800
  * validation command outcomes recorded on the manifest.
2791
2801
  *
2802
+ * `fixed` counts SUCCESSFUL fixes applied in that round, taken from the
2803
+ * transcript's `fixes` (each `{ round, success }`) — the review `findings`
2804
+ * schema carries no `status`, so reading `f.status` here always yielded 0.
2805
+ *
2792
2806
  * Cross-*session* comparison is intentionally out of scope — that is what the
2793
2807
  * #4 pull instruction is for (see `buildDiskSnapshotInstruction`).
2794
2808
  *
2795
2809
  * @param {unknown} report
2796
2810
  * @param {unknown} validations
2811
+ * @param {unknown} [fixes] transcript fixes (`{round, success}[]`) for the fixed counts
2797
2812
  * @returns {Array<{round: number, findings: number, fixed: number, severities: Record<string, number>, validations: Array<{command: string, exitCode: number|null, allowed: boolean}>}>}
2798
2813
  */
2799
- export function buildRoundComparison(report, validations) {
2814
+ export function buildRoundComparison(report, validations, fixes) {
2800
2815
  const rounds = new Map()
2801
2816
  const rowsFor = (round) => {
2802
2817
  const n = Number(round)
@@ -2814,7 +2829,15 @@ export function buildRoundComparison(report, validations) {
2814
2829
  row.findings += 1
2815
2830
  const sev = typeof f.severity === 'string' && f.severity ? f.severity : 'unknown'
2816
2831
  row.severities[sev] = (row.severities[sev] || 0) + 1
2817
- if (f.status === 'fixed') row.fixed += 1
2832
+ }
2833
+
2834
+ // Fixed counts come from applied fixes (real data), not a finding `status`
2835
+ // the findings schema never produces.
2836
+ const fixList = Array.isArray(fixes) ? fixes : []
2837
+ for (const fx of fixList) {
2838
+ if (!fx || typeof fx !== 'object') continue
2839
+ if ((/** @type {Record<string, unknown>} */ (fx)).success !== true) continue
2840
+ rowsFor((/** @type {Record<string, unknown>} */ (fx)).round).fixed += 1
2818
2841
  }
2819
2842
 
2820
2843
  const vals = Array.isArray(validations) ? validations : []
package/package.json CHANGED
@@ -1,17 +1,16 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
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",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/jingzhao-l/iterate-skill.git",
10
- "directory": "harness/iterate-plugin"
9
+ "url": "https://github.com/jingzhao-l/iterate-plugin.git"
11
10
  },
12
- "homepage": "https://github.com/jingzhao-l/iterate-skill/tree/main/harness/iterate-plugin",
11
+ "homepage": "https://github.com/jingzhao-l/iterate-plugin#readme",
13
12
  "bugs": {
14
- "url": "https://github.com/jingzhao-l/iterate-skill/issues"
13
+ "url": "https://github.com/jingzhao-l/iterate-plugin/issues"
15
14
  },
16
15
  "keywords": [
17
16
  "dsh-plugin",
@@ -47,7 +46,9 @@
47
46
  "0.2.0-rc.1": "compatible",
48
47
  "0.2.0-rc.2": "compatible",
49
48
  "0.2.1-alpha.1": "compatible",
50
- "0.2.1-alpha.2": "compatible"
49
+ "0.2.1-alpha.2": "compatible",
50
+ "0.2.1-alpha.3": "compatible",
51
+ "0.2.1-alpha.4": "compatible"
51
52
  }
52
53
  },
53
54
  "client": {
@@ -84,15 +85,31 @@
84
85
  "test:validate": "tsx --test test/validate.test.ts"
85
86
  },
86
87
  "dependencies": {
87
- "@deepseek-ai/cordis": "4.0.5-alpha.1",
88
- "@deepseek-ai/dsh-tools": "0.2.1-alpha.2",
89
- "@deepseek-ai/dsh-util-values": "0.2.1-alpha.2",
90
88
  "js-yaml": "4.3.2"
91
89
  },
90
+ "peerDependencies": {
91
+ "@deepseek-ai/cordis": "^4.0.5-alpha.2",
92
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4",
93
+ "@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4"
94
+ },
95
+ "peerDependenciesMeta": {
96
+ "@deepseek-ai/cordis": {
97
+ "optional": true
98
+ },
99
+ "@deepseek-ai/dsh-tools": {
100
+ "optional": true
101
+ },
102
+ "@deepseek-ai/dsh-util-values": {
103
+ "optional": true
104
+ }
105
+ },
92
106
  "devDependencies": {
93
- "@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
94
- "@deepseek-ai/dsh-jobs": "0.2.1-alpha.2",
95
- "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
107
+ "@deepseek-ai/cordis": "4.0.5-alpha.2",
108
+ "@deepseek-ai/dsh-agent": "0.2.1-alpha.4",
109
+ "@deepseek-ai/dsh-jobs": "0.2.1-alpha.4",
110
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.4",
111
+ "@deepseek-ai/dsh-tools": "0.2.1-alpha.4",
112
+ "@deepseek-ai/dsh-util-values": "0.2.1-alpha.4",
96
113
  "@types/js-yaml": "4.0.9",
97
114
  "@types/node": "22.15.0",
98
115
  "@types/react": "19.2.2",
@@ -104,6 +121,7 @@
104
121
  "typescript": "5.9.3"
105
122
  },
106
123
  "engines": {
107
- "node": ">=20"
124
+ "node": ">=20",
125
+ "dsh": ">=0.1.1-rc.1"
108
126
  }
109
127
  }
@@ -1054,9 +1054,22 @@ function ConvergenceDashboard(props: SlotProps) {
1054
1054
  }, [session])
1055
1055
  const report = latestReport(session)
1056
1056
 
1057
+ // Mount-time suppression: opening a session that ALREADY has a report mounts
1058
+ // this component with that report, and an effect keyed on the report hash
1059
+ // fires immediately — popping a stale "Round X 完成" bubble for work that did
1060
+ // not just complete. Track the first key this instance sees and skip it; only
1061
+ // a subsequent round progression pulses.
1062
+ const pulseSeenRef = React.useRef<string | null>(null)
1057
1063
  React.useEffect(() => {
1058
1064
  if (!report) return
1059
1065
  const cur = getCurrentRound(report)
1066
+ const key = hashReport(report) + ':' + String(cur)
1067
+ if (pulseSeenRef.current === null) {
1068
+ pulseSeenRef.current = key
1069
+ return
1070
+ }
1071
+ if (pulseSeenRef.current === key) return
1072
+ pulseSeenRef.current = key
1060
1073
  const conv = report.convergence
1061
1074
  emitRoundPulse(cur, conv?.converged === true)
1062
1075
  setPulseKey((k) => k + 1)
@@ -1406,6 +1419,11 @@ function TriagePanel(props: SlotProps) {
1406
1419
  }
1407
1420
 
1408
1421
  // ── Keyboard shortcuts (y / n / a on the selected finding, ↑/↓ to move) ──
1422
+ // Multi-instance scoping: every turn that has a report mounts its OWN triage
1423
+ // panel. Keying off `document.querySelector('[data-iterate="triage"]')` would
1424
+ // return only the FIRST one, so y/n/a/↑/↓ silently dead whenever focus sits
1425
+ // in a later panel. A per-component ref points at THIS instance's root.
1426
+ const rootRef = React.useRef<HTMLDivElement | null>(null)
1409
1427
  React.useEffect(() => {
1410
1428
  const doc = typeof document !== 'undefined' ? document : null
1411
1429
  if (!doc) return
@@ -1417,10 +1435,10 @@ function TriagePanel(props: SlotProps) {
1417
1435
  const t = ev.target as HTMLElement | null
1418
1436
  if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
1419
1437
  if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
1420
- // Only act when focus is INSIDE the triage panel. Everything else keeps
1438
+ // Only act when focus is INSIDE THIS panel. Everything else keeps
1421
1439
  // standard behavior (↑/↓ page scroll, typing in the composer), so we do
1422
1440
  // not hijack document-level keys just because a report is mounted.
1423
- const rootEl = doc.querySelector('[data-iterate="triage"]')
1441
+ const rootEl = rootRef.current
1424
1442
  if (!rootEl) return
1425
1443
  const activeEl = doc.activeElement as HTMLElement | null
1426
1444
  if (!activeEl) return
@@ -1584,7 +1602,7 @@ function TriagePanel(props: SlotProps) {
1584
1602
  )
1585
1603
  })
1586
1604
 
1587
- return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
1605
+ return React.createElement('div', { ref: rootRef, 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
1588
1606
  React.createElement('div', { className: 'iterate-triage-head' },
1589
1607
  React.createElement('span', { role: 'heading', 'aria-level': 3 }, tf(`Iterate · Findings 分诊 ({a0}/{a1})`, { a0: filtered.length, a1: findings.length })),
1590
1608
  React.createElement('span', { className: 'iterate-triage-hint' }, t('y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择')),
@@ -2525,7 +2543,7 @@ function ObservatoryPanel(props: SlotProps) {
2525
2543
  // client has no filesystem access, so `iterate_history` aggregates have to
2526
2544
  // be echoed back into the session first).
2527
2545
  const report = latestReport(session)
2528
- const comparison = buildRoundComparison(report, manifest.validations || [])
2546
+ const comparison = buildRoundComparison(report, manifest.validations || [], manifest.fixes || [])
2529
2547
  const comparisonTable = comparison.length === 0
2530
2548
  ? null
2531
2549
  : React.createElement('table', { className: 'iterate-obs-table' },