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.
@@ -23,12 +23,83 @@ 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";
29
30
  import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from "../transcript.js";
30
31
  import { readDecisionLogDetailed } from "./decision-log.js";
31
32
  import { readLive } from "../live.js";
33
+ import { buildRunRecord, appendRunRecord, readRunRecords, sessionOf, } from "../session-record.js";
34
+ /** Severity tally over a manifest's (globally deduped) findings. */
35
+ function severityCounts(findings) {
36
+ const counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
37
+ if (!Array.isArray(findings))
38
+ return counts;
39
+ for (const f of findings) {
40
+ if (!f || typeof f !== 'object')
41
+ continue;
42
+ const sev = f.severity;
43
+ counts.total++;
44
+ if (sev === 'critical')
45
+ counts.critical++;
46
+ else if (sev === 'high')
47
+ counts.high++;
48
+ else if (sev === 'medium')
49
+ counts.medium++;
50
+ else
51
+ counts.low++;
52
+ }
53
+ return counts;
54
+ }
55
+ /** Build the compact session-linked record from a finished manifest. */
56
+ function runRecordFromManifest(manifest, qualityStatus = null) {
57
+ return buildRunRecord({
58
+ project: manifest.project,
59
+ goal: manifest.goal,
60
+ mode: manifest.mode,
61
+ taskMode: manifest.taskMode,
62
+ round: manifest.round,
63
+ maxRounds: manifest.maxRounds,
64
+ active: manifest.active,
65
+ stoppedReason: manifest.stoppedReason,
66
+ convergence: manifest.convergence,
67
+ counts: severityCounts(manifest.findings),
68
+ fixedCount: Array.isArray(manifest.fixes) ? manifest.fixes.filter((f) => f.success !== false).length : 0,
69
+ qualityStatus,
70
+ });
71
+ }
72
+ /**
73
+ * Rebuild a (summary-only) transcript manifest from a session-linked record.
74
+ *
75
+ * Degraded by design: the record carries the run SUMMARY (mode / goal / round /
76
+ * max-rounds / end state / convergence series), not the per-finding detail, so
77
+ * the recovered manifest has an empty `findings`/`fixes`/`timeline`. It exists
78
+ * so the observatory shows the real run header + trend after `.iterate/` is
79
+ * lost, instead of a blank panel. Round rows stay empty because the record has
80
+ * no per-thread narration.
81
+ */
82
+ export function recoverManifestFromRecord(record, fallbackProject, approval) {
83
+ const builder = new ReviewTranscriptBuilder({
84
+ project: record.project || fallbackProject,
85
+ mode: record.mode,
86
+ taskMode: record.taskMode,
87
+ approval,
88
+ goal: record.goal,
89
+ maxRounds: record.maxRounds,
90
+ });
91
+ const series = Array.isArray(record.convergence) ? record.convergence : [];
92
+ for (let i = 0; i < Math.min(series.length, MAX_ROUNDS); i += 1) {
93
+ const n = series[i];
94
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
95
+ builder.snapshotConvergence(i + 1, n);
96
+ }
97
+ if (record.round > 0)
98
+ builder.advanceRound(record.round, record.maxRounds);
99
+ if (!record.active)
100
+ builder.finish(record.stoppedReason ?? undefined);
101
+ return builder.serialize();
102
+ }
32
103
  /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
33
104
  function captureRound(builder, round) {
34
105
  if (!round || typeof round !== 'object')
@@ -73,7 +144,7 @@ function normalizeCheckpoint(input) {
73
144
  if (round <= 0)
74
145
  return null;
75
146
  return {
76
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
147
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
77
148
  round,
78
149
  maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
79
150
  fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
@@ -142,26 +213,36 @@ function isManifestShaped(value) {
142
213
  export function registerTranscriptTool(ctx) {
143
214
  ctx.tools.register(defineTool({
144
215
  name: 'iterate_transcript',
145
- // `read` never writes; `capture`/`nudge` persist transcript state → only
146
- // read joins a parallel dispatch group.
147
- isConcurrencySafe: (args) => args.operation === 'read',
216
+ // `read`/`recall` never write; `capture`/`nudge` persist transcript state
217
+ // → only the read shapes join a parallel dispatch group.
218
+ isConcurrencySafe: (args) => {
219
+ const op = args.operation;
220
+ return op === 'read' || op === 'recall';
221
+ },
148
222
  description: 'Runtime-observatory transcript for the iterate workflow. ' +
149
223
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
150
224
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
151
225
  'written for the next round) — or, when the file is missing/malformed, a structured ' +
152
- 'empty view (`found:false` plus a blank manifest). ' +
226
+ 'empty view (`found:false` plus a blank manifest). When the on-disk file is gone but the ' +
227
+ 'session carries a linked run record (dsh ≥ 0.2.1-rc.1), `read` RECOVERS a summary manifest ' +
228
+ 'from that record and sets `recovered:true`. ' +
153
229
  '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
154
230
  'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
155
231
  'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
156
232
  'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
233
+ 'On a successful capture the tool ALSO appends a compact run record onto the dsh session log ' +
234
+ '(best-effort; reported as `sessionRecord`) so the run survives `.iterate/` loss and travels with ' +
235
+ 'the session. ' +
236
+ '`recall` lists the session-linked run records (newest first) — useful to audit a run after ' +
237
+ 'restart or to confirm the write took. ' +
157
238
  '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
158
239
  'Purely local and deterministic — never touches source files.',
159
240
  parameters: {
160
241
  operation: {
161
242
  type: 'string',
162
243
  required: true,
163
- description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
164
- enum: ['read', 'capture', 'nudge'],
244
+ description: '"read" to fetch the manifest, "capture" to persist one, "recall" to list session-linked run records, "nudge" to set steering text.',
245
+ enum: ['read', 'capture', 'recall', 'nudge'],
165
246
  },
166
247
  rounds: {
167
248
  type: 'json',
@@ -198,7 +279,23 @@ export function registerTranscriptTool(ctx) {
198
279
  'the run console can show which commands ran and whether they passed.',
199
280
  },
200
281
  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).' },
282
+ stoppedReason: {
283
+ type: 'string',
284
+ // Enum guard: the label renderer keys off this value, so a stray or
285
+ // prototype-chain string (e.g. "__proto__") must not enter the tool.
286
+ // The render path additionally uses Object.hasOwn (defense for
287
+ // hand-edited manifests that bypass this schema).
288
+ enum: [
289
+ 'converged',
290
+ 'max_rounds_reached',
291
+ 'aborted_by_validation',
292
+ 'aborted_by_config',
293
+ 'schema_invalid',
294
+ 'no_usable_reviewer_output',
295
+ 'inconclusive',
296
+ ],
297
+ 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).'
298
+ },
202
299
  // `string | null` (NOT plain `type: 'string'`): dsh validates arguments
203
300
  // against the compiled schema BEFORE execute, and the docs — plus the
204
301
  // client's clear button — advertise `{"operation":"nudge","text":null}`.
@@ -227,6 +324,19 @@ export function registerTranscriptTool(ctx) {
227
324
  description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
228
325
  },
229
326
  reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
327
+ recovered: {
328
+ type: 'boolean',
329
+ description: 'For `read`: true when the manifest was rebuilt from a session-linked run record because the on-disk file was missing or unreadable.',
330
+ },
331
+ supported: {
332
+ type: 'boolean',
333
+ description: 'For `recall`: whether the host exposes the dsh session state-record API (dsh ≥ 0.2.1-rc.1).',
334
+ },
335
+ records: { type: 'json', description: 'For `recall`: session-linked run records, newest first.' },
336
+ sessionRecord: {
337
+ type: 'json',
338
+ description: 'For `capture`/`read`: the session-link outcome/position (`{linked, seq}` on capture, `{seq, updatedAt}` on a recovery).',
339
+ },
230
340
  error: { type: 'string' },
231
341
  },
232
342
  },
@@ -256,13 +366,59 @@ export function registerTranscriptTool(ctx) {
256
366
  approval,
257
367
  }).serialize(),
258
368
  });
259
- if (!existsSync(file))
369
+ // Recovery (dsh ≥ 0.2.1-rc.1): when the on-disk manifest is gone
370
+ // but the SESSION that produced the run carries a linked run record,
371
+ // rebuild a summary manifest so the observatory shows the real run
372
+ // header + trend instead of a blank panel. Best-effort: no session,
373
+ // no API, or no record all fall back to `emptyView()`.
374
+ const recover = async () => {
375
+ const session = sessionOf(exec);
376
+ if (!session)
377
+ return null;
378
+ const { supported, records } = await readRunRecords(session);
379
+ if (!supported || records.length === 0)
380
+ return null;
381
+ const latest = records[records.length - 1];
382
+ return {
383
+ transcript: normalizeManifestBounds(recoverManifestFromRecord(latest, projectRoot, approval)),
384
+ sessionRecord: {
385
+ seq: latest.seq,
386
+ time: latest.time,
387
+ updatedAt: latest.updatedAt,
388
+ },
389
+ };
390
+ };
391
+ if (!existsSync(file)) {
392
+ const recovered = await recover();
393
+ if (recovered) {
394
+ return {
395
+ operation: 'read',
396
+ found: true,
397
+ recovered: true,
398
+ live: live,
399
+ transcript: recovered.transcript,
400
+ sessionRecord: recovered.sessionRecord,
401
+ };
402
+ }
260
403
  return emptyView();
404
+ }
261
405
  let parsed;
262
406
  try {
263
407
  parsed = JSON.parse(await readFile(file, 'utf-8'));
264
408
  }
265
409
  catch (err) {
410
+ const recovered = await recover();
411
+ if (recovered) {
412
+ return {
413
+ operation: 'read',
414
+ found: true,
415
+ recovered: true,
416
+ live: live,
417
+ transcript: recovered.transcript,
418
+ sessionRecord: recovered.sessionRecord,
419
+ error: `Recovered a summary from the session log; on-disk transcript was unreadable (${err instanceof Error ? err.message : String(err)}).`,
420
+ };
421
+ }
266
422
  return {
267
423
  ...emptyView(),
268
424
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
@@ -272,6 +428,18 @@ export function registerTranscriptTool(ctx) {
272
428
  // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
273
429
  // are parseable but carry no manifest. Surfacing them as
274
430
  // `found:true` made the observatory render junk panels.
431
+ const recovered = await recover();
432
+ if (recovered) {
433
+ return {
434
+ operation: 'read',
435
+ found: true,
436
+ recovered: true,
437
+ live: live,
438
+ transcript: recovered.transcript,
439
+ sessionRecord: recovered.sessionRecord,
440
+ error: 'Recovered a summary from the session log; on-disk file is not a valid manifest.',
441
+ };
442
+ }
275
443
  return {
276
444
  ...emptyView(),
277
445
  error: 'Transcript file is not a valid manifest; returning an empty view.',
@@ -287,53 +455,81 @@ export function registerTranscriptTool(ctx) {
287
455
  transcript: normalizeManifestBounds(parsed),
288
456
  };
289
457
  }
458
+ if (args.operation === 'recall') {
459
+ // New capability: list the run records this plugin appended onto the
460
+ // dsh session log (dsh ≥ 0.2.1-rc.1). Read-only; degrades to
461
+ // `supported:false` on a host without the API.
462
+ const session = sessionOf(exec);
463
+ const { supported, records, error } = await readRunRecords(session);
464
+ const newestFirst = [...records].reverse();
465
+ return {
466
+ operation: 'recall',
467
+ found: newestFirst.length > 0,
468
+ supported,
469
+ records: newestFirst,
470
+ ...(error ? { error } : {}),
471
+ };
472
+ }
290
473
  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;
474
+ // Read-modify-write across processes: a concurrent nudge in a sibling
475
+ // process would otherwise read the same manifest and write back over
476
+ // each other's steering text. Serialize on the shared transcript lock
477
+ // (same pattern as the config/triage writers). The body still awaits,
478
+ // so the lock is held across the read and write; a same-process
479
+ // re-entry degrades to unlocked (the advisory lock is per-pid), which
480
+ // is the documented best-effort fallback for a low-value projection.
481
+ return withProjectLock(projectRoot, 'transcript', async () => {
482
+ let manifest = null;
483
+ if (existsSync(file)) {
484
+ try {
485
+ const parsed = JSON.parse(await readFile(file, 'utf-8'));
486
+ manifest = parsed;
487
+ }
488
+ catch {
489
+ manifest = null;
490
+ }
296
491
  }
297
- catch {
298
- manifest = null;
492
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
493
+ // or a non-object) must never crash the nudge — degrade to a fresh
494
+ // builder so the steering text still lands. The fallback must keep
495
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
496
+ // stray malformed field can never silently rewrite a dry-run run
497
+ // into a `normal` one.
498
+ const fallbackOpts = {
499
+ project: projectRoot,
500
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null),
501
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null),
502
+ approval,
503
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
504
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
505
+ };
506
+ let builder;
507
+ if (manifest) {
508
+ try {
509
+ builder = rehydrateBuilder(manifest, approval);
510
+ }
511
+ catch {
512
+ builder = new ReviewTranscriptBuilder(fallbackOpts);
513
+ }
299
514
  }
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);
515
+ else {
516
+ // No manifest yet: the run's mode is genuinely unknown at nudge
517
+ // time (capture sets it). Recording `mode: 'normal'` here would
518
+ // rewrite a dry-run run into a `normal` one in the status/badge
519
+ // until capture lands — `null` honestly says "not captured yet".
520
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval });
319
521
  }
320
- catch {
321
- builder = new ReviewTranscriptBuilder(fallbackOpts);
522
+ builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
523
+ const persisted = await persistChecked(file, builder.serialize());
524
+ if (!persisted.ok) {
525
+ return { operation: 'nudge', updated: false, error: persisted.error };
322
526
  }
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
- };
527
+ return {
528
+ operation: 'nudge',
529
+ updated: true,
530
+ transcript: builder.serialize(),
531
+ };
532
+ });
337
533
  }
338
534
  // capture
339
535
  // `observatory.capture: false` opts the project out of transcript
@@ -450,17 +646,28 @@ export function registerTranscriptTool(ctx) {
450
646
  builder.finish('converged');
451
647
  else if (roundsExecuted > 0)
452
648
  builder.finish('max_rounds_reached');
453
- const persisted = await persistChecked(file, builder.serialize());
649
+ const manifest = builder.serialize();
650
+ const persisted = await persistChecked(file, manifest);
454
651
  if (!persisted.ok) {
455
652
  return { operation: 'capture', found: true, updated: false, error: persisted.error };
456
653
  }
654
+ // Session link (dsh ≥ 0.2.1-rc.1): append a compact run record onto the
655
+ // session log so the run survives `.iterate/` loss and travels with the
656
+ // session. Best-effort — a link failure is reported, never fatal (the
657
+ // on-disk capture above already succeeded).
658
+ let sessionRecord;
659
+ const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest));
660
+ sessionRecord = (linkSession.ok
661
+ ? { linked: true, seq: linkSession.seq }
662
+ : { linked: false, supported: linkSession.supported, reason: linkSession.error });
457
663
  const live = await readLive(projectRoot);
458
664
  return {
459
665
  operation: 'capture',
460
666
  found: true,
461
667
  updated: true,
462
668
  live: live,
463
- transcript: builder.serialize(),
669
+ transcript: manifest,
670
+ sessionRecord,
464
671
  };
465
672
  },
466
673
  }));
@@ -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),