iterate-plugin 3.9.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/dist/file-lock.js +29 -15
- package/dist/git-scope.js +25 -10
- package/dist/report-render.js +15 -5
- package/dist/session-record.js +24 -2
- package/dist/tools/report.js +2 -2
- package/dist/tools/transcript.js +32 -1
- package/dist/transcript.js +18 -0
- package/lib/client.js +13 -6
- package/package.json +1 -1
- package/src/file-lock.ts +47 -9
- package/src/git-scope.ts +29 -15
- package/src/report-render.ts +16 -5
- package/src/session-record.ts +25 -3
- package/src/tools/report.ts +2 -2
- package/src/tools/transcript.ts +28 -1
- package/src/transcript.ts +18 -0
package/dist/file-lock.js
CHANGED
|
@@ -103,17 +103,23 @@ export function acquireProjectLockChecked(projectRoot, name, options = {}) {
|
|
|
103
103
|
catch (err) {
|
|
104
104
|
if (err.code !== 'EEXIST')
|
|
105
105
|
return { acquired: false, release: () => { } };
|
|
106
|
-
// Lock exists — steal it when the owner is gone or the
|
|
106
|
+
// Lock exists — steal it when the owner is VERIFIABLY gone, or when the
|
|
107
|
+
// lock file is ancient. The stamp is read defensively: a lock is created
|
|
108
|
+
// empty (`openSync(..., 'wx')`) and the holder's pid lands a moment later,
|
|
109
|
+
// so an empty/garbled stamp means "just created, holder still writing" —
|
|
110
|
+
// treating `Number('') === 0` as a dead owner would let a contender steal
|
|
111
|
+
// a live lock and enter the same critical section. Only a parseable
|
|
112
|
+
// positive pid that is dead (or an ancient mtime) is stolen.
|
|
107
113
|
let stale = false;
|
|
108
114
|
try {
|
|
109
115
|
const st = statSync(lockPath);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
const ancient = Date.now() - st.mtimeMs > staleMs;
|
|
117
|
+
const raw = readFileSync(lockPath, 'utf-8').trim();
|
|
118
|
+
const pid = /^\d+$/.test(raw) ? Number(raw) : 0;
|
|
119
|
+
if (pid > 0)
|
|
120
|
+
stale = !processAlive(pid) || ancient;
|
|
121
|
+
else
|
|
122
|
+
stale = ancient;
|
|
117
123
|
}
|
|
118
124
|
catch {
|
|
119
125
|
stale = true; // unreadable/vanishing lock → retry as stale
|
|
@@ -238,17 +244,25 @@ export function makeLockRelease(lockPath, ino) {
|
|
|
238
244
|
}
|
|
239
245
|
};
|
|
240
246
|
}
|
|
241
|
-
/**
|
|
242
|
-
* Run `fn` while holding the advisory lock named `name`.
|
|
243
|
-
* The release is guaranteed via `finally`; `fn`'s value or error propagates
|
|
244
|
-
* unchanged. Not reentrant for the same (projectRoot, name) — see header.
|
|
245
|
-
*/
|
|
246
247
|
export function withProjectLock(projectRoot, name, fn, options = {}) {
|
|
247
248
|
const release = acquireProjectLock(projectRoot, name, options);
|
|
249
|
+
let out;
|
|
248
250
|
try {
|
|
249
|
-
|
|
251
|
+
out = fn();
|
|
250
252
|
}
|
|
251
|
-
|
|
253
|
+
catch (err) {
|
|
252
254
|
release();
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
if (out && typeof out.then === 'function') {
|
|
258
|
+
return out.then((value) => {
|
|
259
|
+
release();
|
|
260
|
+
return value;
|
|
261
|
+
}, (err) => {
|
|
262
|
+
release();
|
|
263
|
+
throw err;
|
|
264
|
+
});
|
|
253
265
|
}
|
|
266
|
+
release();
|
|
267
|
+
return out;
|
|
254
268
|
}
|
package/dist/git-scope.js
CHANGED
|
@@ -172,16 +172,31 @@ export function decideScope(changedFiles) {
|
|
|
172
172
|
*/
|
|
173
173
|
export function runGit(args, cwd, opts = {}) {
|
|
174
174
|
return new Promise((resolve) => {
|
|
175
|
-
execFile(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
175
|
+
// execFile can THROW SYNCHRONOUSLY (rather than call back with an error)
|
|
176
|
+
// when an argument cannot be encoded — e.g. a NUL or lone surrogate in the
|
|
177
|
+
// config-supplied target branch. Without this guard that throw escapes the
|
|
178
|
+
// promise executor, rejecting `runGit` and propagating out of the tool call
|
|
179
|
+
// instead of degrading to the documented full-scope fallback.
|
|
180
|
+
try {
|
|
181
|
+
execFile('git', args, {
|
|
182
|
+
cwd,
|
|
183
|
+
timeout: 30_000,
|
|
184
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
185
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
186
|
+
signal: opts.signal,
|
|
187
|
+
}, (error, stdout, stderr) => {
|
|
188
|
+
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
|
|
189
|
+
resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
resolve({
|
|
194
|
+
ok: false,
|
|
195
|
+
stdout: '',
|
|
196
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
197
|
+
exitCode: 1,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
185
200
|
});
|
|
186
201
|
}
|
|
187
202
|
/**
|
package/dist/report-render.js
CHANGED
|
@@ -138,6 +138,10 @@ function asString(value) {
|
|
|
138
138
|
function asNumber(value) {
|
|
139
139
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
140
140
|
}
|
|
141
|
+
/** Non-negative integer (floors fractions); 0 for anything else. */
|
|
142
|
+
function asInt(value) {
|
|
143
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
144
|
+
}
|
|
141
145
|
function asArray(value) {
|
|
142
146
|
return Array.isArray(value) ? value : [];
|
|
143
147
|
}
|
|
@@ -184,7 +188,7 @@ export function buildRunReportData(manifest, extra) {
|
|
|
184
188
|
const findings = asArray(get(src, 'findings')).map((f) => ({
|
|
185
189
|
dimension: asString(get(f, 'dimension')),
|
|
186
190
|
file: asString(get(f, 'file')),
|
|
187
|
-
line:
|
|
191
|
+
line: asInt(get(f, 'line')),
|
|
188
192
|
severity: findingSeverity(f),
|
|
189
193
|
summary: asString(get(f, 'summary')),
|
|
190
194
|
failureScenario: asString(get(f, 'failure_scenario')),
|
|
@@ -205,7 +209,7 @@ export function buildRunReportData(manifest, extra) {
|
|
|
205
209
|
const validations = asArray(get(src, 'validations')).map((v) => ({
|
|
206
210
|
round: asNumber(get(v, 'round')),
|
|
207
211
|
command: asString(get(v, 'command')),
|
|
208
|
-
exitCode: typeof get(v, 'exitCode') === 'number' ?
|
|
212
|
+
exitCode: typeof get(v, 'exitCode') === 'number' ? asInt(get(v, 'exitCode')) : null,
|
|
209
213
|
allowed: get(v, 'allowed') === true,
|
|
210
214
|
rejectReason: asString(get(v, 'rejectReason')),
|
|
211
215
|
}));
|
|
@@ -225,7 +229,11 @@ export function buildRunReportData(manifest, extra) {
|
|
|
225
229
|
taskMode: taskMode === 'code' || taskMode === 'iterate' ? taskMode : null,
|
|
226
230
|
round: asNumber(get(src, 'round')),
|
|
227
231
|
maxRounds: asNumber(get(src, 'maxRounds')),
|
|
228
|
-
active
|
|
232
|
+
// An ABSENT `active` means "no run / unknown", not "running": a missing or
|
|
233
|
+
// empty transcript used to render a live, pulsing run at round 0. The
|
|
234
|
+
// transcript builder always writes an explicit boolean, so a strict `true`
|
|
235
|
+
// only ever loses the phantom run, never a real one.
|
|
236
|
+
active: get(src, 'active') === true,
|
|
229
237
|
stoppedReason: typeof stoppedReason === 'string' && stoppedReason ? stoppedReason : null,
|
|
230
238
|
updatedAt: asString(get(src, 'updatedAt')),
|
|
231
239
|
convergence: asArray(get(src, 'convergence')).filter((n) => typeof n === 'number' && Number.isFinite(n) && n >= 0),
|
|
@@ -296,7 +304,7 @@ export function renderRunReportMarkdown(manifest, extra, opts) {
|
|
|
296
304
|
lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`);
|
|
297
305
|
lines.push('| --- | --- | --- | --- |');
|
|
298
306
|
for (const f of data.findings) {
|
|
299
|
-
const loc = f.file ? `${f.file}${f.line >
|
|
307
|
+
const loc = f.file ? `${f.file}${f.line > 0 ? `:${f.line}` : ''}` : '';
|
|
300
308
|
lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`);
|
|
301
309
|
}
|
|
302
310
|
}
|
|
@@ -395,7 +403,9 @@ export function renderRunReportSarif(manifest, extra, opts) {
|
|
|
395
403
|
result.fixes = [{ description: { text: f.suggestedFix } }];
|
|
396
404
|
return result;
|
|
397
405
|
});
|
|
398
|
-
|
|
406
|
+
// `exitCode: null` is the documented "unknown / no exit code" value, not a
|
|
407
|
+
// failure: only a concrete non-zero code makes the run unsuccessful.
|
|
408
|
+
const failedValidations = data.validations.filter((v) => v.exitCode !== null && v.exitCode !== 0).length;
|
|
399
409
|
return {
|
|
400
410
|
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
401
411
|
version: '2.1.0',
|
package/dist/session-record.js
CHANGED
|
@@ -100,7 +100,9 @@ export function normalizeRunRecord(data) {
|
|
|
100
100
|
const d = data;
|
|
101
101
|
// A record written by a future/incompatible build is skipped rather than
|
|
102
102
|
// coerced: the version gate is the contract that lets the payload evolve.
|
|
103
|
-
|
|
103
|
+
// The check is exact (an integer compare, not a floor) so a payload stamped
|
|
104
|
+
// `1.9` by a future build is rejected instead of rounded down to `1`.
|
|
105
|
+
if (!Number.isInteger(d.version) || d.version !== ITERATE_RUN_RECORD_VERSION)
|
|
104
106
|
return null;
|
|
105
107
|
const findings = (d.findings && typeof d.findings === 'object' && !Array.isArray(d.findings)
|
|
106
108
|
? d.findings
|
|
@@ -132,12 +134,17 @@ export function normalizeRunRecord(data) {
|
|
|
132
134
|
let cachedApi;
|
|
133
135
|
/** Test override; when set (including `null`) the dynamic import is bypassed. */
|
|
134
136
|
let testApiOverride;
|
|
137
|
+
/** Module loader; replaceable for tests to exercise import failure/late availability. */
|
|
138
|
+
let moduleImporter = () => import('@deepseek-ai/dsh-session');
|
|
135
139
|
/** Resolve the `@deepseek-ai/dsh-session` record API, or `null` when absent. */
|
|
136
140
|
export function loadSessionRecordApi() {
|
|
137
141
|
if (testApiOverride !== undefined)
|
|
138
142
|
return Promise.resolve(testApiOverride);
|
|
139
143
|
if (cachedApi === undefined) {
|
|
140
|
-
|
|
144
|
+
// `Promise.resolve().then(...)` (not a bare call) so a loader that throws
|
|
145
|
+
// SYNCHRONOUSLY still lands in the `.catch` instead of escaping.
|
|
146
|
+
cachedApi = Promise.resolve()
|
|
147
|
+
.then(() => moduleImporter())
|
|
141
148
|
.then((mod) => {
|
|
142
149
|
const m = mod;
|
|
143
150
|
if (typeof m.appendPluginRecord !== 'function' || typeof m.pluginRecordOf !== 'function') {
|
|
@@ -149,9 +156,24 @@ export function loadSessionRecordApi() {
|
|
|
149
156
|
};
|
|
150
157
|
})
|
|
151
158
|
.catch(() => null);
|
|
159
|
+
// A `null` resolution (module/API absent, or a transient import failure) is
|
|
160
|
+
// NOT memoized: it is cleared so the next call retries. Otherwise one
|
|
161
|
+
// unlucky first import would disable session linking for the whole process.
|
|
162
|
+
void cachedApi.then((api) => {
|
|
163
|
+
if (!api)
|
|
164
|
+
cachedApi = undefined;
|
|
165
|
+
});
|
|
152
166
|
}
|
|
153
167
|
return cachedApi;
|
|
154
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Replace the module loader for tests (to exercise the import-failure and
|
|
171
|
+
* late-availability paths). Pass `undefined` to restore the real dynamic import.
|
|
172
|
+
*/
|
|
173
|
+
export function setSessionRecordImporterForTests(loader) {
|
|
174
|
+
moduleImporter = loader ?? (() => import('@deepseek-ai/dsh-session'));
|
|
175
|
+
cachedApi = undefined;
|
|
176
|
+
}
|
|
155
177
|
/**
|
|
156
178
|
* Override the session-record API for tests. Pass `undefined` to restore the
|
|
157
179
|
* real dynamic import, or an object / `null` to force a deterministic result.
|
package/dist/tools/report.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* iterate_report — render the current iterate run into a portable report.
|
|
5
5
|
*
|
|
6
6
|
* Two formats, one normalized data model (shared with the client export
|
|
7
|
-
* buttons via `
|
|
8
|
-
* drift):
|
|
7
|
+
* buttons via `src/report-render.ts`, so the on-screen export and this tool
|
|
8
|
+
* can never drift):
|
|
9
9
|
*
|
|
10
10
|
* - `markdown` — a PR-pasteable run report: header (mode/round/status), the
|
|
11
11
|
* quality-gate table, the findings table, the fixes table,
|
package/dist/tools/transcript.js
CHANGED
|
@@ -29,6 +29,7 @@ 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";
|
|
33
34
|
import { buildRunRecord, appendRunRecord, readRunRecords, sessionOf, } from "../session-record.js";
|
|
34
35
|
/** Severity tally over a manifest's (globally deduped) findings. */
|
|
@@ -656,7 +657,18 @@ export function registerTranscriptTool(ctx) {
|
|
|
656
657
|
// session. Best-effort — a link failure is reported, never fatal (the
|
|
657
658
|
// on-disk capture above already succeeded).
|
|
658
659
|
let sessionRecord;
|
|
659
|
-
|
|
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));
|
|
660
672
|
sessionRecord = (linkSession.ok
|
|
661
673
|
? { linked: true, seq: linkSession.seq }
|
|
662
674
|
: { linked: false, supported: linkSession.supported, reason: linkSession.error });
|
|
@@ -682,6 +694,12 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
682
694
|
goal: manifest.goal,
|
|
683
695
|
maxRounds: manifest.maxRounds,
|
|
684
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
|
+
}
|
|
685
703
|
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
686
704
|
// A malformed/null round row must be skipped, never a throw (the caller
|
|
687
705
|
// wraps rehydrate in a fallback, but preserving sibling rows is better).
|
|
@@ -689,6 +707,10 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
689
707
|
continue;
|
|
690
708
|
builder.roundStart(r.round, manifest.maxRounds);
|
|
691
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;
|
|
692
714
|
// restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
|
|
693
715
|
// message ARRAY boundaries and the thread's own dimension: rehydration
|
|
694
716
|
// previously collapsed each thread's messages into one string and, once a
|
|
@@ -708,6 +730,15 @@ function rehydrateBuilder(manifest, approval) {
|
|
|
708
730
|
if (typeof n === 'number' && n >= 0)
|
|
709
731
|
builder.snapshotConvergence(idx + 1, n);
|
|
710
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);
|
|
711
742
|
if (manifest.checkpoint)
|
|
712
743
|
builder.recordCheckpoint(manifest.checkpoint);
|
|
713
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) {
|
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* iterate-plugin client source-digest:
|
|
1
|
+
/* iterate-plugin client source-digest: 2819f2e6f5f182bb31b9e47c356f60e0b8d97fcdbd232c31b89438619623cf93 */
|
|
2
2
|
window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {
|
|
3
3
|
var module = { exports: {} };
|
|
4
4
|
var exports = module.exports;
|
|
@@ -2023,6 +2023,9 @@ function asString(value) {
|
|
|
2023
2023
|
function asNumber(value) {
|
|
2024
2024
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2025
2025
|
}
|
|
2026
|
+
function asInt(value) {
|
|
2027
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
2028
|
+
}
|
|
2026
2029
|
function asArray(value) {
|
|
2027
2030
|
return Array.isArray(value) ? value : [];
|
|
2028
2031
|
}
|
|
@@ -2056,7 +2059,7 @@ function buildRunReportData(manifest, extra) {
|
|
|
2056
2059
|
const findings = asArray(get(src, "findings")).map((f) => ({
|
|
2057
2060
|
dimension: asString(get(f, "dimension")),
|
|
2058
2061
|
file: asString(get(f, "file")),
|
|
2059
|
-
line:
|
|
2062
|
+
line: asInt(get(f, "line")),
|
|
2060
2063
|
severity: findingSeverity(f),
|
|
2061
2064
|
summary: asString(get(f, "summary")),
|
|
2062
2065
|
failureScenario: asString(get(f, "failure_scenario")),
|
|
@@ -2078,7 +2081,7 @@ function buildRunReportData(manifest, extra) {
|
|
|
2078
2081
|
const validations = asArray(get(src, "validations")).map((v) => ({
|
|
2079
2082
|
round: asNumber(get(v, "round")),
|
|
2080
2083
|
command: asString(get(v, "command")),
|
|
2081
|
-
exitCode: typeof get(v, "exitCode") === "number" ?
|
|
2084
|
+
exitCode: typeof get(v, "exitCode") === "number" ? asInt(get(v, "exitCode")) : null,
|
|
2082
2085
|
allowed: get(v, "allowed") === true,
|
|
2083
2086
|
rejectReason: asString(get(v, "rejectReason"))
|
|
2084
2087
|
}));
|
|
@@ -2097,7 +2100,11 @@ function buildRunReportData(manifest, extra) {
|
|
|
2097
2100
|
taskMode: taskMode === "code" || taskMode === "iterate" ? taskMode : null,
|
|
2098
2101
|
round: asNumber(get(src, "round")),
|
|
2099
2102
|
maxRounds: asNumber(get(src, "maxRounds")),
|
|
2100
|
-
active
|
|
2103
|
+
// An ABSENT `active` means "no run / unknown", not "running": a missing or
|
|
2104
|
+
// empty transcript used to render a live, pulsing run at round 0. The
|
|
2105
|
+
// transcript builder always writes an explicit boolean, so a strict `true`
|
|
2106
|
+
// only ever loses the phantom run, never a real one.
|
|
2107
|
+
active: get(src, "active") === true,
|
|
2101
2108
|
stoppedReason: typeof stoppedReason === "string" && stoppedReason ? stoppedReason : null,
|
|
2102
2109
|
updatedAt: asString(get(src, "updatedAt")),
|
|
2103
2110
|
convergence: asArray(get(src, "convergence")).filter(
|
|
@@ -2170,7 +2177,7 @@ function renderRunReportMarkdown(manifest, extra, opts) {
|
|
|
2170
2177
|
lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`);
|
|
2171
2178
|
lines.push("| --- | --- | --- | --- |");
|
|
2172
2179
|
for (const f of data.findings) {
|
|
2173
|
-
const loc = f.file ? `${f.file}${f.line >
|
|
2180
|
+
const loc = f.file ? `${f.file}${f.line > 0 ? `:${f.line}` : ""}` : "";
|
|
2174
2181
|
lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`);
|
|
2175
2182
|
}
|
|
2176
2183
|
}
|
|
@@ -2269,7 +2276,7 @@ function renderRunReportSarif(manifest, extra, opts) {
|
|
|
2269
2276
|
if (f.suggestedFix) result.fixes = [{ description: { text: f.suggestedFix } }];
|
|
2270
2277
|
return result;
|
|
2271
2278
|
});
|
|
2272
|
-
const failedValidations = data.validations.filter((v) => v.exitCode !== 0).length;
|
|
2279
|
+
const failedValidations = data.validations.filter((v) => v.exitCode !== null && v.exitCode !== 0).length;
|
|
2273
2280
|
return {
|
|
2274
2281
|
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
2275
2282
|
version: "2.1.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.1",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/file-lock.ts
CHANGED
|
@@ -133,15 +133,21 @@ export function acquireProjectLockChecked(
|
|
|
133
133
|
fd = openSync(lockPath, 'wx')
|
|
134
134
|
} catch (err) {
|
|
135
135
|
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
|
|
136
|
-
// Lock exists — steal it when the owner is gone or the
|
|
136
|
+
// Lock exists — steal it when the owner is VERIFIABLY gone, or when the
|
|
137
|
+
// lock file is ancient. The stamp is read defensively: a lock is created
|
|
138
|
+
// empty (`openSync(..., 'wx')`) and the holder's pid lands a moment later,
|
|
139
|
+
// so an empty/garbled stamp means "just created, holder still writing" —
|
|
140
|
+
// treating `Number('') === 0` as a dead owner would let a contender steal
|
|
141
|
+
// a live lock and enter the same critical section. Only a parseable
|
|
142
|
+
// positive pid that is dead (or an ancient mtime) is stolen.
|
|
137
143
|
let stale = false
|
|
138
144
|
try {
|
|
139
145
|
const st = statSync(lockPath)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
const ancient = Date.now() - st.mtimeMs > staleMs
|
|
147
|
+
const raw = readFileSync(lockPath, 'utf-8').trim()
|
|
148
|
+
const pid = /^\d+$/.test(raw) ? Number(raw) : 0
|
|
149
|
+
if (pid > 0) stale = !processAlive(pid) || ancient
|
|
150
|
+
else stale = ancient
|
|
145
151
|
} catch {
|
|
146
152
|
stale = true // unreadable/vanishing lock → retry as stale
|
|
147
153
|
}
|
|
@@ -265,17 +271,49 @@ export function makeLockRelease(lockPath: string, ino?: number): () => void {
|
|
|
265
271
|
* Run `fn` while holding the advisory lock named `name`.
|
|
266
272
|
* The release is guaranteed via `finally`; `fn`'s value or error propagates
|
|
267
273
|
* unchanged. Not reentrant for the same (projectRoot, name) — see header.
|
|
274
|
+
*
|
|
275
|
+
* `fn` may return a promise: in that case the lock is held until it settles, so
|
|
276
|
+
* a read-modify-write that spans `await`ed fs calls is actually serialized
|
|
277
|
+
* (releasing when `fn()` *returns its promise* would defeat the lock entirely).
|
|
268
278
|
*/
|
|
269
279
|
export function withProjectLock<T>(
|
|
270
280
|
projectRoot: string,
|
|
271
281
|
name: string,
|
|
272
282
|
fn: () => T,
|
|
283
|
+
options?: LockOptions,
|
|
284
|
+
): T
|
|
285
|
+
export function withProjectLock<T>(
|
|
286
|
+
projectRoot: string,
|
|
287
|
+
name: string,
|
|
288
|
+
fn: () => Promise<T>,
|
|
289
|
+
options?: LockOptions,
|
|
290
|
+
): Promise<T>
|
|
291
|
+
export function withProjectLock<T>(
|
|
292
|
+
projectRoot: string,
|
|
293
|
+
name: string,
|
|
294
|
+
fn: () => T | Promise<T>,
|
|
273
295
|
options: LockOptions = {},
|
|
274
|
-
): T {
|
|
296
|
+
): T | Promise<T> {
|
|
275
297
|
const release = acquireProjectLock(projectRoot, name, options)
|
|
298
|
+
let out: T | Promise<T>
|
|
276
299
|
try {
|
|
277
|
-
|
|
278
|
-
}
|
|
300
|
+
out = fn()
|
|
301
|
+
} catch (err) {
|
|
279
302
|
release()
|
|
303
|
+
throw err
|
|
304
|
+
}
|
|
305
|
+
if (out && typeof (out as Promise<T>).then === 'function') {
|
|
306
|
+
return (out as Promise<T>).then(
|
|
307
|
+
(value) => {
|
|
308
|
+
release()
|
|
309
|
+
return value
|
|
310
|
+
},
|
|
311
|
+
(err) => {
|
|
312
|
+
release()
|
|
313
|
+
throw err
|
|
314
|
+
},
|
|
315
|
+
)
|
|
280
316
|
}
|
|
317
|
+
release()
|
|
318
|
+
return out
|
|
281
319
|
}
|
package/src/git-scope.ts
CHANGED
|
@@ -177,21 +177,35 @@ export function runGit(
|
|
|
177
177
|
opts: { signal?: AbortSignal } = {},
|
|
178
178
|
): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
|
|
179
179
|
return new Promise((resolve) => {
|
|
180
|
-
execFile(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
180
|
+
// execFile can THROW SYNCHRONOUSLY (rather than call back with an error)
|
|
181
|
+
// when an argument cannot be encoded — e.g. a NUL or lone surrogate in the
|
|
182
|
+
// config-supplied target branch. Without this guard that throw escapes the
|
|
183
|
+
// promise executor, rejecting `runGit` and propagating out of the tool call
|
|
184
|
+
// instead of degrading to the documented full-scope fallback.
|
|
185
|
+
try {
|
|
186
|
+
execFile(
|
|
187
|
+
'git',
|
|
188
|
+
args,
|
|
189
|
+
{
|
|
190
|
+
cwd,
|
|
191
|
+
timeout: 30_000,
|
|
192
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
193
|
+
env: { ...process.env, PAGER: 'cat' },
|
|
194
|
+
signal: opts.signal,
|
|
195
|
+
},
|
|
196
|
+
(error, stdout, stderr) => {
|
|
197
|
+
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
|
|
198
|
+
resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
resolve({
|
|
203
|
+
ok: false,
|
|
204
|
+
stdout: '',
|
|
205
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
206
|
+
exitCode: 1,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
195
209
|
})
|
|
196
210
|
}
|
|
197
211
|
|
package/src/report-render.ts
CHANGED
|
@@ -205,6 +205,11 @@ function asNumber(value: unknown): number {
|
|
|
205
205
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/** Non-negative integer (floors fractions); 0 for anything else. */
|
|
209
|
+
function asInt(value: unknown): number {
|
|
210
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
|
211
|
+
}
|
|
212
|
+
|
|
208
213
|
function asArray(value: unknown): unknown[] {
|
|
209
214
|
return Array.isArray(value) ? value : []
|
|
210
215
|
}
|
|
@@ -258,7 +263,7 @@ export function buildRunReportData(
|
|
|
258
263
|
const findings: ReportFinding[] = asArray(get(src, 'findings')).map((f) => ({
|
|
259
264
|
dimension: asString(get(f, 'dimension')),
|
|
260
265
|
file: asString(get(f, 'file')),
|
|
261
|
-
line:
|
|
266
|
+
line: asInt(get(f, 'line')),
|
|
262
267
|
severity: findingSeverity(f),
|
|
263
268
|
summary: asString(get(f, 'summary')),
|
|
264
269
|
failureScenario: asString(get(f, 'failure_scenario')),
|
|
@@ -284,7 +289,7 @@ export function buildRunReportData(
|
|
|
284
289
|
const validations: ReportValidation[] = asArray(get(src, 'validations')).map((v) => ({
|
|
285
290
|
round: asNumber(get(v, 'round')),
|
|
286
291
|
command: asString(get(v, 'command')),
|
|
287
|
-
exitCode: typeof get(v, 'exitCode') === 'number' ?
|
|
292
|
+
exitCode: typeof get(v, 'exitCode') === 'number' ? asInt(get(v, 'exitCode')) : null,
|
|
288
293
|
allowed: get(v, 'allowed') === true,
|
|
289
294
|
rejectReason: asString(get(v, 'rejectReason')),
|
|
290
295
|
}))
|
|
@@ -306,7 +311,11 @@ export function buildRunReportData(
|
|
|
306
311
|
taskMode: taskMode === 'code' || taskMode === 'iterate' ? taskMode : null,
|
|
307
312
|
round: asNumber(get(src, 'round')),
|
|
308
313
|
maxRounds: asNumber(get(src, 'maxRounds')),
|
|
309
|
-
active
|
|
314
|
+
// An ABSENT `active` means "no run / unknown", not "running": a missing or
|
|
315
|
+
// empty transcript used to render a live, pulsing run at round 0. The
|
|
316
|
+
// transcript builder always writes an explicit boolean, so a strict `true`
|
|
317
|
+
// only ever loses the phantom run, never a real one.
|
|
318
|
+
active: get(src, 'active') === true,
|
|
310
319
|
stoppedReason: typeof stoppedReason === 'string' && stoppedReason ? stoppedReason : null,
|
|
311
320
|
updatedAt: asString(get(src, 'updatedAt')),
|
|
312
321
|
convergence: asArray(get(src, 'convergence')).filter(
|
|
@@ -392,7 +401,7 @@ export function renderRunReportMarkdown(
|
|
|
392
401
|
lines.push(`| ${labels.severity} | ${labels.dimension} | ${labels.location} | ${labels.summary} |`)
|
|
393
402
|
lines.push('| --- | --- | --- | --- |')
|
|
394
403
|
for (const f of data.findings) {
|
|
395
|
-
const loc = f.file ? `${f.file}${f.line >
|
|
404
|
+
const loc = f.file ? `${f.file}${f.line > 0 ? `:${f.line}` : ''}` : ''
|
|
396
405
|
lines.push(`| ${mdCell(f.severity)} | ${mdCell(f.dimension)} | ${mdCell(loc)} | ${mdCell(f.summary)} |`)
|
|
397
406
|
}
|
|
398
407
|
}
|
|
@@ -513,7 +522,9 @@ export function renderRunReportSarif(
|
|
|
513
522
|
if (f.suggestedFix) result.fixes = [{ description: { text: f.suggestedFix } }]
|
|
514
523
|
return result
|
|
515
524
|
})
|
|
516
|
-
|
|
525
|
+
// `exitCode: null` is the documented "unknown / no exit code" value, not a
|
|
526
|
+
// failure: only a concrete non-zero code makes the run unsuccessful.
|
|
527
|
+
const failedValidations = data.validations.filter((v) => v.exitCode !== null && v.exitCode !== 0).length
|
|
517
528
|
return {
|
|
518
529
|
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
519
530
|
version: '2.1.0',
|
package/src/session-record.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface IterateRunRecord {
|
|
|
63
63
|
}
|
|
64
64
|
/** Applied (successful) fixes recorded so far. */
|
|
65
65
|
fixedCount: number
|
|
66
|
-
/**
|
|
66
|
+
/** Quality-gate verdict snapshotted at write time (`pending` when undecided). */
|
|
67
67
|
qualityStatus: 'pass' | 'fail' | 'pending' | null
|
|
68
68
|
/** ISO timestamp the record was written. */
|
|
69
69
|
updatedAt: string
|
|
@@ -193,7 +193,9 @@ export function normalizeRunRecord(data: unknown): IterateRunRecord | null {
|
|
|
193
193
|
const d = data as Record<string, unknown>
|
|
194
194
|
// A record written by a future/incompatible build is skipped rather than
|
|
195
195
|
// coerced: the version gate is the contract that lets the payload evolve.
|
|
196
|
-
|
|
196
|
+
// The check is exact (an integer compare, not a floor) so a payload stamped
|
|
197
|
+
// `1.9` by a future build is rejected instead of rounded down to `1`.
|
|
198
|
+
if (!Number.isInteger(d.version) || d.version !== ITERATE_RUN_RECORD_VERSION) return null
|
|
197
199
|
const findings = (d.findings && typeof d.findings === 'object' && !Array.isArray(d.findings)
|
|
198
200
|
? (d.findings as Record<string, unknown>)
|
|
199
201
|
: {}) as Record<string, unknown>
|
|
@@ -226,12 +228,17 @@ export function normalizeRunRecord(data: unknown): IterateRunRecord | null {
|
|
|
226
228
|
let cachedApi: Promise<SessionRecordApi | null> | undefined
|
|
227
229
|
/** Test override; when set (including `null`) the dynamic import is bypassed. */
|
|
228
230
|
let testApiOverride: SessionRecordApi | null | undefined
|
|
231
|
+
/** Module loader; replaceable for tests to exercise import failure/late availability. */
|
|
232
|
+
let moduleImporter: () => Promise<unknown> = () => import('@deepseek-ai/dsh-session')
|
|
229
233
|
|
|
230
234
|
/** Resolve the `@deepseek-ai/dsh-session` record API, or `null` when absent. */
|
|
231
235
|
export function loadSessionRecordApi(): Promise<SessionRecordApi | null> {
|
|
232
236
|
if (testApiOverride !== undefined) return Promise.resolve(testApiOverride)
|
|
233
237
|
if (cachedApi === undefined) {
|
|
234
|
-
|
|
238
|
+
// `Promise.resolve().then(...)` (not a bare call) so a loader that throws
|
|
239
|
+
// SYNCHRONOUSLY still lands in the `.catch` instead of escaping.
|
|
240
|
+
cachedApi = Promise.resolve()
|
|
241
|
+
.then(() => moduleImporter())
|
|
235
242
|
.then((mod) => {
|
|
236
243
|
const m = mod as unknown as Partial<SessionRecordApi>
|
|
237
244
|
if (typeof m.appendPluginRecord !== 'function' || typeof m.pluginRecordOf !== 'function') {
|
|
@@ -243,10 +250,25 @@ export function loadSessionRecordApi(): Promise<SessionRecordApi | null> {
|
|
|
243
250
|
}
|
|
244
251
|
})
|
|
245
252
|
.catch(() => null)
|
|
253
|
+
// A `null` resolution (module/API absent, or a transient import failure) is
|
|
254
|
+
// NOT memoized: it is cleared so the next call retries. Otherwise one
|
|
255
|
+
// unlucky first import would disable session linking for the whole process.
|
|
256
|
+
void cachedApi.then((api) => {
|
|
257
|
+
if (!api) cachedApi = undefined
|
|
258
|
+
})
|
|
246
259
|
}
|
|
247
260
|
return cachedApi
|
|
248
261
|
}
|
|
249
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Replace the module loader for tests (to exercise the import-failure and
|
|
265
|
+
* late-availability paths). Pass `undefined` to restore the real dynamic import.
|
|
266
|
+
*/
|
|
267
|
+
export function setSessionRecordImporterForTests(loader: (() => Promise<unknown>) | undefined): void {
|
|
268
|
+
moduleImporter = loader ?? (() => import('@deepseek-ai/dsh-session'))
|
|
269
|
+
cachedApi = undefined
|
|
270
|
+
}
|
|
271
|
+
|
|
250
272
|
/**
|
|
251
273
|
* Override the session-record API for tests. Pass `undefined` to restore the
|
|
252
274
|
* real dynamic import, or an object / `null` to force a deterministic result.
|
package/src/tools/report.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* iterate_report — render the current iterate run into a portable report.
|
|
5
5
|
*
|
|
6
6
|
* Two formats, one normalized data model (shared with the client export
|
|
7
|
-
* buttons via `
|
|
8
|
-
* drift):
|
|
7
|
+
* buttons via `src/report-render.ts`, so the on-screen export and this tool
|
|
8
|
+
* can never drift):
|
|
9
9
|
*
|
|
10
10
|
* - `markdown` — a PR-pasteable run report: header (mode/round/status), the
|
|
11
11
|
* quality-gate table, the findings table, the fixes table,
|
package/src/tools/transcript.ts
CHANGED
|
@@ -34,6 +34,7 @@ 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'
|
|
38
39
|
import {
|
|
39
40
|
buildRunRecord,
|
|
@@ -685,7 +686,17 @@ export function registerTranscriptTool(ctx: {
|
|
|
685
686
|
// session. Best-effort — a link failure is reported, never fatal (the
|
|
686
687
|
// on-disk capture above already succeeded).
|
|
687
688
|
let sessionRecord: JsonValue
|
|
688
|
-
|
|
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))
|
|
689
700
|
sessionRecord = (
|
|
690
701
|
linkSession.ok
|
|
691
702
|
? { linked: true, seq: linkSession.seq }
|
|
@@ -715,12 +726,20 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
715
726
|
goal: manifest.goal,
|
|
716
727
|
maxRounds: manifest.maxRounds,
|
|
717
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
|
+
}
|
|
718
734
|
for (const r of Array.isArray(manifest.rounds) ? manifest.rounds : []) {
|
|
719
735
|
// A malformed/null round row must be skipped, never a throw (the caller
|
|
720
736
|
// wraps rehydrate in a fallback, but preserving sibling rows is better).
|
|
721
737
|
if (!r || typeof r !== 'object') continue
|
|
722
738
|
builder.roundStart(r.round, manifest.maxRounds)
|
|
723
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
|
|
724
743
|
// restoreThread (not reviewerStart + reviewerMessage(join)) preserves the
|
|
725
744
|
// message ARRAY boundaries and the thread's own dimension: rehydration
|
|
726
745
|
// previously collapsed each thread's messages into one string and, once a
|
|
@@ -739,6 +758,14 @@ function rehydrateBuilder(manifest: TranscriptManifest, approval: 'ask' | 'deny'
|
|
|
739
758
|
const n = manifest.convergence[idx]
|
|
740
759
|
if (typeof n === 'number' && n >= 0) builder.snapshotConvergence(idx + 1, n)
|
|
741
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)
|
|
742
769
|
if (manifest.checkpoint) builder.recordCheckpoint(manifest.checkpoint)
|
|
743
770
|
if (Array.isArray(manifest.fixes)) for (const fx of manifest.fixes) builder.fix(fx as TranscriptFix)
|
|
744
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. */
|