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.
@@ -30,6 +30,76 @@ 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
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
+ }
33
103
  /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
34
104
  function captureRound(builder, round) {
35
105
  if (!round || typeof round !== 'object')
@@ -143,26 +213,36 @@ function isManifestShaped(value) {
143
213
  export function registerTranscriptTool(ctx) {
144
214
  ctx.tools.register(defineTool({
145
215
  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',
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
+ },
149
222
  description: 'Runtime-observatory transcript for the iterate workflow. ' +
150
223
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
151
224
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
152
225
  'written for the next round) — or, when the file is missing/malformed, a structured ' +
153
- '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`. ' +
154
229
  '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
155
230
  'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
156
231
  'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
157
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. ' +
158
238
  '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
159
239
  'Purely local and deterministic — never touches source files.',
160
240
  parameters: {
161
241
  operation: {
162
242
  type: 'string',
163
243
  required: true,
164
- description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
165
- 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'],
166
246
  },
167
247
  rounds: {
168
248
  type: 'json',
@@ -244,6 +324,19 @@ export function registerTranscriptTool(ctx) {
244
324
  description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
245
325
  },
246
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
+ },
247
340
  error: { type: 'string' },
248
341
  },
249
342
  },
@@ -273,13 +366,59 @@ export function registerTranscriptTool(ctx) {
273
366
  approval,
274
367
  }).serialize(),
275
368
  });
276
- 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
+ }
277
403
  return emptyView();
404
+ }
278
405
  let parsed;
279
406
  try {
280
407
  parsed = JSON.parse(await readFile(file, 'utf-8'));
281
408
  }
282
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
+ }
283
422
  return {
284
423
  ...emptyView(),
285
424
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
@@ -289,6 +428,18 @@ export function registerTranscriptTool(ctx) {
289
428
  // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
290
429
  // are parseable but carry no manifest. Surfacing them as
291
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
+ }
292
443
  return {
293
444
  ...emptyView(),
294
445
  error: 'Transcript file is not a valid manifest; returning an empty view.',
@@ -304,6 +455,21 @@ export function registerTranscriptTool(ctx) {
304
455
  transcript: normalizeManifestBounds(parsed),
305
456
  };
306
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
+ }
307
473
  if (args.operation === 'nudge') {
308
474
  // Read-modify-write across processes: a concurrent nudge in a sibling
309
475
  // process would otherwise read the same manifest and write back over
@@ -480,17 +646,28 @@ export function registerTranscriptTool(ctx) {
480
646
  builder.finish('converged');
481
647
  else if (roundsExecuted > 0)
482
648
  builder.finish('max_rounds_reached');
483
- const persisted = await persistChecked(file, builder.serialize());
649
+ const manifest = builder.serialize();
650
+ const persisted = await persistChecked(file, manifest);
484
651
  if (!persisted.ok) {
485
652
  return { operation: 'capture', found: true, updated: false, error: persisted.error };
486
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 });
487
663
  const live = await readLive(projectRoot);
488
664
  return {
489
665
  operation: 'capture',
490
666
  found: true,
491
667
  updated: true,
492
668
  live: live,
493
- transcript: builder.serialize(),
669
+ transcript: manifest,
670
+ sessionRecord,
494
671
  };
495
672
  },
496
673
  }));