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.
@@ -34,12 +34,91 @@ import {
34
34
  import { transcriptPath } from '../paths.ts'
35
35
  import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from '../transcript.ts'
36
36
  import { readDecisionLogDetailed } from './decision-log.ts'
37
+ import { readQualityGate } from './quality-store.ts'
37
38
  import { readLive } from '../live.ts'
39
+ import {
40
+ buildRunRecord,
41
+ appendRunRecord,
42
+ readRunRecords,
43
+ sessionOf,
44
+ type IterateRunRecord,
45
+ type StoredRunRecord,
46
+ } from '../session-record.ts'
38
47
  import type {
39
48
  TranscriptManifest,
40
49
  TranscriptFix,
41
50
  } from '../types.ts'
42
51
 
52
+ /** Severity tally over a manifest's (globally deduped) findings. */
53
+ function severityCounts(findings: unknown): IterateRunRecord['findings'] {
54
+ const counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
55
+ if (!Array.isArray(findings)) return counts
56
+ for (const f of findings) {
57
+ if (!f || typeof f !== 'object') continue
58
+ const sev = (f as { severity?: unknown }).severity
59
+ counts.total++
60
+ if (sev === 'critical') counts.critical++
61
+ else if (sev === 'high') counts.high++
62
+ else if (sev === 'medium') counts.medium++
63
+ else counts.low++
64
+ }
65
+ return counts
66
+ }
67
+
68
+ /** Build the compact session-linked record from a finished manifest. */
69
+ function runRecordFromManifest(
70
+ manifest: TranscriptManifest,
71
+ qualityStatus: 'pass' | 'fail' | 'pending' | null = null,
72
+ ): IterateRunRecord {
73
+ return buildRunRecord({
74
+ project: manifest.project,
75
+ goal: manifest.goal,
76
+ mode: manifest.mode,
77
+ taskMode: manifest.taskMode,
78
+ round: manifest.round,
79
+ maxRounds: manifest.maxRounds,
80
+ active: manifest.active,
81
+ stoppedReason: manifest.stoppedReason,
82
+ convergence: manifest.convergence,
83
+ counts: severityCounts(manifest.findings),
84
+ fixedCount: Array.isArray(manifest.fixes) ? manifest.fixes.filter((f) => f.success !== false).length : 0,
85
+ qualityStatus,
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Rebuild a (summary-only) transcript manifest from a session-linked record.
91
+ *
92
+ * Degraded by design: the record carries the run SUMMARY (mode / goal / round /
93
+ * max-rounds / end state / convergence series), not the per-finding detail, so
94
+ * the recovered manifest has an empty `findings`/`fixes`/`timeline`. It exists
95
+ * so the observatory shows the real run header + trend after `.iterate/` is
96
+ * lost, instead of a blank panel. Round rows stay empty because the record has
97
+ * no per-thread narration.
98
+ */
99
+ export function recoverManifestFromRecord(
100
+ record: IterateRunRecord,
101
+ fallbackProject: string,
102
+ approval: 'ask' | 'deny' | 'allow',
103
+ ): TranscriptManifest {
104
+ const builder = new ReviewTranscriptBuilder({
105
+ project: record.project || fallbackProject,
106
+ mode: record.mode,
107
+ taskMode: record.taskMode,
108
+ approval,
109
+ goal: record.goal,
110
+ maxRounds: record.maxRounds,
111
+ })
112
+ const series = Array.isArray(record.convergence) ? record.convergence : []
113
+ for (let i = 0; i < Math.min(series.length, MAX_ROUNDS); i += 1) {
114
+ const n = series[i]
115
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) builder.snapshotConvergence(i + 1, n)
116
+ }
117
+ if (record.round > 0) builder.advanceRound(record.round, record.maxRounds)
118
+ if (!record.active) builder.finish(record.stoppedReason ?? undefined)
119
+ return builder.serialize()
120
+ }
121
+
43
122
  /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
44
123
  function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
45
124
  if (!round || typeof round !== 'object') return
@@ -158,27 +237,37 @@ export function registerTranscriptTool(ctx: {
158
237
  ctx.tools.register(
159
238
  defineTool({
160
239
  name: 'iterate_transcript',
161
- // `read` never writes; `capture`/`nudge` persist transcript state → only
162
- // read joins a parallel dispatch group.
163
- isConcurrencySafe: (args) => (args as { operation?: unknown }).operation === 'read',
240
+ // `read`/`recall` never write; `capture`/`nudge` persist transcript state
241
+ // → only the read shapes join a parallel dispatch group.
242
+ isConcurrencySafe: (args) => {
243
+ const op = (args as { operation?: unknown }).operation
244
+ return op === 'read' || op === 'recall'
245
+ },
164
246
  description:
165
247
  'Runtime-observatory transcript for the iterate workflow. ' +
166
248
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
167
249
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
168
250
  'written for the next round) — or, when the file is missing/malformed, a structured ' +
169
- 'empty view (`found:false` plus a blank manifest). ' +
251
+ 'empty view (`found:false` plus a blank manifest). When the on-disk file is gone but the ' +
252
+ 'session carries a linked run record (dsh ≥ 0.2.1-rc.1), `read` RECOVERS a summary manifest ' +
253
+ 'from that record and sets `recovered:true`. ' +
170
254
  '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
171
255
  'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
172
256
  'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
173
257
  'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
258
+ 'On a successful capture the tool ALSO appends a compact run record onto the dsh session log ' +
259
+ '(best-effort; reported as `sessionRecord`) so the run survives `.iterate/` loss and travels with ' +
260
+ 'the session. ' +
261
+ '`recall` lists the session-linked run records (newest first) — useful to audit a run after ' +
262
+ 'restart or to confirm the write took. ' +
174
263
  '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
175
264
  'Purely local and deterministic — never touches source files.',
176
265
  parameters: {
177
266
  operation: {
178
267
  type: 'string',
179
268
  required: true,
180
- description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
181
- enum: ['read', 'capture', 'nudge'],
269
+ description: '"read" to fetch the manifest, "capture" to persist one, "recall" to list session-linked run records, "nudge" to set steering text.',
270
+ enum: ['read', 'capture', 'recall', 'nudge'],
182
271
  },
183
272
  rounds: {
184
273
  type: 'json',
@@ -261,6 +350,19 @@ export function registerTranscriptTool(ctx: {
261
350
  description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
262
351
  },
263
352
  reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
353
+ recovered: {
354
+ type: 'boolean',
355
+ description: 'For `read`: true when the manifest was rebuilt from a session-linked run record because the on-disk file was missing or unreadable.',
356
+ },
357
+ supported: {
358
+ type: 'boolean',
359
+ description: 'For `recall`: whether the host exposes the dsh session state-record API (dsh ≥ 0.2.1-rc.1).',
360
+ },
361
+ records: { type: 'json', description: 'For `recall`: session-linked run records, newest first.' },
362
+ sessionRecord: {
363
+ type: 'json',
364
+ description: 'For `capture`/`read`: the session-link outcome/position (`{linked, seq}` on capture, `{seq, updatedAt}` on a recovery).',
365
+ },
264
366
  error: { type: 'string' },
265
367
  },
266
368
  },
@@ -296,11 +398,59 @@ export function registerTranscriptTool(ctx: {
296
398
  approval,
297
399
  }).serialize() as unknown as JsonValue,
298
400
  })
299
- if (!existsSync(file)) return emptyView()
401
+ // Recovery (dsh ≥ 0.2.1-rc.1): when the on-disk manifest is gone
402
+ // but the SESSION that produced the run carries a linked run record,
403
+ // rebuild a summary manifest so the observatory shows the real run
404
+ // header + trend instead of a blank panel. Best-effort: no session,
405
+ // no API, or no record all fall back to `emptyView()`.
406
+ const recover = async ():
407
+ | Promise<{ transcript: JsonValue; sessionRecord: JsonValue } | null> => {
408
+ const session = sessionOf(exec)
409
+ if (!session) return null
410
+ const { supported, records } = await readRunRecords(session)
411
+ if (!supported || records.length === 0) return null
412
+ const latest = records[records.length - 1] as StoredRunRecord
413
+ return {
414
+ transcript: normalizeManifestBounds(
415
+ recoverManifestFromRecord(latest, projectRoot, approval),
416
+ ) as unknown as JsonValue,
417
+ sessionRecord: {
418
+ seq: latest.seq,
419
+ time: latest.time,
420
+ updatedAt: latest.updatedAt,
421
+ } as unknown as JsonValue,
422
+ }
423
+ }
424
+ if (!existsSync(file)) {
425
+ const recovered = await recover()
426
+ if (recovered) {
427
+ return {
428
+ operation: 'read',
429
+ found: true,
430
+ recovered: true,
431
+ live: live as unknown as JsonValue,
432
+ transcript: recovered.transcript,
433
+ sessionRecord: recovered.sessionRecord,
434
+ }
435
+ }
436
+ return emptyView()
437
+ }
300
438
  let parsed: unknown
301
439
  try {
302
440
  parsed = JSON.parse(await readFile(file, 'utf-8'))
303
441
  } catch (err) {
442
+ const recovered = await recover()
443
+ if (recovered) {
444
+ return {
445
+ operation: 'read',
446
+ found: true,
447
+ recovered: true,
448
+ live: live as unknown as JsonValue,
449
+ transcript: recovered.transcript,
450
+ sessionRecord: recovered.sessionRecord,
451
+ error: `Recovered a summary from the session log; on-disk transcript was unreadable (${err instanceof Error ? err.message : String(err)}).`,
452
+ }
453
+ }
304
454
  return {
305
455
  ...emptyView(),
306
456
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
@@ -310,6 +460,18 @@ export function registerTranscriptTool(ctx: {
310
460
  // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
311
461
  // are parseable but carry no manifest. Surfacing them as
312
462
  // `found:true` made the observatory render junk panels.
463
+ const recovered = await recover()
464
+ if (recovered) {
465
+ return {
466
+ operation: 'read',
467
+ found: true,
468
+ recovered: true,
469
+ live: live as unknown as JsonValue,
470
+ transcript: recovered.transcript,
471
+ sessionRecord: recovered.sessionRecord,
472
+ error: 'Recovered a summary from the session log; on-disk file is not a valid manifest.',
473
+ }
474
+ }
313
475
  return {
314
476
  ...emptyView(),
315
477
  error: 'Transcript file is not a valid manifest; returning an empty view.',
@@ -326,6 +488,22 @@ export function registerTranscriptTool(ctx: {
326
488
  }
327
489
  }
328
490
 
491
+ if (args.operation === 'recall') {
492
+ // New capability: list the run records this plugin appended onto the
493
+ // dsh session log (dsh ≥ 0.2.1-rc.1). Read-only; degrades to
494
+ // `supported:false` on a host without the API.
495
+ const session = sessionOf(exec)
496
+ const { supported, records, error } = await readRunRecords(session)
497
+ const newestFirst = [...records].reverse()
498
+ return {
499
+ operation: 'recall',
500
+ found: newestFirst.length > 0,
501
+ supported,
502
+ records: newestFirst as unknown as JsonValue,
503
+ ...(error ? { error } : {}),
504
+ }
505
+ }
506
+
329
507
  if (args.operation === 'nudge') {
330
508
  // Read-modify-write across processes: a concurrent nudge in a sibling
331
509
  // process would otherwise read the same manifest and write back over
@@ -498,17 +676,40 @@ export function registerTranscriptTool(ctx: {
498
676
  else if (convergence.length > 0 && last === 0) builder.finish('converged')
499
677
  else if (roundsExecuted > 0) builder.finish('max_rounds_reached')
500
678
 
501
- const persisted = await persistChecked(file, builder.serialize())
679
+ const manifest = builder.serialize()
680
+ const persisted = await persistChecked(file, manifest)
502
681
  if (!persisted.ok) {
503
682
  return { operation: 'capture', found: true, updated: false, error: persisted.error }
504
683
  }
684
+ // Session link (dsh ≥ 0.2.1-rc.1): append a compact run record onto the
685
+ // session log so the run survives `.iterate/` loss and travels with the
686
+ // session. Best-effort — a link failure is reported, never fatal (the
687
+ // on-disk capture above already succeeded).
688
+ let sessionRecord: JsonValue
689
+ // Snapshot the CURRENT quality-gate verdict into the record so a
690
+ // recovered run keeps its PASS/FAIL badge. An absent gate reads as the
691
+ // store's default (`pending`); a corrupt/throwing read degrades to
692
+ // `null` and never fails the capture.
693
+ let qualityStatus: 'pass' | 'fail' | 'pending' | null = null
694
+ try {
695
+ qualityStatus = readQualityGate(projectRoot).overallStatus ?? null
696
+ } catch {
697
+ qualityStatus = null
698
+ }
699
+ const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest, qualityStatus))
700
+ sessionRecord = (
701
+ linkSession.ok
702
+ ? { linked: true, seq: linkSession.seq }
703
+ : { linked: false, supported: linkSession.supported, reason: linkSession.error }
704
+ ) as unknown as JsonValue
505
705
  const live = await readLive(projectRoot)
506
706
  return {
507
707
  operation: 'capture',
508
708
  found: true,
509
709
  updated: true,
510
710
  live: live as unknown as JsonValue,
511
- transcript: builder.serialize() as unknown as JsonValue,
711
+ transcript: manifest as unknown as JsonValue,
712
+ sessionRecord,
512
713
  }
513
714
  },
514
715
  }),
@@ -525,12 +726,20 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
525
726
  goal: manifest.goal,
526
727
  maxRounds: manifest.maxRounds,
527
728
  })
729
+ // Phase breadcrumbs are part of the run narrative the F1 stage tracker shows;
730
+ // replay them before the rounds so an edit round-trip keeps the stage order.
731
+ for (const p of Array.isArray(manifest.phases) ? manifest.phases : []) {
732
+ if (typeof p === 'string') builder.phase(p)
733
+ }
528
734
  for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
529
735
  // A malformed/null round row must be skipped, never a throw (the caller
530
736
  // wraps rehydrate in a fallback, but preserving sibling rows is better).
531
737
  if (!r || typeof r !== 'object') continue
532
738
  builder.roundStart(r.round, manifest.maxRounds)
533
739
  for (const t of Array.isArray(r.threads) ? r.threads : []) {
740
+ // A null/garbled thread row must be skipped too: reading `t.dimension`
741
+ // off it would throw and cost the WHOLE manifest its history.
742
+ if (!t || typeof t !== 'object') continue
534
743
  // restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
535
744
  // message ARRAY boundaries and the thread's own dimension: rehydration
536
745
  // previously collapsed each thread's messages into one string and, once a
@@ -549,6 +758,14 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
549
758
  const n = manifest.convergence[idx]
550
759
  if (typeof n === 'number' && n >= 0) builder.snapshotConvergence(idx + 1, n)
551
760
  }
761
+ // Top-level findings live in the global newest-wins list (the F3 panel reads
762
+ // `manifest.findings`); restoreThread only rebuilds the per-round threads, so
763
+ // without this a nudge/rollback re-persist blanked the findings panel.
764
+ if (Array.isArray(manifest.findings)) builder.restoreFindings(manifest.findings)
765
+ // Move the round marker forward past the captured rows: `roundStart` reopens
766
+ // rows, but a manifest whose `round` outruns its rows (advanceRound semantics)
767
+ // would otherwise rewind the reported round.
768
+ builder.advanceRound(manifest.round, manifest.maxRounds)
552
769
  if (manifest.checkpoint) builder.recordCheckpoint(manifest.checkpoint)
553
770
  if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
554
771
  // Validation rows survive a nudge-edit re-persist (same defensive
package/src/transcript.ts CHANGED
@@ -486,6 +486,24 @@ export class ReviewTranscriptBuilder {
486
486
  this.touch()
487
487
  }
488
488
 
489
+ /**
490
+ * Re-insert top-level findings when rehydrating a persisted manifest (the
491
+ * nudge / rollback edit round-trip). `restoreThread` already rebuilds the
492
+ * per-round thread copy of each finding but never the global list, so an edit
493
+ * that only rewrites steering text used to blank `manifest.findings` and the
494
+ * F3 findings panel. This refills the global newest-wins list (and its dedupe
495
+ * set), mirroring `reviewerFindings` without needing a live thread.
496
+ */
497
+ restoreFindings(findings: readonly unknown[]): void {
498
+ if (Array.isArray(findings)) {
499
+ for (const raw of findings) {
500
+ const f = normalizeFinding(raw)
501
+ if (f) this.addGlobal(f)
502
+ }
503
+ }
504
+ this.touch()
505
+ }
506
+
489
507
  // ─── Convergence (F2) ───────────────────────────────────────────────────
490
508
 
491
509
  /** Record a round's new-finding count for the convergence series. */