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.
- package/README.md +10 -7
- package/README.zh-CN.md +4 -3
- 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 +2 -1
- package/dist/tools/report.js +239 -0
- package/dist/tools/transcript.js +186 -9
- package/lib/client.js +453 -13
- package/package.json +17 -10
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +44 -0
- 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 +2 -1
- package/src/tools/report.ts +244 -0
- package/src/tools/transcript.ts +199 -9
package/src/tools/transcript.ts
CHANGED
|
@@ -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
|
|
162
|
-
// read
|
|
163
|
-
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
|
+
},
|
|
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
|
-
|
|
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
|
|
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:
|
|
700
|
+
transcript: manifest as unknown as JsonValue,
|
|
701
|
+
sessionRecord,
|
|
512
702
|
}
|
|
513
703
|
},
|
|
514
704
|
}),
|