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.
@@ -24,6 +24,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
24
24
  import { mkdir, readFile } from 'node:fs/promises'
25
25
  import { existsSync } from 'node:fs'
26
26
  import { writeTextAtomicAsync } from '../atomic-fs.ts'
27
+ import { withProjectLock } from '../file-lock.ts'
27
28
  import { dirname } from 'node:path'
28
29
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
29
30
  import {
@@ -81,7 +82,7 @@ function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
81
82
  const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
82
83
  if (round <= 0) return null
83
84
  return {
84
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
85
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
85
86
  round,
86
87
  maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
87
88
  fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
@@ -215,7 +216,22 @@ export function registerTranscriptTool(ctx: {
215
216
  'the run console can show which commands ran and whether they passed.',
216
217
  },
217
218
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
218
- 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).' },
219
+ stoppedReason: {
220
+ type: 'string',
221
+ // Enum guard: the label renderer keys off this value, so a stray or
222
+ // prototype-chain string (e.g. "__proto__") must not enter the tool.
223
+ // The render path additionally uses Object.hasOwn (defense for
224
+ // hand-edited manifests that bypass this schema).
225
+ enum: [
226
+ 'converged',
227
+ 'max_rounds_reached',
228
+ 'aborted_by_validation',
229
+ 'aborted_by_config',
230
+ 'schema_invalid',
231
+ 'no_usable_reviewer_output',
232
+ 'inconclusive',
233
+ ],
234
+ 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).' },
219
235
  // `string | null` (NOT plain `type: 'string'`): dsh validates arguments
220
236
  // against the compiled schema BEFORE execute, and the docs — plus the
221
237
  // client's clear button — advertise `{"operation":"nudge","text":null}`.
@@ -311,49 +327,62 @@ export function registerTranscriptTool(ctx: {
311
327
  }
312
328
 
313
329
  if (args.operation === 'nudge') {
314
- let manifest: TranscriptManifest | null = null
315
- if (existsSync(file)) {
316
- try {
317
- const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
318
- manifest = parsed
319
- } catch {
320
- manifest = null
330
+ // Read-modify-write across processes: a concurrent nudge in a sibling
331
+ // process would otherwise read the same manifest and write back over
332
+ // each other's steering text. Serialize on the shared transcript lock
333
+ // (same pattern as the config/triage writers). The body still awaits,
334
+ // so the lock is held across the read and write; a same-process
335
+ // re-entry degrades to unlocked (the advisory lock is per-pid), which
336
+ // is the documented best-effort fallback for a low-value projection.
337
+ return withProjectLock(projectRoot, 'transcript', async () => {
338
+ let manifest: TranscriptManifest | null = null
339
+ if (existsSync(file)) {
340
+ try {
341
+ const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
342
+ manifest = parsed
343
+ } catch {
344
+ manifest = null
345
+ }
321
346
  }
322
- }
323
- // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
324
- // or a non-object) must never crash the nudge — degrade to a fresh
325
- // builder so the steering text still lands. The fallback must keep
326
- // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
327
- // stray malformed field can never silently rewrite a dry-run run
328
- // into a `normal` one.
329
- const fallbackOpts = {
330
- project: projectRoot,
331
- mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
332
- taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
333
- approval,
334
- goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
335
- maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
336
- }
337
- let builder: ReviewTranscriptBuilder
338
- if (manifest) {
339
- try {
340
- builder = rehydrateBuilder(manifest, approval)
341
- } catch {
342
- builder = new ReviewTranscriptBuilder(fallbackOpts)
347
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
348
+ // or a non-object) must never crash the nudge — degrade to a fresh
349
+ // builder so the steering text still lands. The fallback must keep
350
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
351
+ // stray malformed field can never silently rewrite a dry-run run
352
+ // into a `normal` one.
353
+ const fallbackOpts = {
354
+ project: projectRoot,
355
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
356
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
357
+ approval,
358
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
359
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
343
360
  }
344
- } else {
345
- builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
346
- }
347
- builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
348
- const persisted = await persistChecked(file, builder.serialize())
349
- if (!persisted.ok) {
350
- return { operation: 'nudge', updated: false, error: persisted.error }
351
- }
352
- return {
353
- operation: 'nudge',
354
- updated: true,
355
- transcript: builder.serialize() as unknown as JsonValue,
356
- }
361
+ let builder: ReviewTranscriptBuilder
362
+ if (manifest) {
363
+ try {
364
+ builder = rehydrateBuilder(manifest, approval)
365
+ } catch {
366
+ builder = new ReviewTranscriptBuilder(fallbackOpts)
367
+ }
368
+ } else {
369
+ // No manifest yet: the run's mode is genuinely unknown at nudge
370
+ // time (capture sets it). Recording `mode: 'normal'` here would
371
+ // rewrite a dry-run run into a `normal` one in the status/badge
372
+ // until capture lands — `null` honestly says "not captured yet".
373
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval })
374
+ }
375
+ builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
376
+ const persisted = await persistChecked(file, builder.serialize())
377
+ if (!persisted.ok) {
378
+ return { operation: 'nudge', updated: false, error: persisted.error }
379
+ }
380
+ return {
381
+ operation: 'nudge',
382
+ updated: true,
383
+ transcript: builder.serialize() as unknown as JsonValue,
384
+ }
385
+ })
357
386
  }
358
387
 
359
388
  // capture
@@ -6,6 +6,7 @@ import yaml from 'js-yaml'
6
6
  import { resolveProjectRootForExec } from '../config-loader.ts'
7
7
  import { writeTextAtomic } from '../atomic-fs.ts'
8
8
  import { configBackupSuffix } from '../config-write.ts'
9
+ import { withProjectLock } from '../file-lock.ts'
9
10
  import { WHOLE_FILE_LINE } from '../evidence.ts'
10
11
  import type { KnownIntentional } from '../types.ts'
11
12
 
@@ -268,56 +269,62 @@ function applyEntries(
268
269
  error: string
269
270
  } {
270
271
  const configPath = join(projectRoot, CONFIG_FILE)
271
- let config: Record<string, unknown>
272
- try {
273
- config = readConfigFile(configPath)
274
- } catch (err) {
275
- // The file exists but is malformed — refuse to overwrite user data.
276
- return { ok: false, error: `Failed to read config: ${String(err)}` }
277
- }
278
- const existing = readKnownIntentional(config)
279
- const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming)
280
- const nextConfig = buildConfigWithKnownIntentional(config, merged)
272
+ // Serialize against `iterate_config write` (SAME lock name) so a triage apply
273
+ // and a config write on one project cannot interleave their read-modify-write
274
+ // and silently drop each other's updates. The lock spans read → merge →
275
+ // backup → write → backup-prune so the whole cycle is atomic.
276
+ return withProjectLock(projectRoot, 'config-write', () => {
277
+ let config: Record<string, unknown>
278
+ try {
279
+ config = readConfigFile(configPath)
280
+ } catch (err) {
281
+ // The file exists but is malformed — refuse to overwrite user data.
282
+ return { ok: false, error: `Failed to read config: ${String(err)}` }
283
+ }
284
+ const existing = readKnownIntentional(config)
285
+ const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming)
286
+ const nextConfig = buildConfigWithKnownIntentional(config, merged)
281
287
 
282
- const hadFile = existsSync(configPath)
283
- const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null
288
+ const hadFile = existsSync(configPath)
289
+ const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null
290
+
291
+ if (backupPath) {
292
+ try {
293
+ copyFileSync(configPath, backupPath)
294
+ } catch (err) {
295
+ return {
296
+ ok: false,
297
+ error: `Failed to create backup: ${String(err)}`,
298
+ }
299
+ }
300
+ }
284
301
 
285
- if (backupPath) {
302
+ const yamlText = yaml.dump(nextConfig, { noRefs: true })
286
303
  try {
287
- copyFileSync(configPath, backupPath)
304
+ writeTextAtomic(configPath, yamlText)
288
305
  } catch (err) {
306
+ // Rollback: restore the backup, or REMOVE the file we just created when
307
+ // there was no prior config — an empty file left behind would poison all
308
+ // future config reads (empty YAML is not a valid mapping).
309
+ let rollbackError = ''
310
+ try {
311
+ if (backupPath) copyFileSync(backupPath, configPath)
312
+ else if (existsSync(configPath)) rmSync(configPath, { force: true })
313
+ } catch (rbErr) {
314
+ rollbackError = `; rollback also failed: ${String(rbErr)}`
315
+ }
289
316
  return {
290
317
  ok: false,
291
- error: `Failed to create backup: ${String(err)}`,
318
+ error: `Failed to write config: ${String(err)}${rollbackError}`,
292
319
  }
293
320
  }
294
- }
295
-
296
- const yamlText = yaml.dump(nextConfig, { noRefs: true })
297
- try {
298
- writeTextAtomic(configPath, yamlText)
299
- } catch (err) {
300
- // Rollback: restore the backup, or REMOVE the file we just created when
301
- // there was no prior config — an empty file left behind would poison all
302
- // future config reads (empty YAML is not a valid mapping).
303
- let rollbackError = ''
304
- try {
305
- if (backupPath) copyFileSync(backupPath, configPath)
306
- else if (existsSync(configPath)) rmSync(configPath, { force: true })
307
- } catch (rbErr) {
308
- rollbackError = `; rollback also failed: ${String(rbErr)}`
309
- }
310
- return {
311
- ok: false,
312
- error: `Failed to write config: ${String(err)}${rollbackError}`,
313
- }
314
- }
315
321
 
316
- // Success: bound the accumulation of timestamped backups so a long-lived
317
- // project never collects an unbounded pile of config snapshots.
318
- if (backupPath) pruneOldConfigBackups(configPath)
322
+ // Success: bound the accumulation of timestamped backups so a long-lived
323
+ // project never collects an unbounded pile of config snapshots.
324
+ if (backupPath) pruneOldConfigBackups(configPath)
319
325
 
320
- return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath }
326
+ return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath }
327
+ })
321
328
  }
322
329
 
323
330
  /**
package/src/transcript.ts CHANGED
@@ -565,7 +565,7 @@ export class ReviewTranscriptBuilder {
565
565
  return
566
566
  }
567
567
  this.checkpoint = {
568
- mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : 'normal',
568
+ mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : null,
569
569
  round: typeof state.round === 'number' ? state.round : 0,
570
570
  maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
571
571
  fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
@@ -804,7 +804,7 @@ function normalizeCheckpointRow(input: unknown): TranscriptCheckpoint | null {
804
804
  const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
805
805
  if (round <= 0) return null
806
806
  return {
807
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
807
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
808
808
  round: clampRoundValue(c.round),
809
809
  maxRounds: clampCountValue(c.maxRounds),
810
810
  fixedCount: clampCountValue(c.fixedCount),
package/src/types.ts CHANGED
@@ -320,7 +320,7 @@ export interface TranscriptEntry {
320
320
 
321
321
  /** Checkpoint summary surfaced for resume actions (F5). */
322
322
  export interface TranscriptCheckpoint {
323
- mode: 'dry-run' | 'normal'
323
+ mode: 'dry-run' | 'normal' | null
324
324
  round: number
325
325
  maxRounds: number
326
326
  fixedCount: number