@feng0531/dsh-code-review 0.86.1 → 0.87.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.
@@ -0,0 +1,59 @@
1
+ const FILE_STATES = ['completed', 'excluded', 'blocked', 'failed', 'cancelled', 'pending'];
2
+ const STATUS_LABELS = { completed: '已完成', partial: '部分完成', failed: '执行失败', cancelled: '已取消', interrupted: '已中断', pending: '待执行', running: '执行中' };
3
+ const safe = value => Array.from(String(value ?? '').slice(0, 240)).map(c => {
4
+ if (c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) return ' ';
5
+ if (c === '&') return '&amp;';
6
+ if (c === '<') return '&lt;';
7
+ if (c === '>') return '&gt;';
8
+ if ('*_[]#|!()\\'.includes(c) || c.charCodeAt(0) === 96) return '\\' + c;
9
+ return c;
10
+ }).join('');
11
+ const rows = value => Array.isArray(value) ? value : [];
12
+ const validCount = n => Number.isSafeInteger(n) && n >= 0;
13
+ const supported = finding => finding?.verification?.status === 'completed' && finding.verification.verdict === 'supported' && rows(finding.verification.evidence).length > 0 && finding.verification.evidence.every(ref => ref?.status === 'references-validated');
14
+
15
+ // Presentation consumes host-validated results; it neither re-reads sources nor
16
+ // upgrades model verdicts, reference validation, or reuse into causal proof.
17
+ function comparisonValidated(finding) {
18
+ const { evidence, regression } = finding.verification ?? {};
19
+ if (regression?.status !== 'comparison-references-validated' || !Array.isArray(evidence)) return false;
20
+ const old = regression.oldEvidence, fresh = regression.newEvidence;
21
+ if (![old, fresh].every(indices => Array.isArray(indices) && indices.length > 0 && indices.length <= 8 && new Set(indices).size === indices.length && indices.every(i => Number.isSafeInteger(i) && i >= 0 && i < evidence.length && evidence[i]?.status === 'references-validated'))) return false;
22
+ if (old.some(index => fresh.includes(index))) return false;
23
+ // The comparison validator already checks primary-file identity and sides.
24
+ // Reject inconsistent copied summaries without guessing sides from source IDs.
25
+ return old.every(i => typeof evidence[i].sourceId === 'string' && evidence[i].sourceId) && fresh.every(i => typeof evidence[i].sourceId === 'string' && evidence[i].sourceId && old.every(j => evidence[j].sourceId !== evidence[i].sourceId));
26
+ }
27
+
28
+ export function reportSummaryMarkdown(report = {}) {
29
+ const findings = rows(report.findings), files = rows(report.files);
30
+ const counts = { introduced: 0, unresolved: 0, refuted: 0, preexisting: 0, current: 0 };
31
+ for (const finding of findings) {
32
+ const verification = finding?.verification;
33
+ if (verification?.status === 'completed' && verification.verdict === 'refuted' && rows(verification.evidence).length && verification.evidence.every(ref => ref?.status === 'references-validated')) counts.refuted++;
34
+ else if (!supported(finding)) counts.unresolved++;
35
+ else if (report.reviewMode === 'project' || report.projectScope || finding.attribution?.causality === 'current-source-only') counts.current++;
36
+ else if (comparisonValidated(finding) && verification.regression.classification === 'introduced') counts.introduced++;
37
+ else if (comparisonValidated(finding) && verification.regression.classification === 'preexisting') counts.preexisting++;
38
+ else counts.unresolved++;
39
+ }
40
+ const coverage = Object.fromEntries(FILE_STATES.map(status => [status, validCount(report.coverage?.[status]) ? report.coverage[status] : files.filter(f => f?.status === status).length]));
41
+ const total = validCount(report.coverage?.total) ? report.coverage.total : files.length;
42
+ const unfinished = files.filter(file => !['completed', 'excluded'].includes(file?.status) || rows(file.stageFailures).length || file.retrievalLimited || rows(file.windowCoverage?.pending).length || file.synthesisStatus && file.synthesisStatus !== 'completed' || file.verificationStatus && file.verificationStatus !== 'completed' || rows(file.reviewPasses).some(pass => pass?.status !== 'completed') || findings.some(finding => finding?.fileId === file.fileId && finding?.verification?.status !== 'completed'));
43
+ const incompleteInteractions = rows(report.interactions).filter(item => item?.status !== 'completed');
44
+ const output = ['', '## 结论摘要', '- 整体状态:' + (STATUS_LABELS[report.status] ?? '未知') + ';完成状态只表示处理进度,不表示代码正确。',
45
+ '- 已确认新增(独立复核支持、旧新对照引用已校验):' + counts.introduced + ';未决/证据不足:' + counts.unresolved + ';已反驳候选:' + counts.refuted + ';既有问题(复核支持且旧新对照已校验):' + counts.preexisting + '。'];
46
+ if (counts.current) output.push('- 当前源码复核支持:' + counts.current + ';当前源码模式不评估历史新增归因,不计为已确认新增。');
47
+ output.push('- 以上按候选记录计数,不是去重根因数;“已确认新增”仅指模型复核支持及引用校验成立,因果解释仍未经验证,不是事实或因果证明。');
48
+ if (findings.some(finding => finding?.reused || finding?.priorRun)) output.push('- 含既往轮次复用发现;保留原复核来源,不表示本轮重新取证。');
49
+ if (report.outdated) output.push('- 注意:来源已变化或无法复核;这些结论仅对应原批准快照,不能当作当前源码结论。');
50
+ output.push('- 范围:' + total + ' 个文件;完成 ' + coverage.completed + ',排除 ' + coverage.excluded + ',阻断 ' + coverage.blocked + ',失败 ' + coverage.failed + ',取消 ' + coverage.cancelled + ',待审 ' + coverage.pending + '。排除/阻断不是通过。');
51
+ if (unfinished.length) {
52
+ output.push('- 关键未完成范围:' + unfinished.slice(0, 5).map(file => safe(file.path ?? '未命名文件')).join('、') + (unfinished.length > 5 ? ';另有 ' + (unfinished.length - 5) + ' 个文件,见覆盖明细。' : ';阶段和限制见覆盖明细。'));
53
+ }
54
+ if (incompleteInteractions.length) output.push('- 跨文件综合未完成:' + incompleteInteractions.length + ' 项;主文件完成不能替代交互覆盖。');
55
+ if (rows(report.limitations).length) output.push('- 已记录限制:' + report.limitations.length + ' 项;完整内容保留在限制明细,不能据空发现判定通过。');
56
+ if (total === 0) output.push('- 未选择文件,没有执行代码审核;不能解释为零缺陷结论。');
57
+ if (!findings.length) output.push(total > 0 && coverage.completed > 0 && report.status === 'completed' && !unfinished.length && !incompleteInteractions.length && !rows(report.limitations).length && !report.outdated ? '- 已审范围内没有候选问题记录;不证明不存在缺陷。' : '- 当前没有候选问题记录;执行状态、未完成范围或限制不支持“审核通过”。');
58
+ return output;
59
+ }
@@ -46,11 +46,13 @@ function roundBudgetError() {
46
46
  return error;
47
47
  }
48
48
 
49
- export async function retrievalLoop(model, request, scope, { correctProtocol = false, maxRounds = Number.MAX_SAFE_INTEGER, maxInputBytes = 96 * 1024, beforeCall = () => {}, onTruncated = () => {}, onRetrieved = () => {}, onBudgetLimited = () => {}, initialRetrieved = [], allowExhaustedInitial = false, enableSearchSeeds = false } = {}) {
49
+ export async function retrievalLoop(model, request, scope, { correctProtocol = false, maxRounds = Number.MAX_SAFE_INTEGER, maxInputBytes = 96 * 1024, beforeCall = () => {}, onTruncated = () => {}, onRetrieved = () => {}, onBudgetLimited = () => {}, initialRetrieved = [], initialContext, yieldOnBudget = false, allowExhaustedInitial = false, enableSearchSeeds = false } = {}) {
50
50
  if (!Number.isSafeInteger(maxRounds) || maxRounds < 0 || !Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1) throw new Error('Invalid retrieval loop budget');
51
51
  // Only internal callers can seed this loop; the public model response cannot supply it.
52
52
  const allowedSeeds = new Set(scope.catalog().map(item => item.id));
53
- if (!Array.isArray(initialRetrieved) || initialRetrieved.length > 50 || initialRetrieved.some(item => item?.request?.kind !== 'read' || !allowedSeeds.has(item.request.id) || item.result?.sourceId !== item.request.id || typeof item.result?.text !== 'string')) throw new Error('Invalid initial retrieval evidence');
53
+ const validateSeeds = items => { if (!Array.isArray(items) || items.length > 50 || items.some(item => item?.request?.kind !== 'read' || !allowedSeeds.has(item.request.id) || item.result?.sourceId !== item.request.id || typeof item.result?.text !== 'string')) throw new Error('Invalid initial retrieval evidence'); };
54
+ validateSeeds(initialRetrieved);
55
+ if (initialContext !== undefined && typeof initialContext !== 'function') throw new Error('Invalid initial evidence context producer');
54
56
  const retrieved = structuredClone(initialRetrieved);
55
57
  if (retrieved.length) {
56
58
  const seeded = retrievalRequest(request, scope, retrieved);
@@ -60,6 +62,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
60
62
  const delivered = new Set();
61
63
  let protocolCorrection, finalization, searchSeedAttempts = 0, currentRound = 0;
62
64
  const finalize = async error => {
65
+ if (yieldOnBudget && ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error.code)) throw error;
63
66
  finalization = { code: error.code, reason: error.message, ...(scope.budget ? { budget: scope.budget() } : {}) };
64
67
  await onBudgetLimited(finalization);
65
68
  await request.onProtocolEvent?.({ action: 'retrieval-budget-finalization', ...finalization });
@@ -100,6 +103,16 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
100
103
  request.signal?.throwIfAborted();
101
104
  beforeCall();
102
105
  request.signal?.throwIfAborted();
106
+ // Optional redelivery starts only after model-call admission. Never spend a
107
+ // stage's reader budget on a call already vetoed by the shared model gate.
108
+ if (round === 0 && !finalization && initialContext) {
109
+ const seeds = initialContext(retrieved, records => ({ ...request, ...nextRequest(records) }));
110
+ validateSeeds(seeds);
111
+ retrieved.push(...seeds);
112
+ ({ input, instructions } = nextRequest(retrieved));
113
+ if (!fits({ ...request, input, instructions })) throw new Error('Retrieval input budget exceeded');
114
+ request.signal?.throwIfAborted();
115
+ }
103
116
  for (const item of retrieved) {
104
117
  if (!item.result.omitted && !delivered.has(item)) {
105
118
  recordRetrieval(item.request, item.result, onTruncated, onRetrieved);
@@ -132,7 +145,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
132
145
  batch = scope.transaction ? scope.transaction(execute) : execute();
133
146
  } catch (error) {
134
147
  request.signal?.throwIfAborted();
135
- if (!finalization && budgetFailure(error)) { await finalize(error); continue; }
148
+ if (!finalization && budgetFailure(error)) { if (yieldOnBudget) throw Object.assign(error, { pendingRequests: parseRetrievalResponse(parsed) }); await finalize(error); continue; }
136
149
  if (finalization || !correctProtocol || protocolCorrection || !(/Invalid retrieval batch|Invalid read request|Invalid search request|Invalid find request|Invalid line range|Line range outside source/.test(error.message))) throw error;
137
150
  protocolCorrection = { code: error.code ?? 'MODEL_INVALID_RETRIEVAL' };
138
151
  await request.onProtocolEvent?.({ action: 'retrieval-protocol-correction', code: protocolCorrection.code });
@@ -0,0 +1,17 @@
1
+ import { changeMap } from './change-map.mjs';
2
+ import { changeRegion } from './change-region.mjs';
3
+
4
+ // Canonical review transport coordinates, recomputed solely from approved source.
5
+ // An envelope is a bounded broad region, never an exact edit or causal proof.
6
+ export function reviewChangeMap(before, after) {
7
+ const changes = changeMap(before, after);
8
+ if (changes.status !== 'limited') return changes;
9
+ const region = changeRegion(before, after);
10
+ if (region.status !== 'changed') return changes;
11
+ return {
12
+ status: 'changed', precision: 'envelope',
13
+ reason: 'Exact diff unavailable; broad region includes potentially unchanged lines, not exact edits or causal proof.',
14
+ edits: [{ id: 'envelope1', old: region.old, new: region.new }],
15
+ hunks: [{ id: 'envelope-h1', old: region.old, new: region.new, editIds: ['envelope1'] }],
16
+ };
17
+ }
@@ -1,7 +1,9 @@
1
1
  // Per-stage admission over ONE approved root ledger, not independent readers.
2
2
  // Fixed proportional floors protect future stages; released unused floors become
3
3
  // shared capacity. No content/source identifiers are emitted by summary().
4
- export function createReviewReaderAllocation(root, units) {
4
+ export function createReviewReaderAllocation(root, units, { maxResumeAttempts = 2 } = {}) {
5
+ if (!Number.isSafeInteger(maxResumeAttempts) || maxResumeAttempts < 1 || maxResumeAttempts > 4) throw new Error('Invalid reader resume attempts');
6
+ let releaseRevision = 0; const yielded = [];
5
7
  if (!root || !['budget', 'usage', 'preflight', 'transaction', 'read', 'find', 'search', 'catalog'].every(key => typeof root[key] === 'function')) throw new Error('Invalid root reader allocation');
6
8
  if (!Array.isArray(units) || units.length > 2000) throw new Error('Invalid reader allocation units');
7
9
  const budget = root.budget();
@@ -9,7 +11,7 @@ export function createReviewReaderAllocation(root, units) {
9
11
  const states = new Map(); let totalWeight = 0;
10
12
  for (const unit of units) {
11
13
  if (!unit || typeof unit.id !== 'string' || !unit.id || unit.id.length > 500 || states.has(unit.id) || !Number.isSafeInteger(unit.weight) || unit.weight < 1 || unit.weight > 1000000) throw new Error('Invalid reader allocation unit');
12
- states.set(unit.id, { weight: unit.weight, calls: 0, outputBytes: 0, cacheHits: 0, released: false, inTransaction: false });
14
+ states.set(unit.id, { weight: unit.weight, calls: 0, outputBytes: 0, cacheHits: 0, released: false, inTransaction: false, yielded: false, resumeAttempts: 0, yieldRevision: 0, pendingReason: null });
13
15
  totalWeight += unit.weight;
14
16
  }
15
17
  for (const state of states.values()) {
@@ -17,7 +19,7 @@ export function createReviewReaderAllocation(root, units) {
17
19
  state.byteFloor = Math.floor(budget.remainingOutputBytes * state.weight / totalWeight);
18
20
  }
19
21
  const fail = kind => { const error = new Error('Reader allocation ' + kind + ' budget exceeded'); error.code = kind === 'call' ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED'; throw error; };
20
- const live = id => { const state = states.get(id); if (!state || state.released) throw new Error('Reader allocation unit unavailable'); return state; };
22
+ const live = id => { const state = states.get(id); if (!state || state.released || state.yielded) throw new Error('Reader allocation unit unavailable'); return state; };
21
23
  const available = state => {
22
24
  const remaining = root.budget(); let protectedCalls = 0, protectedBytes = 0;
23
25
  for (const other of states.values()) {
@@ -28,6 +30,25 @@ export function createReviewReaderAllocation(root, units) {
28
30
  return { ...remaining, remainingCalls: Math.max(0, remaining.remainingCalls - protectedCalls), remainingOutputBytes: Math.max(0, remaining.remainingOutputBytes - protectedBytes) };
29
31
  };
30
32
  const check = (state, cost) => { const remaining = available(state); if (cost.calls > remaining.remainingCalls) fail('call'); if (cost.outputBytes > remaining.remainingOutputBytes) fail('output'); };
33
+ const resumeWithCost = (id, costForAttempt) => {
34
+ const state = states.get(id);
35
+ if (!state || state.released || !state.yielded) throw new Error('Reader allocation unit unavailable');
36
+ if (state.resumeAttempts >= maxResumeAttempts) return { resumed: false, reason: 'READER_RESUME_ATTEMPTS_EXHAUSTED', attempts: state.resumeAttempts };
37
+ const head = yielded.find(key => { const row = states.get(key); return row && !row.released && row.yielded && row.resumeAttempts < maxResumeAttempts && row.yieldRevision < releaseRevision; });
38
+ if (head !== id || state.yieldRevision >= releaseRevision) return { resumed: false, reason: 'READER_RESUME_WAITING', attempts: state.resumeAttempts };
39
+ state.resumeAttempts++; state.yieldRevision = releaseRevision;
40
+ yielded.splice(yielded.indexOf(id), 1); yielded.push(id);
41
+ try {
42
+ const cost = costForAttempt();
43
+ if (!cost || !Number.isSafeInteger(cost.calls) || cost.calls < 0 || !Number.isSafeInteger(cost.outputBytes) || cost.outputBytes < 0) throw new Error('Reader allocation preflight unavailable');
44
+ check(state, cost);
45
+ state.yielded = false; state.pendingReason = null; yielded.splice(yielded.indexOf(id), 1);
46
+ return { resumed: true, reason: null, attempts: state.resumeAttempts };
47
+ } catch (error) {
48
+ if (!['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) throw error;
49
+ return { resumed: false, reason: error.code, attempts: state.resumeAttempts };
50
+ }
51
+ };
31
52
  const scopes = new Map();
32
53
  return Object.freeze({
33
54
  scope(id) {
@@ -69,7 +90,28 @@ export function createReviewReaderAllocation(root, units) {
69
90
  });
70
91
  scopes.set(id, scope); return scope;
71
92
  },
72
- release(id) { const state = states.get(id); if (!state) return; if (state.inTransaction) throw new Error('Reader allocation transaction active'); state.released = true; },
93
+ // Yield retains this unit's floor; only the host owning a terminal stage may
94
+ // release it. Pausing a peer never grants permission to steal its reserve.
95
+ yield(id, { reason = 'READER_CALL_BUDGET_EXCEEDED' } = {}) {
96
+ const state = live(id);
97
+ if (state.inTransaction) throw new Error('Reader allocation transaction active');
98
+ if (!['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(reason)) throw new Error('Invalid reader yield reason');
99
+ state.yielded = true; state.yieldRevision = releaseRevision; state.pendingReason = reason; yielded.push(id);
100
+ return { yielded: true, attempts: state.resumeAttempts };
101
+ },
102
+ resume(id, requests = []) {
103
+ const state = states.get(id);
104
+ if (!state || state.released || !state.yielded) throw new Error('Reader allocation unit unavailable');
105
+ if (!Array.isArray(requests) || !requests.length) return { resumed: false, reason: 'READER_RESUME_REQUESTS_REQUIRED', attempts: state.resumeAttempts };
106
+ return resumeWithCost(id, () => root.preflight(requests));
107
+ },
108
+ // Scalar admission is only a retry opportunity, never authority to read.
109
+ // Every actual read still goes through exact preflight and root accounting.
110
+ resumeBudget(id, { requiredCalls = 1, requiredOutputBytes = 1 } = {}) {
111
+ if (![requiredCalls, requiredOutputBytes].every(value => Number.isSafeInteger(value) && value >= 0) || requiredCalls + requiredOutputBytes === 0) throw new Error('Invalid reader resume budget');
112
+ return resumeWithCost(id, () => ({ calls: requiredCalls, outputBytes: requiredOutputBytes }));
113
+ },
114
+ release(id) { const state = states.get(id); if (!state) return; if (state.inTransaction) throw new Error('Reader allocation transaction active'); if (!state.released) { state.released = true; releaseRevision++; } },
73
115
  summary() { const usage = root.usage(); return { units: states.size, releasedUnits: [...states.values()].filter(state => state.released).length, calls: usage.calls, outputBytes: usage.outputBytes, ...(usage.cacheHits !== undefined ? { cacheHits: usage.cacheHits } : {}) }; },
74
116
  });
75
117
  }
@@ -1,15 +1,140 @@
1
1
  import { hash } from './content.mjs';
2
2
  import { validateBusinessGroups } from './business-groups.mjs';
3
+ import { prepareStageGenerations } from './review-stage-state.mjs';
4
+ import { createStageContinuation } from './stage-continuation.mjs';
3
5
 
4
6
  const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])])) : value;
5
7
  const digest = value => hash(JSON.stringify(canonical(value)));
8
+
9
+ const MAX_BYTES = 8 * 1024 * 1024;
10
+ const stageEnabled = options => options?.execution?.enableStageContinuation === true || options?.enableStageContinuation === true;
11
+ const invalid = () => { throw new Error('Invalid stage resume checkpoint'); };
12
+ // Private export-only sentinel: never infer capacity from an arbitrary error/code.
13
+ class ResumeStorageCapacityError extends Error {
14
+ constructor() { super('Resume checkpoint exceeds storage capacity'); this.code = 'RESUME_STORAGE_CAPACITY_EXCEEDED'; }
15
+ }
16
+ const capacity = mode => { if (mode === 'export') throw new ResumeStorageCapacityError(); if (mode !== 'scan') invalid(); };
17
+ export const RESUME_CHECKPOINT_CAPACITY_LIMITATION = '续审检查点容量受限:仅保存适配容量的完整历史单元,未保存部分将重新执行;不影响本次发现或授予证据权限。';
18
+ const exact = (value, keys) => { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k))) invalid(); };
19
+ const RECEIPT_KEYS = new Set(['receiptId', 'receipts', 'retrievalAudit', 'retrieved', 'resolveEvidence']);
20
+ function privateClone(value, { stripReceipts = false, capacityMode } = {}, seen = new Set(), depth = 0, budget = { nodes: 0, bytes: 0 }) {
21
+ if (depth > 32) invalid();
22
+ if (++budget.nodes > 300000) capacity(capacityMode);
23
+ if (typeof value === 'string') { budget.bytes += Buffer.byteLength(value); if (budget.bytes > MAX_BYTES) capacity(capacityMode); return value; }
24
+ if (value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return value;
25
+ if (!value || typeof value !== 'object' || seen.has(value) || ![Object.prototype, Array.prototype, null].includes(Object.getPrototypeOf(value))) invalid();
26
+ if (Array.isArray(value) && (value.length > 100000 || Object.keys(value).length !== value.length)) invalid();
27
+ seen.add(value); const out = Array.isArray(value) ? [] : {};
28
+ for (const key of Reflect.ownKeys(value)) {
29
+ if (Array.isArray(value) && key === 'length') continue;
30
+ if (typeof key !== 'string' || ['__proto__', 'constructor', 'toJSON', 'prototype'].includes(key)) invalid();
31
+ const d = Object.getOwnPropertyDescriptor(value, key); if (!d.enumerable || !Object.hasOwn(d, 'value')) invalid();
32
+ // Host report objects use optional undefined fields; normalize only exports.
33
+ // Imported checkpoints remain JSON-only and reject undefined explicitly.
34
+ if (stripReceipts && !Array.isArray(value) && d.value === undefined) continue;
35
+ if (RECEIPT_KEYS.has(key)) { if (stripReceipts) continue; invalid(); }
36
+ out[key] = privateClone(d.value, { stripReceipts, capacityMode }, seen, depth + 1, budget);
37
+ }
38
+ seen.delete(value);return out;
39
+ }
40
+ function bounded(value, capacityMode) { if (Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES) capacity(capacityMode); return value; }
41
+
42
+ function exportWithinCapacity(value) {
43
+ const cloned = bounded(privateClone(value, { stripReceipts: true, capacityMode: 'export' }), 'export');
44
+ // Accepted-stage import has a stricter aggregate node budget than the wrapper.
45
+ const stageBudget = { nodes: 0, bytes: 0 };
46
+ privateClone(cloned.stageGenerations, { capacityMode: 'export' }, new Set(), 0, stageBudget);
47
+ if (stageBudget.nodes > 200000) throw new ResumeStorageCapacityError();
48
+ return cloned;
49
+ }
50
+ function capacityFits(value) {
51
+ try { exportWithinCapacity(value); return true; }
52
+ catch (error) { if (error instanceof ResumeStorageCapacityError) return false; throw error; }
53
+ }
54
+ function boundedExport(result, report, snapshot) {
55
+ // Every unit has already passed structural and semantic checks before admission.
56
+ // Only the serialized storage/node aggregate may degrade; unknown errors escape.
57
+ if (capacityFits(result)) return prepareV2(exportWithinCapacity(result), snapshot);
58
+ const next = { schemaVersion: 2, snapshotId: result.snapshotId, identityHash: result.identityHash, completed: [], interactions: [], stageGenerations: [] };
59
+ exportWithinCapacity(next); // Even an unusable identity shell must fail normally.
60
+ const omitted = { completed: 0, stageGenerations: 0, interactions: 0, continuation: 0, businessGrouping: 0 };
61
+ for (const row of result.completed) {
62
+ const candidate = { ...next, completed: [...next.completed, row] };
63
+ if (capacityFits(candidate)) next.completed.push(row); else omitted.completed++;
64
+ }
65
+ if (result.businessGrouping !== undefined) {
66
+ if (capacityFits({ ...next, businessGrouping: result.businessGrouping })) next.businessGrouping = result.businessGrouping; else omitted.businessGrouping++;
67
+ }
68
+ for (const row of result.stageGenerations) {
69
+ if (capacityFits({ ...next, stageGenerations: [...next.stageGenerations, row] })) next.stageGenerations.push(row); else omitted.stageGenerations++;
70
+ }
71
+ if (result.continuation !== undefined) {
72
+ // Historical continuation cannot imply completion for a dropped primary unit.
73
+ if (!omitted.stageGenerations && !omitted.completed && capacityFits({ ...next, continuation: result.continuation })) next.continuation = result.continuation; else omitted.continuation++;
74
+ }
75
+ const kept = new Set(next.completed.map(row => row.fileId));
76
+ for (const row of result.interactions) {
77
+ if (row.fileIds.every(id => kept.has(id)) && capacityFits({ ...next, interactions: [...next.interactions, row] })) next.interactions.push(row); else omitted.interactions++;
78
+ }
79
+ const limitation = { code: 'RESUME_CHECKPOINT_CAPACITY_EXCEEDED', text: RESUME_CHECKPOINT_CAPACITY_LIMITATION, omitted };
80
+ report.resumeLimitations ??= [];
81
+ const previous = report.resumeLimitations.findIndex(row => row.code === limitation.code);
82
+ if (previous < 0) report.resumeLimitations.push(limitation); else report.resumeLimitations[previous] = limitation;
83
+ report.limitations ??= [];
84
+ if (!report.limitations.some(row => row.text === limitation.text)) report.limitations.push({ text: limitation.text });
85
+ return prepareV2(exportWithinCapacity(next), snapshot);
86
+ }
87
+ function continuationState(snapshot, wrapper) {
88
+ exact(wrapper, ['options', 'state']);
89
+ const o = wrapper.options, cp = wrapper.state;
90
+ exact(o, ['rounds', 'maxInputBytes', 'enableMigrationContracts', 'enableMigrationRegression']);
91
+ if (!Number.isSafeInteger(o.rounds) || o.rounds < 1 || o.rounds > 50 || !Number.isSafeInteger(o.maxInputBytes) || o.maxInputBytes < 1 || o.maxInputBytes > 16 * 1024 * 1024 || typeof o.enableMigrationContracts !== 'boolean' || typeof o.enableMigrationRegression !== 'boolean' || !Array.isArray(cp?.units)) invalid();
92
+ const units = cp.units.map(({ id, kind, fileIds, dependencies }) => ({ id, kind, fileIds, dependencies }));
93
+ const machine = createStageContinuation(snapshot, o, { completedPrimary: cp.completedPrimary, units, maxAttempts: cp.maxAttempts, checkpoint: cp });
94
+ // Keep the original checkpoint (not the machine's running->pending projection).
95
+ // This is historical bookkeeping only, never automatically applied to a new run.
96
+ machine.summary();
97
+ return { options: structuredClone(o), state: structuredClone(cp) };
98
+ }
99
+ function prepareV2(value, snapshot) {
100
+ exact(value, ['schemaVersion', 'snapshotId', 'identityHash', 'completed', 'businessGrouping', 'interactions', 'stageGenerations', 'continuation']);
101
+ if (!Array.isArray(value.stageGenerations)) invalid();
102
+ const generations = prepareStageGenerations(snapshot, value.stageGenerations);
103
+ const done = new Set(value.completed.map(item => item.fileId));
104
+ if (generations.some(row => row.phase !== 'primary' || done.has(row.fileId) || row.needsFreshProvenance)) invalid();
105
+ const result = { ...value, stageGenerations: generations };
106
+ if (value.continuation !== undefined) result.continuation = continuationState(snapshot, value.continuation);
107
+ return bounded(result);
108
+ }
109
+
6
110
  const safeFile = file => !file?.stageFailures?.length && !file?.reviewPasses?.some(pass => pass.status !== 'completed') && !file?.windowCoverage?.pending?.length && file?.status === 'completed' && file.resumeEligible === true && !file.retrievalLimited && (!file.synthesisStatus || file.synthesisStatus === 'completed') && (!file.verificationStatus || !['pending', 'failed'].includes(file.verificationStatus));
7
111
 
8
112
  // Host-private, schema-versioned completed-chain state. A previous result grants
9
113
  // neither source authority nor model approval. Exact whole-snapshot binding is
10
114
  // deliberately conservative: any changed context/rule/source invalidates reuse.
11
115
  export function buildResumeState(snapshot, options, report) {
12
- if (typeof snapshot?.id !== 'string' || !Array.isArray(snapshot.files) || !report || !Array.isArray(report.files) || !Array.isArray(report.findings)) throw new Error('Invalid resume checkpoint');
116
+ if (typeof snapshot?.id !== 'string' || !Array.isArray(snapshot.files) || !report || typeof report !== 'object') throw new Error('Invalid resume checkpoint');
117
+ if (stageEnabled(options)) {
118
+ // Scan the entire export payload before any filtering/size admission. Unlike
119
+ // imports this scan records no storage entitlement, but never executes hooks.
120
+ if (![Object.prototype, null].includes(Object.getPrototypeOf(report))) invalid();
121
+ const noticeDescriptor = Object.getOwnPropertyDescriptor(report, 'resumeLimitations');
122
+ if (noticeDescriptor && (!noticeDescriptor.enumerable || !Object.hasOwn(noticeDescriptor, 'value') || !Array.isArray(noticeDescriptor.value))) invalid();
123
+ const keys = ['files', 'findings', 'limitations', 'interactions', 'businessGrouping', 'stageGenerations', 'continuationOptions', 'continuation', 'resumeLimitations'];
124
+ const candidate = {};
125
+ for (const key of keys) {
126
+ const descriptor = Object.getOwnPropertyDescriptor(report, key);
127
+ if (descriptor) { if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) invalid(); candidate[key] = descriptor.value; }
128
+ }
129
+ const safe = privateClone(candidate, { stripReceipts: true, capacityMode: 'scan' });
130
+ // Preserve mutation target only for explicit capacity notices below.
131
+ return buildStageResumeState(snapshot, options, safe, report);
132
+ }
133
+ return buildStageResumeState(snapshot, options, report, report);
134
+ }
135
+
136
+ function buildStageResumeState(snapshot, options, report, noticeTarget) {
137
+ if (!Array.isArray(report.files) || !Array.isArray(report.findings)) throw new Error('Invalid resume checkpoint');
13
138
  const approved = new Map(snapshot.files.filter(file => file.eligibility === 'reviewable').map(file => [file.id, file]));
14
139
  const completed = [];
15
140
  for (const state of report.files) {
@@ -28,12 +153,37 @@ export function buildResumeState(snapshot, options, report) {
28
153
  const interactions = structuredClone((report.interactions ?? []).filter(item => Array.isArray(item.fileIds) && item.fileIds.length && item.status === 'completed' && item.fileIds.every(id => completed.some(row => row.fileId === id))));
29
154
  const identity = digest({ snapshot, options });
30
155
  const result = { schemaVersion: 1, snapshotId: snapshot.id, identityHash: identity, completed, ...(businessGrouping ? { businessGrouping } : {}), interactions };
156
+ if (stageEnabled(options)) {
157
+ result.schemaVersion = 2;
158
+ const stageKeys = new Set();
159
+ result.stageGenerations = (report.stageGenerations ?? []).flatMap(record => {
160
+ const [row] = prepareStageGenerations(snapshot, [record]);
161
+ if (stageKeys.has(row.key)) invalid(); stageKeys.add(row.key);
162
+ return row.phase === 'primary' && !completed.some(item => item.fileId === row.fileId) ? [row] : [];
163
+ });
164
+ if (report.continuation !== undefined) result.continuation = continuationState(snapshot, { options: report.continuationOptions, state: report.continuation });
165
+ // Check every completed chain, including units later omitted for capacity.
166
+ for (const item of completed) {
167
+ const file = approved.get(item.fileId);
168
+ if (!file || !safeFile(item.state) || item.state.fileId !== item.fileId || item.state.path !== item.path ||
169
+ !Array.isArray(item.findings) || item.findings.some(f => f?.fileId !== item.fileId || f.verification?.status !== 'completed') ||
170
+ !Array.isArray(item.limitations) || item.limitations.some(row => row?.fileId !== item.fileId)) throw new Error('Invalid completed resume chain');
171
+ }
172
+ return boundedExport(result, noticeTarget, snapshot);
173
+ }
31
174
  if (Buffer.byteLength(JSON.stringify(result)) > 8 * 1024 * 1024) throw new Error('Resume checkpoint exceeds storage limit');
32
175
  return result;
33
176
  }
34
177
 
35
178
  export function validateResumeState(value, snapshot, options) {
36
- if (!value || value.schemaVersion !== 1 || value.snapshotId !== snapshot?.id || value.identityHash !== digest({ snapshot, options }) || !Array.isArray(value.completed) || value.completed.length > snapshot.files.length) throw new Error('Resume identity mismatch; capture a fresh independent review');
179
+ // Select the version without invoking a caller-supplied accessor or inherited
180
+ // schema property before the strict schema-two descriptor scan.
181
+ if (!value || typeof value !== 'object') invalid();
182
+ const version = Object.getOwnPropertyDescriptor(value, 'schemaVersion');
183
+ if (!version || !Object.hasOwn(version, 'value')) invalid();
184
+ const v2 = version.value === 2;
185
+ if (v2) { if (!stageEnabled(options)) invalid(); value = bounded(privateClone(value)); }
186
+ if (!value || ![1, ...(stageEnabled(options) ? [2] : [])].includes(value.schemaVersion) || value.snapshotId !== snapshot?.id || value.identityHash !== digest({ snapshot, options }) || !Array.isArray(value.completed) || value.completed.length > snapshot.files.length) throw new Error('Resume identity mismatch; capture a fresh independent review');
37
187
  const seen = new Set();
38
188
  for (const item of value.completed) {
39
189
  const source = snapshot.files.find(file => file.id === item?.fileId && file.path === item.path && file.eligibility === 'reviewable');
@@ -45,9 +195,10 @@ export function validateResumeState(value, snapshot, options) {
45
195
  validateBusinessGroups({ groups: value.businessGrouping.groups.map(group => ({ fileIds: group.fileIds, reason: group.reason })) }, snapshot.files);
46
196
  }
47
197
  if (!Array.isArray(value.interactions) || value.interactions.some(item => !Array.isArray(item?.fileIds) || !item.fileIds.length || item.status !== 'completed' || item.fileIds.some(id => !seen.has(id)))) throw new Error('Invalid resume interactions');
48
- return structuredClone(value);
198
+ return v2 ? prepareV2(value, snapshot) : structuredClone(value);
49
199
  }
50
200
 
51
201
  export function resumePreview(state, sourceJobId) {
202
+ if (state.schemaVersion === 2) return { sourceJobId, reusableFiles: state.completed.length, fileIds: state.completed.map(item => item.fileId), reusableGenerations: state.stageGenerations.length, stageFileIds: [...new Set(state.stageGenerations.map(item => item.fileId))], notice: 'Exact-identity accepted primary generations may skip generation only. Pending chains are not completed reviews. Verification always runs again with fresh independent receipts; secondary stages restart at a fresh provenance boundary. Saved continuation is historical bookkeeping, not fresh completion or a new attempt allowance. Fresh preview confirmation and model authorization remain required.' };
52
203
  return { sourceJobId, reusableFiles: state.completed.length, fileIds: state.completed.map(item => item.fileId), notice: 'Only exact-identity completed file chains are reusable. Pending/interrupted stages run again; old findings retain prior-run provenance, not fresh model evidence. Fresh preview confirmation and model authorization are still required.' };
53
204
  }
@@ -0,0 +1,148 @@
1
+ import { hash } from './content.mjs';
2
+ import { changeMap } from './change-map.mjs';
3
+ import { reviewChangeMap } from './review-change-map.mjs';
4
+ import { changeWindows } from './change-windows.mjs';
5
+ import { sourceSegments } from './source-segments.mjs';
6
+ import { validateWindowFindings } from './window-findings.mjs';
7
+ import { validateAttribution } from './attribution.mjs';
8
+
9
+ const LIMIT = 8 * 1024 * 1024;
10
+ // Host-only accepted generation: member model 50 + group model 50 + host notes 2.
11
+ // This is checkpoint/report metadata, never a wider model response or evidence grant.
12
+ export const MAX_STAGE_LIMITATIONS = 102;
13
+ const PHASES = ['primary', 'window-synthesis', 'file-interaction'];
14
+ const DROP = ['verification', 'regression', 'priorRun', 'retrievalAudit', 'receipts', 'receiptId'];
15
+ const fail = () => { throw Object.assign(new Error('Invalid accepted stage generation'), { code: 'INVALID_STAGE_GENERATION' }); };
16
+ const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 8000;
17
+ const identity = value => typeof value === 'string' && value.length > 0 && value.length <= 1000 && !/[\x00-\x1f]/.test(value);
18
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
19
+ function exact(value, allowed) { if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail(); }
20
+ // Private checkpoints are still imported data. Inspect descriptors before JSON
21
+ // serialization: getters/toJSON/prototypes must never execute during validation.
22
+ function clone(value, seen = new Set(), depth = 0, budget = { nodes: 0, bytes: 0 }) {
23
+ if (++budget.nodes > 200000 || depth > 24) fail();
24
+ if (typeof value === 'string') { budget.bytes += Buffer.byteLength(value); if (budget.bytes > LIMIT) fail(); return value; }
25
+ if (value === null || typeof value === 'boolean') return value;
26
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
27
+ if (!value || typeof value !== 'object' || seen.has(value)) fail();
28
+ if (Object.getPrototypeOf(value) !== (Array.isArray(value) ? Array.prototype : Object.prototype) && Object.getPrototypeOf(value) !== null) fail();
29
+ if (Array.isArray(value) && (value.length > 100000 || Object.keys(value).length !== value.length || Object.keys(value).some((key, index) => key !== String(index)))) fail();
30
+ seen.add(value);
31
+ const output = Array.isArray(value) ? [] : {};
32
+ for (const key of Reflect.ownKeys(value)) {
33
+ if (Array.isArray(value) && key === 'length') continue;
34
+ if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype', 'toJSON'].includes(key)) fail();
35
+ const d = Object.getOwnPropertyDescriptor(value, key);
36
+ if (!d.enumerable || !Object.hasOwn(d, 'value')) fail();
37
+ output[key] = clone(d.value, seen, depth + 1, budget);
38
+ }
39
+ seen.delete(value);
40
+ return output;
41
+ }
42
+
43
+ export function stageKey(fileId, { phase, round = 1, windowIds = [], groupId } = {}) {
44
+ if (!identity(fileId) || !PHASES.includes(phase) || !Number.isSafeInteger(round) || round < 1 || round > 50 || !Array.isArray(windowIds) || windowIds.length > 100000 || windowIds.some(id => !identity(id)) || new Set(windowIds).size !== windowIds.length || (groupId !== undefined && !identity(groupId)) || (phase === 'file-interaction' && !groupId)) fail();
45
+ return JSON.stringify([fileId, phase, round, [...windowIds].sort(), groupId ?? null]);
46
+ }
47
+
48
+ function normalizeFinding(raw, file, changes, phase, files) {
49
+ exact(raw, ['id', 'fileId', 'path', 'severity', 'title', 'evidence', 'trigger', 'impact', 'suggestion', 'anchor', 'attribution', 'interaction', 'reviewRound', ...DROP]);
50
+ if (raw.fileId !== file.id || (raw.path !== undefined && raw.path !== file.path) || !['critical', 'high', 'medium', 'low'].includes(raw.severity)) fail();
51
+ for (const key of ['title', 'evidence', 'trigger', 'impact', 'suggestion']) if (!text(raw[key])) fail();
52
+ const a = raw.anchor;
53
+ if (!object(a) || !['line', 'property', 'file'].includes(a.kind)) fail();
54
+ if (file.reviewMode === 'project' && (a.side === 'old' || a.kind === 'property')) fail();
55
+ let anchor;
56
+ if (a.kind === 'line') {
57
+ exact(a, ['kind', 'side', 'start', 'end', 'snippet']);
58
+ const side = a.side === 'old' ? file.left : a.side === 'new' ? file.right : null;
59
+ if (!side || typeof side.text !== 'string' || !Number.isSafeInteger(a.start) || !Number.isSafeInteger(a.end) || a.start < 1 || a.end < a.start || !text(a.snippet)) fail();
60
+ const lines = side.text.replaceAll('\r\n', '\n').split('\n');
61
+ if (a.end > lines.length || lines.slice(a.start - 1, a.end).join('\n') !== a.snippet.replaceAll('\r\n', '\n')) fail();
62
+ anchor = { kind: 'line', side: a.side, start: a.start, end: a.end, snippet: a.snippet };
63
+ } else if (a.kind === 'property') {
64
+ exact(a, ['kind', 'name']);
65
+ if (!file.properties?.some(p => p.name === a.name && p.old !== p.new)) fail();
66
+ anchor = { kind: 'property', name: a.name };
67
+ } else { exact(a, ['kind']); anchor = { kind: 'file' }; }
68
+ let attribution;
69
+ if (raw.attribution !== undefined) exact(raw.attribution, ['status', 'causality', 'precision', 'notice', 'editIds', 'properties', 'beforeBehavior', 'afterBehavior', 'reason']);
70
+ if (file.reviewMode === 'project') {
71
+ if (raw.attribution !== undefined && (raw.attribution.status !== 'not-applicable' || raw.attribution.causality !== 'current-source-only')) fail();
72
+ attribution = { status: 'not-applicable', causality: 'current-source-only' };
73
+ } else {
74
+ const value = raw.attribution?.status === 'missing' ? undefined : raw.attribution;
75
+ if (raw.attribution?.status === 'missing' && Object.keys(raw.attribution).some(k => !['status', 'causality'].includes(k))) fail();
76
+ attribution = validateAttribution(value, { ...file, properties: file.properties ?? [] }, changes);
77
+ if (raw.attribution?.status !== undefined && raw.attribution.status !== attribution.status) fail();
78
+ for (const key of ['precision', 'notice', 'causality']) if (raw.attribution?.[key] !== undefined && raw.attribution[key] !== attribution[key]) fail();
79
+ }
80
+ const result = { fileId: file.id, path: file.path, severity: raw.severity, anchor, attribution };
81
+ for (const key of ['title', 'evidence', 'trigger', 'impact', 'suggestion']) result[key] = raw[key];
82
+ const id = hash(JSON.stringify(result));
83
+ if (raw.id !== undefined && raw.id !== id) fail();
84
+ if (raw.interaction !== undefined) {
85
+ if (phase !== 'file-interaction') fail();
86
+ exact(raw.interaction, ['status', 'causality', 'editRefs']);
87
+ const refs = raw.interaction.editRefs;
88
+ if (!Array.isArray(refs) || refs.length < 2 || refs.length > 50) fail();
89
+ for (const ref of refs) {
90
+ exact(ref, ['fileId', 'editId']);
91
+ const target = files.get(ref.fileId);
92
+ if (!target || target.reviewMode === 'project') fail();
93
+ const edits = changeMap(target.left.text, target.right.text);
94
+ if (edits.precision !== 'exact' || !edits.edits.some(e => e.id === ref.editId)) fail();
95
+ }
96
+ if (new Set(refs.map(r => r.fileId)).size < 2 || new Set(refs.map(r => JSON.stringify([r.fileId, r.editId]))).size !== refs.length) fail();
97
+ const primary = refs.filter(r => r.fileId === file.id).map(r => r.editId);
98
+ if (attribution.status !== 'references-validated' || !primary.length || primary.some(id => !attribution.editIds.includes(id)) || attribution.editIds.some(id => !primary.includes(id)) || anchor.kind !== 'line' || !changes.edits.some(e => primary.includes(e.id) && e[anchor.side].count > 0 && anchor.start < e[anchor.side].start + e[anchor.side].count && anchor.end >= e[anchor.side].start)) fail();
99
+ result.interaction = { status: 'references-validated', causality: 'unverified', editRefs: refs.map(r => ({ ...r })) };
100
+ } else if (phase === 'file-interaction') fail();
101
+ return { id, ...result };
102
+ }
103
+
104
+ // Returns accepted generation payloads, NOT completed review stages. In particular,
105
+ // old verification and read delivery records never authorize a new verification.
106
+ // The enclosing resume state must bind the entire snapshot and execution options.
107
+ export function prepareStageGenerations(snapshot, records, { maxBytes = LIMIT, validateGeneration } = {}) {
108
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > LIMIT || (validateGeneration !== undefined && typeof validateGeneration !== 'function') || !Array.isArray(snapshot?.files) || !Array.isArray(records) || records.length > 100000) fail();
109
+ const safe = clone(records);
110
+ if (Buffer.byteLength(JSON.stringify(safe)) > maxBytes) fail();
111
+ const files = new Map(), allIds = new Set();
112
+ for (const file of snapshot.files) {
113
+ if (!identity(file?.id) || allIds.has(file.id)) fail();
114
+ allIds.add(file.id);
115
+ if (file.eligibility === 'reviewable') files.set(file.id, file);
116
+ }
117
+ const seen = new Set();
118
+ const output = safe.map(record => {
119
+ exact(record, ['key', 'fileId', 'path', 'phase', 'round', 'windowIds', 'groupId', 'generation', 'needsFreshProvenance', ...DROP]);
120
+ const file = files.get(record.fileId);
121
+ if (!file || (record.path !== undefined && record.path !== file.path)) fail();
122
+ const { phase, round = 1, windowIds = [], groupId } = record;
123
+ const key = stageKey(file.id, { phase, round, windowIds, groupId });
124
+ if (record.key !== key || seen.has(key) || (phase === 'file-interaction' && windowIds.length)) fail();
125
+ seen.add(key);
126
+ exact(record.generation, ['findings', 'limitations', ...DROP]);
127
+ const g = record.generation;
128
+ if (!Array.isArray(g.findings) || g.findings.length > 50 || !Array.isArray(g.limitations) || g.limitations.length > MAX_STAGE_LIMITATIONS || g.limitations.some(s => !text(s))) fail();
129
+ const changes = reviewChangeMap(file.left.text, file.right.text);
130
+ const findings = g.findings.map(raw => {
131
+ if (raw.reviewRound !== undefined && raw.reviewRound !== round) fail();
132
+ return normalizeFinding(raw, file, changes, phase, files);
133
+ });
134
+ const generation = { findings: [...new Map(findings.map(f => [f.id, f])).values()], limitations: [...g.limitations] };
135
+ if (windowIds.length || phase === 'window-synthesis') {
136
+ const windows = sourceSegments(changeWindows(file, changes) ?? []);
137
+ const selected = windows.filter(w => windowIds.includes(w.id));
138
+ if (!windowIds.length || selected.length !== windowIds.length) fail();
139
+ validateWindowFindings(generation.findings, selected, phase === 'window-synthesis');
140
+ }
141
+ const normalized = { key, fileId: file.id, path: file.path, phase, round, windowIds: [...windowIds].sort(), ...(groupId !== undefined ? { groupId } : {}), generation, needsFreshProvenance: phase !== 'primary' };
142
+ const checked = validateGeneration?.(clone(generation), file, clone(normalized));
143
+ if (checked === false || checked?.then) fail();
144
+ return normalized;
145
+ });
146
+ if (Buffer.byteLength(JSON.stringify(output)) > maxBytes) fail();
147
+ return output;
148
+ }