iterate-plugin 3.7.0 → 3.9.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.
@@ -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