iterate-plugin 3.7.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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * src/tools/report.ts — `iterate_report` tool.
3
+ *
4
+ * iterate_report — render the current iterate run into a portable report.
5
+ *
6
+ * Two formats, one normalized data model (shared with the client export
7
+ * buttons via `lib/parse.js`, so the on-screen export and this tool can never
8
+ * drift):
9
+ *
10
+ * - `markdown` — a PR-pasteable run report: header (mode/round/status), the
11
+ * quality-gate table, the findings table, the fixes table,
12
+ * validations, defense events, and the experience bank.
13
+ * - `sarif` — a SARIF 2.1.0 log so CI / GitHub Code Scanning can consume
14
+ * iterate findings as code-scanning results (ruleId =
15
+ * dimension, severity → error/warning/note, file+line
16
+ * locations, suggested fix attached).
17
+ *
18
+ * Reads only local state (`.iterate/transcript.json` + the quality / experience
19
+ * / defense stores) and writes only `.iterate/report.<ext>` when `write:true`.
20
+ * Malformed or missing state is rendered as-is (empty tables) — an export must
21
+ * never fail because a run was partial.
22
+ */
23
+
24
+ import { existsSync, readFileSync } from 'node:fs'
25
+ import { mkdirSync, writeFileSync } from 'node:fs'
26
+ import { defineTool } from '@deepseek-ai/dsh-tools'
27
+ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
28
+ import { resolveProjectRootForExec } from '../config-loader.ts'
29
+ import { iterateDir, transcriptPath } from '../paths.ts'
30
+ import { readQualityGate } from './quality-store.ts'
31
+ import { readExperienceBank } from './experience-store.ts'
32
+ import { readDefenseEvents } from './defense-store.ts'
33
+ import { resultHeadline } from './present.ts'
34
+ import { renderRunReportMarkdown, renderRunReportSarif, REPORT_FORMATS } from '../report-render.ts'
35
+
36
+ /** Formats accepted by the tool (kept in sync with the shared renderer). */
37
+ const FORMATS: ReadonlySet<string> = new Set(REPORT_FORMATS)
38
+
39
+ /** Read the persisted transcript manifest, tolerating a missing/garbled file. */
40
+ function readManifest(projectRoot: string): { manifest: unknown; source: 'disk' | 'empty' } {
41
+ const file = transcriptPath(projectRoot)
42
+ if (!existsSync(file)) return { manifest: {}, source: 'empty' }
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(file, 'utf-8')) as unknown
45
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
46
+ return { manifest: parsed, source: 'disk' }
47
+ }
48
+ return { manifest: {}, source: 'empty' }
49
+ } catch {
50
+ return { manifest: {}, source: 'empty' }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Register the `iterate_report` tool.
56
+ * Renders the current run into a Markdown or SARIF report.
57
+ */
58
+ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
59
+ ctx.tools.register(
60
+ defineTool({
61
+ name: 'iterate_report',
62
+ // Result card: surface the report headline ("report: markdown · N findings")
63
+ // as the card title. Pure: reads only the rendered result (replay-safe).
64
+ presentResult: (_args, result) => {
65
+ const title = resultHeadline(result)
66
+ return title ? { card: 'generic', title } : undefined
67
+ },
68
+ // `write:true` persists a file; a pure render joins a parallel group.
69
+ isConcurrencySafe: (args) => (args as { write?: unknown }).write !== true,
70
+ description:
71
+ 'Export the current iterate run as a portable report. ' +
72
+ '`format:"markdown"` (default) renders a PR-pasteable report: run header (mode / round / status), ' +
73
+ 'quality-gate table, findings table, fixes table, per-round validations, defense events, and the ' +
74
+ 'experience bank. `format:"sarif"` renders a SARIF 2.1.0 log for CI / GitHub Code Scanning ' +
75
+ '(one rule per review dimension; finding severity maps to error/warning/note; findings carry ' +
76
+ 'file + line locations and the suggested fix). ' +
77
+ 'Set `write:true` to also persist the report under `.iterate/report.md` / `.iterate/report.sarif` ' +
78
+ 'and get the path back. `lang` selects the report language (zh | en). ' +
79
+ 'Read-only apart from the optional report file; never touches source files.',
80
+ parameters: {
81
+ format: {
82
+ type: 'string',
83
+ description: 'Report format: "markdown" (default) or "sarif".',
84
+ enum: ['markdown', 'sarif'],
85
+ },
86
+ write: {
87
+ type: 'boolean',
88
+ description: 'When true, persist the report to `.iterate/report.md` / `.iterate/report.sarif` and return its path. Default: false.',
89
+ },
90
+ lang: {
91
+ type: 'string',
92
+ description: 'Report language: "zh" (default) or "en". Affects labels/headings only.',
93
+ enum: ['zh', 'en'],
94
+ },
95
+ path: {
96
+ type: 'string',
97
+ description: 'Project root directory (default: current working directory).',
98
+ },
99
+ },
100
+
101
+ output: {
102
+ schema: {
103
+ type: 'object',
104
+ additionalProperties: false,
105
+ properties: {
106
+ ok: { type: 'boolean', required: true },
107
+ kind: { type: 'string' },
108
+ format: { type: 'string' },
109
+ /** The rendered report text (Markdown, or pretty-printed SARIF JSON). */
110
+ content: { type: 'string' },
111
+ /** The SARIF log object, when format is "sarif". */
112
+ sarif: { type: 'json' },
113
+ /** Whether a source transcript manifest was found on disk. */
114
+ source: { type: 'string' },
115
+ written: { type: 'boolean' },
116
+ reportPath: { type: 'string' },
117
+ findings: { type: 'number' },
118
+ error: { type: 'string' },
119
+ },
120
+ },
121
+ render: (_args, value) => {
122
+ if (!value.ok) return [{ type: 'text', text: `report export failed: ${value.error}` }]
123
+ const content = typeof value.content === 'string' ? value.content : ''
124
+ const header = [
125
+ `report: ${String(value.format ?? 'markdown')} · ${String(value.findings ?? 0)} findings · source=${String(value.source ?? 'empty')}`,
126
+ value.written ? `written to ${String(value.reportPath ?? '')}` : '',
127
+ ].filter(Boolean).join(' · ')
128
+ return [{ type: 'text', text: `${header}\n\n${content}` }]
129
+ },
130
+ },
131
+
132
+ async execute(args, exec) {
133
+ const resolved = resolveProjectRootForExec(exec, args.path)
134
+ if (!resolved.ok) return { ok: false, kind: 'report', error: resolved.reason }
135
+ const projectRoot = resolved.root
136
+
137
+ const rawFormat = typeof args.format === 'string' ? args.format : 'markdown'
138
+ const format = FORMATS.has(rawFormat) ? rawFormat : 'markdown'
139
+ const lang = args.lang === 'en' ? 'en' : 'zh'
140
+
141
+ const { manifest, source } = readManifest(projectRoot)
142
+ let qualityGate: unknown = null
143
+ let experienceBank: unknown = null
144
+ let defenseEvents: unknown = null
145
+ try {
146
+ qualityGate = readQualityGate(projectRoot)
147
+ } catch {
148
+ qualityGate = null
149
+ }
150
+ try {
151
+ experienceBank = readExperienceBank(projectRoot)
152
+ } catch {
153
+ experienceBank = null
154
+ }
155
+ try {
156
+ defenseEvents = readDefenseEvents(projectRoot)
157
+ } catch {
158
+ defenseEvents = null
159
+ }
160
+
161
+ const extra = { qualityGate, experienceBank, defenseEvents }
162
+ let content: string
163
+ let sarif: JsonValue | undefined
164
+ let findings = 0
165
+ try {
166
+ if (format === 'sarif') {
167
+ const doc = renderRunReportSarif(manifest, extra, {
168
+ toolVersion: pluginVersion(),
169
+ }) as unknown as { runs?: Array<{ results?: unknown[] }> }
170
+ sarif = doc as unknown as JsonValue
171
+ content = JSON.stringify(doc, null, 2)
172
+ findings = Array.isArray(doc?.runs?.[0]?.results) ? (doc.runs[0].results as unknown[]).length : 0
173
+ } else {
174
+ content = renderRunReportMarkdown(manifest, extra, { lang }) as unknown as string
175
+ findings = countMarkdownFindings(content)
176
+ }
177
+ } catch (err) {
178
+ return {
179
+ ok: false,
180
+ kind: 'report',
181
+ format,
182
+ error: `failed to render report: ${err instanceof Error ? err.message : String(err)}`,
183
+ }
184
+ }
185
+
186
+ let written = false
187
+ let reportPath: string | undefined
188
+ if (args.write === true) {
189
+ try {
190
+ const dir = iterateDir(projectRoot)
191
+ mkdirSync(dir, { recursive: true })
192
+ reportPath = `${dir}/report.${format === 'sarif' ? 'sarif' : 'md'}`
193
+ writeFileSync(reportPath, content, 'utf-8')
194
+ written = true
195
+ } catch (err) {
196
+ return {
197
+ ok: false,
198
+ kind: 'report',
199
+ format,
200
+ error: `failed to write report: ${err instanceof Error ? err.message : String(err)}`,
201
+ }
202
+ }
203
+ }
204
+
205
+ return {
206
+ ok: true,
207
+ kind: 'report',
208
+ format,
209
+ content,
210
+ ...(sarif !== undefined ? { sarif } : {}),
211
+ source,
212
+ written,
213
+ ...(reportPath ? { reportPath } : {}),
214
+ findings,
215
+ }
216
+ },
217
+ }),
218
+ )
219
+ }
220
+
221
+ /**
222
+ * Best-effort plugin version for the SARIF driver. Resolved from the package
223
+ * manifest next to this module (`src/tools` and `dist/tools` are both two
224
+ * levels below the root); an unreadable/unparsable manifest yields ''.
225
+ */
226
+ function pluginVersion(): string {
227
+ try {
228
+ const raw = readFileSync(new URL('../../package.json', import.meta.url), 'utf-8')
229
+ const pkg = JSON.parse(raw) as { version?: unknown }
230
+ return typeof pkg.version === 'string' ? pkg.version : ''
231
+ } catch {
232
+ return ''
233
+ }
234
+ }
235
+
236
+ /** Count the findings table rows in a rendered Markdown report (for the card). */
237
+ function countMarkdownFindings(markdown: string): number {
238
+ const m = /##\s+.*?\((\d+)\)/.exec(markdown)
239
+ if (m && m[1]) {
240
+ const n = Number(m[1])
241
+ if (Number.isFinite(n)) return n
242
+ }
243
+ return 0
244
+ }
@@ -24,6 +24,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
24
24
  import { mkdir, readFile } from 'node:fs/promises'
25
25
  import { existsSync } from 'node:fs'
26
26
  import { writeTextAtomicAsync } from '../atomic-fs.ts'
27
+ import { withProjectLock } from '../file-lock.ts'
27
28
  import { dirname } from 'node:path'
28
29
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
29
30
  import {
@@ -34,11 +35,89 @@ import { transcriptPath } from '../paths.ts'
34
35
  import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from '../transcript.ts'
35
36
  import { readDecisionLogDetailed } from './decision-log.ts'
36
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'
37
46
  import type {
38
47
  TranscriptManifest,
39
48
  TranscriptFix,
40
49
  } from '../types.ts'
41
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
+
42
121
  /** Build per-dimension threads for one round from its (dimension-tagged) findings. */
43
122
  function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
44
123
  if (!round || typeof round !== 'object') return
@@ -81,7 +160,7 @@ function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
81
160
  const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
82
161
  if (round <= 0) return null
83
162
  return {
84
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
163
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
85
164
  round,
86
165
  maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
87
166
  fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
@@ -157,27 +236,37 @@ export function registerTranscriptTool(ctx: {
157
236
  ctx.tools.register(
158
237
  defineTool({
159
238
  name: 'iterate_transcript',
160
- // `read` never writes; `capture`/`nudge` persist transcript state → only
161
- // read joins a parallel dispatch group.
162
- 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
+ },
163
245
  description:
164
246
  'Runtime-observatory transcript for the iterate workflow. ' +
165
247
  '`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
166
248
  'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
167
249
  'written for the next round) — or, when the file is missing/malformed, a structured ' +
168
- '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`. ' +
169
253
  '`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
170
254
  'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
171
255
  'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
172
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. ' +
173
262
  '`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
174
263
  'Purely local and deterministic — never touches source files.',
175
264
  parameters: {
176
265
  operation: {
177
266
  type: 'string',
178
267
  required: true,
179
- description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
180
- 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'],
181
270
  },
182
271
  rounds: {
183
272
  type: 'json',
@@ -215,7 +304,22 @@ export function registerTranscriptTool(ctx: {
215
304
  'the run console can show which commands ran and whether they passed.',
216
305
  },
217
306
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
218
- stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
307
+ stoppedReason: {
308
+ type: 'string',
309
+ // Enum guard: the label renderer keys off this value, so a stray or
310
+ // prototype-chain string (e.g. "__proto__") must not enter the tool.
311
+ // The render path additionally uses Object.hasOwn (defense for
312
+ // hand-edited manifests that bypass this schema).
313
+ enum: [
314
+ 'converged',
315
+ 'max_rounds_reached',
316
+ 'aborted_by_validation',
317
+ 'aborted_by_config',
318
+ 'schema_invalid',
319
+ 'no_usable_reviewer_output',
320
+ 'inconclusive',
321
+ ],
322
+ description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
219
323
  // `string | null` (NOT plain `type: 'string'`): dsh validates arguments
220
324
  // against the compiled schema BEFORE execute, and the docs — plus the
221
325
  // client's clear button — advertise `{"operation":"nudge","text":null}`.
@@ -245,6 +349,19 @@ export function registerTranscriptTool(ctx: {
245
349
  description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
246
350
  },
247
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
+ },
248
365
  error: { type: 'string' },
249
366
  },
250
367
  },
@@ -280,11 +397,59 @@ export function registerTranscriptTool(ctx: {
280
397
  approval,
281
398
  }).serialize() as unknown as JsonValue,
282
399
  })
283
- 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
+ }
284
437
  let parsed: unknown
285
438
  try {
286
439
  parsed = JSON.parse(await readFile(file, 'utf-8'))
287
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
+ }
288
453
  return {
289
454
  ...emptyView(),
290
455
  error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
@@ -294,6 +459,18 @@ export function registerTranscriptTool(ctx: {
294
459
  // `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
295
460
  // are parseable but carry no manifest. Surfacing them as
296
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
+ }
297
474
  return {
298
475
  ...emptyView(),
299
476
  error: 'Transcript file is not a valid manifest; returning an empty view.',
@@ -310,50 +487,79 @@ export function registerTranscriptTool(ctx: {
310
487
  }
311
488
  }
312
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
+
313
506
  if (args.operation === 'nudge') {
314
- let manifest: TranscriptManifest | null = null
315
- if (existsSync(file)) {
316
- try {
317
- const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
318
- manifest = parsed
319
- } catch {
320
- manifest = null
507
+ // Read-modify-write across processes: a concurrent nudge in a sibling
508
+ // process would otherwise read the same manifest and write back over
509
+ // each other's steering text. Serialize on the shared transcript lock
510
+ // (same pattern as the config/triage writers). The body still awaits,
511
+ // so the lock is held across the read and write; a same-process
512
+ // re-entry degrades to unlocked (the advisory lock is per-pid), which
513
+ // is the documented best-effort fallback for a low-value projection.
514
+ return withProjectLock(projectRoot, 'transcript', async () => {
515
+ let manifest: TranscriptManifest | null = null
516
+ if (existsSync(file)) {
517
+ try {
518
+ const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
519
+ manifest = parsed
520
+ } catch {
521
+ manifest = null
522
+ }
321
523
  }
322
- }
323
- // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
324
- // or a non-object) must never crash the nudge — degrade to a fresh
325
- // builder so the steering text still lands. The fallback must keep
326
- // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
327
- // stray malformed field can never silently rewrite a dry-run run
328
- // into a `normal` one.
329
- const fallbackOpts = {
330
- project: projectRoot,
331
- mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
332
- taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
333
- approval,
334
- goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
335
- maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
336
- }
337
- let builder: ReviewTranscriptBuilder
338
- if (manifest) {
339
- try {
340
- builder = rehydrateBuilder(manifest, approval)
341
- } catch {
342
- builder = new ReviewTranscriptBuilder(fallbackOpts)
524
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
525
+ // or a non-object) must never crash the nudge — degrade to a fresh
526
+ // builder so the steering text still lands. The fallback must keep
527
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
528
+ // stray malformed field can never silently rewrite a dry-run run
529
+ // into a `normal` one.
530
+ const fallbackOpts = {
531
+ project: projectRoot,
532
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
533
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
534
+ approval,
535
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
536
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
343
537
  }
344
- } else {
345
- builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
346
- }
347
- builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
348
- const persisted = await persistChecked(file, builder.serialize())
349
- if (!persisted.ok) {
350
- return { operation: 'nudge', updated: false, error: persisted.error }
351
- }
352
- return {
353
- operation: 'nudge',
354
- updated: true,
355
- transcript: builder.serialize() as unknown as JsonValue,
356
- }
538
+ let builder: ReviewTranscriptBuilder
539
+ if (manifest) {
540
+ try {
541
+ builder = rehydrateBuilder(manifest, approval)
542
+ } catch {
543
+ builder = new ReviewTranscriptBuilder(fallbackOpts)
544
+ }
545
+ } else {
546
+ // No manifest yet: the run's mode is genuinely unknown at nudge
547
+ // time (capture sets it). Recording `mode: 'normal'` here would
548
+ // rewrite a dry-run run into a `normal` one in the status/badge
549
+ // until capture lands — `null` honestly says "not captured yet".
550
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval })
551
+ }
552
+ builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
553
+ const persisted = await persistChecked(file, builder.serialize())
554
+ if (!persisted.ok) {
555
+ return { operation: 'nudge', updated: false, error: persisted.error }
556
+ }
557
+ return {
558
+ operation: 'nudge',
559
+ updated: true,
560
+ transcript: builder.serialize() as unknown as JsonValue,
561
+ }
562
+ })
357
563
  }
358
564
 
359
565
  // capture
@@ -469,17 +675,30 @@ export function registerTranscriptTool(ctx: {
469
675
  else if (convergence.length > 0 && last === 0) builder.finish('converged')
470
676
  else if (roundsExecuted > 0) builder.finish('max_rounds_reached')
471
677
 
472
- const persisted = await persistChecked(file, builder.serialize())
678
+ const manifest = builder.serialize()
679
+ const persisted = await persistChecked(file, manifest)
473
680
  if (!persisted.ok) {
474
681
  return { operation: 'capture', found: true, updated: false, error: persisted.error }
475
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
476
694
  const live = await readLive(projectRoot)
477
695
  return {
478
696
  operation: 'capture',
479
697
  found: true,
480
698
  updated: true,
481
699
  live: live as unknown as JsonValue,
482
- transcript: builder.serialize() as unknown as JsonValue,
700
+ transcript: manifest as unknown as JsonValue,
701
+ sessionRecord,
483
702
  }
484
703
  },
485
704
  }),