iterate-plugin 3.8.0 → 3.9.1
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/file-lock.js +29 -15
- package/dist/git-scope.js +25 -10
- package/dist/index.js +9 -4
- package/dist/report-render.js +462 -0
- package/dist/session-record.js +268 -0
- package/dist/skill-prompt.js +2 -1
- package/dist/tools/report.js +239 -0
- package/dist/tools/transcript.js +217 -9
- package/dist/transcript.js +18 -0
- package/lib/client.js +460 -13
- package/package.json +17 -10
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +44 -0
- package/src/file-lock.ts +47 -9
- package/src/git-scope.ts +29 -15
- package/src/index.ts +9 -4
- package/src/report-render.ts +589 -0
- package/src/session-record.ts +355 -0
- package/src/skill-prompt.ts +2 -1
- package/src/tools/report.ts +244 -0
- package/src/tools/transcript.ts +226 -9
- package/src/transcript.ts +18 -0
package/src/tools/transcript.ts
CHANGED
|
@@ -34,12 +34,91 @@ import {
|
|
|
34
34
|
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
|
+
import { readQualityGate } from './quality-store.ts'
|
|
37
38
|
import { readLive } from '../live.ts'
|
|
39
|
+
import {
|
|
40
|
+
buildRunRecord,
|
|
41
|
+
appendRunRecord,
|
|
42
|
+
readRunRecords,
|
|
43
|
+
sessionOf,
|
|
44
|
+
type IterateRunRecord,
|
|
45
|
+
type StoredRunRecord,
|
|
46
|
+
} from '../session-record.ts'
|
|
38
47
|
import type {
|
|
39
48
|
TranscriptManifest,
|
|
40
49
|
TranscriptFix,
|
|
41
50
|
} from '../types.ts'
|
|
42
51
|
|
|
52
|
+
/** Severity tally over a manifest's (globally deduped) findings. */
|
|
53
|
+
function severityCounts(findings: unknown): IterateRunRecord['findings'] {
|
|
54
|
+
const counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
55
|
+
if (!Array.isArray(findings)) return counts
|
|
56
|
+
for (const f of findings) {
|
|
57
|
+
if (!f || typeof f !== 'object') continue
|
|
58
|
+
const sev = (f as { severity?: unknown }).severity
|
|
59
|
+
counts.total++
|
|
60
|
+
if (sev === 'critical') counts.critical++
|
|
61
|
+
else if (sev === 'high') counts.high++
|
|
62
|
+
else if (sev === 'medium') counts.medium++
|
|
63
|
+
else counts.low++
|
|
64
|
+
}
|
|
65
|
+
return counts
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build the compact session-linked record from a finished manifest. */
|
|
69
|
+
function runRecordFromManifest(
|
|
70
|
+
manifest: TranscriptManifest,
|
|
71
|
+
qualityStatus: 'pass' | 'fail' | 'pending' | null = null,
|
|
72
|
+
): IterateRunRecord {
|
|
73
|
+
return buildRunRecord({
|
|
74
|
+
project: manifest.project,
|
|
75
|
+
goal: manifest.goal,
|
|
76
|
+
mode: manifest.mode,
|
|
77
|
+
taskMode: manifest.taskMode,
|
|
78
|
+
round: manifest.round,
|
|
79
|
+
maxRounds: manifest.maxRounds,
|
|
80
|
+
active: manifest.active,
|
|
81
|
+
stoppedReason: manifest.stoppedReason,
|
|
82
|
+
convergence: manifest.convergence,
|
|
83
|
+
counts: severityCounts(manifest.findings),
|
|
84
|
+
fixedCount: Array.isArray(manifest.fixes) ? manifest.fixes.filter((f) => f.success !== false).length : 0,
|
|
85
|
+
qualityStatus,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Rebuild a (summary-only) transcript manifest from a session-linked record.
|
|
91
|
+
*
|
|
92
|
+
* Degraded by design: the record carries the run SUMMARY (mode / goal / round /
|
|
93
|
+
* max-rounds / end state / convergence series), not the per-finding detail, so
|
|
94
|
+
* the recovered manifest has an empty `findings`/`fixes`/`timeline`. It exists
|
|
95
|
+
* so the observatory shows the real run header + trend after `.iterate/` is
|
|
96
|
+
* lost, instead of a blank panel. Round rows stay empty because the record has
|
|
97
|
+
* no per-thread narration.
|
|
98
|
+
*/
|
|
99
|
+
export function recoverManifestFromRecord(
|
|
100
|
+
record: IterateRunRecord,
|
|
101
|
+
fallbackProject: string,
|
|
102
|
+
approval: 'ask' | 'deny' | 'allow',
|
|
103
|
+
): TranscriptManifest {
|
|
104
|
+
const builder = new ReviewTranscriptBuilder({
|
|
105
|
+
project: record.project || fallbackProject,
|
|
106
|
+
mode: record.mode,
|
|
107
|
+
taskMode: record.taskMode,
|
|
108
|
+
approval,
|
|
109
|
+
goal: record.goal,
|
|
110
|
+
maxRounds: record.maxRounds,
|
|
111
|
+
})
|
|
112
|
+
const series = Array.isArray(record.convergence) ? record.convergence : []
|
|
113
|
+
for (let i = 0; i < Math.min(series.length, MAX_ROUNDS); i += 1) {
|
|
114
|
+
const n = series[i]
|
|
115
|
+
if (typeof n === 'number' && Number.isFinite(n) && n >= 0) builder.snapshotConvergence(i + 1, n)
|
|
116
|
+
}
|
|
117
|
+
if (record.round > 0) builder.advanceRound(record.round, record.maxRounds)
|
|
118
|
+
if (!record.active) builder.finish(record.stoppedReason ?? undefined)
|
|
119
|
+
return builder.serialize()
|
|
120
|
+
}
|
|
121
|
+
|
|
43
122
|
/** Build per-dimension threads for one round from its (dimension-tagged) findings. */
|
|
44
123
|
function captureRound(builder: ReviewTranscriptBuilder, round: unknown): void {
|
|
45
124
|
if (!round || typeof round !== 'object') return
|
|
@@ -158,27 +237,37 @@ export function registerTranscriptTool(ctx: {
|
|
|
158
237
|
ctx.tools.register(
|
|
159
238
|
defineTool({
|
|
160
239
|
name: 'iterate_transcript',
|
|
161
|
-
// `read` never
|
|
162
|
-
// read
|
|
163
|
-
isConcurrencySafe: (args) =>
|
|
240
|
+
// `read`/`recall` never write; `capture`/`nudge` persist transcript state
|
|
241
|
+
// → only the read shapes join a parallel dispatch group.
|
|
242
|
+
isConcurrencySafe: (args) => {
|
|
243
|
+
const op = (args as { operation?: unknown }).operation
|
|
244
|
+
return op === 'read' || op === 'recall'
|
|
245
|
+
},
|
|
164
246
|
description:
|
|
165
247
|
'Runtime-observatory transcript for the iterate workflow. ' +
|
|
166
248
|
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
167
249
|
'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
|
|
168
250
|
'written for the next round) — or, when the file is missing/malformed, a structured ' +
|
|
169
|
-
'empty view (`found:false` plus a blank manifest). ' +
|
|
251
|
+
'empty view (`found:false` plus a blank manifest). When the on-disk file is gone but the ' +
|
|
252
|
+
'session carries a linked run record (dsh ≥ 0.2.1-rc.1), `read` RECOVERS a summary manifest ' +
|
|
253
|
+
'from that record and sets `recovered:true`. ' +
|
|
170
254
|
'`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
|
|
171
255
|
'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
|
|
172
256
|
'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
|
|
173
257
|
'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
|
|
258
|
+
'On a successful capture the tool ALSO appends a compact run record onto the dsh session log ' +
|
|
259
|
+
'(best-effort; reported as `sessionRecord`) so the run survives `.iterate/` loss and travels with ' +
|
|
260
|
+
'the session. ' +
|
|
261
|
+
'`recall` lists the session-linked run records (newest first) — useful to audit a run after ' +
|
|
262
|
+
'restart or to confirm the write took. ' +
|
|
174
263
|
'`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
|
|
175
264
|
'Purely local and deterministic — never touches source files.',
|
|
176
265
|
parameters: {
|
|
177
266
|
operation: {
|
|
178
267
|
type: 'string',
|
|
179
268
|
required: true,
|
|
180
|
-
description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
|
|
181
|
-
enum: ['read', 'capture', 'nudge'],
|
|
269
|
+
description: '"read" to fetch the manifest, "capture" to persist one, "recall" to list session-linked run records, "nudge" to set steering text.',
|
|
270
|
+
enum: ['read', 'capture', 'recall', 'nudge'],
|
|
182
271
|
},
|
|
183
272
|
rounds: {
|
|
184
273
|
type: 'json',
|
|
@@ -261,6 +350,19 @@ export function registerTranscriptTool(ctx: {
|
|
|
261
350
|
description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
|
|
262
351
|
},
|
|
263
352
|
reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
|
|
353
|
+
recovered: {
|
|
354
|
+
type: 'boolean',
|
|
355
|
+
description: 'For `read`: true when the manifest was rebuilt from a session-linked run record because the on-disk file was missing or unreadable.',
|
|
356
|
+
},
|
|
357
|
+
supported: {
|
|
358
|
+
type: 'boolean',
|
|
359
|
+
description: 'For `recall`: whether the host exposes the dsh session state-record API (dsh ≥ 0.2.1-rc.1).',
|
|
360
|
+
},
|
|
361
|
+
records: { type: 'json', description: 'For `recall`: session-linked run records, newest first.' },
|
|
362
|
+
sessionRecord: {
|
|
363
|
+
type: 'json',
|
|
364
|
+
description: 'For `capture`/`read`: the session-link outcome/position (`{linked, seq}` on capture, `{seq, updatedAt}` on a recovery).',
|
|
365
|
+
},
|
|
264
366
|
error: { type: 'string' },
|
|
265
367
|
},
|
|
266
368
|
},
|
|
@@ -296,11 +398,59 @@ export function registerTranscriptTool(ctx: {
|
|
|
296
398
|
approval,
|
|
297
399
|
}).serialize() as unknown as JsonValue,
|
|
298
400
|
})
|
|
299
|
-
|
|
401
|
+
// Recovery (dsh ≥ 0.2.1-rc.1): when the on-disk manifest is gone
|
|
402
|
+
// but the SESSION that produced the run carries a linked run record,
|
|
403
|
+
// rebuild a summary manifest so the observatory shows the real run
|
|
404
|
+
// header + trend instead of a blank panel. Best-effort: no session,
|
|
405
|
+
// no API, or no record all fall back to `emptyView()`.
|
|
406
|
+
const recover = async ():
|
|
407
|
+
| Promise<{ transcript: JsonValue; sessionRecord: JsonValue } | null> => {
|
|
408
|
+
const session = sessionOf(exec)
|
|
409
|
+
if (!session) return null
|
|
410
|
+
const { supported, records } = await readRunRecords(session)
|
|
411
|
+
if (!supported || records.length === 0) return null
|
|
412
|
+
const latest = records[records.length - 1] as StoredRunRecord
|
|
413
|
+
return {
|
|
414
|
+
transcript: normalizeManifestBounds(
|
|
415
|
+
recoverManifestFromRecord(latest, projectRoot, approval),
|
|
416
|
+
) as unknown as JsonValue,
|
|
417
|
+
sessionRecord: {
|
|
418
|
+
seq: latest.seq,
|
|
419
|
+
time: latest.time,
|
|
420
|
+
updatedAt: latest.updatedAt,
|
|
421
|
+
} as unknown as JsonValue,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!existsSync(file)) {
|
|
425
|
+
const recovered = await recover()
|
|
426
|
+
if (recovered) {
|
|
427
|
+
return {
|
|
428
|
+
operation: 'read',
|
|
429
|
+
found: true,
|
|
430
|
+
recovered: true,
|
|
431
|
+
live: live as unknown as JsonValue,
|
|
432
|
+
transcript: recovered.transcript,
|
|
433
|
+
sessionRecord: recovered.sessionRecord,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return emptyView()
|
|
437
|
+
}
|
|
300
438
|
let parsed: unknown
|
|
301
439
|
try {
|
|
302
440
|
parsed = JSON.parse(await readFile(file, 'utf-8'))
|
|
303
441
|
} catch (err) {
|
|
442
|
+
const recovered = await recover()
|
|
443
|
+
if (recovered) {
|
|
444
|
+
return {
|
|
445
|
+
operation: 'read',
|
|
446
|
+
found: true,
|
|
447
|
+
recovered: true,
|
|
448
|
+
live: live as unknown as JsonValue,
|
|
449
|
+
transcript: recovered.transcript,
|
|
450
|
+
sessionRecord: recovered.sessionRecord,
|
|
451
|
+
error: `Recovered a summary from the session log; on-disk transcript was unreadable (${err instanceof Error ? err.message : String(err)}).`,
|
|
452
|
+
}
|
|
453
|
+
}
|
|
304
454
|
return {
|
|
305
455
|
...emptyView(),
|
|
306
456
|
error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -310,6 +460,18 @@ export function registerTranscriptTool(ctx: {
|
|
|
310
460
|
// `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
|
|
311
461
|
// are parseable but carry no manifest. Surfacing them as
|
|
312
462
|
// `found:true` made the observatory render junk panels.
|
|
463
|
+
const recovered = await recover()
|
|
464
|
+
if (recovered) {
|
|
465
|
+
return {
|
|
466
|
+
operation: 'read',
|
|
467
|
+
found: true,
|
|
468
|
+
recovered: true,
|
|
469
|
+
live: live as unknown as JsonValue,
|
|
470
|
+
transcript: recovered.transcript,
|
|
471
|
+
sessionRecord: recovered.sessionRecord,
|
|
472
|
+
error: 'Recovered a summary from the session log; on-disk file is not a valid manifest.',
|
|
473
|
+
}
|
|
474
|
+
}
|
|
313
475
|
return {
|
|
314
476
|
...emptyView(),
|
|
315
477
|
error: 'Transcript file is not a valid manifest; returning an empty view.',
|
|
@@ -326,6 +488,22 @@ export function registerTranscriptTool(ctx: {
|
|
|
326
488
|
}
|
|
327
489
|
}
|
|
328
490
|
|
|
491
|
+
if (args.operation === 'recall') {
|
|
492
|
+
// New capability: list the run records this plugin appended onto the
|
|
493
|
+
// dsh session log (dsh ≥ 0.2.1-rc.1). Read-only; degrades to
|
|
494
|
+
// `supported:false` on a host without the API.
|
|
495
|
+
const session = sessionOf(exec)
|
|
496
|
+
const { supported, records, error } = await readRunRecords(session)
|
|
497
|
+
const newestFirst = [...records].reverse()
|
|
498
|
+
return {
|
|
499
|
+
operation: 'recall',
|
|
500
|
+
found: newestFirst.length > 0,
|
|
501
|
+
supported,
|
|
502
|
+
records: newestFirst as unknown as JsonValue,
|
|
503
|
+
...(error ? { error } : {}),
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
329
507
|
if (args.operation === 'nudge') {
|
|
330
508
|
// Read-modify-write across processes: a concurrent nudge in a sibling
|
|
331
509
|
// process would otherwise read the same manifest and write back over
|
|
@@ -498,17 +676,40 @@ export function registerTranscriptTool(ctx: {
|
|
|
498
676
|
else if (convergence.length > 0 && last === 0) builder.finish('converged')
|
|
499
677
|
else if (roundsExecuted > 0) builder.finish('max_rounds_reached')
|
|
500
678
|
|
|
501
|
-
const
|
|
679
|
+
const manifest = builder.serialize()
|
|
680
|
+
const persisted = await persistChecked(file, manifest)
|
|
502
681
|
if (!persisted.ok) {
|
|
503
682
|
return { operation: 'capture', found: true, updated: false, error: persisted.error }
|
|
504
683
|
}
|
|
684
|
+
// Session link (dsh ≥ 0.2.1-rc.1): append a compact run record onto the
|
|
685
|
+
// session log so the run survives `.iterate/` loss and travels with the
|
|
686
|
+
// session. Best-effort — a link failure is reported, never fatal (the
|
|
687
|
+
// on-disk capture above already succeeded).
|
|
688
|
+
let sessionRecord: JsonValue
|
|
689
|
+
// Snapshot the CURRENT quality-gate verdict into the record so a
|
|
690
|
+
// recovered run keeps its PASS/FAIL badge. An absent gate reads as the
|
|
691
|
+
// store's default (`pending`); a corrupt/throwing read degrades to
|
|
692
|
+
// `null` and never fails the capture.
|
|
693
|
+
let qualityStatus: 'pass' | 'fail' | 'pending' | null = null
|
|
694
|
+
try {
|
|
695
|
+
qualityStatus = readQualityGate(projectRoot).overallStatus ?? null
|
|
696
|
+
} catch {
|
|
697
|
+
qualityStatus = null
|
|
698
|
+
}
|
|
699
|
+
const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest, qualityStatus))
|
|
700
|
+
sessionRecord = (
|
|
701
|
+
linkSession.ok
|
|
702
|
+
? { linked: true, seq: linkSession.seq }
|
|
703
|
+
: { linked: false, supported: linkSession.supported, reason: linkSession.error }
|
|
704
|
+
) as unknown as JsonValue
|
|
505
705
|
const live = await readLive(projectRoot)
|
|
506
706
|
return {
|
|
507
707
|
operation: 'capture',
|
|
508
708
|
found: true,
|
|
509
709
|
updated: true,
|
|
510
710
|
live: live as unknown as JsonValue,
|
|
511
|
-
transcript:
|
|
711
|
+
transcript: manifest as unknown as JsonValue,
|
|
712
|
+
sessionRecord,
|
|
512
713
|
}
|
|
513
714
|
},
|
|
514
715
|
}),
|
|
@@ -525,12 +726,20 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
525
726
|
goal: manifest.goal,
|
|
526
727
|
maxRounds: manifest.maxRounds,
|
|
527
728
|
})
|
|
729
|
+
// Phase breadcrumbs are part of the run narrative the F1 stage tracker shows;
|
|
730
|
+
// replay them before the rounds so an edit round-trip keeps the stage order.
|
|
731
|
+
for (const p of Array.isArray(manifest.phases) ? manifest.phases : []) {
|
|
732
|
+
if (typeof p === 'string') builder.phase(p)
|
|
733
|
+
}
|
|
528
734
|
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
529
735
|
// A malformed/null round row must be skipped, never a throw (the caller
|
|
530
736
|
// wraps rehydrate in a fallback, but preserving sibling rows is better).
|
|
531
737
|
if (!r || typeof r !== 'object') continue
|
|
532
738
|
builder.roundStart(r.round, manifest.maxRounds)
|
|
533
739
|
for (const t of Array.isArray(r.threads) ? r.threads : []) {
|
|
740
|
+
// A null/garbled thread row must be skipped too: reading `t.dimension`
|
|
741
|
+
// off it would throw and cost the WHOLE manifest its history.
|
|
742
|
+
if (!t || typeof t !== 'object') continue
|
|
534
743
|
// restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
|
|
535
744
|
// message ARRAY boundaries and the thread's own dimension: rehydration
|
|
536
745
|
// previously collapsed each thread's messages into one string and, once a
|
|
@@ -549,6 +758,14 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
549
758
|
const n = manifest.convergence[idx]
|
|
550
759
|
if (typeof n === 'number' && n >= 0) builder.snapshotConvergence(idx + 1, n)
|
|
551
760
|
}
|
|
761
|
+
// Top-level findings live in the global newest-wins list (the F3 panel reads
|
|
762
|
+
// `manifest.findings`); restoreThread only rebuilds the per-round threads, so
|
|
763
|
+
// without this a nudge/rollback re-persist blanked the findings panel.
|
|
764
|
+
if (Array.isArray(manifest.findings)) builder.restoreFindings(manifest.findings)
|
|
765
|
+
// Move the round marker forward past the captured rows: `roundStart` reopens
|
|
766
|
+
// rows, but a manifest whose `round` outruns its rows (advanceRound semantics)
|
|
767
|
+
// would otherwise rewind the reported round.
|
|
768
|
+
builder.advanceRound(manifest.round, manifest.maxRounds)
|
|
552
769
|
if (manifest.checkpoint) builder.recordCheckpoint(manifest.checkpoint)
|
|
553
770
|
if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
|
|
554
771
|
// Validation rows survive a nudge-edit re-persist (same defensive
|
package/src/transcript.ts
CHANGED
|
@@ -486,6 +486,24 @@ export class ReviewTranscriptBuilder {
|
|
|
486
486
|
this.touch()
|
|
487
487
|
}
|
|
488
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Re-insert top-level findings when rehydrating a persisted manifest (the
|
|
491
|
+
* nudge / rollback edit round-trip). `restoreThread` already rebuilds the
|
|
492
|
+
* per-round thread copy of each finding but never the global list, so an edit
|
|
493
|
+
* that only rewrites steering text used to blank `manifest.findings` and the
|
|
494
|
+
* F3 findings panel. This refills the global newest-wins list (and its dedupe
|
|
495
|
+
* set), mirroring `reviewerFindings` without needing a live thread.
|
|
496
|
+
*/
|
|
497
|
+
restoreFindings(findings: readonly unknown[]): void {
|
|
498
|
+
if (Array.isArray(findings)) {
|
|
499
|
+
for (const raw of findings) {
|
|
500
|
+
const f = normalizeFinding(raw)
|
|
501
|
+
if (f) this.addGlobal(f)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
this.touch()
|
|
505
|
+
}
|
|
506
|
+
|
|
489
507
|
// ─── Convergence (F2) ───────────────────────────────────────────────────
|
|
490
508
|
|
|
491
509
|
/** Record a round's new-finding count for the convergence series. */
|