iterate-plugin 2.11.0 → 2.12.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 +41 -1
- package/README.zh-CN.md +39 -1
- package/dist/approval-gate.js +92 -0
- package/dist/config-loader.js +4 -0
- package/dist/index.js +15 -5
- package/dist/live.js +155 -0
- package/dist/paths.js +4 -0
- package/dist/session-hooks.js +89 -0
- package/dist/skill-prompt.js +56 -4
- package/dist/tools/transcript.js +324 -0
- package/dist/transcript.js +421 -0
- package/lib/client.js +865 -0
- package/lib/parse.js +276 -0
- package/package.json +1 -1
- package/src/approval-gate.ts +119 -0
- package/src/client/index.ts +710 -6
- package/src/config-loader.ts +4 -0
- package/src/index.ts +17 -6
- package/src/live.ts +185 -0
- package/src/paths.ts +5 -0
- package/src/session-hooks.ts +90 -0
- package/src/skill-prompt.ts +56 -4
- package/src/tools/transcript.ts +334 -0
- package/src/transcript.ts +475 -0
- package/src/types.ts +117 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/transcript.ts — runtime-observatory transcript builder.
|
|
3
|
+
*
|
|
4
|
+
* Pure, deterministic, memory-bounded accumulator that turns normalized
|
|
5
|
+
* iteration events into a serializable {@link TranscriptManifest} the client
|
|
6
|
+
* renders. This is the data backbone for the observatory UI layer:
|
|
7
|
+
* - F1 — per-reviewer sub-agent message streams (threads per round/dimension).
|
|
8
|
+
* - F2 — per-round convergence series.
|
|
9
|
+
* - F3 — full finding list with file/line location for jump + triage.
|
|
10
|
+
* - F4 — applied-fix records (for diff + rollback).
|
|
11
|
+
* - F5 — checkpoint summary (resume).
|
|
12
|
+
* - F6 — nudge channel (steer the next round).
|
|
13
|
+
* - F7 — append-only decision timeline.
|
|
14
|
+
*
|
|
15
|
+
* It performs NO I/O and NEVER touches the filesystem — persistence lives in
|
|
16
|
+
* the `iterate_transcript` tool. Inputs are defensively normalized so a
|
|
17
|
+
* malformed event can never crash dedupe/sort or leak non-JSON state.
|
|
18
|
+
*
|
|
19
|
+
* Growth is bounded per field (threads/round capped, messages per thread
|
|
20
|
+
* capped, timeline capped) so a very long run cannot blow up memory or the
|
|
21
|
+
* client payload; the newest events win when a cap is hit.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
TranscriptCheckpoint,
|
|
26
|
+
TranscriptEntry,
|
|
27
|
+
TranscriptFinding,
|
|
28
|
+
TranscriptFix,
|
|
29
|
+
TranscriptManifest,
|
|
30
|
+
TranscriptNudge,
|
|
31
|
+
TranscriptRound,
|
|
32
|
+
} from './types.ts'
|
|
33
|
+
|
|
34
|
+
/** Manifest schema version (bump on incompatible shape change). */
|
|
35
|
+
export const TRANSCRIPT_VERSION = 1
|
|
36
|
+
|
|
37
|
+
/** Max threads recorded per round (extra dimensions/retries beyond this drop). */
|
|
38
|
+
const MAX_THREADS_PER_ROUND = 12
|
|
39
|
+
|
|
40
|
+
/** Max narration messages kept per thread (newest wins). */
|
|
41
|
+
const MAX_MESSAGES_PER_THREAD = 40
|
|
42
|
+
|
|
43
|
+
/** Max findings kept per thread. */
|
|
44
|
+
const MAX_FINDINGS_PER_THREAD = 100
|
|
45
|
+
|
|
46
|
+
/** Max global findings kept in the manifest. */
|
|
47
|
+
const MAX_FINDINGS_TOTAL = 2000
|
|
48
|
+
|
|
49
|
+
/** Max timeline entries kept (newest wins). */
|
|
50
|
+
const MAX_TIMELINE = 500
|
|
51
|
+
|
|
52
|
+
/** Thresholds applied when reducing a string list under a cap. */
|
|
53
|
+
function clampStringList(source: string[], cap: number): string[] {
|
|
54
|
+
const out: string[] = []
|
|
55
|
+
for (const item of source) {
|
|
56
|
+
if (typeof item !== 'string') continue
|
|
57
|
+
const trimmed = item.trim()
|
|
58
|
+
if (!trimmed) continue
|
|
59
|
+
out.push(trimmed)
|
|
60
|
+
if (out.length >= cap) break
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Normalize a single finding, dropping malformed entries. */
|
|
66
|
+
function normalizeFinding(input: unknown): TranscriptFinding | null {
|
|
67
|
+
if (!input || typeof input !== 'object') return null
|
|
68
|
+
const f = input as Record<string, unknown>
|
|
69
|
+
const dimension = typeof f.dimension === 'string' ? f.dimension : ''
|
|
70
|
+
const file = typeof f.file === 'string' ? f.file : ''
|
|
71
|
+
const summary = typeof f.summary === 'string' ? f.summary : ''
|
|
72
|
+
if (!dimension || !file || !summary) return null
|
|
73
|
+
const sev = f.severity
|
|
74
|
+
const severity =
|
|
75
|
+
sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
|
|
76
|
+
? sev
|
|
77
|
+
: 'low'
|
|
78
|
+
const line = typeof f.line === 'number' && Number.isFinite(f.line) ? f.line : 0
|
|
79
|
+
return {
|
|
80
|
+
dimension,
|
|
81
|
+
file,
|
|
82
|
+
line,
|
|
83
|
+
severity,
|
|
84
|
+
summary,
|
|
85
|
+
failure_scenario: typeof f.failure_scenario === 'string' ? f.failure_scenario : undefined,
|
|
86
|
+
suggested_fix: typeof f.suggested_fix === 'string' ? f.suggested_fix : undefined,
|
|
87
|
+
is_atomic: typeof f.is_atomic === 'boolean' ? f.is_atomic : undefined,
|
|
88
|
+
acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Stage an in-progress thread so the builder can append messages/reads/findings. */
|
|
93
|
+
interface LiveThread {
|
|
94
|
+
dimension: string
|
|
95
|
+
attempt: number
|
|
96
|
+
messages: string[]
|
|
97
|
+
readFiles: string[]
|
|
98
|
+
findings: unknown[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Stage an in-progress round. */
|
|
102
|
+
interface LiveRound {
|
|
103
|
+
round: number
|
|
104
|
+
threads: LiveThread[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Merge a report snapshot's findings/readFiles into a thread by dimension. */
|
|
108
|
+
function mergeReportIntoThread(
|
|
109
|
+
thread: LiveThread,
|
|
110
|
+
findings: readonly unknown[],
|
|
111
|
+
readFiles: readonly unknown[] | undefined,
|
|
112
|
+
): void {
|
|
113
|
+
for (const raw of findings) {
|
|
114
|
+
const f = normalizeFinding(raw)
|
|
115
|
+
if (f) thread.findings.push(raw)
|
|
116
|
+
}
|
|
117
|
+
for (const r of readFiles ?? []) {
|
|
118
|
+
if (typeof r === 'string') thread.readFiles.push(r)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The transcript builder. Create one per project run, feed normalized events,
|
|
124
|
+
* then call {@link serialize}. Safe to call from any thread sequentially.
|
|
125
|
+
*/
|
|
126
|
+
export class ReviewTranscriptBuilder {
|
|
127
|
+
private readonly project: string
|
|
128
|
+
private readonly mode: 'dry-run' | 'normal' | null
|
|
129
|
+
private readonly approval: 'ask' | 'deny' | 'allow'
|
|
130
|
+
private goal = ''
|
|
131
|
+
private readonly phases: string[] = []
|
|
132
|
+
private round = 0
|
|
133
|
+
private maxRounds = 0
|
|
134
|
+
private active = true
|
|
135
|
+
private readonly rounds: LiveRound[] = []
|
|
136
|
+
private readonly convergence: number[] = []
|
|
137
|
+
private readonly globalFindings: TranscriptFinding[] = []
|
|
138
|
+
private readonly fixes: TranscriptFix[] = []
|
|
139
|
+
private checkpoint: TranscriptCheckpoint | null = null
|
|
140
|
+
private readonly timeline: TranscriptEntry[] = []
|
|
141
|
+
private nudge: TranscriptNudge | null = null
|
|
142
|
+
private updatedAt: string
|
|
143
|
+
|
|
144
|
+
constructor(input: {
|
|
145
|
+
project: string
|
|
146
|
+
mode?: 'dry-run' | 'normal' | null
|
|
147
|
+
approval?: 'ask' | 'deny' | 'allow'
|
|
148
|
+
goal?: string
|
|
149
|
+
maxRounds?: number
|
|
150
|
+
now?: () => string
|
|
151
|
+
}) {
|
|
152
|
+
this.project = input.project || ''
|
|
153
|
+
this.mode =
|
|
154
|
+
input.mode === 'dry-run' || input.mode === 'normal' ? input.mode : null
|
|
155
|
+
this.approval =
|
|
156
|
+
input.approval === 'ask' || input.approval === 'deny' || input.approval === 'allow'
|
|
157
|
+
? input.approval
|
|
158
|
+
: 'ask'
|
|
159
|
+
this.goal = typeof input.goal === 'string' ? input.goal : ''
|
|
160
|
+
this.maxRounds =
|
|
161
|
+
typeof input.maxRounds === 'number' && Number.isFinite(input.maxRounds) && input.maxRounds >= 0
|
|
162
|
+
? Math.floor(input.maxRounds)
|
|
163
|
+
: 0
|
|
164
|
+
this.updatedAt = input.now ? input.now() : new Date().toISOString()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Run lifecycle ──────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/** Mark the run started (clears the transcript for a fresh session). */
|
|
170
|
+
begin(goal?: string, maxRounds?: number): void {
|
|
171
|
+
if (typeof goal === 'string' && goal) this.goal = goal
|
|
172
|
+
if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
|
|
173
|
+
this.maxRounds = Math.floor(maxRounds)
|
|
174
|
+
}
|
|
175
|
+
this.active = true
|
|
176
|
+
this.touch()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Record a workflow phase name (plan / review / fix / validate / report …). */
|
|
180
|
+
phase(name: string): void {
|
|
181
|
+
const n = typeof name === 'string' ? name.trim() : ''
|
|
182
|
+
if (!n) return
|
|
183
|
+
if (this.phases[this.phases.length - 1] !== n) this.phases.push(n)
|
|
184
|
+
this.touch()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** End the run (stops the "active" pulsing in the UI). */
|
|
188
|
+
finish(): void {
|
|
189
|
+
this.active = false
|
|
190
|
+
this.touch()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Open a review round, capturing the current round index. */
|
|
194
|
+
roundStart(round: number, maxRounds?: number): void {
|
|
195
|
+
const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
|
|
196
|
+
this.round = r > 0 ? r : 1
|
|
197
|
+
if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
|
|
198
|
+
this.maxRounds = Math.floor(maxRounds)
|
|
199
|
+
}
|
|
200
|
+
while (this.rounds.length < this.round) {
|
|
201
|
+
this.rounds.push({ round: this.rounds.length + 1, threads: [] })
|
|
202
|
+
}
|
|
203
|
+
this.touch()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Reviewer threads (F1) ──────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/** Start a reviewer sub-agent's thread for the current round. */
|
|
209
|
+
reviewerStart(dimension: string, attempt = 1): void {
|
|
210
|
+
const dim = typeof dimension === 'string' ? dimension.trim() : 'review'
|
|
211
|
+
const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1
|
|
212
|
+
this.roundStart(this.round)
|
|
213
|
+
const live = this.rounds[this.round - 1]! as LiveRound | undefined
|
|
214
|
+
if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
|
|
215
|
+
live.threads.push({
|
|
216
|
+
dimension: dim || 'review',
|
|
217
|
+
attempt: att > 0 ? att : 1,
|
|
218
|
+
messages: [],
|
|
219
|
+
readFiles: [],
|
|
220
|
+
findings: [],
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
this.touch()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Append narration (assistant text) to the current reviewer thread. */
|
|
227
|
+
reviewerMessage(text: string): void {
|
|
228
|
+
if (typeof text !== 'string' || !text.trim()) return
|
|
229
|
+
const thread = this.currentThread()
|
|
230
|
+
if (!thread) return
|
|
231
|
+
thread.messages.push(text)
|
|
232
|
+
if (thread.messages.length > MAX_MESSAGES_PER_THREAD) {
|
|
233
|
+
thread.messages.splice(0, thread.messages.length - MAX_MESSAGES_PER_THREAD)
|
|
234
|
+
}
|
|
235
|
+
this.touch()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Record files the current reviewer opened (read_file). */
|
|
239
|
+
reviewerRead(files: readonly unknown[]): void {
|
|
240
|
+
const thread = this.currentThread()
|
|
241
|
+
if (!thread) return
|
|
242
|
+
for (const f of files ?? []) {
|
|
243
|
+
if (typeof f === 'string') thread.readFiles.push(f)
|
|
244
|
+
}
|
|
245
|
+
this.touch()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Record findings the current reviewer produced (both raw and normalized). */
|
|
249
|
+
reviewerFindings(findings: readonly unknown[]): void {
|
|
250
|
+
const thread = this.currentThread()
|
|
251
|
+
if (!thread) return
|
|
252
|
+
if (Array.isArray(findings)) {
|
|
253
|
+
for (const raw of findings) {
|
|
254
|
+
const f = normalizeFinding(raw)
|
|
255
|
+
if (f) {
|
|
256
|
+
thread.findings.push(raw)
|
|
257
|
+
this.globalFindings.push(f)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
this.reevaluateGlobal()
|
|
262
|
+
this.touch()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Merge a round-level report snapshot (findings + readFiles) into a thread. */
|
|
266
|
+
reviewerSnapshot(dimension: string, findings: readonly unknown[], readFiles?: readonly unknown[]): void {
|
|
267
|
+
this.reviewerStart(dimension)
|
|
268
|
+
const thread = this.currentThread()
|
|
269
|
+
if (!thread) return
|
|
270
|
+
mergeReportIntoThread(thread, findings, readFiles)
|
|
271
|
+
for (const raw of findings) {
|
|
272
|
+
const f = normalizeFinding(raw)
|
|
273
|
+
if (f) this.globalFindings.push(f)
|
|
274
|
+
}
|
|
275
|
+
this.reevaluateGlobal()
|
|
276
|
+
this.touch()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Convergence (F2) ───────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
/** Record a round's new-finding count for the convergence series. */
|
|
282
|
+
snapshotConvergence(round: number, newCount: number): void {
|
|
283
|
+
const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1
|
|
284
|
+
const n = typeof newCount === 'number' && Number.isFinite(newCount) ? newCount : 0
|
|
285
|
+
while (this.convergence.length < r) this.convergence.push(-1)
|
|
286
|
+
this.convergence[r - 1] = Math.floor(n)
|
|
287
|
+
this.touch()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Fixes (F4) ─────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/** Record an applied atomic fix. */
|
|
293
|
+
fix(record: Partial<TranscriptFix>): void {
|
|
294
|
+
if (!record || typeof record !== 'object') return
|
|
295
|
+
const id = typeof record.id === 'string' ? record.id : ''
|
|
296
|
+
const file = typeof record.file === 'string' ? record.file : ''
|
|
297
|
+
if (!id || !file) return
|
|
298
|
+
this.fixes.push({
|
|
299
|
+
id,
|
|
300
|
+
timestamp: typeof record.timestamp === 'string' ? record.timestamp : isoNow(),
|
|
301
|
+
round: typeof record.round === 'number' ? record.round : this.round,
|
|
302
|
+
file,
|
|
303
|
+
summary: typeof record.summary === 'string' ? record.summary : '',
|
|
304
|
+
linesAdded:
|
|
305
|
+
typeof record.linesAdded === 'number' ? Math.floor(record.linesAdded) : 0,
|
|
306
|
+
linesRemoved:
|
|
307
|
+
typeof record.linesRemoved === 'number' ? Math.floor(record.linesRemoved) : 0,
|
|
308
|
+
success: record.success !== false,
|
|
309
|
+
})
|
|
310
|
+
this.touch()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Flag a fix as rolled back (kept in the list so the UI shows the reversal). */
|
|
314
|
+
markFixRolledBack(id: string): void {
|
|
315
|
+
for (const f of this.fixes) {
|
|
316
|
+
if (f.id === id) f.success = false
|
|
317
|
+
}
|
|
318
|
+
this.touch()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Checkpoint (F5) ────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/** Record the current checkpoint summary (null clears it). */
|
|
324
|
+
recordCheckpoint(state: TranscriptCheckpoint | null): void {
|
|
325
|
+
if (!state || typeof state !== 'object') {
|
|
326
|
+
this.checkpoint = null
|
|
327
|
+
this.touch()
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
this.checkpoint = {
|
|
331
|
+
mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : 'normal',
|
|
332
|
+
round: typeof state.round === 'number' ? state.round : 0,
|
|
333
|
+
maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
|
|
334
|
+
fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
|
|
335
|
+
resumeCount: typeof state.resumeCount === 'number' ? state.resumeCount : 0,
|
|
336
|
+
updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : isoNow(),
|
|
337
|
+
}
|
|
338
|
+
this.touch()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Decision timeline (F7) ─────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/** Append one decision-log entry to the timeline (newest wins under the cap). */
|
|
344
|
+
decision(entry: Partial<TranscriptEntry>): void {
|
|
345
|
+
if (!entry || typeof entry !== 'object') return
|
|
346
|
+
const type = typeof entry.type === 'string' ? entry.type : 'decision'
|
|
347
|
+
this.timeline.push({
|
|
348
|
+
timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : isoNow(),
|
|
349
|
+
round: typeof entry.round === 'number' ? entry.round : this.round,
|
|
350
|
+
type,
|
|
351
|
+
data:
|
|
352
|
+
entry.data && typeof entry.data === 'object'
|
|
353
|
+
? (entry.data as Record<string, unknown>)
|
|
354
|
+
: {},
|
|
355
|
+
})
|
|
356
|
+
if (this.timeline.length > MAX_TIMELINE) {
|
|
357
|
+
this.timeline.splice(0, this.timeline.length - MAX_TIMELINE)
|
|
358
|
+
}
|
|
359
|
+
this.touch()
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Nudge (F6) ─────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
/** Write steering text for the next round (null clears it). */
|
|
365
|
+
setNudge(text: string | null): void {
|
|
366
|
+
if (typeof text === 'string' && text.trim()) {
|
|
367
|
+
this.nudge = { timestamp: isoNow(), text: text.trim() }
|
|
368
|
+
} else {
|
|
369
|
+
this.nudge = null
|
|
370
|
+
}
|
|
371
|
+
this.touch()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Serialization ──────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
/** Produce the current serializable manifest. */
|
|
377
|
+
serialize(): TranscriptManifest {
|
|
378
|
+
const rounds: TranscriptRound[] = this.rounds.map((r, idx) => ({
|
|
379
|
+
round: r.round,
|
|
380
|
+
threads: r.threads.map((t) => ({
|
|
381
|
+
dimension: t.dimension,
|
|
382
|
+
attempt: t.attempt,
|
|
383
|
+
messages: clampStringList(t.messages, MAX_MESSAGES_PER_THREAD),
|
|
384
|
+
readFiles: dedupePaths(t.readFiles),
|
|
385
|
+
findings: t.findings
|
|
386
|
+
.map((x) => normalizeFinding(x))
|
|
387
|
+
.filter((x): x is TranscriptFinding => x !== null),
|
|
388
|
+
})),
|
|
389
|
+
}))
|
|
390
|
+
return {
|
|
391
|
+
version: TRANSCRIPT_VERSION,
|
|
392
|
+
project: this.project,
|
|
393
|
+
updatedAt: this.updatedAt,
|
|
394
|
+
active: this.active,
|
|
395
|
+
mode: this.mode,
|
|
396
|
+
goal: this.goal,
|
|
397
|
+
phases: this.phases,
|
|
398
|
+
round: this.round,
|
|
399
|
+
maxRounds: this.maxRounds,
|
|
400
|
+
rounds,
|
|
401
|
+
convergence: this.convergence,
|
|
402
|
+
findings:
|
|
403
|
+
this.globalFindings.length > MAX_FINDINGS_TOTAL
|
|
404
|
+
? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
|
|
405
|
+
: this.globalFindings,
|
|
406
|
+
fixes: this.fixes,
|
|
407
|
+
checkpoint: this.checkpoint,
|
|
408
|
+
timeline: this.timeline,
|
|
409
|
+
nudge: this.nudge,
|
|
410
|
+
approval: {
|
|
411
|
+
active: this.approval !== 'allow',
|
|
412
|
+
policy: this.approval,
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── Internals ──────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/** Bump the manifest's updatedAt to reflect a fresh mutation. */
|
|
420
|
+
private touch(): void {
|
|
421
|
+
this.updatedAt = isoNow()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Current round's most recent thread, if any. */
|
|
425
|
+
private currentThread(): LiveThread | null {
|
|
426
|
+
const live = this.rounds[this.round - 1] as LiveRound | undefined
|
|
427
|
+
if (!live) return null
|
|
428
|
+
const thread = live.threads[live.threads.length - 1]
|
|
429
|
+
return thread ?? null
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Count threads already recorded for a round. */
|
|
433
|
+
private threadCount(live: LiveRound): number {
|
|
434
|
+
return live.threads.length
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Recompute the global finding list from per-thread findings (dedup). */
|
|
438
|
+
private reevaluateGlobal(): void {
|
|
439
|
+
// Rebuild from threads to derive a deterministic global list.
|
|
440
|
+
this.globalFindings.length = 0
|
|
441
|
+
const seen = new Set<string>()
|
|
442
|
+
for (const r of this.rounds) {
|
|
443
|
+
for (const t of r.threads) {
|
|
444
|
+
for (const raw of t.findings) {
|
|
445
|
+
const f = normalizeFinding(raw)
|
|
446
|
+
if (!f) continue
|
|
447
|
+
const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`
|
|
448
|
+
if (seen.has(key)) continue
|
|
449
|
+
seen.add(key)
|
|
450
|
+
this.globalFindings.push(f)
|
|
451
|
+
if (this.globalFindings.length >= MAX_FINDINGS_TOTAL) return
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Dedupe + bound an ordered string list of file paths. */
|
|
459
|
+
function dedupePaths(paths: string[]): string[] {
|
|
460
|
+
const seen = new Set<string>()
|
|
461
|
+
const out: string[] = []
|
|
462
|
+
for (const p of paths) {
|
|
463
|
+
if (typeof p !== 'string' || !p.trim()) continue
|
|
464
|
+
const k = p.trim()
|
|
465
|
+
if (seen.has(k)) continue
|
|
466
|
+
seen.add(k)
|
|
467
|
+
out.push(k)
|
|
468
|
+
}
|
|
469
|
+
return out
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** ISO timestamp helper (kept injectable in tests via the builder's now). */
|
|
473
|
+
function isoNow(): string {
|
|
474
|
+
return new Date().toISOString()
|
|
475
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,22 @@ export interface IterateConfig {
|
|
|
22
22
|
coverage_validation: boolean
|
|
23
23
|
scope_chunk_size: number
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Runtime observatory: live review-transcript capture and the destructive
|
|
27
|
+
* tool approval gate. Absent → defaults (capture on, approval per `policy`).
|
|
28
|
+
*/
|
|
29
|
+
observatory?: {
|
|
30
|
+
/** Persist a review transcript to `.iterate/transcript.json` for the client observatory. */
|
|
31
|
+
capture?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Approval policy for destructive iterate tools (iterate_fix /
|
|
34
|
+
* iterate_rollback / iterate_prune with dryRun:false).
|
|
35
|
+
* - 'ask': prompt the human through the dsh approval service before running.
|
|
36
|
+
* - 'deny': refuse the call outright (fail-closed).
|
|
37
|
+
* - 'allow': always run (debug/trusted). Default 'ask'.
|
|
38
|
+
*/
|
|
39
|
+
approval?: 'ask' | 'deny' | 'allow'
|
|
40
|
+
}
|
|
25
41
|
onboarding?: Record<string, unknown>
|
|
26
42
|
personalization?: Record<string, unknown>
|
|
27
43
|
}
|
|
@@ -188,4 +204,105 @@ export interface IterationStatus {
|
|
|
188
204
|
resumeCount: number
|
|
189
205
|
checkpoint: IterationCheckpoint | null
|
|
190
206
|
lastUpdated: string | null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** ─── Runtime observatory (transcript) ───────────────────────────────────── */
|
|
210
|
+
|
|
211
|
+
/** Keyboard-control-free direction to steer the running workflow's next round. */
|
|
212
|
+
export interface TranscriptNudge {
|
|
213
|
+
/** ISO timestamp the nudge was written. */
|
|
214
|
+
timestamp: string
|
|
215
|
+
/** Free-form steering text injected at the front of the next round's prompt. */
|
|
216
|
+
text: string
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** One finding rendered for the observatory (location + evidence kept for jump/triage). */
|
|
220
|
+
export interface TranscriptFinding {
|
|
221
|
+
dimension: string
|
|
222
|
+
file: string
|
|
223
|
+
line?: number
|
|
224
|
+
severity: 'critical' | 'high' | 'medium' | 'low'
|
|
225
|
+
summary: string
|
|
226
|
+
failure_scenario?: string
|
|
227
|
+
suggested_fix?: string
|
|
228
|
+
is_atomic?: boolean
|
|
229
|
+
/** True when this finding was already marked known_intentional (filtered from active work). */
|
|
230
|
+
acknowledged?: boolean
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One reviewer sub-agent's visible stream within a round. */
|
|
234
|
+
export interface TranscriptThread {
|
|
235
|
+
/** Target dimension the reviewer was asked to review. */
|
|
236
|
+
dimension: string
|
|
237
|
+
/** 1-based attempt within the round (schema-validation retries bump this). */
|
|
238
|
+
attempt: number
|
|
239
|
+
/** Natural-language narration the reviewer produced (F1 message stream). */
|
|
240
|
+
messages: string[]
|
|
241
|
+
/** Files the reviewer opened with read_file (F1 "what it read"). */
|
|
242
|
+
readFiles: string[]
|
|
243
|
+
/** Findings the reviewer produced in this thread. */
|
|
244
|
+
findings: TranscriptFinding[]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A review round grouping its reviewer threads. */
|
|
248
|
+
export interface TranscriptRound {
|
|
249
|
+
round: number
|
|
250
|
+
threads: TranscriptThread[]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A recorded atomic fix shown in the observatory (F4). */
|
|
254
|
+
export interface TranscriptFix {
|
|
255
|
+
id: string
|
|
256
|
+
timestamp: string
|
|
257
|
+
round: number
|
|
258
|
+
file: string
|
|
259
|
+
summary: string
|
|
260
|
+
linesAdded: number
|
|
261
|
+
linesRemoved: number
|
|
262
|
+
success: boolean
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A single decision-log entry in the timeline (F7). */
|
|
266
|
+
export interface TranscriptEntry {
|
|
267
|
+
timestamp: string
|
|
268
|
+
round: number
|
|
269
|
+
type: string
|
|
270
|
+
data: Record<string, unknown>
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Checkpoint summary surfaced for resume actions (F5). */
|
|
274
|
+
export interface TranscriptCheckpoint {
|
|
275
|
+
mode: 'dry-run' | 'normal'
|
|
276
|
+
round: number
|
|
277
|
+
maxRounds: number
|
|
278
|
+
fixedCount: number
|
|
279
|
+
resumeCount: number
|
|
280
|
+
updatedAt: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Serializable runtime-observatory manifest the client renders. Every field is
|
|
285
|
+
* `JsonValue`-safe; the builder caps growth so a long run cannot blow up memory.
|
|
286
|
+
*/
|
|
287
|
+
export interface TranscriptManifest {
|
|
288
|
+
version: number
|
|
289
|
+
project: string
|
|
290
|
+
updatedAt: string
|
|
291
|
+
active: boolean
|
|
292
|
+
mode: 'dry-run' | 'normal' | null
|
|
293
|
+
goal: string
|
|
294
|
+
phases: string[]
|
|
295
|
+
round: number
|
|
296
|
+
maxRounds: number
|
|
297
|
+
rounds: TranscriptRound[]
|
|
298
|
+
convergence: number[]
|
|
299
|
+
findings: TranscriptFinding[]
|
|
300
|
+
fixes: TranscriptFix[]
|
|
301
|
+
checkpoint: TranscriptCheckpoint | null
|
|
302
|
+
timeline: TranscriptEntry[]
|
|
303
|
+
nudge: TranscriptNudge | null
|
|
304
|
+
approval: {
|
|
305
|
+
active: boolean
|
|
306
|
+
policy: 'ask' | 'deny' | 'allow'
|
|
307
|
+
}
|
|
191
308
|
}
|