iterate-plugin 3.8.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.
@@ -35,11 +35,89 @@ 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
37
  import { readLive } from '../live.ts'
38
+ import {
39
+ buildRunRecord,
40
+ appendRunRecord,
41
+ readRunRecords,
42
+ sessionOf,
43
+ type IterateRunRecord,
44
+ type StoredRunRecord,
45
+ } from '../session-record.ts'
38
46
  import type {
39
47
  TranscriptManifest,
40
48
  TranscriptFix,
41
49
  } from '../types.ts'
42
50
 
51
+ /** Severity tally over a manifest's (globally deduped) findings. */
52
+ function severityCounts(findings: unknown): IterateRunRecord['findings'] {
53
+ const counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
54
+ if (!Array.isArray(findings)) return counts
55
+ for (const f of findings) {
56
+ if (!f || typeof f !== 'object') continue
57
+ const sev = (f as { severity?: unknown }).severity
58
+ counts.total++
59
+ if (sev === 'critical') counts.critical++
60
+ else if (sev === 'high') counts.high++
61
+ else if (sev === 'medium') counts.medium++
62
+ else counts.low++
63
+ }
64
+ return counts
65
+ }
66
+
67
+ /** Build the compact session-linked record from a finished manifest. */
68
+ function runRecordFromManifest(
69
+ manifest: TranscriptManifest,
70
+ qualityStatus: 'pass' | 'fail' | 'pending' | null = null,
71
+ ): IterateRunRecord {
72
+ return buildRunRecord({
73
+ project: manifest.project,
74
+ goal: manifest.goal,
75
+ mode: manifest.mode,
76
+ taskMode: manifest.taskMode,
77
+ round: manifest.round,
78
+ maxRounds: manifest.maxRounds,
79
+ active: manifest.active,
80
+ stoppedReason: manifest.stoppedReason,
81
+ convergence: manifest.convergence,
82
+ counts: severityCounts(manifest.findings),
83
+ fixedCount: Array.isArray(manifest.fixes) ? manifest.fixes.filter((f) => f.success !== false).length : 0,
84
+ qualityStatus,
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Rebuild a (summary-only) transcript manifest from a session-linked record.
90
+ *
91
+ * Degraded by design: the record carries the run SUMMARY (mode / goal / round /
92
+ * max-rounds / end state / convergence series), not the per-finding detail, so
93
+ * the recovered manifest has an empty `findings`/`fixes`/`timeline`. It exists
94
+ * so the observatory shows the real run header + trend after `.iterate/` is
95
+ * lost, instead of a blank panel. Round rows stay empty because the record has
96
+ * no per-thread narration.
97
+ */
98
+ export function recoverManifestFromRecord(
99
+ record: IterateRunRecord,
100
+ fallbackProject: string,
101
+ approval: 'ask' | 'deny' | 'allow',
102
+ ): TranscriptManifest {
103
+ const builder = new ReviewTranscriptBuilder({
104
+ project: record.project || fallbackProject,
105
+ mode: record.mode,
106
+ taskMode: record.taskMode,
107
+ approval,
108
+ goal: record.goal,
109
+ maxRounds: record.maxRounds,
110
+ })
111
+ const series = Array.isArray(record.convergence) ? record.convergence : []
112
+ for (let i = 0; i < Math.min(series.length, MAX_ROUNDS); i += 1) {
113
+ const n = series[i]
114
+ if (typeof n === 'number' && Number.isFinite(n) && n >= 0) builder.snapshotConvergence(i + 1, n)
115
+ }
116
+ if (record.round > 0) builder.advanceRound(record.round, record.maxRounds)
117
+ if (!record.active) builder.finish(record.stoppedReason ?? undefined)
118
+ return builder.serialize()
119
+ }
120
+
43
121
  /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
44
122
  function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
45
123
  if (!round || typeof round !== 'object') return
@@ -158,27 +236,37 @@ export function registerTranscriptTool(ctx: {
158
236
  ctx.tools.register(
159
237
  defineTool({
160
238
  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',
239
+ // `read`/`recall` never write; `capture`/`nudge` persist transcript state
240
+ // → only the read shapes join a parallel dispatch group.
241
+ isConcurrencySafe: (args) => {
242
+ const op = (args as { operation?: unknown }).operation
243
+ return op === 'read' || op === 'recall'
244
+ },
164
245
  description:
165
246
  'Runtime-observatory transcript for the iterate workflow. ' +
166
247
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
167
248
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
168
249
  'written for the next round) — or, when the file is missing/malformed, a structured ' +
169
- 'empty view (`found:false` plus a blank manifest). ' +
250
+ 'empty view (`found:false` plus a blank manifest). When the on-disk file is gone but the ' +
251
+ 'session carries a linked run record (dsh ≥ 0.2.1-rc.1), `read` RECOVERS a summary manifest ' +
252
+ 'from that record and sets `recovered:true`. ' +
170
253
  '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
171
254
  'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
172
255
  'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
173
256
  'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
257
+ 'On a successful capture the tool ALSO appends a compact run record onto the dsh session log ' +
258
+ '(best-effort; reported as `sessionRecord`) so the run survives `.iterate/` loss and travels with ' +
259
+ 'the session. ' +
260
+ '`recall` lists the session-linked run records (newest first) — useful to audit a run after ' +
261
+ 'restart or to confirm the write took. ' +
174
262
  '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
175
263
  'Purely local and deterministic — never touches source files.',
176
264
  parameters: {
177
265
  operation: {
178
266
  type: 'string',
179
267
  required: true,
180
- description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
181
- enum: ['read', 'capture', 'nudge'],
268
+ description: '"read" to fetch the manifest, "capture" to persist one, "recall" to list session-linked run records, "nudge" to set steering text.',
269
+ enum: ['read', 'capture', 'recall', 'nudge'],
182
270
  },
183
271
  rounds: {
184
272
  type: 'json',
@@ -261,6 +349,19 @@ export function registerTranscriptTool(ctx: {
261
349
  description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
262
350
  },
263
351
  reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
352
+ recovered: {
353
+ type: 'boolean',
354
+ description: 'For `read`: true when the manifest was rebuilt from a session-linked run record because the on-disk file was missing or unreadable.',
355
+ },
356
+ supported: {
357
+ type: 'boolean',
358
+ description: 'For `recall`: whether the host exposes the dsh session state-record API (dsh ≥ 0.2.1-rc.1).',
359
+ },
360
+ records: { type: 'json', description: 'For `recall`: session-linked run records, newest first.' },
361
+ sessionRecord: {
362
+ type: 'json',
363
+ description: 'For `capture`/`read`: the session-link outcome/position (`{linked, seq}` on capture, `{seq, updatedAt}` on a recovery).',
364
+ },
264
365
  error: { type: 'string' },
265
366
  },
266
367
  },
@@ -296,11 +397,59 @@ export function registerTranscriptTool(ctx: {
296
397
  approval,
297
398
  }).serialize() as unknown as JsonValue,
298
399
  })
299
- if (!existsSync(file)) return emptyView()
400
+ // Recovery (dsh ≥ 0.2.1-rc.1): when the on-disk manifest is gone
401
+ // but the SESSION that produced the run carries a linked run record,
402
+ // rebuild a summary manifest so the observatory shows the real run
403
+ // header + trend instead of a blank panel. Best-effort: no session,
404
+ // no API, or no record all fall back to `emptyView()`.
405
+ const recover = async ():
406
+ | Promise<{ transcript: JsonValue; sessionRecord: JsonValue } | null> => {
407
+ const session = sessionOf(exec)
408
+ if (!session) return null
409
+ const { supported, records } = await readRunRecords(session)
410
+ if (!supported || records.length === 0) return null
411
+ const latest = records[records.length - 1] as StoredRunRecord
412
+ return {
413
+ transcript: normalizeManifestBounds(
414
+ recoverManifestFromRecord(latest, projectRoot, approval),
415
+ ) as unknown as JsonValue,
416
+ sessionRecord: {
417
+ seq: latest.seq,
418
+ time: latest.time,
419
+ updatedAt: latest.updatedAt,
420
+ } as unknown as JsonValue,
421
+ }
422
+ }
423
+ if (!existsSync(file)) {
424
+ const recovered = await recover()
425
+ if (recovered) {
426
+ return {
427
+ operation: 'read',
428
+ found: true,
429
+ recovered: true,
430
+ live: live as unknown as JsonValue,
431
+ transcript: recovered.transcript,
432
+ sessionRecord: recovered.sessionRecord,
433
+ }
434
+ }
435
+ return emptyView()
436
+ }
300
437
  let parsed: unknown
301
438
  try {
302
439
  parsed = JSON.parse(await readFile(file, 'utf-8'))
303
440
  } catch (err) {
441
+ const recovered = await recover()
442
+ if (recovered) {
443
+ return {
444
+ operation: 'read',
445
+ found: true,
446
+ recovered: true,
447
+ live: live as unknown as JsonValue,
448
+ transcript: recovered.transcript,
449
+ sessionRecord: recovered.sessionRecord,
450
+ error: `Recovered a summary from the session log; on-disk transcript was unreadable (${err instanceof Error ? err.message : String(err)}).`,
451
+ }
452
+ }
304
453
  return {
305
454
  ...emptyView(),
306
455
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
@@ -310,6 +459,18 @@ export function registerTranscriptTool(ctx: {
310
459
  // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
311
460
  // are parseable but carry no manifest. Surfacing them as
312
461
  // `found:true` made the observatory render junk panels.
462
+ const recovered = await recover()
463
+ if (recovered) {
464
+ return {
465
+ operation: 'read',
466
+ found: true,
467
+ recovered: true,
468
+ live: live as unknown as JsonValue,
469
+ transcript: recovered.transcript,
470
+ sessionRecord: recovered.sessionRecord,
471
+ error: 'Recovered a summary from the session log; on-disk file is not a valid manifest.',
472
+ }
473
+ }
313
474
  return {
314
475
  ...emptyView(),
315
476
  error: 'Transcript file is not a valid manifest; returning an empty view.',
@@ -326,6 +487,22 @@ export function registerTranscriptTool(ctx: {
326
487
  }
327
488
  }
328
489
 
490
+ if (args.operation === 'recall') {
491
+ // New capability: list the run records this plugin appended onto the
492
+ // dsh session log (dsh ≥ 0.2.1-rc.1). Read-only; degrades to
493
+ // `supported:false` on a host without the API.
494
+ const session = sessionOf(exec)
495
+ const { supported, records, error } = await readRunRecords(session)
496
+ const newestFirst = [...records].reverse()
497
+ return {
498
+ operation: 'recall',
499
+ found: newestFirst.length > 0,
500
+ supported,
501
+ records: newestFirst as unknown as JsonValue,
502
+ ...(error ? { error } : {}),
503
+ }
504
+ }
505
+
329
506
  if (args.operation === 'nudge') {
330
507
  // Read-modify-write across processes: a concurrent nudge in a sibling
331
508
  // process would otherwise read the same manifest and write back over
@@ -498,17 +675,30 @@ export function registerTranscriptTool(ctx: {
498
675
  else if (convergence.length > 0 && last === 0) builder.finish('converged')
499
676
  else if (roundsExecuted > 0) builder.finish('max_rounds_reached')
500
677
 
501
- const persisted = await persistChecked(file, builder.serialize())
678
+ const manifest = builder.serialize()
679
+ const persisted = await persistChecked(file, manifest)
502
680
  if (!persisted.ok) {
503
681
  return { operation: 'capture', found: true, updated: false, error: persisted.error }
504
682
  }
683
+ // Session link (dsh ≥ 0.2.1-rc.1): append a compact run record onto the
684
+ // session log so the run survives `.iterate/` loss and travels with the
685
+ // session. Best-effort — a link failure is reported, never fatal (the
686
+ // on-disk capture above already succeeded).
687
+ let sessionRecord: JsonValue
688
+ const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest))
689
+ sessionRecord = (
690
+ linkSession.ok
691
+ ? { linked: true, seq: linkSession.seq }
692
+ : { linked: false, supported: linkSession.supported, reason: linkSession.error }
693
+ ) as unknown as JsonValue
505
694
  const live = await readLive(projectRoot)
506
695
  return {
507
696
  operation: 'capture',
508
697
  found: true,
509
698
  updated: true,
510
699
  live: live as unknown as JsonValue,
511
- transcript: builder.serialize() as unknown as JsonValue,
700
+ transcript: manifest as unknown as JsonValue,
701
+ sessionRecord,
512
702
  }
513
703
  },
514
704
  }),