iterate-plugin 2.11.0 → 2.12.1

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.
@@ -22,6 +22,7 @@ You have the iterate plugin installed, which registers these tools:
22
22
  - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
23
23
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
24
24
  - \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
25
+ - \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
25
26
 
26
27
  ### When to use
27
28
  When the user asks to review or iterate on the project (e.g. "review this project", "iterate on error handling", "check the codebase for issues", "dry-run review", "反复审查"), run an iterate **workflow** by calling the \`workflow\` tool.
@@ -77,6 +78,14 @@ const knownIntentional = (plan.knownIntentional || []) // config personalizati
77
78
  let known = [] // cumulative DEDUPED findings fed back to reviewers
78
79
  const rounds = [] // raw per-round findings
79
80
 
81
+ phase('transcript')
82
+ // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
83
+ const transRead = await agent(
84
+ 'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
85
+ Object.assign({ label: 'transcript:read' }, backend)
86
+ )
87
+ const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
88
+
80
89
  phase('review')
81
90
  for (let r = 1; r <= maxRounds; r++) {
82
91
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
@@ -98,6 +107,7 @@ for (let r = 1; r <= maxRounds; r++) {
98
107
  ? meta.reviewerPrompt
99
108
  : 'Review dimension "' + dim + '".'
100
109
  const extra =
110
+ (steering ? '\\n STEERING — read this first: ' + steering : '') +
101
111
  (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
102
112
  '\\n Already-known findings (do NOT re-report): ' +
103
113
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
@@ -156,6 +166,13 @@ const metaRes = await agent(
156
166
  const finalReport = metaRes && metaRes.finalReport ? metaRes.finalReport : null
157
167
  const metaAudit = finalReport && finalReport.metaReview ? finalReport.metaReview : null
158
168
 
169
+ // Persist the run's observatory transcript (reviewer threads, trend, findings)
170
+ // so the client observatory panel reflects this review. Writes ONLY .iterate/transcript.json.
171
+ await agent(
172
+ 'Call iterate_transcript({operation:"capture", mode:"dry-run", goal:' + JSON.stringify(report.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + report.convergence.totalRounds + ', findingsByRound:' + JSON.stringify(report.convergence.findingsByRound || []) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
173
+ Object.assign({ label: 'transcript:capture' }, backend)
174
+ )
175
+
159
176
  return {
160
177
  mode: 'dry-run',
161
178
  goal: report.goal,
@@ -180,7 +197,7 @@ Key rules for dry-run:
180
197
  - Stop when a round reports 0 new findings (converged) or maxReviewRounds is reached.
181
198
  - The report (with per-round convergence stats + suggested fix priorities) is the deliverable.
182
199
  - **Meta-review**: after building the report, audit it with \`iterate_review({operation:"meta-review"})\` for internal consistency (counts, severity buckets, dimension sums, sort order, convergence math). The meta-review ALSO runs the hard code-evidence gate (default on): every finding's file/line is validated against real files on disk, so any fabricated location surfaces as a critical \`EVIDENCE_VIOLATION\` and flips the verdict to \`needs_revision\`. The \`finalReport.verdict\` is \`approved\` only when the report passes every check AND every finding anchors to real, read code; otherwise \`needs_revision\`. Surface the final report and its verdict as the closing deliverable.
183
- - Only a single \`report\` entry may be appended to the decision log; nothing else is written.
200
+ - Only a single \`report\` entry may be appended to the decision log; nothing else is written to source files. The final \`iterate_transcript capture\` writes ONLY the observatory file (\`.iterate/transcript.json\`) so the client panel reflects the run — it is not a source-code write.
184
201
 
185
202
  ### Normal-mode workflow (autonomous closed loop)
186
203
  Set \`args.mode = "normal"\`. Loop: resume → plan → parallel review ×N → atomic fixes via \`iterate_fix\` → validate → rollback on failure → checkpoint → loop → auto-stop when zero findings remain.
@@ -238,6 +255,14 @@ let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? che
238
255
  let converged = false
239
256
  let abortedByValidation = false
240
257
  let failedCommands = []
258
+ const fixRecords = [] // observatory fix records collected round by round
259
+
260
+ // Read any steering nudge intended for this run's reviewers.
261
+ const transRead = await agent(
262
+ 'Call iterate_transcript({operation:"read"}) and return {nudge:<transcript.nudge ? transcript.nudge.text : null>}.',
263
+ Object.assign({ label: 'transcript:read' }, backend)
264
+ )
265
+ const steering = transRead && typeof transRead.nudge === 'string' && transRead.nudge ? transRead.nudge : null
241
266
 
242
267
  phase('loop')
243
268
  for (let r = startRound; r <= maxRounds; r++) {
@@ -264,6 +289,7 @@ for (let r = startRound; r <= maxRounds; r++) {
264
289
  ? meta.reviewerPrompt
265
290
  : 'Review dimension "' + dim + '" on the CURRENT code state (previous atomic findings are fixed).'
266
291
  const extra =
292
+ (steering ? '\\n STEERING — read this first: ' + steering : '') +
267
293
  (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
268
294
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
269
295
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
@@ -308,12 +334,14 @@ for (let r = startRound; r <= maxRounds; r++) {
308
334
  'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
309
335
  'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
310
336
  'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
311
- 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff. ' +
312
- 'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error} per iterate_fix call.',
337
+ 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff and ' +
338
+ 'read its line statistics (lines added/removed). ' +
339
+ 'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
340
+ '(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
313
341
  Object.assign({ label: 'fix:' + file, phase: 'fix', schema: {
314
342
  type: 'object', additionalProperties: false,
315
343
  properties: {
316
- fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' } }, required: ['id', 'ok'] } }
344
+ fixes: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, ok: { type: 'boolean' }, error: { type: 'string' }, file: { type: 'string' }, linesAdded: { type: 'integer' }, linesRemoved: { type: 'integer' } }, required: ['id', 'ok'] } }
317
345
  },
318
346
  required: ['fixes'] } }, backend)
319
347
  )))
@@ -321,6 +349,17 @@ for (let r = startRound; r <= maxRounds; r++) {
321
349
  if (res && Array.isArray(res.fixes)) {
322
350
  for (const fx of res.fixes) {
323
351
  if (fx && fx.ok === true) { fixedCount += 1; roundFixIds.push(fx.id) }
352
+ // Collect fix records for the observatory transcript (defensive defaults).
353
+ const fixFileKeys = Object.keys(byFile)
354
+ fixRecords.push({
355
+ id: fx && typeof fx.id === 'string' ? fx.id : '',
356
+ file: fx && typeof fx.file === 'string' ? fx.file : (fixFileKeys.length === 1 ? fixFileKeys[0] : ''),
357
+ round: r,
358
+ summary: '',
359
+ linesAdded: fx && typeof fx.linesAdded === 'number' ? fx.linesAdded : 0,
360
+ linesRemoved: fx && typeof fx.linesRemoved === 'number' ? fx.linesRemoved : 0,
361
+ success: !!(fx && fx.ok === true),
362
+ })
324
363
  }
325
364
  }
326
365
  }
@@ -403,6 +442,19 @@ if (!abortedByValidation) {
403
442
  { label: 'checkpoint:clear' }
404
443
  )
405
444
  }
445
+ // Persist the run's observatory transcript (threads, trend, fixes, checkpoint)
446
+ // so the client observatory panel reflects the run. Writes ONLY .iterate/transcript.json.
447
+ const obsCheckpoint = abortedByValidation ? null : {
448
+ mode: 'normal',
449
+ round: rounds.length,
450
+ maxRounds: maxRounds,
451
+ fixedCount: fixedCount,
452
+ resumeCount: effectiveResumeCount,
453
+ }
454
+ await agent(
455
+ 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
456
+ { label: 'transcript:capture' }
457
+ )
406
458
  return {
407
459
  mode: 'normal',
408
460
  goal: plan.goal,
@@ -0,0 +1,324 @@
1
+ /**
2
+ * src/tools/transcript.ts — `iterate_transcript` tool.
3
+ *
4
+ * Exposes the runtime-observatory manifest to the model (and, via its persisted
5
+ * on-disk copy, to the client observatory panel). Purely local, deterministic,
6
+ * and safe:
7
+ *
8
+ * - `read` — return the persisted transcript manifest (or a structured
9
+ * "not found" empty view). Used each round by the workflow to
10
+ * pick up steering nudges, and polled by tool-reading agents.
11
+ * - `capture` — build a fresh transcript from the review `rounds` + `report`
12
+ * and persist it. Called by the canonical scripts after the
13
+ * final aggregate so the client always sees the latest run.
14
+ * - `nudge` — set (`text`) or clear (`text: null`) steering text persisted
15
+ * for the next round's reviewers to read.
16
+ *
17
+ * All writes are persisted to `.iterate/transcript.json` via an atomic
18
+ * tmp+rename so a crashed writer never leaves a corrupt manifest.
19
+ */
20
+ import { defineTool } from '@deepseek-ai/dsh-tools';
21
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
22
+ import { existsSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loader.js";
25
+ import { transcriptPath } from "../paths.js";
26
+ import { ReviewTranscriptBuilder } from "../transcript.js";
27
+ import { readLive } from "../live.js";
28
+ /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
29
+ function captureRound(builder, round) {
30
+ if (!round || typeof round !== 'object')
31
+ return;
32
+ const r = round;
33
+ const roundNo = typeof r.round === 'number' ? Math.floor(r.round) : 0;
34
+ if (roundNo <= 0)
35
+ return;
36
+ builder.roundStart(roundNo);
37
+ const findings = Array.isArray(r.findings) ? r.findings : [];
38
+ const readFiles = Array.isArray(r.readFiles) ? r.readFiles : [];
39
+ // Group the round's findings by dimension → one reviewer thread each.
40
+ const byDim = new Map();
41
+ for (const f of findings) {
42
+ if (!f || typeof f !== 'object')
43
+ continue;
44
+ const rec = f;
45
+ const dim = typeof rec.dimension === 'string' && rec.dimension ? rec.dimension : 'review';
46
+ const list = byDim.get(dim) ?? [];
47
+ list.push(f);
48
+ byDim.set(dim, list);
49
+ }
50
+ if (byDim.size === 0) {
51
+ builder.reviewerSnapshot('review', [], readFiles);
52
+ }
53
+ else {
54
+ for (const [dim, list] of byDim)
55
+ builder.reviewerSnapshot(dim, list, readFiles);
56
+ }
57
+ }
58
+ /** Normalize the checkpoint shape if present. */
59
+ function normalizeCheckpoint(input) {
60
+ if (!input || typeof input !== 'object')
61
+ return null;
62
+ const c = input;
63
+ const round = typeof c.round === 'number' ? c.round : 0;
64
+ if (round <= 0)
65
+ return null;
66
+ return {
67
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
68
+ round,
69
+ maxRounds: typeof c.maxRounds === 'number' ? c.maxRounds : 0,
70
+ fixedCount: typeof c.fixedCount === 'number' ? c.fixedCount : 0,
71
+ resumeCount: typeof c.resumeCount === 'number' ? c.resumeCount : 0,
72
+ updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : new Date().toISOString(),
73
+ };
74
+ }
75
+ /** Normalize a fix record. */
76
+ function normalizeFix(input) {
77
+ if (!input || typeof input !== 'object')
78
+ return null;
79
+ const f = input;
80
+ const id = typeof f.id === 'string' ? f.id : '';
81
+ const file = typeof f.file === 'string' ? f.file : '';
82
+ if (!id || !file)
83
+ return null;
84
+ return {
85
+ id,
86
+ timestamp: typeof f.timestamp === 'string' ? f.timestamp : new Date().toISOString(),
87
+ round: typeof f.round === 'number' ? f.round : 0,
88
+ file,
89
+ summary: typeof f.summary === 'string' ? f.summary : '',
90
+ linesAdded: typeof f.linesAdded === 'number' ? f.linesAdded : 0,
91
+ linesRemoved: typeof f.linesRemoved === 'number' ? f.linesRemoved : 0,
92
+ success: f.success !== false,
93
+ };
94
+ }
95
+ /** Register the `iterate_transcript` tool. */
96
+ export function registerTranscriptTool(ctx) {
97
+ ctx.tools.register(defineTool({
98
+ name: 'iterate_transcript',
99
+ description: 'Runtime-observatory transcript for the iterate workflow. ' +
100
+ '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
101
+ 'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
102
+ 'written for the next round). ' +
103
+ '`capture` builds a fresh transcript from the review `rounds` + `report` and persists it ' +
104
+ '(call once after the final aggregate so the UI reflects the run). ' +
105
+ '`nudge` sets (text) or clears (text:null) steering text the next round\'s reviewers read. ' +
106
+ 'Purely local and deterministic — never touches source files.',
107
+ parameters: {
108
+ operation: {
109
+ type: 'string',
110
+ required: true,
111
+ description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
112
+ enum: ['read', 'capture', 'nudge'],
113
+ },
114
+ rounds: {
115
+ type: 'json',
116
+ description: 'For `capture`: per-round findings, each [{round, findings:[{dimension,file,line?,severity,summary,…}], readFiles:[…]}].',
117
+ },
118
+ report: {
119
+ type: 'json',
120
+ description: 'For `capture`: the ReviewReport (convergence.findingsByRound used for the trend).',
121
+ },
122
+ mode: {
123
+ type: 'string',
124
+ description: 'For `capture`: run mode ("dry-run" | "normal"). Default dry-run.',
125
+ enum: ['dry-run', 'normal'],
126
+ },
127
+ goal: { type: 'string', description: 'For `capture`: run goal.' },
128
+ maxRounds: { type: 'integer', description: 'For `capture`: round cap.' },
129
+ roundsExecuted: { type: 'integer', description: 'For `capture`: number of rounds actually executed.' },
130
+ findingsByRound: { type: 'json', description: 'For `capture`: the per-round new-findings count series (report.convergence.findingsByRound). Preferred over passing the whole report.' },
131
+ checkpoint: { type: 'json', description: 'For `capture`: checkpoint summary (optional).' },
132
+ fixes: {
133
+ type: 'json',
134
+ description: 'For `capture`: array of applied fixes [{id, file, round, summary, linesAdded, linesRemoved, success}].',
135
+ },
136
+ refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
137
+ text: { type: 'string', description: 'For `nudge`: steering text to set (or null to clear).' },
138
+ path: { type: 'string', description: 'Project root directory (default: current working directory).' },
139
+ },
140
+ output: {
141
+ schema: {
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: {
145
+ operation: { type: 'string', required: true },
146
+ found: { type: 'boolean' },
147
+ transcript: { type: 'json' },
148
+ live: { type: 'json', description: 'Recent live reviewer-activity entries (newest first).' },
149
+ updated: { type: 'boolean' },
150
+ error: { type: 'string' },
151
+ },
152
+ },
153
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
154
+ },
155
+ async execute(args, exec) {
156
+ const resolved = resolveProjectRootForExec(exec, args.path);
157
+ if (!resolved.ok)
158
+ return { operation: args.operation, error: resolved.reason };
159
+ const projectRoot = resolved.root;
160
+ const file = transcriptPath(projectRoot);
161
+ const { config } = loadEffectiveConfig(projectRoot);
162
+ const approval = config.observatory?.approval ?? 'ask';
163
+ if (args.operation === 'read') {
164
+ const live = await readLive(projectRoot);
165
+ if (!existsSync(file)) {
166
+ return {
167
+ operation: 'read',
168
+ found: false,
169
+ live: live,
170
+ transcript: new ReviewTranscriptBuilder({
171
+ project: projectRoot,
172
+ approval,
173
+ }).serialize(),
174
+ };
175
+ }
176
+ try {
177
+ const raw = await readFile(file, 'utf-8');
178
+ const parsed = JSON.parse(raw);
179
+ return {
180
+ operation: 'read',
181
+ found: true,
182
+ live: live,
183
+ transcript: parsed,
184
+ };
185
+ }
186
+ catch (err) {
187
+ return {
188
+ operation: 'read',
189
+ found: false,
190
+ error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
191
+ };
192
+ }
193
+ }
194
+ if (args.operation === 'nudge') {
195
+ let manifest = null;
196
+ if (existsSync(file)) {
197
+ try {
198
+ const parsed = JSON.parse(await readFile(file, 'utf-8'));
199
+ manifest = parsed;
200
+ }
201
+ catch {
202
+ manifest = null;
203
+ }
204
+ }
205
+ const builder = manifest
206
+ ? rehydrateBuilder(manifest, approval)
207
+ : new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval });
208
+ builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null);
209
+ await persist(file, builder.serialize());
210
+ return {
211
+ operation: 'nudge',
212
+ updated: true,
213
+ transcript: builder.serialize(),
214
+ };
215
+ }
216
+ // capture
217
+ const mode = args.mode === 'normal' ? 'normal' : 'dry-run';
218
+ const goal = typeof args.goal === 'string' ? args.goal : '';
219
+ const maxRounds = typeof args.maxRounds === 'number' ? Math.floor(args.maxRounds) : 0;
220
+ const builder = new ReviewTranscriptBuilder({ project: projectRoot, mode, approval, goal, maxRounds });
221
+ const report = args.report;
222
+ const reportFindings = report && typeof report === 'object' && Array.isArray(report.findings)
223
+ ? report.findings
224
+ : [];
225
+ const convergence = Array.isArray(args.findingsByRound) ? args.findingsByRound
226
+ : report && typeof report === 'object' && report.convergence
227
+ ? report.convergence.findingsByRound ?? []
228
+ : [];
229
+ const rounds = Array.isArray(args.rounds) ? args.rounds : [];
230
+ for (const r of rounds)
231
+ captureRound(builder, r);
232
+ if (rounds.length === 0) {
233
+ // No pre-grouped rounds: fall back to the report's flattened findings.
234
+ const readFiles = Array.isArray(args.refReadFiles) ? args.refReadFiles : [];
235
+ const byDim = new Map();
236
+ for (const f of reportFindings) {
237
+ if (!f || typeof f !== 'object')
238
+ continue;
239
+ const rec = f;
240
+ const dim = typeof rec.dimension === 'string' && rec.dimension ? rec.dimension : 'review';
241
+ const list = byDim.get(dim) ?? [];
242
+ list.push(f);
243
+ byDim.set(dim, list);
244
+ }
245
+ for (const [dim, list] of byDim)
246
+ builder.reviewerSnapshot(dim, list, readFiles);
247
+ }
248
+ // Convergence series from the report (position per round).
249
+ for (let i = 0; i < convergence.length; i += 1) {
250
+ const n = convergence[i];
251
+ if (typeof n === 'number')
252
+ builder.snapshotConvergence(i + 1, n);
253
+ }
254
+ const roundsExecuted = typeof args.roundsExecuted === 'number' ? Math.floor(args.roundsExecuted) : rounds.length;
255
+ if (roundsExecuted > 0)
256
+ builder.roundStart(roundsExecuted, maxRounds);
257
+ builder.recordCheckpoint(normalizeCheckpoint(args.checkpoint));
258
+ if (Array.isArray(args.fixes)) {
259
+ for (const fx of args.fixes) {
260
+ const record = normalizeFix(fx);
261
+ if (record)
262
+ builder.fix(record);
263
+ }
264
+ }
265
+ // Convergence "found nothing → settled" marker when the trend ends on 0.
266
+ const last = convergence[convergence.length - 1];
267
+ if (convergence.length > 0 && last === 0)
268
+ builder.finish();
269
+ await persist(file, builder.serialize());
270
+ const live = await readLive(projectRoot);
271
+ return {
272
+ operation: 'capture',
273
+ found: true,
274
+ updated: true,
275
+ live: live,
276
+ transcript: builder.serialize(),
277
+ };
278
+ },
279
+ }));
280
+ }
281
+ /** Rebuild a builder from a persisted manifest so nudge edits preserve history. */
282
+ function rehydrateBuilder(manifest, approval) {
283
+ const builder = new ReviewTranscriptBuilder({
284
+ project: manifest.project,
285
+ mode: manifest.mode ?? null,
286
+ approval,
287
+ goal: manifest.goal,
288
+ maxRounds: manifest.maxRounds,
289
+ });
290
+ for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
291
+ builder.roundStart(r.round, manifest.maxRounds);
292
+ for (const t of Array.isArray(r.threads) ? r.threads : []) {
293
+ builder.reviewerStart(t.dimension || 'review', t.attempt || 1);
294
+ builder.reviewerMessage((t.messages ?? []).join('\n'));
295
+ builder.reviewerRead(t.readFiles ?? []);
296
+ for (const f of t.findings ?? [])
297
+ builder.reviewerFindings([f]);
298
+ }
299
+ }
300
+ for (let idx = 0; idx < (manifest.convergence ?? []).length; idx += 1) {
301
+ const n = manifest.convergence[idx];
302
+ if (typeof n === 'number' && n >= 0)
303
+ builder.snapshotConvergence(idx + 1, n);
304
+ }
305
+ if (manifest.checkpoint)
306
+ builder.recordCheckpoint(manifest.checkpoint);
307
+ if (Array.isArray(manifest.fixes))
308
+ for (const fx of manifest.fixes)
309
+ builder.fix(fx);
310
+ if (Array.isArray(manifest.timeline))
311
+ for (const e of manifest.timeline)
312
+ builder.decision(e);
313
+ builder.setNudge(manifest.nudge?.text ?? null);
314
+ if (!manifest.active)
315
+ builder.finish();
316
+ return builder;
317
+ }
318
+ /** Atomically persist a manifest (tmp + rename) under `.iterate/`. */
319
+ async function persist(file, manifest) {
320
+ await mkdir(dirname(file), { recursive: true });
321
+ const tmp = `${file}.tmp`;
322
+ await writeFile(tmp, JSON.stringify(manifest, null, 2), 'utf-8');
323
+ await rename(tmp, file);
324
+ }