iterate-plugin 2.10.0 → 2.12.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.
Files changed (55) hide show
  1. package/README.md +42 -2
  2. package/README.zh-CN.md +40 -2
  3. package/dist/approval-gate.js +92 -0
  4. package/dist/config-loader.js +18 -3
  5. package/dist/config-write.js +7 -4
  6. package/dist/evidence.js +67 -1
  7. package/dist/git-scope.js +35 -6
  8. package/dist/index.js +15 -5
  9. package/dist/live.js +155 -0
  10. package/dist/meta-review.js +19 -5
  11. package/dist/method-scope.js +5 -1
  12. package/dist/paths.js +4 -0
  13. package/dist/review-scope.js +12 -8
  14. package/dist/review.js +76 -24
  15. package/dist/session-hooks.js +89 -0
  16. package/dist/skill-prompt.js +101 -19
  17. package/dist/tools/checkpoint.js +10 -3
  18. package/dist/tools/context.js +16 -4
  19. package/dist/tools/decision-log.js +29 -9
  20. package/dist/tools/fix.js +120 -3
  21. package/dist/tools/prune.js +16 -9
  22. package/dist/tools/review.js +4 -1
  23. package/dist/tools/transcript.js +324 -0
  24. package/dist/tools/triage.js +9 -6
  25. package/dist/tools/validate.js +5 -2
  26. package/dist/transcript.js +421 -0
  27. package/lib/client.js +966 -80
  28. package/lib/parse.js +302 -17
  29. package/package.json +1 -1
  30. package/src/approval-gate.ts +119 -0
  31. package/src/client/index.ts +807 -62
  32. package/src/config-loader.ts +16 -2
  33. package/src/config-write.ts +6 -4
  34. package/src/evidence.ts +69 -1
  35. package/src/git-scope.ts +34 -6
  36. package/src/index.ts +17 -6
  37. package/src/live.ts +185 -0
  38. package/src/meta-review.ts +24 -10
  39. package/src/method-scope.ts +5 -1
  40. package/src/paths.ts +5 -0
  41. package/src/review-scope.ts +11 -7
  42. package/src/review.ts +82 -25
  43. package/src/session-hooks.ts +90 -0
  44. package/src/skill-prompt.ts +101 -19
  45. package/src/tools/checkpoint.ts +10 -3
  46. package/src/tools/context.ts +14 -3
  47. package/src/tools/decision-log.ts +27 -10
  48. package/src/tools/fix.ts +114 -3
  49. package/src/tools/prune.ts +14 -11
  50. package/src/tools/review.ts +5 -2
  51. package/src/tools/transcript.ts +334 -0
  52. package/src/tools/triage.ts +9 -6
  53. package/src/tools/validate.ts +5 -2
  54. package/src/transcript.ts +475 -0
  55. package/src/types.ts +129 -0
@@ -0,0 +1,421 @@
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
+ /** Manifest schema version (bump on incompatible shape change). */
24
+ export const TRANSCRIPT_VERSION = 1;
25
+ /** Max threads recorded per round (extra dimensions/retries beyond this drop). */
26
+ const MAX_THREADS_PER_ROUND = 12;
27
+ /** Max narration messages kept per thread (newest wins). */
28
+ const MAX_MESSAGES_PER_THREAD = 40;
29
+ /** Max findings kept per thread. */
30
+ const MAX_FINDINGS_PER_THREAD = 100;
31
+ /** Max global findings kept in the manifest. */
32
+ const MAX_FINDINGS_TOTAL = 2000;
33
+ /** Max timeline entries kept (newest wins). */
34
+ const MAX_TIMELINE = 500;
35
+ /** Thresholds applied when reducing a string list under a cap. */
36
+ function clampStringList(source, cap) {
37
+ const out = [];
38
+ for (const item of source) {
39
+ if (typeof item !== 'string')
40
+ continue;
41
+ const trimmed = item.trim();
42
+ if (!trimmed)
43
+ continue;
44
+ out.push(trimmed);
45
+ if (out.length >= cap)
46
+ break;
47
+ }
48
+ return out;
49
+ }
50
+ /** Normalize a single finding, dropping malformed entries. */
51
+ function normalizeFinding(input) {
52
+ if (!input || typeof input !== 'object')
53
+ return null;
54
+ const f = input;
55
+ const dimension = typeof f.dimension === 'string' ? f.dimension : '';
56
+ const file = typeof f.file === 'string' ? f.file : '';
57
+ const summary = typeof f.summary === 'string' ? f.summary : '';
58
+ if (!dimension || !file || !summary)
59
+ return null;
60
+ const sev = f.severity;
61
+ const severity = sev === 'critical' || sev === 'high' || sev === 'medium' || sev === 'low'
62
+ ? sev
63
+ : 'low';
64
+ const line = typeof f.line === 'number' && Number.isFinite(f.line) ? f.line : 0;
65
+ return {
66
+ dimension,
67
+ file,
68
+ line,
69
+ severity,
70
+ summary,
71
+ failure_scenario: typeof f.failure_scenario === 'string' ? f.failure_scenario : undefined,
72
+ suggested_fix: typeof f.suggested_fix === 'string' ? f.suggested_fix : undefined,
73
+ is_atomic: typeof f.is_atomic === 'boolean' ? f.is_atomic : undefined,
74
+ acknowledged: typeof f.acknowledged === 'boolean' ? f.acknowledged : undefined,
75
+ };
76
+ }
77
+ /** Merge a report snapshot's findings/readFiles into a thread by dimension. */
78
+ function mergeReportIntoThread(thread, findings, readFiles) {
79
+ for (const raw of findings) {
80
+ const f = normalizeFinding(raw);
81
+ if (f)
82
+ thread.findings.push(raw);
83
+ }
84
+ for (const r of readFiles ?? []) {
85
+ if (typeof r === 'string')
86
+ thread.readFiles.push(r);
87
+ }
88
+ }
89
+ /**
90
+ * The transcript builder. Create one per project run, feed normalized events,
91
+ * then call {@link serialize}. Safe to call from any thread sequentially.
92
+ */
93
+ export class ReviewTranscriptBuilder {
94
+ project;
95
+ mode;
96
+ approval;
97
+ goal = '';
98
+ phases = [];
99
+ round = 0;
100
+ maxRounds = 0;
101
+ active = true;
102
+ rounds = [];
103
+ convergence = [];
104
+ globalFindings = [];
105
+ fixes = [];
106
+ checkpoint = null;
107
+ timeline = [];
108
+ nudge = null;
109
+ updatedAt;
110
+ constructor(input) {
111
+ this.project = input.project || '';
112
+ this.mode =
113
+ input.mode === 'dry-run' || input.mode === 'normal' ? input.mode : null;
114
+ this.approval =
115
+ input.approval === 'ask' || input.approval === 'deny' || input.approval === 'allow'
116
+ ? input.approval
117
+ : 'ask';
118
+ this.goal = typeof input.goal === 'string' ? input.goal : '';
119
+ this.maxRounds =
120
+ typeof input.maxRounds === 'number' && Number.isFinite(input.maxRounds) && input.maxRounds >= 0
121
+ ? Math.floor(input.maxRounds)
122
+ : 0;
123
+ this.updatedAt = input.now ? input.now() : new Date().toISOString();
124
+ }
125
+ // ─── Run lifecycle ──────────────────────────────────────────────────────
126
+ /** Mark the run started (clears the transcript for a fresh session). */
127
+ begin(goal, maxRounds) {
128
+ if (typeof goal === 'string' && goal)
129
+ this.goal = goal;
130
+ if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
131
+ this.maxRounds = Math.floor(maxRounds);
132
+ }
133
+ this.active = true;
134
+ this.touch();
135
+ }
136
+ /** Record a workflow phase name (plan / review / fix / validate / report …). */
137
+ phase(name) {
138
+ const n = typeof name === 'string' ? name.trim() : '';
139
+ if (!n)
140
+ return;
141
+ if (this.phases[this.phases.length - 1] !== n)
142
+ this.phases.push(n);
143
+ this.touch();
144
+ }
145
+ /** End the run (stops the "active" pulsing in the UI). */
146
+ finish() {
147
+ this.active = false;
148
+ this.touch();
149
+ }
150
+ /** Open a review round, capturing the current round index. */
151
+ roundStart(round, maxRounds) {
152
+ const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
153
+ this.round = r > 0 ? r : 1;
154
+ if (typeof maxRounds === 'number' && Number.isFinite(maxRounds) && maxRounds >= 0) {
155
+ this.maxRounds = Math.floor(maxRounds);
156
+ }
157
+ while (this.rounds.length < this.round) {
158
+ this.rounds.push({ round: this.rounds.length + 1, threads: [] });
159
+ }
160
+ this.touch();
161
+ }
162
+ // ─── Reviewer threads (F1) ──────────────────────────────────────────────
163
+ /** Start a reviewer sub-agent's thread for the current round. */
164
+ reviewerStart(dimension, attempt = 1) {
165
+ const dim = typeof dimension === 'string' ? dimension.trim() : 'review';
166
+ const att = typeof attempt === 'number' && Number.isFinite(attempt) ? Math.floor(attempt) : 1;
167
+ this.roundStart(this.round);
168
+ const live = this.rounds[this.round - 1];
169
+ if (live && this.threadCount(live) < MAX_THREADS_PER_ROUND) {
170
+ live.threads.push({
171
+ dimension: dim || 'review',
172
+ attempt: att > 0 ? att : 1,
173
+ messages: [],
174
+ readFiles: [],
175
+ findings: [],
176
+ });
177
+ }
178
+ this.touch();
179
+ }
180
+ /** Append narration (assistant text) to the current reviewer thread. */
181
+ reviewerMessage(text) {
182
+ if (typeof text !== 'string' || !text.trim())
183
+ return;
184
+ const thread = this.currentThread();
185
+ if (!thread)
186
+ return;
187
+ thread.messages.push(text);
188
+ if (thread.messages.length > MAX_MESSAGES_PER_THREAD) {
189
+ thread.messages.splice(0, thread.messages.length - MAX_MESSAGES_PER_THREAD);
190
+ }
191
+ this.touch();
192
+ }
193
+ /** Record files the current reviewer opened (read_file). */
194
+ reviewerRead(files) {
195
+ const thread = this.currentThread();
196
+ if (!thread)
197
+ return;
198
+ for (const f of files ?? []) {
199
+ if (typeof f === 'string')
200
+ thread.readFiles.push(f);
201
+ }
202
+ this.touch();
203
+ }
204
+ /** Record findings the current reviewer produced (both raw and normalized). */
205
+ reviewerFindings(findings) {
206
+ const thread = this.currentThread();
207
+ if (!thread)
208
+ return;
209
+ if (Array.isArray(findings)) {
210
+ for (const raw of findings) {
211
+ const f = normalizeFinding(raw);
212
+ if (f) {
213
+ thread.findings.push(raw);
214
+ this.globalFindings.push(f);
215
+ }
216
+ }
217
+ }
218
+ this.reevaluateGlobal();
219
+ this.touch();
220
+ }
221
+ /** Merge a round-level report snapshot (findings + readFiles) into a thread. */
222
+ reviewerSnapshot(dimension, findings, readFiles) {
223
+ this.reviewerStart(dimension);
224
+ const thread = this.currentThread();
225
+ if (!thread)
226
+ return;
227
+ mergeReportIntoThread(thread, findings, readFiles);
228
+ for (const raw of findings) {
229
+ const f = normalizeFinding(raw);
230
+ if (f)
231
+ this.globalFindings.push(f);
232
+ }
233
+ this.reevaluateGlobal();
234
+ this.touch();
235
+ }
236
+ // ─── Convergence (F2) ───────────────────────────────────────────────────
237
+ /** Record a round's new-finding count for the convergence series. */
238
+ snapshotConvergence(round, newCount) {
239
+ const r = typeof round === 'number' && Number.isFinite(round) ? Math.floor(round) : 1;
240
+ const n = typeof newCount === 'number' && Number.isFinite(newCount) ? newCount : 0;
241
+ while (this.convergence.length < r)
242
+ this.convergence.push(-1);
243
+ this.convergence[r - 1] = Math.floor(n);
244
+ this.touch();
245
+ }
246
+ // ─── Fixes (F4) ─────────────────────────────────────────────────────────
247
+ /** Record an applied atomic fix. */
248
+ fix(record) {
249
+ if (!record || typeof record !== 'object')
250
+ return;
251
+ const id = typeof record.id === 'string' ? record.id : '';
252
+ const file = typeof record.file === 'string' ? record.file : '';
253
+ if (!id || !file)
254
+ return;
255
+ this.fixes.push({
256
+ id,
257
+ timestamp: typeof record.timestamp === 'string' ? record.timestamp : isoNow(),
258
+ round: typeof record.round === 'number' ? record.round : this.round,
259
+ file,
260
+ summary: typeof record.summary === 'string' ? record.summary : '',
261
+ linesAdded: typeof record.linesAdded === 'number' ? Math.floor(record.linesAdded) : 0,
262
+ linesRemoved: typeof record.linesRemoved === 'number' ? Math.floor(record.linesRemoved) : 0,
263
+ success: record.success !== false,
264
+ });
265
+ this.touch();
266
+ }
267
+ /** Flag a fix as rolled back (kept in the list so the UI shows the reversal). */
268
+ markFixRolledBack(id) {
269
+ for (const f of this.fixes) {
270
+ if (f.id === id)
271
+ f.success = false;
272
+ }
273
+ this.touch();
274
+ }
275
+ // ─── Checkpoint (F5) ────────────────────────────────────────────────────
276
+ /** Record the current checkpoint summary (null clears it). */
277
+ recordCheckpoint(state) {
278
+ if (!state || typeof state !== 'object') {
279
+ this.checkpoint = null;
280
+ this.touch();
281
+ return;
282
+ }
283
+ this.checkpoint = {
284
+ mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : 'normal',
285
+ round: typeof state.round === 'number' ? state.round : 0,
286
+ maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
287
+ fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
288
+ resumeCount: typeof state.resumeCount === 'number' ? state.resumeCount : 0,
289
+ updatedAt: typeof state.updatedAt === 'string' ? state.updatedAt : isoNow(),
290
+ };
291
+ this.touch();
292
+ }
293
+ // ─── Decision timeline (F7) ─────────────────────────────────────────────
294
+ /** Append one decision-log entry to the timeline (newest wins under the cap). */
295
+ decision(entry) {
296
+ if (!entry || typeof entry !== 'object')
297
+ return;
298
+ const type = typeof entry.type === 'string' ? entry.type : 'decision';
299
+ this.timeline.push({
300
+ timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : isoNow(),
301
+ round: typeof entry.round === 'number' ? entry.round : this.round,
302
+ type,
303
+ data: entry.data && typeof entry.data === 'object'
304
+ ? entry.data
305
+ : {},
306
+ });
307
+ if (this.timeline.length > MAX_TIMELINE) {
308
+ this.timeline.splice(0, this.timeline.length - MAX_TIMELINE);
309
+ }
310
+ this.touch();
311
+ }
312
+ // ─── Nudge (F6) ─────────────────────────────────────────────────────────
313
+ /** Write steering text for the next round (null clears it). */
314
+ setNudge(text) {
315
+ if (typeof text === 'string' && text.trim()) {
316
+ this.nudge = { timestamp: isoNow(), text: text.trim() };
317
+ }
318
+ else {
319
+ this.nudge = null;
320
+ }
321
+ this.touch();
322
+ }
323
+ // ─── Serialization ──────────────────────────────────────────────────────
324
+ /** Produce the current serializable manifest. */
325
+ serialize() {
326
+ const rounds = this.rounds.map((r, idx) => ({
327
+ round: r.round,
328
+ threads: r.threads.map((t) => ({
329
+ dimension: t.dimension,
330
+ attempt: t.attempt,
331
+ messages: clampStringList(t.messages, MAX_MESSAGES_PER_THREAD),
332
+ readFiles: dedupePaths(t.readFiles),
333
+ findings: t.findings
334
+ .map((x) => normalizeFinding(x))
335
+ .filter((x) => x !== null),
336
+ })),
337
+ }));
338
+ return {
339
+ version: TRANSCRIPT_VERSION,
340
+ project: this.project,
341
+ updatedAt: this.updatedAt,
342
+ active: this.active,
343
+ mode: this.mode,
344
+ goal: this.goal,
345
+ phases: this.phases,
346
+ round: this.round,
347
+ maxRounds: this.maxRounds,
348
+ rounds,
349
+ convergence: this.convergence,
350
+ findings: this.globalFindings.length > MAX_FINDINGS_TOTAL
351
+ ? this.globalFindings.slice(0, MAX_FINDINGS_TOTAL)
352
+ : this.globalFindings,
353
+ fixes: this.fixes,
354
+ checkpoint: this.checkpoint,
355
+ timeline: this.timeline,
356
+ nudge: this.nudge,
357
+ approval: {
358
+ active: this.approval !== 'allow',
359
+ policy: this.approval,
360
+ },
361
+ };
362
+ }
363
+ // ─── Internals ──────────────────────────────────────────────────────────
364
+ /** Bump the manifest's updatedAt to reflect a fresh mutation. */
365
+ touch() {
366
+ this.updatedAt = isoNow();
367
+ }
368
+ /** Current round's most recent thread, if any. */
369
+ currentThread() {
370
+ const live = this.rounds[this.round - 1];
371
+ if (!live)
372
+ return null;
373
+ const thread = live.threads[live.threads.length - 1];
374
+ return thread ?? null;
375
+ }
376
+ /** Count threads already recorded for a round. */
377
+ threadCount(live) {
378
+ return live.threads.length;
379
+ }
380
+ /** Recompute the global finding list from per-thread findings (dedup). */
381
+ reevaluateGlobal() {
382
+ // Rebuild from threads to derive a deterministic global list.
383
+ this.globalFindings.length = 0;
384
+ const seen = new Set();
385
+ for (const r of this.rounds) {
386
+ for (const t of r.threads) {
387
+ for (const raw of t.findings) {
388
+ const f = normalizeFinding(raw);
389
+ if (!f)
390
+ continue;
391
+ const key = `${f.file}\u0000${f.line ?? 0}\u0000${f.dimension}\u0000${f.summary}`;
392
+ if (seen.has(key))
393
+ continue;
394
+ seen.add(key);
395
+ this.globalFindings.push(f);
396
+ if (this.globalFindings.length >= MAX_FINDINGS_TOTAL)
397
+ return;
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ /** Dedupe + bound an ordered string list of file paths. */
404
+ function dedupePaths(paths) {
405
+ const seen = new Set();
406
+ const out = [];
407
+ for (const p of paths) {
408
+ if (typeof p !== 'string' || !p.trim())
409
+ continue;
410
+ const k = p.trim();
411
+ if (seen.has(k))
412
+ continue;
413
+ seen.add(k);
414
+ out.push(k);
415
+ }
416
+ return out;
417
+ }
418
+ /** ISO timestamp helper (kept injectable in tests via the builder's now). */
419
+ function isoNow() {
420
+ return new Date().toISOString();
421
+ }