iterate-plugin 3.8.0 → 3.9.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.
@@ -29,7 +29,78 @@ import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loade
29
29
  import { transcriptPath } from "../paths.js";
30
30
  import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from "../transcript.js";
31
31
  import { readDecisionLogDetailed } from "./decision-log.js";
32
+ import { readQualityGate } from "./quality-store.js";
32
33
  import { readLive } from "../live.js";
34
+ import { buildRunRecord, appendRunRecord, readRunRecords, sessionOf, } from "../session-record.js";
35
+ /** Severity tally over a manifest's (globally deduped) findings. */
36
+ function severityCounts(findings) {
37
+ const counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
38
+ if (!Array.isArray(findings))
39
+ return counts;
40
+ for (const f of findings) {
41
+ if (!f || typeof f !== 'object')
42
+ continue;
43
+ const sev = f.severity;
44
+ counts.total++;
45
+ if (sev === 'critical')
46
+ counts.critical++;
47
+ else if (sev === 'high')
48
+ counts.high++;
49
+ else if (sev === 'medium')
50
+ counts.medium++;
51
+ else
52
+ counts.low++;
53
+ }
54
+ return counts;
55
+ }
56
+ /** Build the compact session-linked record from a finished manifest. */
57
+ function runRecordFromManifest(manifest, qualityStatus = null) {
58
+ return buildRunRecord({
59
+ project: manifest.project,
60
+ goal: manifest.goal,
61
+ mode: manifest.mode,
62
+ taskMode: manifest.taskMode,
63
+ round: manifest.round,
64
+ maxRounds: manifest.maxRounds,
65
+ active: manifest.active,
66
+ stoppedReason: manifest.stoppedReason,
67
+ convergence: manifest.convergence,
68
+ counts: severityCounts(manifest.findings),
69
+ fixedCount: Array.isArray(manifest.fixes) ? manifest.fixes.filter((f) => f.success !== false).length : 0,
70
+ qualityStatus,
71
+ });
72
+ }
73
+ /**
74
+ * Rebuild a (summary-only) transcript manifest from a session-linked record.
75
+ *
76
+ * Degraded by design: the record carries the run SUMMARY (mode / goal / round /
77
+ * max-rounds / end state / convergence series), not the per-finding detail, so
78
+ * the recovered manifest has an empty `findings`/`fixes`/`timeline`. It exists
79
+ * so the observatory shows the real run header + trend after `.iterate/` is
80
+ * lost, instead of a blank panel. Round rows stay empty because the record has
81
+ * no per-thread narration.
82
+ */
83
+ export function recoverManifestFromRecord(record, fallbackProject, approval) {
84
+ const builder = new ReviewTranscriptBuilder({
85
+ project: record.project || fallbackProject,
86
+ mode: record.mode,
87
+ taskMode: record.taskMode,
88
+ approval,
89
+ goal: record.goal,
90
+ maxRounds: record.maxRounds,
91
+ });
92
+ const series = Array.isArray(record.convergence) ? record.convergence : [];
93
+ for (let i = 0; i < Math.min(series.length, MAX_ROUNDS); i += 1) {
94
+ const n = series[i];
95
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
96
+ builder.snapshotConvergence(i + 1, n);
97
+ }
98
+ if (record.round > 0)
99
+ builder.advanceRound(record.round, record.maxRounds);
100
+ if (!record.active)
101
+ builder.finish(record.stoppedReason ?? undefined);
102
+ return builder.serialize();
103
+ }
33
104
  /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
34
105
  function captureRound(builder, round) {
35
106
  if (!round || typeof round !== 'object')
@@ -143,26 +214,36 @@ function isManifestShaped(value) {
143
214
  export function registerTranscriptTool(ctx) {
144
215
  ctx.tools.register(defineTool({
145
216
  name: 'iterate_transcript',
146
- // `read` never writes; `capture`/`nudge` persist transcript state → only
147
- // read joins a parallel dispatch group.
148
- isConcurrencySafe: (args) => args.operation === 'read',
217
+ // `read`/`recall` never write; `capture`/`nudge` persist transcript state
218
+ // → only the read shapes join a parallel dispatch group.
219
+ isConcurrencySafe: (args) => {
220
+ const op = args.operation;
221
+ return op === 'read' || op === 'recall';
222
+ },
149
223
  description: 'Runtime-observatory transcript for the iterate workflow. ' +
150
224
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
151
225
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
152
226
  'written for the next round) — or, when the file is missing/malformed, a structured ' +
153
- 'empty view (`found:false` plus a blank manifest). ' +
227
+ 'empty view (`found:false` plus a blank manifest). When the on-disk file is gone but the ' +
228
+ 'session carries a linked run record (dsh ≥ 0.2.1-rc.1), `read` RECOVERS a summary manifest ' +
229
+ 'from that record and sets `recovered:true`. ' +
154
230
  '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
155
231
  'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
156
232
  'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
157
233
  'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
234
+ 'On a successful capture the tool ALSO appends a compact run record onto the dsh session log ' +
235
+ '(best-effort; reported as `sessionRecord`) so the run survives `.iterate/` loss and travels with ' +
236
+ 'the session. ' +
237
+ '`recall` lists the session-linked run records (newest first) — useful to audit a run after ' +
238
+ 'restart or to confirm the write took. ' +
158
239
  '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
159
240
  'Purely local and deterministic — never touches source files.',
160
241
  parameters: {
161
242
  operation: {
162
243
  type: 'string',
163
244
  required: true,
164
- description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
165
- enum: ['read', 'capture', 'nudge'],
245
+ description: '"read" to fetch the manifest, "capture" to persist one, "recall" to list session-linked run records, "nudge" to set steering text.',
246
+ enum: ['read', 'capture', 'recall', 'nudge'],
166
247
  },
167
248
  rounds: {
168
249
  type: 'json',
@@ -244,6 +325,19 @@ export function registerTranscriptTool(ctx) {
244
325
  description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
245
326
  },
246
327
  reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
328
+ recovered: {
329
+ type: 'boolean',
330
+ description: 'For `read`: true when the manifest was rebuilt from a session-linked run record because the on-disk file was missing or unreadable.',
331
+ },
332
+ supported: {
333
+ type: 'boolean',
334
+ description: 'For `recall`: whether the host exposes the dsh session state-record API (dsh ≥ 0.2.1-rc.1).',
335
+ },
336
+ records: { type: 'json', description: 'For `recall`: session-linked run records, newest first.' },
337
+ sessionRecord: {
338
+ type: 'json',
339
+ description: 'For `capture`/`read`: the session-link outcome/position (`{linked, seq}` on capture, `{seq, updatedAt}` on a recovery).',
340
+ },
247
341
  error: { type: 'string' },
248
342
  },
249
343
  },
@@ -273,13 +367,59 @@ export function registerTranscriptTool(ctx) {
273
367
  approval,
274
368
  }).serialize(),
275
369
  });
276
- if (!existsSync(file))
370
+ // Recovery (dsh ≥ 0.2.1-rc.1): when the on-disk manifest is gone
371
+ // but the SESSION that produced the run carries a linked run record,
372
+ // rebuild a summary manifest so the observatory shows the real run
373
+ // header + trend instead of a blank panel. Best-effort: no session,
374
+ // no API, or no record all fall back to `emptyView()`.
375
+ const recover = async () => {
376
+ const session = sessionOf(exec);
377
+ if (!session)
378
+ return null;
379
+ const { supported, records } = await readRunRecords(session);
380
+ if (!supported || records.length === 0)
381
+ return null;
382
+ const latest = records[records.length - 1];
383
+ return {
384
+ transcript: normalizeManifestBounds(recoverManifestFromRecord(latest, projectRoot, approval)),
385
+ sessionRecord: {
386
+ seq: latest.seq,
387
+ time: latest.time,
388
+ updatedAt: latest.updatedAt,
389
+ },
390
+ };
391
+ };
392
+ if (!existsSync(file)) {
393
+ const recovered = await recover();
394
+ if (recovered) {
395
+ return {
396
+ operation: 'read',
397
+ found: true,
398
+ recovered: true,
399
+ live: live,
400
+ transcript: recovered.transcript,
401
+ sessionRecord: recovered.sessionRecord,
402
+ };
403
+ }
277
404
  return emptyView();
405
+ }
278
406
  let parsed;
279
407
  try {
280
408
  parsed = JSON.parse(await readFile(file, 'utf-8'));
281
409
  }
282
410
  catch (err) {
411
+ const recovered = await recover();
412
+ if (recovered) {
413
+ return {
414
+ operation: 'read',
415
+ found: true,
416
+ recovered: true,
417
+ live: live,
418
+ transcript: recovered.transcript,
419
+ sessionRecord: recovered.sessionRecord,
420
+ error: `Recovered a summary from the session log; on-disk transcript was unreadable (${err instanceof Error ? err.message : String(err)}).`,
421
+ };
422
+ }
283
423
  return {
284
424
  ...emptyView(),
285
425
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
@@ -289,6 +429,18 @@ export function registerTranscriptTool(ctx) {
289
429
  // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
290
430
  // are parseable but carry no manifest. Surfacing them as
291
431
  // `found:true` made the observatory render junk panels.
432
+ const recovered = await recover();
433
+ if (recovered) {
434
+ return {
435
+ operation: 'read',
436
+ found: true,
437
+ recovered: true,
438
+ live: live,
439
+ transcript: recovered.transcript,
440
+ sessionRecord: recovered.sessionRecord,
441
+ error: 'Recovered a summary from the session log; on-disk file is not a valid manifest.',
442
+ };
443
+ }
292
444
  return {
293
445
  ...emptyView(),
294
446
  error: 'Transcript file is not a valid manifest; returning an empty view.',
@@ -304,6 +456,21 @@ export function registerTranscriptTool(ctx) {
304
456
  transcript: normalizeManifestBounds(parsed),
305
457
  };
306
458
  }
459
+ if (args.operation === 'recall') {
460
+ // New capability: list the run records this plugin appended onto the
461
+ // dsh session log (dsh ≥ 0.2.1-rc.1). Read-only; degrades to
462
+ // `supported:false` on a host without the API.
463
+ const session = sessionOf(exec);
464
+ const { supported, records, error } = await readRunRecords(session);
465
+ const newestFirst = [...records].reverse();
466
+ return {
467
+ operation: 'recall',
468
+ found: newestFirst.length > 0,
469
+ supported,
470
+ records: newestFirst,
471
+ ...(error ? { error } : {}),
472
+ };
473
+ }
307
474
  if (args.operation === 'nudge') {
308
475
  // Read-modify-write across processes: a concurrent nudge in a sibling
309
476
  // process would otherwise read the same manifest and write back over
@@ -480,17 +647,39 @@ export function registerTranscriptTool(ctx) {
480
647
  builder.finish('converged');
481
648
  else if (roundsExecuted > 0)
482
649
  builder.finish('max_rounds_reached');
483
- const persisted = await persistChecked(file, builder.serialize());
650
+ const manifest = builder.serialize();
651
+ const persisted = await persistChecked(file, manifest);
484
652
  if (!persisted.ok) {
485
653
  return { operation: 'capture', found: true, updated: false, error: persisted.error };
486
654
  }
655
+ // Session link (dsh ≥ 0.2.1-rc.1): append a compact run record onto the
656
+ // session log so the run survives `.iterate/` loss and travels with the
657
+ // session. Best-effort — a link failure is reported, never fatal (the
658
+ // on-disk capture above already succeeded).
659
+ let sessionRecord;
660
+ // Snapshot the CURRENT quality-gate verdict into the record so a
661
+ // recovered run keeps its PASS/FAIL badge. An absent gate reads as the
662
+ // store's default (`pending`); a corrupt/throwing read degrades to
663
+ // `null` and never fails the capture.
664
+ let qualityStatus = null;
665
+ try {
666
+ qualityStatus = readQualityGate(projectRoot).overallStatus ?? null;
667
+ }
668
+ catch {
669
+ qualityStatus = null;
670
+ }
671
+ const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest, qualityStatus));
672
+ sessionRecord = (linkSession.ok
673
+ ? { linked: true, seq: linkSession.seq }
674
+ : { linked: false, supported: linkSession.supported, reason: linkSession.error });
487
675
  const live = await readLive(projectRoot);
488
676
  return {
489
677
  operation: 'capture',
490
678
  found: true,
491
679
  updated: true,
492
680
  live: live,
493
- transcript: builder.serialize(),
681
+ transcript: manifest,
682
+ sessionRecord,
494
683
  };
495
684
  },
496
685
  }));
@@ -505,6 +694,12 @@ function rehydrateBuilder(manifest, approval) {
505
694
  goal: manifest.goal,
506
695
  maxRounds: manifest.maxRounds,
507
696
  });
697
+ // Phase breadcrumbs are part of the run narrative the F1 stage tracker shows;
698
+ // replay them before the rounds so an edit round-trip keeps the stage order.
699
+ for (const p of Array.isArray(manifest.phases) ? manifest.phases : []) {
700
+ if (typeof p === 'string')
701
+ builder.phase(p);
702
+ }
508
703
  for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
509
704
  // A malformed/null round row must be skipped, never a throw (the caller
510
705
  // wraps rehydrate in a fallback, but preserving sibling rows is better).
@@ -512,6 +707,10 @@ function rehydrateBuilder(manifest, approval) {
512
707
  continue;
513
708
  builder.roundStart(r.round, manifest.maxRounds);
514
709
  for (const t of Array.isArray(r.threads) ? r.threads : []) {
710
+ // A null/garbled thread row must be skipped too: reading `t.dimension`
711
+ // off it would throw and cost the WHOLE manifest its history.
712
+ if (!t || typeof t !== 'object')
713
+ continue;
515
714
  // restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
516
715
  // message ARRAY boundaries and the thread's own dimension: rehydration
517
716
  // previously collapsed each thread's messages into one string and, once a
@@ -531,6 +730,15 @@ function rehydrateBuilder(manifest, approval) {
531
730
  if (typeof n === 'number' && n >= 0)
532
731
  builder.snapshotConvergence(idx + 1, n);
533
732
  }
733
+ // Top-level findings live in the global newest-wins list (the F3 panel reads
734
+ // `manifest.findings`); restoreThread only rebuilds the per-round threads, so
735
+ // without this a nudge/rollback re-persist blanked the findings panel.
736
+ if (Array.isArray(manifest.findings))
737
+ builder.restoreFindings(manifest.findings);
738
+ // Move the round marker forward past the captured rows: `roundStart` reopens
739
+ // rows, but a manifest whose `round` outruns its rows (advanceRound semantics)
740
+ // would otherwise rewind the reported round.
741
+ builder.advanceRound(manifest.round, manifest.maxRounds);
534
742
  if (manifest.checkpoint)
535
743
  builder.recordCheckpoint(manifest.checkpoint);
536
744
  if (Array.isArray(manifest.fixes))
@@ -428,6 +428,24 @@ export class ReviewTranscriptBuilder {
428
428
  }
429
429
  this.touch();
430
430
  }
431
+ /**
432
+ * Re-insert top-level findings when rehydrating a persisted manifest (the
433
+ * nudge / rollback edit round-trip). `restoreThread` already rebuilds the
434
+ * per-round thread copy of each finding but never the global list, so an edit
435
+ * that only rewrites steering text used to blank `manifest.findings` and the
436
+ * F3 findings panel. This refills the global newest-wins list (and its dedupe
437
+ * set), mirroring `reviewerFindings` without needing a live thread.
438
+ */
439
+ restoreFindings(findings) {
440
+ if (Array.isArray(findings)) {
441
+ for (const raw of findings) {
442
+ const f = normalizeFinding(raw);
443
+ if (f)
444
+ this.addGlobal(f);
445
+ }
446
+ }
447
+ this.touch();
448
+ }
431
449
  // ─── Convergence (F2) ───────────────────────────────────────────────────
432
450
  /** Record a round's new-finding count for the convergence series. */
433
451
  snapshotConvergence(round, newCount) {