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.
- package/README.md +43 -8
- package/README.zh-CN.md +26 -5
- package/dist/file-lock.js +75 -14
- package/dist/git-scope.js +22 -2
- package/dist/index.js +9 -4
- package/dist/report-render.js +452 -0
- package/dist/session-record.js +246 -0
- package/dist/skill-prompt.js +4 -3
- package/dist/tools/checkpoint.js +24 -17
- package/dist/tools/config.js +42 -33
- package/dist/tools/decision-log.js +13 -93
- package/dist/tools/report.js +239 -0
- package/dist/tools/transcript.js +260 -53
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/lib/client.js +486 -22
- package/lib/parse.js +29 -6
- package/package.json +38 -13
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +66 -4
- package/src/file-lock.ts +84 -14
- package/src/git-scope.ts +21 -2
- package/src/index.ts +9 -4
- package/src/report-render.ts +578 -0
- package/src/session-record.ts +333 -0
- package/src/skill-prompt.ts +4 -3
- package/src/tools/checkpoint.ts +23 -16
- package/src/tools/config.ts +40 -31
- package/src/tools/decision-log.ts +12 -80
- package/src/tools/report.ts +244 -0
- package/src/tools/transcript.ts +271 -52
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
|
@@ -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
|
+
}
|
package/src/tools/transcript.ts
CHANGED
|
@@ -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 :
|
|
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
|
|
161
|
-
// read
|
|
162
|
-
isConcurrencySafe: (args) =>
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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:
|
|
700
|
+
transcript: manifest as unknown as JsonValue,
|
|
701
|
+
sessionRecord,
|
|
483
702
|
}
|
|
484
703
|
},
|
|
485
704
|
}),
|