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
@@ -7,6 +7,8 @@
7
7
 
8
8
  import * as fs from 'node:fs'
9
9
  import * as path from 'node:path'
10
+ import { iterateDir } from '../paths.ts'
11
+ import { writeJsonAtomic } from '../atomic-fs.ts'
10
12
  import type { ExperienceBank, ExperienceEntry } from '../types.ts'
11
13
 
12
14
  const EXPERIENCE_FILE = 'experience.json'
@@ -45,14 +47,16 @@ export function writeExperienceBank(
45
47
  projectRoot: string,
46
48
  bank: ExperienceBank,
47
49
  ): { ok: true } | { ok: false; error: string } {
48
- const dirPath = path.join(projectRoot, '.iterate')
50
+ const dirPath = iterateDir(projectRoot)
49
51
  const filePath = path.join(dirPath, EXPERIENCE_FILE)
50
52
 
51
53
  try {
52
54
  if (!fs.existsSync(dirPath)) {
53
55
  fs.mkdirSync(dirPath, { recursive: true })
54
56
  }
55
- fs.writeFileSync(filePath, JSON.stringify(bank, null, 2), 'utf-8')
57
+ // Atomic (temp + rename): a crash mid-write can never leave a truncated
58
+ // experience bank behind.
59
+ writeJsonAtomic(filePath, bank)
56
60
  } catch (err) {
57
61
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` }
58
62
  }
package/src/tools/fix.ts CHANGED
@@ -17,7 +17,8 @@
17
17
  * - Atomicity is enforced against `config.atomic.max_lines` unless `force`.
18
18
  */
19
19
 
20
- import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
20
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
21
+ import { writeJsonAtomic, writeTextAtomic } from '../atomic-fs.ts'
21
22
  import { join, sep } from 'node:path'
22
23
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
24
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -277,6 +278,19 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
277
278
  ctx.tools.register(
278
279
  defineTool({
279
280
  name: 'iterate_fix',
281
+ // Pending-call card: surface the file about to change as an inline diff.
282
+ // oldText is null — a call-time presenter has no access to the file's
283
+ // prior content (an overwrite, per the presentation contract).
284
+ presentCall: (args) => {
285
+ const a = args as { file?: unknown; content?: unknown }
286
+ if (typeof a.file !== 'string' || typeof a.content !== 'string') 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
+ },
280
294
  description:
281
295
  'Apply ONE atomic fix to a file. Pass the target relative `file`, the finding that motivated ' +
282
296
  'the fix, the NEW full `content` of that file (after your edit), and the current `round`. ' +
@@ -454,7 +468,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
454
468
  }
455
469
 
456
470
  try {
457
- writeFileSync(target.resolved, args.content, 'utf-8')
471
+ writeTextAtomic(target.resolved, args.content)
458
472
  } catch (err) {
459
473
  return { ok: false, error: `failed to write file: ${String(err)}` }
460
474
  }
@@ -472,7 +486,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
472
486
  }
473
487
  const nextRegistry = upsertRecord(registry, record)
474
488
  try {
475
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
489
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry)
476
490
  } catch (err) {
477
491
  // Registry write failed → the file was already modified but no record
478
492
  // exists, so a later rollback/diff could never see it and a retry would
@@ -524,6 +538,9 @@ export function registerDiffTool(ctx: { tools: { register: (def: ReturnType<type
524
538
  ctx.tools.register(
525
539
  defineTool({
526
540
  name: 'iterate_diff',
541
+ // Read-only (never writes project files or plugin state) → safe to join
542
+ // a parallel dispatch group alongside other read-only sibling calls.
543
+ isConcurrencySafe: () => true,
527
544
  description:
528
545
  'Show the changes made by iterate fixes. With `file`, returns the unified diff of the current ' +
529
546
  'file content vs its original (first backup). Without `file`, returns a summary of every fixed file.',
@@ -627,6 +644,17 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
627
644
  ctx.tools.register(
628
645
  defineTool({
629
646
  name: 'iterate_rollback',
647
+ // Pending-call card: which fix is about to be reverted.
648
+ presentCall: (args) => {
649
+ const a = args as { id?: unknown }
650
+ if (typeof a.id !== 'string' || a.id.length === 0) return undefined
651
+ return {
652
+ card: 'generic',
653
+ title: `Rollback fix ${a.id}`,
654
+ kind: 'edit',
655
+ rawInput: { id: a.id },
656
+ }
657
+ },
630
658
  description:
631
659
  'Revert a previously applied fix. Pass the fix `id` (returned by iterate_fix). ' +
632
660
  'The file is restored from the fix backup, the fix is removed from the registry, ' +
@@ -683,7 +711,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
683
711
 
684
712
  const nextRegistry = removeRecord(registry, id)
685
713
  try {
686
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8')
714
+ writeJsonAtomic(fixRegistryPath(projectRoot), nextRegistry)
687
715
  } catch (err) {
688
716
  return { ok: false, error: `failed to update fix registry: ${String(err)}` }
689
717
  }
@@ -80,6 +80,9 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
80
80
  ctx.tools.register(
81
81
  defineTool({
82
82
  name: 'iterate_history',
83
+ // Read-only audit view over on-disk state → safe to join a parallel
84
+ // dispatch group alongside other read-only sibling calls.
85
+ isConcurrencySafe: () => true,
83
86
  description:
84
87
  'Read the iteration history: decision-log entries (optionally filtered by entry `type`, `since` ' +
85
88
  'timestamp, and a `limit`) plus a summary of the fix registry (per-round fixed/failed counts). ' +
@@ -11,6 +11,8 @@
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.
@@ -18,11 +20,12 @@
18
20
  * - Each deletion is logged to the decision log (when not dry-run).
19
21
  */
20
22
 
21
- import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
23
+ import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs'
22
24
  import { join } from 'node:path'
23
25
  import { defineTool } from '@deepseek-ai/dsh-tools'
24
26
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
25
27
  import { resolveProjectRootForExec } from '../config-loader.ts'
28
+ import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
26
29
  import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
27
30
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
28
31
  import { iterateDir, fixesDir, checkpointPath, fixRegistryPath } from '../paths.ts'
@@ -48,6 +51,26 @@ export function cutoffTimestamp(retainDays: number): string {
48
51
  return d.toISOString()
49
52
  }
50
53
 
54
+ /**
55
+ * Whether a file name inside `.iterate/` is a prunable leftover temp file.
56
+ *
57
+ * Recognized conventions (all written to the SAME directory as their target,
58
+ * so they can never be confused with real state files):
59
+ * - `.<name>.tmp-<pid>-<rand>` — current atomic-fs.ts temp prefix;
60
+ * - `.tmp-<pid>-<rand>` — legacy temp prefix (older plugin builds);
61
+ * - `<name>.tmp` — legacy transcript.ts / live.ts temp suffix.
62
+ *
63
+ * Pure and exported for unit tests. A live writer's temp file is only visible
64
+ * to it (unique name), so deleting any match is always safe.
65
+ */
66
+ export function isPrunableTemp(name: string): boolean {
67
+ if (typeof name !== 'string' || name.length === 0 || name === '.' || name === '..') return false
68
+ if (name.startsWith('.tmp-')) return true
69
+ if (name.endsWith('.tmp')) return true
70
+ // .<basename>.tmp-<pid>-<rand>: starts with a dot and contains ".tmp-" after it.
71
+ return name.startsWith('.') && name.includes('.tmp-')
72
+ }
73
+
51
74
  /**
52
75
  * Inspect the runtime state and report what would be pruned.
53
76
  * Pure (no deletions). Returns a structured report.
@@ -59,6 +82,7 @@ export function inspectPrune(
59
82
  oldLogEntries: number
60
83
  hasCheckpoint: boolean
61
84
  staleBackups: string[]
85
+ staleTemps: string[]
62
86
  emptyRounds: number[]
63
87
  totalLogEntries: number
64
88
  registryRounds: number
@@ -100,10 +124,18 @@ export function inspectPrune(
100
124
  .filter((r) => r.records.length === 0)
101
125
  .map((r) => r.round)
102
126
 
127
+ // 5. Stray atomic-write temp files left behind by a crashed writer. The
128
+ // unique prefix makes them recognizable and safe to delete — no live writer
129
+ // ever reads another writer's temp file.
130
+ const staleTemps = existsSync(iterateDir(projectRoot))
131
+ ? readdirSync(iterateDir(projectRoot)).filter((f) => isPrunableTemp(f)).sort()
132
+ : []
133
+
103
134
  return {
104
135
  oldLogEntries,
105
136
  hasCheckpoint,
106
137
  staleBackups,
138
+ staleTemps,
107
139
  emptyRounds,
108
140
  totalLogEntries: entries.length,
109
141
  registryRounds: registry.rounds.length,
@@ -122,6 +154,7 @@ export function executePrune(
122
154
  deletedLogEntries: number
123
155
  deletedCheckpoint: boolean
124
156
  deletedBackups: string[]
157
+ deletedTemps: string[]
125
158
  trimmedEmptyRounds: number
126
159
  errors: string[]
127
160
  } {
@@ -130,6 +163,7 @@ export function executePrune(
130
163
  deletedLogEntries: 0,
131
164
  deletedCheckpoint: false,
132
165
  deletedBackups: [] as string[],
166
+ deletedTemps: [] as string[],
133
167
  trimmedEmptyRounds: 0,
134
168
  errors: [] as string[],
135
169
  }
@@ -142,9 +176,7 @@ export function executePrune(
142
176
  result.deletedLogEntries = entries.length - kept.length
143
177
  if (result.deletedLogEntries > 0) {
144
178
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
145
- const tmpPath = `${logPath}.tmp-${Date.now()}`
146
- writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
147
- renameSync(tmpPath, logPath)
179
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
148
180
  }
149
181
  } catch (err) {
150
182
  result.errors.push(`failed to rewrite decision log: ${String(err)}`)
@@ -184,13 +216,23 @@ export function executePrune(
184
216
  rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
185
217
  }
186
218
  registry = recomputeRoundCounts(registry)
187
- writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8')
219
+ writeJsonAtomic(fixRegistryPath(projectRoot), registry)
188
220
  result.trimmedEmptyRounds = report.emptyRounds.length
189
221
  } catch (err) {
190
222
  result.errors.push(`failed to trim empty rounds: ${String(err)}`)
191
223
  }
192
224
  }
193
225
 
226
+ // 5. Remove stray atomic-write temp files.
227
+ for (const tmp of report.staleTemps) {
228
+ try {
229
+ rmSync(join(iterateDir(projectRoot), tmp), { force: true })
230
+ result.deletedTemps.push(tmp)
231
+ } catch (err) {
232
+ result.errors.push(`failed to delete temp file ${tmp}: ${String(err)}`)
233
+ }
234
+ }
235
+
194
236
  return result
195
237
  }
196
238
 
@@ -206,7 +248,8 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
206
248
  description:
207
249
  'Inspect or clean up old iterate runtime artifacts (.iterate/). ' +
208
250
  'Defaults to dry-run (report-only, no deletion). Pass `dryRun: false` to actually prune. ' +
209
- 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds. ' +
251
+ 'Manages: old decision-log entries, stale checkpoints, orphaned fix backups, empty fix rounds, ' +
252
+ 'and stray temp files left by crashed atomic writes. ' +
210
253
  'Each deletion is logged to the decision log.',
211
254
  parameters: {
212
255
  dryRun: {
@@ -246,6 +289,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
246
289
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
247
290
  ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
248
291
  ` Stale backups to delete: ${(report?.staleBackups as string[] | undefined)?.length ?? 0}`,
292
+ ` Stray temp files to delete: ${(report?.staleTemps as string[] | undefined)?.length ?? 0}`,
249
293
  ` Empty rounds to trim: ${(report?.emptyRounds as number[] | undefined)?.length ?? 0}`,
250
294
  '',
251
295
  'Pass dryRun:false to execute the prune.',
@@ -257,6 +301,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
257
301
  ` Deleted ${result?.deletedLogEntries ?? 0} old log entries.`,
258
302
  ` Checkpoint deleted: ${result?.deletedCheckpoint ? 'yes' : 'no'}`,
259
303
  ` Deleted ${(result?.deletedBackups as string[] | undefined)?.length ?? 0} stale backups.`,
304
+ ` Deleted ${(result?.deletedTemps as string[] | undefined)?.length ?? 0} stray temp files.`,
260
305
  ` Trimmed ${result?.trimmedEmptyRounds ?? 0} empty rounds.`,
261
306
  ]
262
307
  const errs = (result?.errors as string[] | undefined) ?? []
@@ -64,6 +64,9 @@ export function registerQualityGateTool(ctx: { tools: { register: (def: ReturnTy
64
64
  ctx.tools.register(
65
65
  defineTool({
66
66
  name: 'iterate_quality_gate',
67
+ // `read` never writes; `compute` persists the snapshot → only read
68
+ // joins a parallel dispatch group.
69
+ isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
67
70
  description:
68
71
  'Query or write the quality gate status: dimension convergence rates, verification pass rates, ' +
69
72
  'and overall PASS/FAIL status. ' +
@@ -8,6 +8,7 @@
8
8
 
9
9
  import * as fs from 'node:fs'
10
10
  import * as path from 'node:path'
11
+ import { writeJsonAtomic } from '../atomic-fs.ts'
11
12
  import type { QualityGateSnapshot, QualityGateDimension } from '../types.ts'
12
13
 
13
14
  const QUALITY_GATE_FILE = 'quality-gate.json'
@@ -108,7 +109,7 @@ export function writeQualityGate(
108
109
  if (!fs.existsSync(dirPath)) {
109
110
  fs.mkdirSync(dirPath, { recursive: true })
110
111
  }
111
- fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2), 'utf-8')
112
+ writeJsonAtomic(filePath, snapshot)
112
113
  } catch (err) {
113
114
  return { ok: false, error: `unable to write ${filePath}: ${String(err)}` }
114
115
  }
@@ -19,8 +19,9 @@
19
19
  */
20
20
 
21
21
  import { defineTool } from '@deepseek-ai/dsh-tools'
22
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
22
+ import { mkdir, readFile } from 'node:fs/promises'
23
23
  import { existsSync } from 'node:fs'
24
+ import { writeTextAtomicAsync } from '../atomic-fs.ts'
24
25
  import { dirname } from 'node:path'
25
26
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
26
27
  import {
@@ -107,6 +108,9 @@ export function registerTranscriptTool(ctx: {
107
108
  ctx.tools.register(
108
109
  defineTool({
109
110
  name: 'iterate_transcript',
111
+ // `read` never writes; `capture`/`nudge` persist transcript state → only
112
+ // read joins a parallel dispatch group.
113
+ isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
110
114
  description:
111
115
  'Runtime-observatory transcript for the iterate workflow. ' +
112
116
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
@@ -332,10 +336,8 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
332
336
  return builder
333
337
  }
334
338
 
335
- /** Atomically persist a manifest (tmp + rename) under `.iterate/`. */
339
+ /** Atomically persist a manifest (unique temp + rename) under `.iterate/`. */
336
340
  async function persist(file: string, manifest: TranscriptManifest): Promise<void> {
337
341
  await mkdir(dirname(file), { recursive: true })
338
- const tmp = `${file}.tmp`
339
- await writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf-8')
340
- await rename(tmp, file)
342
+ await writeTextAtomicAsync(file, JSON.stringify(manifest, null, 2))
341
343
  }
@@ -1,9 +1,10 @@
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 type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import yaml from 'js-yaml'
6
6
  import { resolveProjectRootForExec } from '../config-loader.ts'
7
+ import { writeTextAtomic } from '../atomic-fs.ts'
7
8
  import type { KnownIntentional } from '../types.ts'
8
9
 
9
10
  const CONFIG_FILE = 'iterate.config.yaml'
@@ -229,7 +230,7 @@ function applyEntries(
229
230
 
230
231
  const yamlText = yaml.dump(nextConfig, { noRefs: true })
231
232
  try {
232
- writeFileSync(configPath, yamlText, 'utf-8')
233
+ writeTextAtomic(configPath, yamlText)
233
234
  } catch (err) {
234
235
  // Rollback: restore the backup, or REMOVE the file we just created when
235
236
  // there was no prior config — an empty file left behind would poison all
@@ -268,6 +269,9 @@ export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<ty
268
269
  ctx.tools.register(
269
270
  defineTool({
270
271
  name: 'iterate_triage',
272
+ // `list` is a pure config read; `apply` rewrites iterate.config.yaml →
273
+ // only list joins a parallel dispatch group.
274
+ isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'list',
271
275
  description:
272
276
  'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
273
277
  'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
@@ -28,11 +28,18 @@ export function clampTimeout(ms: number | undefined): number {
28
28
  /**
29
29
  * Run a single shell command with timeout and return structured results.
30
30
  * Pure function (no side effects beyond the exec call).
31
+ *
32
+ * `signal` (the caller's `exec.signal` per the dsh tools contract) is forwarded
33
+ * to the child process so a cancelled tool call kills the running command
34
+ * instead of pinning the dispatch open until the timeout elapses. The result
35
+ * distinguishes "cancelled by the caller" (`canceled: true`) from "ran past
36
+ * `timeoutMs`" (`timedOut: true`).
31
37
  */
32
- async function runCommand(
38
+ export async function runCommand(
33
39
  command: string,
34
40
  cwd: string,
35
41
  timeoutMs: number,
42
+ signal?: AbortSignal,
36
43
  ): Promise<ValidationResult> {
37
44
  const start = performance.now()
38
45
  return new Promise<ValidationResult>((resolve) => {
@@ -43,6 +50,7 @@ async function runCommand(
43
50
  timeout: timeoutMs,
44
51
  maxBuffer: 10 * 1024 * 1024, // 10 MB
45
52
  env: { ...process.env, PAGER: 'cat' },
53
+ signal,
46
54
  },
47
55
  (error, stdout, stderr) => {
48
56
  const durationMs = Math.round(performance.now() - start)
@@ -50,12 +58,16 @@ async function runCommand(
50
58
  // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
51
59
  // Coerce to a number so the integer output schema is never violated.
52
60
  const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0)
61
+ const canceled = signal?.aborted === true
53
62
  resolve({
54
63
  command,
55
64
  exitCode,
56
65
  stdout: stdout ?? '',
57
66
  stderr: stderr ?? '',
58
- timedOut: error?.killed === true,
67
+ // A caller cancellation also kills the child (killed === true), but
68
+ // that is not a timeout — only report timedOut for the deadline path.
69
+ timedOut: error?.killed === true && !canceled,
70
+ canceled,
59
71
  durationMs,
60
72
  })
61
73
  },
@@ -72,6 +84,18 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
72
84
  ctx.tools.register(
73
85
  defineTool({
74
86
  name: 'iterate_validate',
87
+ // Pending-call card: render the pending validation as a terminal card.
88
+ // Pure — derived from args only; no `cwd` (the UI bridge resolves the
89
+ // relative command against the session workspace).
90
+ presentCall: (args) => {
91
+ const a = args as { command?: unknown }
92
+ if (typeof a.command !== 'string' || a.command.length === 0) return undefined
93
+ return {
94
+ card: 'terminal',
95
+ title: a.command,
96
+ description: 'Run a preconfigured iterate validation command (exact match on validation.commands).',
97
+ }
98
+ },
75
99
  description:
76
100
  'Run a validation command that is PRECONFIGURED in iterate.config.yaml `validation.commands`. ' +
77
101
  'The command must exactly match one of the configured commands (they are the only ones the user trusts). ' +
@@ -105,6 +129,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
105
129
  stdout: { type: 'string', required: true },
106
130
  stderr: { type: 'string', required: true },
107
131
  timedOut: { type: 'boolean', required: true },
132
+ canceled: { type: 'boolean', required: true },
108
133
  durationMs: { type: 'integer', required: true },
109
134
  rejectReason: { type: 'string' },
110
135
  },
@@ -118,6 +143,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
118
143
  `Exit code: ${value.exitCode}`,
119
144
  `Duration: ${value.durationMs}ms`,
120
145
  value.timedOut ? '⚠ Timed out' : '',
146
+ value.canceled ? '⚠ Cancelled before completion' : '',
121
147
  '',
122
148
  value.stdout ? `[stdout]\n${value.stdout}` : '',
123
149
  value.stderr ? `[stderr]\n${value.stderr}` : '',
@@ -139,6 +165,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
139
165
  stdout: '',
140
166
  stderr: '',
141
167
  timedOut: false,
168
+ canceled: false,
142
169
  durationMs: 0,
143
170
  rejectReason: resolved.reason,
144
171
  }
@@ -161,6 +188,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
161
188
  stdout: '',
162
189
  stderr: '',
163
190
  timedOut: false,
191
+ canceled: false,
164
192
  durationMs: 0,
165
193
  rejectReason:
166
194
  (source === 'defaults'
@@ -177,6 +205,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
177
205
  stdout: '',
178
206
  stderr: '',
179
207
  timedOut: false,
208
+ canceled: false,
180
209
  durationMs: 0,
181
210
  rejectReason:
182
211
  `Command must exactly match a command predefined in iterate.config.yaml validation.commands. ` +
@@ -184,7 +213,7 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
184
213
  }
185
214
  }
186
215
 
187
- const result = await runCommand(args.command, projectRoot, timeout)
216
+ const result = await runCommand(args.command, projectRoot, timeout, exec.signal)
188
217
  return {
189
218
  allowed: true,
190
219
  ...result,
package/src/types.ts CHANGED
@@ -74,6 +74,8 @@ export interface ValidationResult {
74
74
  stdout: string
75
75
  stderr: string
76
76
  timedOut: boolean
77
+ /** True when the caller's AbortSignal fired before the command finished. */
78
+ canceled: boolean
77
79
  durationMs: number
78
80
  }
79
81