iterate-plugin 3.4.0 → 3.5.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.
Files changed (49) hide show
  1. package/README.md +24 -9
  2. package/README.zh-CN.md +19 -9
  3. package/dist/atomic-fs.js +77 -0
  4. package/dist/config-write.js +3 -2
  5. package/dist/git-scope.js +3 -2
  6. package/dist/live.js +5 -4
  7. package/dist/skill-prompt.js +16 -5
  8. package/dist/tools/checkpoint.js +10 -11
  9. package/dist/tools/config.js +14 -0
  10. package/dist/tools/context.js +3 -0
  11. package/dist/tools/decision-log.js +25 -10
  12. package/dist/tools/defense-events.js +3 -0
  13. package/dist/tools/defense-store.js +6 -2
  14. package/dist/tools/experience-bank.js +3 -0
  15. package/dist/tools/experience-store.js +6 -2
  16. package/dist/tools/fix.js +34 -4
  17. package/dist/tools/history.js +3 -0
  18. package/dist/tools/prune.js +50 -6
  19. package/dist/tools/quality-gate.js +3 -0
  20. package/dist/tools/quality-store.js +2 -1
  21. package/dist/tools/transcript.js +7 -5
  22. package/dist/tools/triage.js +6 -2
  23. package/dist/tools/validate.js +32 -3
  24. package/lib/client.js +52 -9
  25. package/lib/parse.js +53 -6
  26. package/package.json +4 -2
  27. package/src/atomic-fs.ts +83 -0
  28. package/src/client/index.ts +12 -4
  29. package/src/config-write.ts +3 -2
  30. package/src/git-scope.ts +3 -2
  31. package/src/live.ts +5 -4
  32. package/src/skill-prompt.ts +16 -5
  33. package/src/tools/checkpoint.ts +10 -11
  34. package/src/tools/config.ts +14 -0
  35. package/src/tools/context.ts +3 -0
  36. package/src/tools/decision-log.ts +35 -10
  37. package/src/tools/defense-events.ts +3 -0
  38. package/src/tools/defense-store.ts +6 -2
  39. package/src/tools/experience-bank.ts +3 -0
  40. package/src/tools/experience-store.ts +6 -2
  41. package/src/tools/fix.ts +32 -4
  42. package/src/tools/history.ts +3 -0
  43. package/src/tools/prune.ts +51 -6
  44. package/src/tools/quality-gate.ts +3 -0
  45. package/src/tools/quality-store.ts +2 -1
  46. package/src/tools/transcript.ts +7 -5
  47. package/src/tools/triage.ts +6 -2
  48. package/src/tools/validate.ts +32 -3
  49. package/src/types.ts +2 -0
package/dist/tools/fix.js CHANGED
@@ -16,7 +16,8 @@
16
16
  * - Backups are written before any write, so a failure never destroys data.
17
17
  * - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
18
18
  */
19
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
20
+ import { writeJsonAtomic, writeTextAtomic } from "../atomic-fs.js";
20
21
  import { join, sep } from 'node:path';
21
22
  import { defineTool } from '@deepseek-ai/dsh-tools';
22
23
  import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
@@ -276,6 +277,20 @@ function readProjectFile(projectRoot, file) {
276
277
  export function registerFixTool(ctx) {
277
278
  ctx.tools.register(defineTool({
278
279
  name: 'iterate_fix',
280
+ // Pending-call card: surface the file about to change as an inline diff.
281
+ // oldText is null — a call-time presenter has no access to the file's
282
+ // prior content (an overwrite, per the presentation contract).
283
+ presentCall: (args) => {
284
+ const a = args;
285
+ if (typeof a.file !== 'string' || typeof a.content !== 'string')
286
+ return undefined;
287
+ return {
288
+ card: 'diff',
289
+ title: `Fix ${a.file}`,
290
+ diffs: [{ path: a.file, oldText: null, newText: a.content }],
291
+ locations: [{ path: a.file }],
292
+ };
293
+ },
279
294
  description: 'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
280
295
  'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
281
296
  'The tool backs up the original, enforces the atomic `max_lines` and `max_adjacent_methods` thresholds (unless `force`), ' +
@@ -445,7 +460,7 @@ export function registerFixTool(ctx) {
445
460
  return { ok: false, error: `failed to create backup: ${String(err)}` };
446
461
  }
447
462
  try {
448
- writeFileSync(target.resolved, args.content, 'utf-8');
463
+ writeTextAtomic(target.resolved, args.content);
449
464
  }
450
465
  catch (err) {
451
466
  return { ok: false, error: `failed to write file: ${String(err)}` };
@@ -463,7 +478,7 @@ export function registerFixTool(ctx) {
463
478
  };
464
479
  const nextRegistry = upsertRecord(registry, record);
465
480
  try {
466
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
481
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
467
482
  }
468
483
  catch (err) {
469
484
  // Registry write failed → the file was already modified but no record
@@ -511,6 +526,9 @@ export function registerFixTool(ctx) {
511
526
  export function registerDiffTool(ctx) {
512
527
  ctx.tools.register(defineTool({
513
528
  name: 'iterate_diff',
529
+ // Read-only (never writes project files or plugin state) → safe to join
530
+ // a parallel dispatch group alongside other read-only sibling calls.
531
+ isConcurrencySafe: () => true,
514
532
  description: 'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
515
533
  'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
516
534
  parameters: {
@@ -612,6 +630,18 @@ export function registerDiffTool(ctx) {
612
630
  export function registerRollbackTool(ctx) {
613
631
  ctx.tools.register(defineTool({
614
632
  name: 'iterate_rollback',
633
+ // Pending-call card: which fix is about to be reverted.
634
+ presentCall: (args) => {
635
+ const a = args;
636
+ if (typeof a.id !== 'string' || a.id.length === 0)
637
+ return undefined;
638
+ return {
639
+ card: 'generic',
640
+ title: `Rollback fix ${a.id}`,
641
+ kind: 'edit',
642
+ rawInput: { id: a.id },
643
+ };
644
+ },
615
645
  description: 'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
616
646
  'The file is restored from the fix backup, the fix is removed from the registry, ' +
617
647
  'and a `revert` entry is appended to the decision log. Use when a round\'s validation fails.',
@@ -667,7 +697,7 @@ export function registerRollbackTool(ctx) {
667
697
  }
668
698
  const nextRegistry = removeRecord(registry, id);
669
699
  try {
670
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
700
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry);
671
701
  }
672
702
  catch (err) {
673
703
  return { ok: false, error: `failed to update fix registry: ${String(err)}` };
@@ -64,6 +64,9 @@ export function summarizeFixRegistry(registry) {
64
64
  export function registerHistoryTool(ctx) {
65
65
  ctx.tools.register(defineTool({
66
66
  name: 'iterate_history',
67
+ // Read-only audit view over on-disk state → safe to join a parallel
68
+ // dispatch group alongside other read-only sibling calls.
69
+ isConcurrencySafe: () => true,
67
70
  description: 'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
68
71
  'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
69
72
  'Read-only — use it to review what the run did, audit a log, or inspect fixes.',
@@ -11,16 +11,19 @@
11
11
  * - Fix backups left over from old rounds (backups whose fix-id no longer
12
12
  * appears in the registry).
13
13
  * - Empty fix rounds (rounds with 0 records).
14
+ * - Stray temp files left behind by a crashed atomic write (see
15
+ * `isPrunableTemp` for the recognized naming conventions).
14
16
  *
15
17
  * Security model:
16
18
  * - Only operates under the resolved project `.iterate/` directory.
17
19
  * - dryRun=true by default — the caller must explicitly opt into deletion.
18
20
  * - Each deletion is logged to the decision log (when not dry-run).
19
21
  */
20
- import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
22
+ import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
21
23
  import { join } from 'node:path';
22
24
  import { defineTool } from '@deepseek-ai/dsh-tools';
23
25
  import { resolveProjectRootForExec } from "../config-loader.js";
26
+ import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
24
27
  import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
25
28
  import { readRegistry, recomputeRoundCounts } from "./fix.js";
26
29
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from "../paths.js";
@@ -41,6 +44,28 @@ export function cutoffTimestamp(retainDays) {
41
44
  d.setDate(d.getDate() - retainDays);
42
45
  return d.toISOString();
43
46
  }
47
+ /**
48
+ * Whether a file name inside `.iterate/` is a prunable leftover temp file.
49
+ *
50
+ * Recognized conventions (all written to the SAME directory as their target,
51
+ * so they can never be confused with real state files):
52
+ * - `.<name>.tmp-<pid>-<rand>` — current atomic-fs.ts temp prefix;
53
+ * - `.tmp-<pid>-<rand>` — legacy temp prefix (older plugin builds);
54
+ * - `<name>.tmp` — legacy transcript.ts / live.ts temp suffix.
55
+ *
56
+ * Pure and exported for unit tests. A live writer's temp file is only visible
57
+ * to it (unique name), so deleting any match is always safe.
58
+ */
59
+ export function isPrunableTemp(name) {
60
+ if (typeof name !== 'string' || name.length === 0 || name === '.' || name === '..')
61
+ return false;
62
+ if (name.startsWith('.tmp-'))
63
+ return true;
64
+ if (name.endsWith('.tmp'))
65
+ return true;
66
+ // .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
67
+ return name.startsWith('.') && name.includes('.tmp-');
68
+ }
44
69
  /**
45
70
  * Inspect the runtime state and report what would be pruned.
46
71
  * Pure (no deletions). Returns a structured report.
@@ -79,10 +104,17 @@ export function inspectPrune(projectRoot, retainDays) {
79
104
  const emptyRounds = registry.rounds
80
105
  .filter((r) => r.records.length === 0)
81
106
  .map((r) => r.round);
107
+ // 5. Stray atomic-write temp files left behind by a crashed writer. The
108
+ // 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
+ : [];
82
113
  return {
83
114
  oldLogEntries,
84
115
  hasCheckpoint,
85
116
  staleBackups,
117
+ staleTemps,
86
118
  emptyRounds,
87
119
  totalLogEntries: entries.length,
88
120
  registryRounds: registry.rounds.length,
@@ -98,6 +130,7 @@ export function executePrune(projectRoot, retainDays, report) {
98
130
  deletedLogEntries: 0,
99
131
  deletedCheckpoint: false,
100
132
  deletedBackups: [],
133
+ deletedTemps: [],
101
134
  trimmedEmptyRounds: 0,
102
135
  errors: [],
103
136
  };
@@ -109,9 +142,7 @@ export function executePrune(projectRoot, retainDays, report) {
109
142
  result.deletedLogEntries = entries.length - kept.length;
110
143
  if (result.deletedLogEntries > 0) {
111
144
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
112
- const tmpPath = `${logPath}.tmp-${Date.now()}`;
113
- writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
114
- renameSync(tmpPath, logPath);
145
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
115
146
  }
116
147
  }
117
148
  catch (err) {
@@ -151,13 +182,23 @@ export function executePrune(projectRoot, retainDays, report) {
151
182
  rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
152
183
  };
153
184
  registry = recomputeRoundCounts(registry);
154
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8');
185
+ writeJsonAtomic(fixRegistryPath(projectRoot), registry);
155
186
  result.trimmedEmptyRounds = report.emptyRounds.length;
156
187
  }
157
188
  catch (err) {
158
189
  result.errors.push(`failed to trim empty rounds: ${String(err)}`);
159
190
  }
160
191
  }
192
+ // 5. Remove stray atomic-write temp files.
193
+ for (const tmp of report.staleTemps) {
194
+ try {
195
+ rmSync(join(iterateDir(projectRoot), tmp), { force: true });
196
+ result.deletedTemps.push(tmp);
197
+ }
198
+ catch (err) {
199
+ result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`);
200
+ }
201
+ }
161
202
  return result;
162
203
  }
163
204
  /**
@@ -170,7 +211,8 @@ export function registerPruneTool(ctx) {
170
211
  name: 'iterate_prune',
171
212
  description: 'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
172
213
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
173
- 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds. ' +
214
+ 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
215
+ 'and stray temp files left by crashed atomic writes. ' +
174
216
  'Each deletion is logged to the decision log.',
175
217
  parameters: {
176
218
  dryRun: {
@@ -210,6 +252,7 @@ export function registerPruneTool(ctx) {
210
252
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
211
253
  ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
212
254
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
255
+ ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
213
256
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
214
257
  '',
215
258
  'Pass dryRun:false to execute the prune.',
@@ -221,6 +264,7 @@ export function registerPruneTool(ctx) {
221
264
  ` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
222
265
  ` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
223
266
  ` Deleted ${result?.deletedBackups?.length ?? 0} stale backups.`,
267
+ ` Deleted ${result?.deletedTemps?.length ?? 0} stray temp files.`,
224
268
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
225
269
  ];
226
270
  const errs = result?.errors ?? [];
@@ -59,6 +59,9 @@ function sanitizeRoundSeries(raw) {
59
59
  export function registerQualityGateTool(ctx) {
60
60
  ctx.tools.register(defineTool({
61
61
  name: 'iterate_quality_gate',
62
+ // `read` never writes; `compute` persists the snapshot → only read
63
+ // joins a parallel dispatch group.
64
+ isConcurrencySafe: (args) => args.operation === 'read',
62
65
  description: 'Query or write the quality gate status: dimension convergence rates, verification pass rates, ' +
63
66
  'and overall PASS/FAIL status. ' +
64
67
  'Operation "read" (default) returns the persisted machine-readable quality certificate. ' +
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
+ import { writeJsonAtomic } from "../atomic-fs.js";
10
11
  const QUALITY_GATE_FILE = 'quality-gate.json';
11
12
  /** Default empty quality gate snapshot. */
12
13
  function emptySnapshot() {
@@ -96,7 +97,7 @@ export function writeQualityGate(projectRoot, snapshot) {
96
97
  if (!fs.existsSync(dirPath)) {
97
98
  fs.mkdirSync(dirPath, { recursive: true });
98
99
  }
99
- fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2), 'utf-8');
100
+ writeJsonAtomic(filePath, snapshot);
100
101
  }
101
102
  catch (err) {
102
103
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` };
@@ -18,8 +18,9 @@
18
18
  * tmp+rename so a crashed writer never leaves a corrupt manifest.
19
19
  */
20
20
  import { defineTool } from '@deepseek-ai/dsh-tools';
21
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
21
+ import { mkdir, readFile } from 'node:fs/promises';
22
22
  import { existsSync } from 'node:fs';
23
+ import { writeTextAtomicAsync } from "../atomic-fs.js";
23
24
  import { dirname } from 'node:path';
24
25
  import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
25
26
  import { transcriptPath } from "../paths.js";
@@ -96,6 +97,9 @@ function normalizeFix(input) {
96
97
  export function registerTranscriptTool(ctx) {
97
98
  ctx.tools.register(defineTool({
98
99
  name: 'iterate_transcript',
100
+ // `read` never writes; `capture`/`nudge` persist transcript state → only
101
+ // read joins a parallel dispatch group.
102
+ isConcurrencySafe: (args) => args.operation === 'read',
99
103
  description: 'Runtime-observatory transcript for the iterate workflow. ' +
100
104
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
101
105
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
@@ -322,10 +326,8 @@ function rehydrateBuilder(manifest, approval) {
322
326
  builder.finish();
323
327
  return builder;
324
328
  }
325
- /** Atomically persist a manifest (tmp + rename) under `.iterate/`. */
329
+ /** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
326
330
  async function persist(file, manifest) {
327
331
  await mkdir(dirname(file), { recursive: true });
328
- const tmp = `${file}.tmp`;
329
- await writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf-8');
330
- await rename(tmp, file);
332
+ await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2));
331
333
  }
@@ -1,8 +1,9 @@
1
- import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import yaml from 'js-yaml';
5
5
  import { resolveProjectRootForExec } from "../config-loader.js";
6
+ import { writeTextAtomic } from "../atomic-fs.js";
6
7
  const CONFIG_FILE = 'iterate.config.yaml';
7
8
  /** Personalization key that holds the known-intentional list. */
8
9
  const PERSONALIZATION_KEY = 'personalization';
@@ -191,7 +192,7 @@ function applyEntries(projectRoot, incoming) {
191
192
  }
192
193
  const yamlText = yaml.dump(nextConfig, { noRefs: true });
193
194
  try {
194
- writeFileSync(configPath, yamlText, 'utf-8');
195
+ writeTextAtomic(configPath, yamlText);
195
196
  }
196
197
  catch (err) {
197
198
  // Rollback: restore the backup, or REMOVE the file we just created when
@@ -231,6 +232,9 @@ function applyEntries(projectRoot, incoming) {
231
232
  export function registerTriageTool(ctx) {
232
233
  ctx.tools.register(defineTool({
233
234
  name: 'iterate_triage',
235
+ // `list` is a pure config read; `apply` rewrites iterate.config.yaml →
236
+ // only list joins a parallel dispatch group.
237
+ isConcurrencySafe: (args) => args.operation === 'list',
234
238
  description: 'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
235
239
  'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
236
240
  'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
@@ -19,8 +19,14 @@ export function clampTimeout(ms) {
19
19
  /**
20
20
  * Run a single shell command with timeout and return structured results.
21
21
  * Pure function (no side effects beyond the exec call).
22
+ *
23
+ * `signal` (the caller's `exec.signal` per the dsh tools contract) is forwarded
24
+ * to the child process so a cancelled tool call kills the running command
25
+ * instead of pinning the dispatch open until the timeout elapses. The result
26
+ * distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
27
+ * `timeoutMs`" (`timedOut: true`).
22
28
  */
23
- async function runCommand(command, cwd, timeoutMs) {
29
+ export async function runCommand(command, cwd, timeoutMs, signal) {
24
30
  const start = performance.now();
25
31
  return new Promise((resolve) => {
26
32
  exec(command, {
@@ -28,18 +34,23 @@ async function runCommand(command, cwd, timeoutMs) {
28
34
  timeout: timeoutMs,
29
35
  maxBuffer: 10 * 1024 * 1024, // 10 MB
30
36
  env: { ...process.env, PAGER: 'cat' },
37
+ signal,
31
38
  }, (error, stdout, stderr) => {
32
39
  const durationMs = Math.round(performance.now() - start);
33
40
  // error.code is the exit code when the command ran; when the binary
34
41
  // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
35
42
  // Coerce to a number so the integer output schema is never violated.
36
43
  const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0);
44
+ const canceled = signal?.aborted === true;
37
45
  resolve({
38
46
  command,
39
47
  exitCode,
40
48
  stdout: stdout ?? '',
41
49
  stderr: stderr ?? '',
42
- timedOut: error?.killed === true,
50
+ // A caller cancellation also kills the child (killed === true), but
51
+ // that is not a timeout — only report timedOut for the deadline path.
52
+ timedOut: error?.killed === true && !canceled,
53
+ canceled,
43
54
  durationMs,
44
55
  });
45
56
  });
@@ -53,6 +64,19 @@ async function runCommand(command, cwd, timeoutMs) {
53
64
  export function registerValidateTool(ctx) {
54
65
  ctx.tools.register(defineTool({
55
66
  name: 'iterate_validate',
67
+ // Pending-call card: render the pending validation as a terminal card.
68
+ // Pure — derived from args only; no `cwd` (the UI bridge resolves the
69
+ // relative command against the session workspace).
70
+ presentCall: (args) => {
71
+ const a = args;
72
+ if (typeof a.command !== 'string' || a.command.length === 0)
73
+ return undefined;
74
+ return {
75
+ card: 'terminal',
76
+ title: a.command,
77
+ description: 'Run a preconfigured iterate validation command (exact match on validation.commands).',
78
+ };
79
+ },
56
80
  description: 'Run a validation command that is PRECONFIGURED in iterate.config.yaml `validation.commands`. ' +
57
81
  'The command must exactly match one of the configured commands (they are the only ones the user trusts). ' +
58
82
  'Returns exit code, stdout, stderr, and duration. ' +
@@ -83,6 +107,7 @@ export function registerValidateTool(ctx) {
83
107
  stdout: { type: 'string', required: true },
84
108
  stderr: { type: 'string', required: true },
85
109
  timedOut: { type: 'boolean', required: true },
110
+ canceled: { type: 'boolean', required: true },
86
111
  durationMs: { type: 'integer', required: true },
87
112
  rejectReason: { type: 'string' },
88
113
  },
@@ -96,6 +121,7 @@ export function registerValidateTool(ctx) {
96
121
  `Exit code: ${value.exitCode}`,
97
122
  `Duration: ${value.durationMs}ms`,
98
123
  value.timedOut ? '⚠ Timed out' : '',
124
+ value.canceled ? '⚠ Cancelled before completion' : '',
99
125
  '',
100
126
  value.stdout ? `[stdout]\n${value.stdout}` : '',
101
127
  value.stderr ? `[stderr]\n${value.stderr}` : '',
@@ -116,6 +142,7 @@ export function registerValidateTool(ctx) {
116
142
  stdout: '',
117
143
  stderr: '',
118
144
  timedOut: false,
145
+ canceled: false,
119
146
  durationMs: 0,
120
147
  rejectReason: resolved.reason,
121
148
  };
@@ -137,6 +164,7 @@ export function registerValidateTool(ctx) {
137
164
  stdout: '',
138
165
  stderr: '',
139
166
  timedOut: false,
167
+ canceled: false,
140
168
  durationMs: 0,
141
169
  rejectReason: (source === 'defaults'
142
170
  ? 'No iterate.config.yaml at project root — running on built-in defaults, which configure NO trusted validation commands. '
@@ -152,12 +180,13 @@ export function registerValidateTool(ctx) {
152
180
  stdout: '',
153
181
  stderr: '',
154
182
  timedOut: false,
183
+ canceled: false,
155
184
  durationMs: 0,
156
185
  rejectReason: `Command must exactly match a command predefined in iterate.config.yaml validation.commands. ` +
157
186
  `Allowed commands: ${predefinedCommands.join(' | ')}`,
158
187
  };
159
188
  }
160
- const result = await runCommand(args.command, projectRoot, timeout);
189
+ const result = await runCommand(args.command, projectRoot, timeout, exec.signal);
161
190
  return {
162
191
  allowed: true,
163
192
  ...result,
package/lib/client.js CHANGED
@@ -115,10 +115,11 @@ function scanSessionForResume(obj, seen, maxDepth = 20) {
115
115
  function countSessionImages(session) {
116
116
  if (!session || typeof session !== "object") return 0;
117
117
  const ids = /* @__PURE__ */ new Set();
118
+ const consumed = /* @__PURE__ */ new Set();
118
119
  let count = 0;
119
120
  const walk = (obj, depth) => {
120
121
  if (depth <= 0 || !obj || typeof obj !== "object") return;
121
- if (seen.has(obj)) return;
122
+ if (seen.has(obj) || consumed.has(obj)) return;
122
123
  seen.add(obj);
123
124
  const o = (
124
125
  /** @type {Record<string, unknown>} */
@@ -142,6 +143,7 @@ function countSessionImages(session) {
142
143
  } else {
143
144
  count += 1;
144
145
  }
146
+ consumed.add(ref);
145
147
  }
146
148
  if (Array.isArray(obj)) {
147
149
  for (const item of obj) walk(item, depth - 1);
@@ -289,16 +291,21 @@ function attachLive(manifest, source) {
289
291
  copy.live = live;
290
292
  return copy;
291
293
  }
292
- function extractTranscript(obj) {
294
+ function extractTranscript(obj, seen, depth) {
295
+ if (depth === void 0) depth = 0;
296
+ if (depth > 20) return null;
293
297
  if (typeof obj === "string") {
294
298
  try {
295
299
  const parsed = JSON.parse(obj);
296
- return extractTranscript(parsed);
300
+ return extractTranscript(parsed, seen, depth + 1);
297
301
  } catch {
298
302
  return null;
299
303
  }
300
304
  }
301
305
  if (!obj || typeof obj !== "object") return null;
306
+ if (!seen) seen = /* @__PURE__ */ new Set();
307
+ if (seen.has(obj)) return null;
308
+ seen.add(obj);
302
309
  if (isTranscriptManifest(obj)) return (
303
310
  /** @type {Record<string, unknown>} */
304
311
  obj
@@ -322,11 +329,11 @@ function extractTranscript(obj) {
322
329
  if (val !== void 0) {
323
330
  if (Array.isArray(val)) {
324
331
  for (const item of val) {
325
- const found = extractTranscript(item);
332
+ const found = extractTranscript(item, seen, depth + 1);
326
333
  if (found) return attachLive(found, o);
327
334
  }
328
335
  } else {
329
- const found = extractTranscript(val);
336
+ const found = extractTranscript(val, seen, depth + 1);
330
337
  if (found) return attachLive(found, o);
331
338
  }
332
339
  }
@@ -569,6 +576,24 @@ function latestToolResultNode(session, toolName) {
569
576
  /** @type {Array<Record<string, unknown>>} */
570
577
  messages
571
578
  );
579
+ for (let i = msgs.length - 1; i >= 0; i--) {
580
+ const msg = msgs[i];
581
+ if (!msg) continue;
582
+ const calls = safeGet(msg, "tool_calls");
583
+ if (!Array.isArray(calls)) continue;
584
+ const callList = (
585
+ /** @type {Array<Record<string, unknown>>} */
586
+ calls
587
+ );
588
+ for (let j = callList.length - 1; j >= 0; j--) {
589
+ const call = callList[j];
590
+ if (!call) continue;
591
+ const name2 = String(safeGet(call, "name") ?? safeGet(call, "tool") ?? "");
592
+ if (name2 !== toolName && !name2.endsWith(toolName)) continue;
593
+ const result = safeGet(call, "result") ?? safeGet(call, "response") ?? safeGet(call, "message");
594
+ if (result !== void 0 && result !== null) return result;
595
+ }
596
+ }
572
597
  for (let i = msgs.length - 1; i >= 0; i--) {
573
598
  const msg = msgs[i];
574
599
  if (!msg) continue;
@@ -895,6 +920,7 @@ function computeSummaryFromFindings(findings) {
895
920
  const counts = { critical: 0, high: 0, medium: 0, low: 0 };
896
921
  const byDimension = {};
897
922
  for (const f of findings) {
923
+ if (!f || typeof f !== "object") continue;
898
924
  const sev = String(f.severity ?? "low");
899
925
  if (sev in counts) counts[sev]++;
900
926
  const dim = String(f.dimension ?? "unknown");
@@ -942,6 +968,7 @@ function severityStats(report) {
942
968
  );
943
969
  const counts = { critical: 0, high: 0, medium: 0, low: 0 };
944
970
  for (const f of findings) {
971
+ if (!f || typeof f !== "object") continue;
945
972
  const sev = String(f.severity ?? "low");
946
973
  if (sev in counts) counts[sev]++;
947
974
  }
@@ -954,6 +981,7 @@ function groupByDimension(report) {
954
981
  );
955
982
  const groups = {};
956
983
  for (const f of findings) {
984
+ if (!f || typeof f !== "object") continue;
957
985
  const dim = String(f.dimension ?? "unknown");
958
986
  if (!groups[dim]) groups[dim] = [];
959
987
  groups[dim].push(f);
@@ -1048,6 +1076,7 @@ function normalizeFindingFilter(filter) {
1048
1076
  return { severities, dimensions, search };
1049
1077
  }
1050
1078
  function findingMatches(finding, filter) {
1079
+ if (!finding || typeof finding !== "object") return false;
1051
1080
  const f = normalizeFindingFilter(filter);
1052
1081
  const sev = String(finding.severity ?? "low");
1053
1082
  if (f.severities.length > 0 && !f.severities.includes(sev)) return false;
@@ -1118,6 +1147,7 @@ function setAllVerdicts(triageState, verdict, indices) {
1118
1147
  function buildRoundHistory(report) {
1119
1148
  const rounds = Array.isArray(report.rounds) ? report.rounds : [];
1120
1149
  return rounds.map((r) => {
1150
+ if (!r || typeof r !== "object") return { round: 0, count: 0, critical: 0, high: 0, medium: 0, low: 0 };
1121
1151
  const rr = (
1122
1152
  /** @type {Record<string, unknown>} */
1123
1153
  r
@@ -1274,7 +1304,13 @@ function filterTimelineEntries(entries, opts) {
1274
1304
  if (type && String(t.type ?? "") !== type) return false;
1275
1305
  if (round && String(t.round ?? "") !== round) return false;
1276
1306
  if (q) {
1277
- const hay = [String(t.type ?? ""), String(t.round ?? ""), JSON.stringify(t.data ?? {})].join(" ").toLowerCase();
1307
+ let dataText = "";
1308
+ try {
1309
+ dataText = JSON.stringify(t.data ?? {});
1310
+ } catch {
1311
+ dataText = t.data === void 0 ? "{}" : String(t.data);
1312
+ }
1313
+ const hay = [String(t.type ?? ""), String(t.round ?? ""), dataText].join(" ").toLowerCase();
1278
1314
  if (hay.indexOf(q) < 0) return false;
1279
1315
  }
1280
1316
  return true;
@@ -2078,7 +2114,7 @@ function TriagePanel(props) {
2078
2114
  const ignoredCount = ignored.length;
2079
2115
  const counts = countVerdicts(verdicts);
2080
2116
  const doApproveArchitecturalFix = () => {
2081
- const cmd = "\u8BF7\u8C03\u7528 `iterate_fix` \u6279\u51C6\u67B6\u6784\u4FEE\u590D\uFF08\u8BBE\u7F6E is_architectural: true\uFF09";
2117
+ const cmd = "\u8BF7\u8C03\u7528 `iterate_fix`\uFF0C\u5BF9\u672C\u9879\u76EE\u5269\u4F59\u67B6\u6784\u578B finding \u751F\u6210\u4FEE\u590D\u5185\u5BB9\u5E76\u8BBE\u7F6E force: true \u5E94\u7528\uFF08\u67B6\u6784\u4FEE\u590D\u5141\u8BB8\u8D85\u8FC7 atomic \u9608\u503C\uFF09";
2082
2118
  copyText(cmd).then((ok) => {
2083
2119
  if (ok) {
2084
2120
  setCmdCopied("approve-arch");
@@ -2096,7 +2132,7 @@ function TriagePanel(props) {
2096
2132
  });
2097
2133
  };
2098
2134
  const doRollbackToCheckpoint = () => {
2099
- const cmd = "\u8BF7\u8C03\u7528 `iterate_checkpoint` \u56DE\u6EDA\u5230\u4E0A\u4E00\u4E2A\u68C0\u67E5\u70B9";
2135
+ const cmd = "\u8BF7\u56DE\u6EDA\u4E0A\u4E00\u8F6E\u8FED\u4EE3\u7684\u4FEE\u590D\uFF1A\u8C03\u7528 `iterate_history` \u67E5\u770B\u672C\u8F6E fix id\uFF0C\u518D\u7528 `iterate_rollback` \u9010\u4E2A\u64A4\u9500\u8FD9\u4E9B\u4FEE\u590D";
2100
2136
  copyText(cmd).then((ok) => {
2101
2137
  if (ok) {
2102
2138
  setCmdCopied("rollback");
@@ -3180,7 +3216,14 @@ ${JSON.stringify({ operation: "nudge", text: null }, null, 2)}
3180
3216
  const sorted = filtered.slice();
3181
3217
  const rows = sorted.map((t, i) => {
3182
3218
  const k = `f7-${i}`;
3183
- const dataText = t.data && typeof t.data === "object" ? JSON.stringify(t.data) : "";
3219
+ let dataText = "";
3220
+ if (t.data && typeof t.data === "object") {
3221
+ try {
3222
+ dataText = JSON.stringify(t.data);
3223
+ } catch {
3224
+ dataText = String(t.data);
3225
+ }
3226
+ }
3184
3227
  return React.createElement(
3185
3228
  "div",
3186
3229
  { key: k, className: "iterate-obs-row", style: { flexDirection: "column", alignItems: "flex-start" } },