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/dist/tools/transcript.js
CHANGED
|
@@ -29,7 +29,78 @@ import { loadEffectiveConfig, resolveProjectRootForExec, } from "../config-loade
|
|
|
29
29
|
import { transcriptPath } from "../paths.js";
|
|
30
30
|
import { ReviewTranscriptBuilder, MAX_TIMELINE, MAX_ROUNDS, normalizeManifestBounds } from "../transcript.js";
|
|
31
31
|
import { readDecisionLogDetailed } from "./decision-log.js";
|
|
32
|
+
import { readQualityGate } from "./quality-store.js";
|
|
32
33
|
import { readLive } from "../live.js";
|
|
34
|
+
import { buildRunRecord, appendRunRecord, readRunRecords, sessionOf, } from "../session-record.js";
|
|
35
|
+
/** Severity tally over a manifest's (globally deduped) findings. */
|
|
36
|
+
function severityCounts(findings) {
|
|
37
|
+
const counts = { total: 0, critical: 0, high: 0, medium: 0, low: 0 };
|
|
38
|
+
if (!Array.isArray(findings))
|
|
39
|
+
return counts;
|
|
40
|
+
for (const f of findings) {
|
|
41
|
+
if (!f || typeof f !== 'object')
|
|
42
|
+
continue;
|
|
43
|
+
const sev = f.severity;
|
|
44
|
+
counts.total++;
|
|
45
|
+
if (sev === 'critical')
|
|
46
|
+
counts.critical++;
|
|
47
|
+
else if (sev === 'high')
|
|
48
|
+
counts.high++;
|
|
49
|
+
else if (sev === 'medium')
|
|
50
|
+
counts.medium++;
|
|
51
|
+
else
|
|
52
|
+
counts.low++;
|
|
53
|
+
}
|
|
54
|
+
return counts;
|
|
55
|
+
}
|
|
56
|
+
/** Build the compact session-linked record from a finished manifest. */
|
|
57
|
+
function runRecordFromManifest(manifest, qualityStatus = null) {
|
|
58
|
+
return buildRunRecord({
|
|
59
|
+
project: manifest.project,
|
|
60
|
+
goal: manifest.goal,
|
|
61
|
+
mode: manifest.mode,
|
|
62
|
+
taskMode: manifest.taskMode,
|
|
63
|
+
round: manifest.round,
|
|
64
|
+
maxRounds: manifest.maxRounds,
|
|
65
|
+
active: manifest.active,
|
|
66
|
+
stoppedReason: manifest.stoppedReason,
|
|
67
|
+
convergence: manifest.convergence,
|
|
68
|
+
counts: severityCounts(manifest.findings),
|
|
69
|
+
fixedCount: Array.isArray(manifest.fixes) ? manifest.fixes.filter((f) => f.success !== false).length : 0,
|
|
70
|
+
qualityStatus,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Rebuild a (summary-only) transcript manifest from a session-linked record.
|
|
75
|
+
*
|
|
76
|
+
* Degraded by design: the record carries the run SUMMARY (mode / goal / round /
|
|
77
|
+
* max-rounds / end state / convergence series), not the per-finding detail, so
|
|
78
|
+
* the recovered manifest has an empty `findings`/`fixes`/`timeline`. It exists
|
|
79
|
+
* so the observatory shows the real run header + trend after `.iterate/` is
|
|
80
|
+
* lost, instead of a blank panel. Round rows stay empty because the record has
|
|
81
|
+
* no per-thread narration.
|
|
82
|
+
*/
|
|
83
|
+
export function recoverManifestFromRecord(record, fallbackProject, approval) {
|
|
84
|
+
const builder = new ReviewTranscriptBuilder({
|
|
85
|
+
project: record.project || fallbackProject,
|
|
86
|
+
mode: record.mode,
|
|
87
|
+
taskMode: record.taskMode,
|
|
88
|
+
approval,
|
|
89
|
+
goal: record.goal,
|
|
90
|
+
maxRounds: record.maxRounds,
|
|
91
|
+
});
|
|
92
|
+
const series = Array.isArray(record.convergence) ? record.convergence : [];
|
|
93
|
+
for (let i = 0; i < Math.min(series.length, MAX_ROUNDS); i += 1) {
|
|
94
|
+
const n = series[i];
|
|
95
|
+
if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
|
|
96
|
+
builder.snapshotConvergence(i + 1, n);
|
|
97
|
+
}
|
|
98
|
+
if (record.round > 0)
|
|
99
|
+
builder.advanceRound(record.round, record.maxRounds);
|
|
100
|
+
if (!record.active)
|
|
101
|
+
builder.finish(record.stoppedReason ?? undefined);
|
|
102
|
+
return builder.serialize();
|
|
103
|
+
}
|
|
33
104
|
/** Build per-dimension threads for one round from its (dimension-tagged) findings. */
|
|
34
105
|
function captureRound(builder, round) {
|
|
35
106
|
if (!round || typeof round !== 'object')
|
|
@@ -143,26 +214,36 @@ function isManifestShaped(value) {
|
|
|
143
214
|
export function registerTranscriptTool(ctx) {
|
|
144
215
|
ctx.tools.register(defineTool({
|
|
145
216
|
name: 'iterate_transcript',
|
|
146
|
-
// `read` never
|
|
147
|
-
// read
|
|
148
|
-
isConcurrencySafe: (args) =>
|
|
217
|
+
// `read`/`recall` never write; `capture`/`nudge` persist transcript state
|
|
218
|
+
// → only the read shapes join a parallel dispatch group.
|
|
219
|
+
isConcurrencySafe: (args) => {
|
|
220
|
+
const op = args.operation;
|
|
221
|
+
return op === 'read' || op === 'recall';
|
|
222
|
+
},
|
|
149
223
|
description: 'Runtime-observatory transcript for the iterate workflow. ' +
|
|
150
224
|
'`read` returns the current persisted transcript manifest (per-reviewer threads, ' +
|
|
151
225
|
'convergence series, findings, fixes, checkpoint, timeline, and any steering nudge ' +
|
|
152
226
|
'written for the next round) — or, when the file is missing/malformed, a structured ' +
|
|
153
|
-
'empty view (`found:false` plus a blank manifest). ' +
|
|
227
|
+
'empty view (`found:false` plus a blank manifest). When the on-disk file is gone but the ' +
|
|
228
|
+
'session carries a linked run record (dsh ≥ 0.2.1-rc.1), `read` RECOVERS a summary manifest ' +
|
|
229
|
+
'from that record and sets `recovered:true`. ' +
|
|
154
230
|
'`capture` builds a fresh transcript from the review `rounds` + `report`, folds in the ' +
|
|
155
231
|
'decision-log timeline, and persists it (call once after the final aggregate so the UI ' +
|
|
156
232
|
'reflects the run; skipped when `observatory.capture` is disabled). Round validation outcomes ' +
|
|
157
233
|
'can be carried on capture via `validations` ({round, command, exitCode, allowed, rejectReason?}). ' +
|
|
234
|
+
'On a successful capture the tool ALSO appends a compact run record onto the dsh session log ' +
|
|
235
|
+
'(best-effort; reported as `sessionRecord`) so the run survives `.iterate/` loss and travels with ' +
|
|
236
|
+
'the session. ' +
|
|
237
|
+
'`recall` lists the session-linked run records (newest first) — useful to audit a run after ' +
|
|
238
|
+
'restart or to confirm the write took. ' +
|
|
158
239
|
'`nudge` sets (text) or clears (text:null or an empty string) steering text the next round\'s reviewers read. ' +
|
|
159
240
|
'Purely local and deterministic — never touches source files.',
|
|
160
241
|
parameters: {
|
|
161
242
|
operation: {
|
|
162
243
|
type: 'string',
|
|
163
244
|
required: true,
|
|
164
|
-
description: '"read" to fetch the manifest, "capture" to persist one, "nudge" to set steering text.',
|
|
165
|
-
enum: ['read', 'capture', 'nudge'],
|
|
245
|
+
description: '"read" to fetch the manifest, "capture" to persist one, "recall" to list session-linked run records, "nudge" to set steering text.',
|
|
246
|
+
enum: ['read', 'capture', 'recall', 'nudge'],
|
|
166
247
|
},
|
|
167
248
|
rounds: {
|
|
168
249
|
type: 'json',
|
|
@@ -244,6 +325,19 @@ export function registerTranscriptTool(ctx) {
|
|
|
244
325
|
description: 'For `capture`: true when nothing was persisted because `observatory.capture` is disabled.',
|
|
245
326
|
},
|
|
246
327
|
reason: { type: 'string', description: 'For a skipped `capture`: why persistence was skipped.' },
|
|
328
|
+
recovered: {
|
|
329
|
+
type: 'boolean',
|
|
330
|
+
description: 'For `read`: true when the manifest was rebuilt from a session-linked run record because the on-disk file was missing or unreadable.',
|
|
331
|
+
},
|
|
332
|
+
supported: {
|
|
333
|
+
type: 'boolean',
|
|
334
|
+
description: 'For `recall`: whether the host exposes the dsh session state-record API (dsh ≥ 0.2.1-rc.1).',
|
|
335
|
+
},
|
|
336
|
+
records: { type: 'json', description: 'For `recall`: session-linked run records, newest first.' },
|
|
337
|
+
sessionRecord: {
|
|
338
|
+
type: 'json',
|
|
339
|
+
description: 'For `capture`/`read`: the session-link outcome/position (`{linked, seq}` on capture, `{seq, updatedAt}` on a recovery).',
|
|
340
|
+
},
|
|
247
341
|
error: { type: 'string' },
|
|
248
342
|
},
|
|
249
343
|
},
|
|
@@ -273,13 +367,59 @@ export function registerTranscriptTool(ctx) {
|
|
|
273
367
|
approval,
|
|
274
368
|
}).serialize(),
|
|
275
369
|
});
|
|
276
|
-
|
|
370
|
+
// Recovery (dsh ≥ 0.2.1-rc.1): when the on-disk manifest is gone
|
|
371
|
+
// but the SESSION that produced the run carries a linked run record,
|
|
372
|
+
// rebuild a summary manifest so the observatory shows the real run
|
|
373
|
+
// header + trend instead of a blank panel. Best-effort: no session,
|
|
374
|
+
// no API, or no record all fall back to `emptyView()`.
|
|
375
|
+
const recover = async () => {
|
|
376
|
+
const session = sessionOf(exec);
|
|
377
|
+
if (!session)
|
|
378
|
+
return null;
|
|
379
|
+
const { supported, records } = await readRunRecords(session);
|
|
380
|
+
if (!supported || records.length === 0)
|
|
381
|
+
return null;
|
|
382
|
+
const latest = records[records.length - 1];
|
|
383
|
+
return {
|
|
384
|
+
transcript: normalizeManifestBounds(recoverManifestFromRecord(latest, projectRoot, approval)),
|
|
385
|
+
sessionRecord: {
|
|
386
|
+
seq: latest.seq,
|
|
387
|
+
time: latest.time,
|
|
388
|
+
updatedAt: latest.updatedAt,
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
if (!existsSync(file)) {
|
|
393
|
+
const recovered = await recover();
|
|
394
|
+
if (recovered) {
|
|
395
|
+
return {
|
|
396
|
+
operation: 'read',
|
|
397
|
+
found: true,
|
|
398
|
+
recovered: true,
|
|
399
|
+
live: live,
|
|
400
|
+
transcript: recovered.transcript,
|
|
401
|
+
sessionRecord: recovered.sessionRecord,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
277
404
|
return emptyView();
|
|
405
|
+
}
|
|
278
406
|
let parsed;
|
|
279
407
|
try {
|
|
280
408
|
parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
281
409
|
}
|
|
282
410
|
catch (err) {
|
|
411
|
+
const recovered = await recover();
|
|
412
|
+
if (recovered) {
|
|
413
|
+
return {
|
|
414
|
+
operation: 'read',
|
|
415
|
+
found: true,
|
|
416
|
+
recovered: true,
|
|
417
|
+
live: live,
|
|
418
|
+
transcript: recovered.transcript,
|
|
419
|
+
sessionRecord: recovered.sessionRecord,
|
|
420
|
+
error: `Recovered a summary from the session log; on-disk transcript was unreadable (${err instanceof Error ? err.message : String(err)}).`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
283
423
|
return {
|
|
284
424
|
...emptyView(),
|
|
285
425
|
error: `Failed to read transcript: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -289,6 +429,18 @@ export function registerTranscriptTool(ctx) {
|
|
|
289
429
|
// `JSON.parse` happily accepts `null`, `[]` and `{}` — values that
|
|
290
430
|
// are parseable but carry no manifest. Surfacing them as
|
|
291
431
|
// `found:true` made the observatory render junk panels.
|
|
432
|
+
const recovered = await recover();
|
|
433
|
+
if (recovered) {
|
|
434
|
+
return {
|
|
435
|
+
operation: 'read',
|
|
436
|
+
found: true,
|
|
437
|
+
recovered: true,
|
|
438
|
+
live: live,
|
|
439
|
+
transcript: recovered.transcript,
|
|
440
|
+
sessionRecord: recovered.sessionRecord,
|
|
441
|
+
error: 'Recovered a summary from the session log; on-disk file is not a valid manifest.',
|
|
442
|
+
};
|
|
443
|
+
}
|
|
292
444
|
return {
|
|
293
445
|
...emptyView(),
|
|
294
446
|
error: 'Transcript file is not a valid manifest; returning an empty view.',
|
|
@@ -304,6 +456,21 @@ export function registerTranscriptTool(ctx) {
|
|
|
304
456
|
transcript: normalizeManifestBounds(parsed),
|
|
305
457
|
};
|
|
306
458
|
}
|
|
459
|
+
if (args.operation === 'recall') {
|
|
460
|
+
// New capability: list the run records this plugin appended onto the
|
|
461
|
+
// dsh session log (dsh ≥ 0.2.1-rc.1). Read-only; degrades to
|
|
462
|
+
// `supported:false` on a host without the API.
|
|
463
|
+
const session = sessionOf(exec);
|
|
464
|
+
const { supported, records, error } = await readRunRecords(session);
|
|
465
|
+
const newestFirst = [...records].reverse();
|
|
466
|
+
return {
|
|
467
|
+
operation: 'recall',
|
|
468
|
+
found: newestFirst.length > 0,
|
|
469
|
+
supported,
|
|
470
|
+
records: newestFirst,
|
|
471
|
+
...(error ? { error } : {}),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
307
474
|
if (args.operation === 'nudge') {
|
|
308
475
|
// Read-modify-write across processes: a concurrent nudge in a sibling
|
|
309
476
|
// process would otherwise read the same manifest and write back over
|
|
@@ -480,17 +647,39 @@ export function registerTranscriptTool(ctx) {
|
|
|
480
647
|
builder.finish('converged');
|
|
481
648
|
else if (roundsExecuted > 0)
|
|
482
649
|
builder.finish('max_rounds_reached');
|
|
483
|
-
const
|
|
650
|
+
const manifest = builder.serialize();
|
|
651
|
+
const persisted = await persistChecked(file, manifest);
|
|
484
652
|
if (!persisted.ok) {
|
|
485
653
|
return { operation: 'capture', found: true, updated: false, error: persisted.error };
|
|
486
654
|
}
|
|
655
|
+
// Session link (dsh ≥ 0.2.1-rc.1): append a compact run record onto the
|
|
656
|
+
// session log so the run survives `.iterate/` loss and travels with the
|
|
657
|
+
// session. Best-effort — a link failure is reported, never fatal (the
|
|
658
|
+
// on-disk capture above already succeeded).
|
|
659
|
+
let sessionRecord;
|
|
660
|
+
// Snapshot the CURRENT quality-gate verdict into the record so a
|
|
661
|
+
// recovered run keeps its PASS/FAIL badge. An absent gate reads as the
|
|
662
|
+
// store's default (`pending`); a corrupt/throwing read degrades to
|
|
663
|
+
// `null` and never fails the capture.
|
|
664
|
+
let qualityStatus = null;
|
|
665
|
+
try {
|
|
666
|
+
qualityStatus = readQualityGate(projectRoot).overallStatus ?? null;
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
qualityStatus = null;
|
|
670
|
+
}
|
|
671
|
+
const linkSession = await appendRunRecord(sessionOf(exec), runRecordFromManifest(manifest, qualityStatus));
|
|
672
|
+
sessionRecord = (linkSession.ok
|
|
673
|
+
? { linked: true, seq: linkSession.seq }
|
|
674
|
+
: { linked: false, supported: linkSession.supported, reason: linkSession.error });
|
|
487
675
|
const live = await readLive(projectRoot);
|
|
488
676
|
return {
|
|
489
677
|
operation: 'capture',
|
|
490
678
|
found: true,
|
|
491
679
|
updated: true,
|
|
492
680
|
live: live,
|
|
493
|
-
transcript:
|
|
681
|
+
transcript: manifest,
|
|
682
|
+
sessionRecord,
|
|
494
683
|
};
|
|
495
684
|
},
|
|
496
685
|
}));
|
|
@@ -505,6 +694,12 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
505
694
|
goal: manifest.goal,
|
|
506
695
|
maxRounds: manifest.maxRounds,
|
|
507
696
|
});
|
|
697
|
+
// Phase breadcrumbs are part of the run narrative the F1 stage tracker shows;
|
|
698
|
+
// replay them before the rounds so an edit round-trip keeps the stage order.
|
|
699
|
+
for (const p of Array.isArray(manifest.phases) ? manifest.phases : []) {
|
|
700
|
+
if (typeof p === 'string')
|
|
701
|
+
builder.phase(p);
|
|
702
|
+
}
|
|
508
703
|
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
509
704
|
// A malformed/null round row must be skipped, never a throw (the caller
|
|
510
705
|
// wraps rehydrate in a fallback, but preserving sibling rows is better).
|
|
@@ -512,6 +707,10 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
512
707
|
continue;
|
|
513
708
|
builder.roundStart(r.round, manifest.maxRounds);
|
|
514
709
|
for (const t of Array.isArray(r.threads) ? r.threads : []) {
|
|
710
|
+
// A null/garbled thread row must be skipped too: reading `t.dimension`
|
|
711
|
+
// off it would throw and cost the WHOLE manifest its history.
|
|
712
|
+
if (!t || typeof t !== 'object')
|
|
713
|
+
continue;
|
|
515
714
|
// restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
|
|
516
715
|
// message ARRAY boundaries and the thread's own dimension: rehydration
|
|
517
716
|
// previously collapsed each thread's messages into one string and, once a
|
|
@@ -531,6 +730,15 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
531
730
|
if (typeof n === 'number' && n >= 0)
|
|
532
731
|
builder.snapshotConvergence(idx + 1, n);
|
|
533
732
|
}
|
|
733
|
+
// Top-level findings live in the global newest-wins list (the F3 panel reads
|
|
734
|
+
// `manifest.findings`); restoreThread only rebuilds the per-round threads, so
|
|
735
|
+
// without this a nudge/rollback re-persist blanked the findings panel.
|
|
736
|
+
if (Array.isArray(manifest.findings))
|
|
737
|
+
builder.restoreFindings(manifest.findings);
|
|
738
|
+
// Move the round marker forward past the captured rows: `roundStart` reopens
|
|
739
|
+
// rows, but a manifest whose `round` outruns its rows (advanceRound semantics)
|
|
740
|
+
// would otherwise rewind the reported round.
|
|
741
|
+
builder.advanceRound(manifest.round, manifest.maxRounds);
|
|
534
742
|
if (manifest.checkpoint)
|
|
535
743
|
builder.recordCheckpoint(manifest.checkpoint);
|
|
536
744
|
if (Array.isArray(manifest.fixes))
|
package/dist/transcript.js
CHANGED
|
@@ -428,6 +428,24 @@ export class ReviewTranscriptBuilder {
|
|
|
428
428
|
}
|
|
429
429
|
this.touch();
|
|
430
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Re-insert top-level findings when rehydrating a persisted manifest (the
|
|
433
|
+
* nudge / rollback edit round-trip). `restoreThread` already rebuilds the
|
|
434
|
+
* per-round thread copy of each finding but never the global list, so an edit
|
|
435
|
+
* that only rewrites steering text used to blank `manifest.findings` and the
|
|
436
|
+
* F3 findings panel. This refills the global newest-wins list (and its dedupe
|
|
437
|
+
* set), mirroring `reviewerFindings` without needing a live thread.
|
|
438
|
+
*/
|
|
439
|
+
restoreFindings(findings) {
|
|
440
|
+
if (Array.isArray(findings)) {
|
|
441
|
+
for (const raw of findings) {
|
|
442
|
+
const f = normalizeFinding(raw);
|
|
443
|
+
if (f)
|
|
444
|
+
this.addGlobal(f);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
this.touch();
|
|
448
|
+
}
|
|
431
449
|
// ─── Convergence (F2) ───────────────────────────────────────────────────
|
|
432
450
|
/** Record a round's new-finding count for the convergence series. */
|
|
433
451
|
snapshotConvergence(round, newCount) {
|