optbrew 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/AGENT_INSTALL.md +94 -0
  5. package/CHANGELOG.md +74 -1
  6. package/README.ko.md +129 -105
  7. package/README.md +129 -105
  8. package/THIRD_PARTY_NOTICES.md +88 -0
  9. package/docs/platforms.md +45 -8
  10. package/docs/release.md +37 -4
  11. package/lib/cli.mjs +5 -2
  12. package/lib/install.mjs +2 -1
  13. package/lib/records.mjs +2 -1
  14. package/lib/transaction.mjs +12 -5
  15. package/lib/update.mjs +3 -2
  16. package/package.json +29 -9
  17. package/plugin.json +2 -2
  18. package/skills/.claude-plugin/icon.png +0 -0
  19. package/skills/.claude-plugin/plugin.json +23 -0
  20. package/skills/README.md +27 -0
  21. package/skills/optbrew/SKILL.md +223 -8
  22. package/skills/optbrew/THIRD_PARTY_NOTICES.md +88 -0
  23. package/skills/optbrew/bundle-manifest.json +409 -49
  24. package/skills/optbrew/modules/naver-searchadvisor/scripts/audit.mjs +4 -4
  25. package/skills/optbrew/references/ai-crawlers.json +1 -1
  26. package/skills/optbrew/references/ai-crawlers.md +1 -1
  27. package/skills/optbrew/references/api.md +6 -4
  28. package/skills/optbrew/references/check-registry.json +16 -16
  29. package/skills/optbrew/references/chrome-paths.json +31 -0
  30. package/skills/optbrew/references/endpoints.json +10 -0
  31. package/skills/optbrew/references/harness-signals.json +5 -0
  32. package/skills/optbrew/references/intake.md +76 -5
  33. package/skills/optbrew/references/judging.md +233 -0
  34. package/skills/optbrew/references/limits.json +26 -0
  35. package/skills/optbrew/references/measurement.md +1 -1
  36. package/skills/optbrew/references/messages.ko.json +170 -0
  37. package/skills/optbrew/references/report.md +18 -6
  38. package/skills/optbrew/references/routing.md +2 -2
  39. package/skills/optbrew/references/runbooks/anthropic-api-key.md +34 -0
  40. package/skills/optbrew/references/runbooks/chrome-install.md +56 -0
  41. package/skills/optbrew/references/runbooks/cwv-site-collection.md +38 -0
  42. package/skills/optbrew/references/runbooks/google-api-key.md +51 -0
  43. package/skills/optbrew/references/runbooks/gsc-export.md +32 -0
  44. package/skills/optbrew/references/runbooks/index.json +18 -0
  45. package/skills/optbrew/references/runbooks/key-set-claude-cloud.md +32 -0
  46. package/skills/optbrew/references/runbooks/key-set-codex.md +33 -0
  47. package/skills/optbrew/references/runbooks/key-set-local.md +40 -0
  48. package/skills/optbrew/references/runbooks/naver-searchadvisor-export.md +32 -0
  49. package/skills/optbrew/references/runbooks/network-claude-cloud-custom.md +33 -0
  50. package/skills/optbrew/references/runbooks/network-codex-approval.md +26 -0
  51. package/skills/optbrew/references/runbooks/network-codex-config.md +36 -0
  52. package/skills/optbrew/references/runbooks/seranking.md +38 -0
  53. package/skills/optbrew/references/schemas/contract.schema.json +42 -15
  54. package/skills/optbrew/references/scoring.md +40 -5
  55. package/skills/optbrew/references/source-analysis.md +40 -0
  56. package/skills/optbrew/references/versions.json +9 -0
  57. package/skills/optbrew/scripts/adapters/anthropic.mjs +5 -5
  58. package/skills/optbrew/scripts/adapters/imports.mjs +138 -1
  59. package/skills/optbrew/scripts/audit.mjs +10 -96
  60. package/skills/optbrew/scripts/cli.mjs +5 -2
  61. package/skills/optbrew/scripts/core/bundle.mjs +30 -13
  62. package/skills/optbrew/scripts/core/capabilities.mjs +8 -4
  63. package/skills/optbrew/scripts/core/cdp.mjs +105 -0
  64. package/skills/optbrew/scripts/core/checks/canonical-consistency.mjs +56 -0
  65. package/skills/optbrew/scripts/core/checks/cwv-field.mjs +77 -0
  66. package/skills/optbrew/scripts/core/checks/discovery.mjs +43 -0
  67. package/skills/optbrew/scripts/core/checks/document-title.mjs +62 -0
  68. package/skills/optbrew/scripts/core/checks/entity-identity.mjs +31 -0
  69. package/skills/optbrew/scripts/core/checks/fact-consistency.mjs +31 -0
  70. package/skills/optbrew/scripts/core/checks/fetch-response.mjs +18 -0
  71. package/skills/optbrew/scripts/core/checks/index-policy.mjs +16 -0
  72. package/skills/optbrew/scripts/core/checks/index.mjs +18 -0
  73. package/skills/optbrew/scripts/core/checks/internal-navigation.mjs +67 -0
  74. package/skills/optbrew/scripts/core/checks/locale-links.mjs +55 -0
  75. package/skills/optbrew/scripts/core/checks/naver-markup.mjs +21 -0
  76. package/skills/optbrew/scripts/core/checks/observation.mjs +37 -0
  77. package/skills/optbrew/scripts/core/checks/prepare.mjs +103 -0
  78. package/skills/optbrew/scripts/core/checks/render-content.mjs +104 -0
  79. package/skills/optbrew/scripts/core/checks/search-crawl-policy.mjs +29 -0
  80. package/skills/optbrew/scripts/core/checks/structured-data-truth.mjs +46 -0
  81. package/skills/optbrew/scripts/core/chrome-temp.mjs +68 -0
  82. package/skills/optbrew/scripts/core/chrome.mjs +546 -0
  83. package/skills/optbrew/scripts/core/collector.mjs +65 -59
  84. package/skills/optbrew/scripts/core/compare.mjs +18 -1
  85. package/skills/optbrew/scripts/core/config.mjs +31 -0
  86. package/skills/optbrew/scripts/core/contracts.mjs +31 -2
  87. package/skills/optbrew/scripts/core/cwv.mjs +100 -0
  88. package/skills/optbrew/scripts/core/derive.mjs +10 -161
  89. package/skills/optbrew/scripts/core/evidence-store.mjs +112 -0
  90. package/skills/optbrew/scripts/core/facts.mjs +203 -19
  91. package/skills/optbrew/scripts/core/findings.mjs +19 -1
  92. package/skills/optbrew/scripts/core/html.mjs +41 -3
  93. package/skills/optbrew/scripts/core/index-targets.mjs +118 -0
  94. package/skills/optbrew/scripts/core/judgments.mjs +675 -0
  95. package/skills/optbrew/scripts/core/keys.mjs +86 -0
  96. package/skills/optbrew/scripts/core/link-graph.mjs +93 -0
  97. package/skills/optbrew/scripts/core/messages.mjs +23 -0
  98. package/skills/optbrew/scripts/core/metrics.mjs +4 -2
  99. package/skills/optbrew/scripts/core/migration.mjs +37 -0
  100. package/skills/optbrew/scripts/core/page-select.mjs +136 -0
  101. package/skills/optbrew/scripts/core/probe.mjs +83 -0
  102. package/skills/optbrew/scripts/core/protocol.mjs +38 -14
  103. package/skills/optbrew/scripts/core/render.mjs +160 -0
  104. package/skills/optbrew/scripts/core/report-compose.mjs +377 -0
  105. package/skills/optbrew/scripts/core/report-html.mjs +198 -0
  106. package/skills/optbrew/scripts/core/report-page.mjs +14 -0
  107. package/skills/optbrew/scripts/core/report.mjs +357 -131
  108. package/skills/optbrew/scripts/core/runbooks.mjs +63 -0
  109. package/skills/optbrew/scripts/core/scope.mjs +23 -5
  110. package/skills/optbrew/scripts/core/score.mjs +12 -4
  111. package/skills/optbrew/scripts/core/secrets.mjs +2 -0
  112. package/skills/optbrew/scripts/core/stages.mjs +694 -0
  113. package/skills/optbrew/scripts/core/transport.mjs +157 -0
  114. package/skills/optbrew/scripts/core/weights.mjs +2 -1
  115. package/skills/optbrew/scripts/key.mjs +90 -0
  116. package/skills/optbrew/scripts/preflight.mjs +176 -0
  117. package/skills/optbrew/scripts/run.mjs +81 -0
  118. package/skills/optbrew/source-lock.json +1 -1
  119. package/source-lock.json +1 -1
@@ -1,27 +1,32 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { readFileSync, realpathSync } from 'node:fs';
3
2
  import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises';
4
3
  import path from 'node:path';
5
4
  import { ContractError, validateEntity } from './contracts.mjs';
5
+ import { endpoints, limits, requestLimit, versions } from './config.mjs';
6
6
  import { score } from './score.mjs';
7
+ import { assertCurrentEvidence, MigrationRequired } from './migration.mjs';
7
8
  import { compare } from './compare.mjs';
8
9
  import { freezeProtocol } from './protocol.mjs';
9
10
  import { measurePanel } from './metrics.mjs';
10
11
  import { normalizeImport } from '../adapters/imports.mjs';
11
- import { mergeFindings, selectRemediations } from './findings.mjs';
12
+ import { mergeFindings, pageNeutral, problemsOf, selectRemediations } from './findings.mjs';
12
13
  import { auditCollected, indexingDirectives } from '../../modules/naver-searchadvisor/scripts/audit.mjs';
13
14
  import { deriveObservations } from './derive.mjs';
15
+ import { isDerivedIndexDecision, notIndexTargets } from './index-targets.mjs';
16
+ import { readVerified } from './evidence-store.mjs';
14
17
  import { links } from './collector.mjs';
18
+ import { checkRenderLedger } from './render.mjs';
15
19
  import { assertUrlInScope } from './scope.mjs';
20
+ import { composeReport, pagesLabel, renderReport } from './report-compose.mjs';
21
+ import { messageOf } from './messages.mjs';
22
+ import { runbookEntry } from './runbooks.mjs';
23
+ import { sensitive } from './secrets.mjs';
24
+ import { ENTITY_UNIT, OBSERVATION_SOURCES, QUEUE_KIND, QUESTION_UNIT, acceptance, alsoQuestionIds, basisAllowed, candidateSpans, modelFailMessages, sentenceBasis, checkSpan, deriveQuestionCoverage, homeOf, identityBasisOf, isCodeJudged, isEmptyPage, isEntityFallback, isModelJudged, operatorNames, queuedSpan, siteChrome } from './judgments.mjs';
16
25
  import { parseRobots, robotsVerdict } from '../../modules/naver-searchadvisor/scripts/robots.mjs';
17
26
 
18
27
  const catalog = JSON.parse(readFileSync(new URL('../../references/remediations.json', import.meta.url))).items;
19
28
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
20
29
  const named = value => typeof value === 'string' && value.trim().length > 0;
21
- const sensitive = /(?:sk-ant-api03-[A-Za-z0-9_-]{32,}|sk-proj-[A-Za-z0-9_-]{32,}|ghp_[A-Za-z0-9]{36,}|AKIA[A-Z0-9]{16}|\b\d{6}-[1-8]\d{6}\b)/;
22
- const html = value => String(value ?? 'unknown').replace(/\\/g, '\\\\').replace(/&/g, '&amp;').replace(/</g, '&lt;')
23
- .replace(/>/g, '&gt;').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
24
- const table = (columns, rows) => `| ${columns.join(' | ')} |\n| ${columns.map(() => '---').join(' | ')} |\n${rows.map(row => `| ${row.map(html).join(' | ')} |`).join('\n') || `| ${columns.map(() => '—').join(' | ')} |`}`;
25
30
  const days = window => (Date.parse(`${window.end}T00:00:00Z`) - Date.parse(`${window.start}T00:00:00Z`)) / 86400000 + 1;
26
31
  const dayBefore = date => new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10);
27
32
  function completeWindow(metric) {
@@ -36,28 +41,6 @@ function completeWindow(metric) {
36
41
  return completeDate <= dayBefore(localCollectionDate) && metric.window.end <= completeDate;
37
42
  }
38
43
 
39
- /** The optbrew operating rule (design spec, scoring.md): at least 80% of the applicable weight must be measured. */
40
- export const COVERAGE_THRESHOLD = 80;
41
-
42
- /** One headline per profile. Below the coverage threshold the single number is withheld and only the measured subset is shown. */
43
- export function scoreHeadline(result) {
44
- const exact = result.A > 0 ? 100 * result.M / result.A : null;
45
- const coverageOk = result.score !== null && exact !== null && exact >= COVERAGE_THRESHOLD - 1e-9;
46
- // The design spec also asks for verdicts on the core access and indexing checks before a score counts as sufficient.
47
- const coreOk = (result.unknown_core ?? []).length === 0;
48
- const status = !coverageOk ? 'insufficient_coverage' : !coreOk ? 'insufficient_core' : 'sufficient';
49
- return { profile: result.profile, status, headline_score: status === 'sufficient' ? result.score : null,
50
- measured_subset_score: result.score, coverage: result.coverage, coverage_exact: exact, coverage_threshold: COVERAGE_THRESHOLD,
51
- unresolved_core: (result.unknown_core ?? []).length, missing_bounds: result.missing_bounds, readiness_status: result.readiness_status };
52
- }
53
- const floorTenth = value => value === null ? 'unknown' : Math.floor(value * 10 + 1e-9) / 10;
54
- const headlineCell = result => {
55
- const headline = scoreHeadline(result);
56
- return headline.status === 'sufficient' ? `${headline.headline_score} (coverage ${headline.coverage}%)` :
57
- headline.status === 'insufficient_core' ? `insufficient core checks (${headline.unresolved_core} unresolved; measured subset ${headline.measured_subset_score ?? 'unknown'}; coverage ${headline.coverage ?? 'unknown'}%; bounds ${headline.missing_bounds?.join('–') ?? 'unknown'})` :
58
- `insufficient coverage (measured subset ${headline.measured_subset_score ?? 'unknown'}; coverage ${headline.coverage ?? 'unknown'}%; bounds ${headline.missing_bounds?.join('–') ?? 'unknown'})`;
59
- };
60
-
61
44
  function metricRows(metrics) {
62
45
  const groups = new Map();
63
46
  for (const metric of metrics) {
@@ -97,18 +80,16 @@ function indexedEvidence(packet) {
97
80
  if (!entry || typeof entry.ref !== 'string' || !entry.ref || !/^[a-f0-9]{64}$/.test(entry.sha256) ||
98
81
  typeof entry.kind !== 'string' || !entry.kind || entry.run_id !== packet.run_context.run_id || index.has(entry.ref))
99
82
  throw new ContractError('invalid or duplicate run evidence entry');
100
- const file = path.resolve(root, entry.ref);
101
- const actual = realpathSync(file);
102
- if (actual !== root && !actual.startsWith(`${root}${path.sep}`)) throw new ContractError(`evidence path outside run directory: ${entry.ref}`);
103
- const bytes = readFileSync(actual);
83
+ let bytes;
84
+ try { bytes = readVerified(root, entry.ref, entry.sha256); }
85
+ catch (error) { throw new ContractError(error.message); }
104
86
  if (sensitive.test(bytes.toString('utf8'))) throw new ContractError('private identifier or secret in report evidence');
105
- if (createHash('sha256').update(bytes).digest('hex') !== entry.sha256)
106
- throw new ContractError(`evidence hash mismatch: ${entry.ref}`);
107
- index.set(entry.ref, { ...entry, text: bytes.toString('utf8') });
87
+ index.set(entry.ref, { ...entry, text: bytes.toString('utf8'), bytes });
108
88
  }
109
- const requireRef = (ref, sourceRef) => {
89
+ /** `source` is the `source_ref` the entry must carry, or `{ anySource: true }` for evidence cited from another source. */
90
+ const requireRef = (ref, source) => {
110
91
  const entry = index.get(ref);
111
- if (!entry || sourceRef && entry.source_ref !== sourceRef) throw new ContractError(`evidence/source link missing: ${ref}`);
92
+ if (!entry || source && !source.anySource && entry.source_ref !== source) throw new ContractError(`evidence/source link missing: ${ref}`);
112
93
  return entry;
113
94
  };
114
95
  const context = requireRef(packet.run_context.evidence_ref);
@@ -168,8 +149,9 @@ function exportPath(value, host) {
168
149
  }
169
150
  if (!text.startsWith('/') || /^\/[/\\]/.test(text)) return null;
170
151
  try {
171
- const url = new URL(text, 'http://path.invalid');
172
- return url.host === 'path.invalid' ? url.pathname : null;
152
+ const base = new URL(endpoints().relative_url_base);
153
+ const url = new URL(text, base);
154
+ return url.host === base.host ? url.pathname : null;
173
155
  } catch { return null; }
174
156
  }
175
157
  const pathWithin = (value, prefix, host) => {
@@ -178,12 +160,32 @@ const pathWithin = (value, prefix, host) => {
178
160
  return wanted === '/' || actual === wanted || actual.startsWith(wanted.endsWith('/') ? wanted : `${wanted}/`);
179
161
  };
180
162
 
163
+ /**
164
+ * A failed fetch has no response, but its answered redirect hops are real requests. They are replayed from the hashed
165
+ * failure record, which also says whether one more attempt got no response.
166
+ */
167
+ function boundFailure(artifact, audit, requireRef) {
168
+ const entry = (audit.failures ?? []).find(x => x.normalized_url === artifact.normalized_url);
169
+ if (!artifact.failure_ref || !entry || entry.evidence_ref !== artifact.failure_ref) throw new ContractError('failure record missing for failed fetch');
170
+ const raw = requireRef(artifact.failure_ref, 'optbrew:collector');
171
+ if (raw.sha256 !== entry.content_hash) throw new ContractError('failure record hash differs from the audit index');
172
+ const record = JSON.parse(raw.text);
173
+ // At most `max_redirects` redirects are followed, and the answer after the last one (a refused redirect) is the last hop that can be recorded.
174
+ if (record.kind !== 'failure' || ![0, 1].includes(record.unanswered) || !Array.isArray(record.redirect_chain) || record.redirect_chain.length > limits().max_redirects + 1 ||
175
+ record.redirect_chain.some((hop, index) => typeof hop?.url !== 'string' || !(hop.status >= 300 && hop.status <= 399) ||
176
+ hop.url !== (index ? record.redirect_chain[index - 1].location : record.normalized_url)))
177
+ throw new ContractError('failure record is malformed');
178
+ for (const key of ['requested_url', 'normalized_url', 'redirect_chain', 'unanswered', 'error'])
179
+ if (!same(record[key], artifact[key])) throw new ContractError(`failure record differs from collection record: ${key}`);
180
+ return { ...artifact, ...record };
181
+ }
182
+
181
183
  /**
182
184
  * Replay a collected response from its hashed record: final URL, redirect chain and header lines come from the
183
185
  * record, and the collection entry must agree with it. Bodyless failures have no response to bind.
184
186
  */
185
187
  function boundArtifact(artifact, audit, requireRef) {
186
- if (!Number.isInteger(artifact.status)) return artifact;
188
+ if (!Number.isInteger(artifact.status)) return boundFailure(artifact, audit, requireRef);
187
189
  const entry = (audit.responses ?? []).find(x => x.normalized_url === artifact.normalized_url);
188
190
  if (!artifact.response_ref || !entry || entry.evidence_ref !== artifact.response_ref)
189
191
  throw new ContractError('response record missing for collected artifact');
@@ -214,13 +216,31 @@ function checkDecisions(input, requireRef, collectedFor) {
214
216
  item.condition === decision.condition && item.reason === decision.reason))
215
217
  throw new ContractError(`applicability decision record does not match ${scope} ${identity.value}`);
216
218
  };
217
- for (const type of input.run_context.protocol.page_types) for (const url of type.urls)
218
- if (url.indexing_decision) decisionRecord(url.indexing_decision, 'url', { key: 'normalized_url', value: url.normalized });
219
+ for (const type of input.run_context.protocol.page_types) for (const url of type.urls) {
220
+ if (!url.indexing_decision) continue;
221
+ // A page the audit found deliberately not indexed has no approval record: checkAuditPhase derives the decision again from the
222
+ // stored bytes, which exist only for an audited phase.
223
+ if (!isDerivedIndexDecision(url.indexing_decision)) decisionRecord(url.indexing_decision, 'url', { key: 'normalized_url', value: url.normalized });
224
+ else if (input.run_context.audited !== true) throw new ContractError(`indexing decision of ${url.normalized} is derived from an audit this phase does not have`);
225
+ }
219
226
  for (const family of input.run_context.protocol.families)
220
227
  if (family.applicability_decision) decisionRecord(family.applicability_decision, 'family', { key: 'family_id', value: family.family_id });
221
228
  for (const observation of input.observations) {
222
- const evidence = requireRef(observation.evidence_ref, observation.source_ref);
223
- if (observation.applicability_decision) decisionRecord(observation.applicability_decision, 'unit', { key: 'unit_key', value: observation.unit_key });
229
+ // A model's verdict cites evidence the collector stored; modelJudgmentRows binds it to that page's raw evidence.
230
+ const anySource = observation.evaluator_type === 'host_model' || observation.source_ref === OBSERVATION_SOURCES.derived;
231
+ const evidence = requireRef(observation.evidence_ref, anySource ? { anySource: true } : observation.source_ref);
232
+ // A collector verdict's own N/A decision cites the page evidence it was derived from, and replay re-derives it (the audit
233
+ // phase compares the decision too). A model's N/A decision cites its verdict's page evidence the same way; its condition is one of
234
+ // the family's registry conditions (the schema check) and its spans are replayed against the judge queue (modelJudgmentRows).
235
+ // Only an outside decision needs an approved decision record.
236
+ if (observation.applicability_decision) {
237
+ const own = observation.evaluator_type === 'code' && observation.source_ref === OBSERVATION_SOURCES.code ||
238
+ observation.evaluator_type === 'host_model' && observation.source_ref === OBSERVATION_SOURCES.host_model;
239
+ if (own) {
240
+ if (observation.applicability_decision.evidence_ref !== observation.evidence_ref)
241
+ throw new ContractError(`N/A decision does not cite the verdict's evidence: ${observation.unit_key}`);
242
+ } else decisionRecord(observation.applicability_decision, 'unit', { key: 'unit_key', value: observation.unit_key });
243
+ }
224
244
  if (observation.check_id === 'index-policy' && observation.state === 'fail') {
225
245
  if (evidence.kind !== 'raw_html' && evidence.kind !== 'http_response')
226
246
  throw new ContractError('index-policy failure requires raw HTML or response-header evidence');
@@ -316,10 +336,16 @@ function checkMetricEvidence(metric, packet, requireRef) {
316
336
  observed = measurePanel(source.panel, source.responses, source.evaluations);
317
337
  } else throw new ContractError('unsupported metric evidence kind');
318
338
  if (!observed.some(item => same(item, metric))) throw new ContractError('metric differs from bound export or panel evidence');
319
- return source.kind === 'native_export' ? 'owner export origin and provider completeness are not independently authenticated' :
320
- 'provider response origin and semantic evaluations are attributed to indexed receipts, not independently authenticated';
339
+ return source.kind === 'native_export' ? ['owner export origin and provider completeness are not independently authenticated', 'metric-origin.owner-export'] :
340
+ ['provider response origin and semantic evaluations are attributed to indexed receipts, not independently authenticated', 'metric-origin.panel'];
321
341
  }
322
342
 
343
+ /**
344
+ * A verdict that replay re-derived against its phase's audit snapshot: the collector's own pair, in a phase whose context
345
+ * records an audit (the report refuses a packet whose snapshots differ from that record). Only such a verdict can verify a change.
346
+ */
347
+ const replayed = (obs, input) => input.run_context.audited === true && obs.evaluator_type === 'code' && obs.source_ref === OBSERVATION_SOURCES.code;
348
+
323
349
  function statusFor(finding, changes, afterInput, target, requireRef) {
324
350
  const linked = changes.filter(change => change.finding_ids.some(id => finding.finding_ids.includes(id)));
325
351
  if (!linked.length) return finding.remediation_ids.length ? 'proposed_only' :
@@ -330,7 +356,7 @@ function statusFor(finding, changes, afterInput, target, requireRef) {
330
356
  afterInput?.run_context.environment === 'production' && linked.every(change => afterInput.observations.some(obs => {
331
357
  if (obs.check_id !== finding.check_id || obs.unit_key !== finding.unit_key ||
332
358
  !['pass', 'partial'].includes(obs.state) || obs.observation_id !== change.verification.observation_id ||
333
- obs.evidence_ref !== change.verification.observation_evidence_ref) return false;
359
+ obs.evidence_ref !== change.verification.observation_evidence_ref || !replayed(obs, afterInput)) return false;
334
360
  const source = requireRef(obs.evidence_ref, obs.source_ref);
335
361
  return source.phase === 'after' && source.environment === 'production' && same(source.target, target);
336
362
  }));
@@ -343,7 +369,7 @@ function statusFor(finding, changes, afterInput, target, requireRef) {
343
369
  linked.every(change => change.environment === 'local' && change.verification.observation_id &&
344
370
  change.verification.observation_evidence_ref && afterInput.observations.some(obs => {
345
371
  if (obs.check_id !== finding.check_id || obs.unit_key !== finding.unit_key || !['pass', 'partial'].includes(obs.state) ||
346
- obs.source_ref !== 'optbrew:collector' || obs.observation_id !== change.verification.observation_id ||
372
+ !replayed(obs, afterInput) || obs.observation_id !== change.verification.observation_id ||
347
373
  obs.evidence_ref !== change.verification.observation_evidence_ref) return false;
348
374
  const source = requireRef(obs.evidence_ref, obs.source_ref);
349
375
  return source.phase === 'after' && source.environment === 'local' && same(source.target, target);
@@ -361,18 +387,19 @@ function checkLedger(input, audit, collection, artifacts, requireRef) {
361
387
  const entries = ledger.entries;
362
388
  const fail = message => { throw new ContractError(`request ledger: ${message}`); };
363
389
  if (!Array.isArray(entries)) fail('entries missing');
364
- const limit = Math.min(input.run_context.scope?.max_urls ?? 50, 50);
390
+ const limit = requestLimit(input.run_context.scope);
365
391
  if (ledger.limit !== limit) fail('request limit differs from the run scope');
366
392
  const total = entries.reduce((sum, entry) => sum + entry.requests, 0);
367
- // The limit decides whether a fetch may start; a redirect chain that has started finishes. A chain is at most
368
- // min(10, limit) redirects long and at most `concurrency` chains are in flight, so the overshoot is bounded by both.
369
- const concurrency = Math.min(input.run_context.scope?.concurrency ?? 2, 2);
370
- if (total > limit + concurrency * Math.min(10, limit)) fail('more requests than the limit');
393
+ // The limit is a hard ceiling: every request, redirect hops included, takes one slot before it is sent.
394
+ if (total > limit) fail('more requests than the limit');
395
+ // One URL, one entry. Skips for out-of-scope and undeclared auxiliary links are recorded per link and may repeat.
396
+ const identities = entries.filter(x => !(x.outcome === 'skipped' && ['outside_scope', 'auxiliary_undeclared'].includes(x.skipped_reason))).map(x => x.url);
397
+ if (new Set(identities).size !== identities.length) fail('duplicate ledger url: a URL appears in more than one entry');
371
398
  const responded = artifacts.filter(x => Number.isInteger(x.status));
372
399
  const failed = artifacts.filter(x => !Number.isInteger(x.status));
373
400
  const byUrl = outcome => new Map(entries.filter(x => x.outcome === outcome).map(x => [x.url, x]));
374
401
  const responses = byUrl('response'), errors = byUrl('error');
375
- if (responses.size !== responded.length || audit.responses.length !== responded.length || errors.size !== failed.length)
402
+ if (responses.size !== responded.length || audit.responses.length !== responded.length || errors.size !== failed.length || audit.failures.length !== failed.length)
376
403
  fail('collected artifacts differ from the ledger or the response records');
377
404
  for (const artifact of responded) {
378
405
  const entry = responses.get(artifact.normalized_url);
@@ -383,16 +410,16 @@ function checkLedger(input, audit, collection, artifacts, requireRef) {
383
410
  for (const artifact of failed) {
384
411
  const entry = errors.get(artifact.normalized_url);
385
412
  if (!entry) fail(`failed fetch of ${artifact.normalized_url} is not recorded`);
386
- // A failed fetch has no hashed response record, so nothing proves how many requests it made: it is always one.
387
- // Neither the redirect chain nor any count inside collection.json can raise it, so a forger cannot pad the total.
388
- if (entry.requests !== 1) fail(`failed fetch of ${artifact.normalized_url} is not booked as exactly one request`);
413
+ // The requests of a failed fetch are the answered hops of its hashed failure record plus at most one attempt that got
414
+ // no response; nothing in collection.json can raise them.
415
+ if (entry.requests !== artifact.redirect_chain.length + artifact.unanswered) fail(`failed fetch of ${artifact.normalized_url} does not match its hashed failure record`);
389
416
  }
390
417
  const key = x => `${x.url}|${x.reason ?? x.skipped_reason}`;
391
418
  const ledgerSkips = entries.filter(x => x.skipped_reason).map(key).sort();
392
419
  if (JSON.stringify((collection.skipped ?? []).map(key).sort()) !== JSON.stringify(ledgerSkips)) fail('skipped list differs from the ledger');
393
420
  const limited = entries.some(x => ['url_limit', 'request_limit'].includes(x.skipped_reason));
394
421
  if (collection.limit_reached !== limited) fail('limit_reached differs from the ledger');
395
- if (limited && total < limit) fail('a request limit is claimed but fewer requests than the limit were made');
422
+ if (limited && total !== limit) fail('a request limit is claimed but the recorded requests do not use the whole limit');
396
423
  if (collection.request_count !== total || collection.evaluated_count !== entries.filter(x => x.requests > 0).length) fail('request or evaluated count differs from the ledger');
397
424
  const selected = new Set(input.run_context.protocol.page_types.flatMap(type => type.urls.map(x => x.normalized)));
398
425
  if (entries.some(x => selected.has(x.url) && x.skipped_reason && x.skipped_reason !== 'url_limit' && x.outcome === 'skipped')) fail('a selected page is skipped for a reason other than the request limit');
@@ -419,6 +446,50 @@ function checkLedger(input, audit, collection, artifacts, requireRef) {
419
446
  entries.some(x => x.requests > 0 && !planned.has(x.url))) fail('a selected page is skipped for the request limit although a discovered page was fetched');
420
447
  }
421
448
 
449
+ /**
450
+ * The render records of an audit index, read through the run's evidence (hash and secret checked): every record against the
451
+ * hash the index lists, its DOM against the hash the record holds, and the render ledger against each record's own requests.
452
+ * An index from before rendering has none of these sections. `renderTextOf` returns the stored DOM of a record.
453
+ */
454
+ function storedRenders(audit, renderBudget, requireRef) {
455
+ const fail = message => { throw new ContractError(message); };
456
+ const dom = new Map();
457
+ const records = (audit.renders ?? []).map(entry => {
458
+ const stored = requireRef(entry.evidence_ref);
459
+ if (stored.sha256 !== entry.sha256) fail('render record hash differs from the audit index');
460
+ let record;
461
+ try { record = JSON.parse(stored.text); } catch { fail('render record is not JSON'); }
462
+ if (typeof record.url !== 'string' || !Array.isArray(record.requests)) fail('render record is malformed');
463
+ // The ref it was read from, as the audit's own reading gives it, so a verdict inherited from a rendered page cites the same record.
464
+ record.evidence_ref = entry.evidence_ref;
465
+ if (record.dom_ref) {
466
+ const rendered = requireRef(record.dom_ref);
467
+ if (rendered.sha256 !== record.dom_sha256) fail('render DOM hash differs from its record');
468
+ dom.set(record, rendered.text);
469
+ }
470
+ return record;
471
+ });
472
+ const ledger = audit.render_ledger ?? [];
473
+ for (const record of records) checkRenderLedger(ledger, record, renderBudget);
474
+ if (ledger.some(entry => !records.some(record => record.url === entry.page))) fail('render ledger: an entry names a page that was not rendered');
475
+ return { records, renderTextOf: record => dom.get(record) ?? '' };
476
+ }
477
+
478
+ /**
479
+ * The field CWV answers of an audit index, read through the run's evidence (hash and secret checked), each one with the ref
480
+ * it was read from. An index from before field data has no `cwv` section.
481
+ */
482
+ function storedCwv(audit, requireRef) {
483
+ return (audit.cwv ?? []).map(entry => {
484
+ const stored = requireRef(entry.evidence_ref);
485
+ if (stored.sha256 !== entry.sha256) throw new ContractError('CWV record hash differs from the audit index');
486
+ let record;
487
+ try { record = JSON.parse(stored.text); } catch { throw new ContractError('CWV record is not JSON'); }
488
+ if (record.source !== entry.source || typeof record.origin !== 'string') throw new ContractError('CWV record is malformed');
489
+ return { ...record, evidence_ref: entry.evidence_ref };
490
+ });
491
+ }
492
+
422
493
  function checkAuditPhase(input, phase, snapshot, requireRef) {
423
494
  if (!input) return;
424
495
  const observations = input.observations.filter(x => x.source_ref === 'optbrew:collector');
@@ -433,8 +504,8 @@ function checkAuditPhase(input, phase, snapshot, requireRef) {
433
504
  const savedObservations = JSON.parse(requireRef(audit.observations_ref).text);
434
505
  if (!Array.isArray(collection.artifacts) || !Array.isArray(audit.raw) || !Array.isArray(savedObservations))
435
506
  throw new ContractError('audit snapshot files incomplete');
436
- if (!Array.isArray(audit.modules) || !audit.ledger_ref || !Array.isArray(audit.responses))
437
- throw new ContractError('audit evidence was produced by an older optbrew release (it has no request ledger or module list); re-run the audit with optbrew 0.1.2 and build a new packet');
507
+ if (!Array.isArray(audit.modules) || !audit.ledger_ref || !Array.isArray(audit.responses) || !Array.isArray(audit.failures))
508
+ throw new MigrationRequired('report packet', 'comes from an older optbrew release (it lacks the request ledger, module list or failure records)');
438
509
  for (const name of audit.modules) if (!Array.isArray(moduleOutputs[name])) throw new ContractError(`planned module output missing: ${name}`);
439
510
  const naverPlanned = audit.modules.includes('naver-searchadvisor');
440
511
  const rawRefs = new Set();
@@ -449,9 +520,21 @@ function checkAuditPhase(input, phase, snapshot, requireRef) {
449
520
  throw new ContractError('audit raw content hash mismatch');
450
521
  rawRefs.add(raw.evidence_ref);
451
522
  }
523
+ // Field CWV answers are collected evidence too; their hashes are checked where the verdicts are re-derived below.
524
+ for (const entry of audit.cwv ?? []) rawRefs.add(entry.evidence_ref);
525
+ // So are the render records: a verdict inherited from a rendered page cites that page's record (the replay compares the exact ref).
526
+ for (const entry of audit.renders ?? []) rawRefs.add(entry.evidence_ref);
452
527
  // Final URL, redirect chain and header lines come from the hashed response records, not from collection.json alone.
453
528
  const artifacts = collection.artifacts.map(x => boundArtifact(x, audit, requireRef));
454
529
  checkLedger(input, audit, collection, artifacts, requireRef);
530
+ // A page marked as deliberately not indexed must be one the stored bytes show so (noindex, not in the sitemap the run read).
531
+ const marked = input.run_context.protocol.page_types.flatMap(type => type.urls).filter(url => isDerivedIndexDecision(url.indexing_decision));
532
+ if (marked.length) {
533
+ const found = new Map(notIndexTargets({ context: input.run_context, artifacts,
534
+ textOf: artifact => artifact.content_hash ? requireRef(artifact.evidence_ref).text : null }).map(target => [target.url, target.evidence_ref]));
535
+ for (const url of marked) if (url.public_indexing !== false || found.get(url.normalized) !== url.indexing_decision.evidence_ref)
536
+ throw new ContractError(`indexing decision is not derivable from the raw evidence: ${url.normalized}`);
537
+ }
455
538
  for (const obs of observations) {
456
539
  if (!savedObservations.some(x => same(x, obs)) || !rawRefs.has(obs.evidence_ref))
457
540
  throw new ContractError('collector observation is not bound to audit index and raw evidence');
@@ -511,10 +594,18 @@ function checkAuditPhase(input, phase, snapshot, requireRef) {
511
594
  throw new ContractError(`selected page missing from the collection and its skipped list: ${url}`);
512
595
  // Re-derive every collector verdict from the hashed raw evidence. Each derived observation must be present in the
513
596
  // packet and in the saved observation file, and no collector observation may lack a derivation.
597
+ const { records, renderTextOf } = storedRenders(audit, input.run_context.protocol.render?.max_render_requests, requireRef);
514
598
  const derived = deriveObservations({ context: input.run_context, artifacts, collection,
515
- textOf: artifact => artifact.content_hash ? requireRef(artifact.evidence_ref).text : null });
516
- const shape = x => [x.state, x.q, x.partial_basis, x.evidence_ref, x.reason, x.evaluator_type, x.source_ref];
599
+ textOf: artifact => artifact.content_hash ? requireRef(artifact.evidence_ref).text : null,
600
+ renders: records, renderTextOf, chrome: audit.chrome ?? null, cwv: storedCwv(audit, requireRef) });
601
+ const shape = x => [x.state, x.q, x.partial_basis, x.evidence_ref, x.reason, x.evaluator_type, x.source_ref, x.applicability_decision ?? null, x.measurement ?? null, x.problem_messages ?? null];
602
+ // The site's identity is the one code-judged unit a model may judge, and only where code gave no verdict: its placeholder is replaced by
603
+ // the model's verdict (modelJudgmentRows replays that one), and a model verdict next to a code verdict of the unit is refused.
604
+ const modelEntity = input.observations.some(x => x.unit_key === ENTITY_UNIT && x.evaluator_type === 'host_model');
605
+ if (modelEntity && derived.some(x => x.unit_key === ENTITY_UNIT && x.state !== 'unknown'))
606
+ throw new ContractError(`${ENTITY_UNIT} has a collector verdict; a model verdict cannot stand beside it`);
517
607
  for (const expected of derived) {
608
+ if (modelEntity && expected.unit_key === ENTITY_UNIT) continue;
518
609
  if (!observations.some(x => x.unit_key === expected.unit_key) || !savedObservations.some(x => x.unit_key === expected.unit_key))
519
610
  throw new ContractError(`planned collector observation missing: ${expected.unit_key}`);
520
611
  const have = observations.find(x => x.unit_key === expected.unit_key);
@@ -527,10 +618,129 @@ function checkAuditPhase(input, phase, snapshot, requireRef) {
527
618
  throw new ContractError(`collector observation is not derivable from raw evidence: ${have.unit_key}`);
528
619
  }
529
620
 
621
+ const MODEL_JUDGMENT_LABEL = '근거 확인됨, 판정은 모델';
622
+
623
+ /**
624
+ * A verdict of the derived source is the question coverage that code computes from the answer-clarity judgments and the fixed
625
+ * panel. Compute it again from the same input: a verdict that does not follow from them, or any other unit with that source, is refused.
626
+ */
627
+ function replayedQuestionCoverage(obs, input, fail) {
628
+ const rest = input.observations.filter(x => x !== obs);
629
+ const expected = obs.unit_key === QUESTION_UNIT && obs.evaluator_type === 'code' ?
630
+ deriveQuestionCoverage({ observations: rest, panel: input.run_context.protocol.question_panel, evidence_ref: input.run_context.evidence_ref }).find(x => x.unit_key === QUESTION_UNIT) : null;
631
+ const shape = x => [x.family_id, x.state, x.q, x.partial_basis, x.evidence_ref, x.reason, x.evaluator_type, x.source_ref, x.measurement ?? null, x.problem_messages ?? null];
632
+ if (!expected || expected.source_ref !== OBSERVATION_SOURCES.derived || !same(shape(obs), shape(expected)))
633
+ fail(obs, 'it does not follow from the answer-clarity judgments and the fixed question list');
634
+ }
635
+
636
+ /**
637
+ * The model judgments of one score input, each replayed against the run's audit evidence. A `host_model` observation is
638
+ * refused for a code-judged family and unless it carries the model source. Its evidence must be the audit raw entry of
639
+ * the page its unit names. A verdict other than unknown cites ranges that are candidates of the queue stored in the audit
640
+ * index, each with that raw entry's hash and a byte range inside it on character boundaries. The label says the evidence was
641
+ * checked and the verdict is the model's.
642
+ */
643
+ function modelJudgmentRows(phase, input, snapshot, requireRef, confirmedNames = []) {
644
+ const fail = (obs, message) => { throw new ContractError(`verdict ${obs.unit_key}: ${message}`); };
645
+ for (const obs of input?.observations ?? []) {
646
+ const type = obs.evaluator_type;
647
+ const entityFallback = isEntityFallback(obs.family_id, obs.unit_key) && type === 'host_model' && obs.source_ref === OBSERVATION_SOURCES.host_model;
648
+ // Next to an audit, a code-judged family is the collector's alone and is re-derived; without one, only a model is refused (an outside source stays unverified).
649
+ // The one exception is the site's identity judged by the model, which checkAuditPhase allows only where code gave no verdict.
650
+ if (isCodeJudged(obs.family_id) && !entityFallback && (type === 'host_model' || snapshot && (type !== 'code' || obs.source_ref !== OBSERVATION_SOURCES.code)))
651
+ fail(obs, `${obs.family_id} is judged by code; a ${type} observation with source_ref ${obs.source_ref} cannot stand for it`);
652
+ if (obs.source_ref === OBSERVATION_SOURCES.derived) replayedQuestionCoverage(obs, input, fail);
653
+ // A content family has exactly one allowed pair: the model's, bound to the judge queue.
654
+ if (isModelJudged(obs.family_id) && (type !== 'host_model' || obs.source_ref !== OBSERVATION_SOURCES.host_model))
655
+ fail(obs, `${obs.family_id} is judged by a model through the judge queue; evaluator_type ${type} with source_ref ${obs.source_ref} is not allowed`);
656
+ if (type === 'host_model' && obs.source_ref !== OBSERVATION_SOURCES.host_model) fail(obs, `source_ref must be ${OBSERVATION_SOURCES.host_model}`);
657
+ // The collector's and the model's source stand for replayed work; without the phase's audit there is nothing to replay against.
658
+ if (!snapshot && Object.values(OBSERVATION_SOURCES).includes(obs.source_ref))
659
+ fail(obs, `source_ref ${obs.source_ref} needs the ${phase} audit snapshot; without an audit only outside inputs are accepted, and they stay unverified`);
660
+ }
661
+ const judgments = (input?.observations ?? []).filter(obs => obs.evaluator_type === 'host_model');
662
+ if (!judgments.length) return [];
663
+ if (!snapshot) throw new ContractError(`${phase} model judgments require their audit snapshot`);
664
+ const audit = JSON.parse(requireRef(snapshot.audit_ref).text);
665
+ const queues = (audit.judge_queue ?? []).map(entry => {
666
+ const stored = requireRef(entry.evidence_ref);
667
+ if (stored.sha256 !== entry.sha256) throw new ContractError('judge queue hash differs from the audit index');
668
+ if (stored.kind !== QUEUE_KIND) throw new ContractError(`judge queue evidence has kind ${stored.kind}, not ${QUEUE_KIND}`);
669
+ return JSON.parse(stored.text);
670
+ });
671
+ // The site's identity is judged on the home page, chosen as the queue chose it: from the pages whose fetch verdict passed.
672
+ const audited = input.observations.filter(x => x.family_id === 'fetch-response' && x.state === 'pass')
673
+ .filter(x => (audit.raw ?? []).some(entry => entry.content_hash && entry.evidence_ref === x.evidence_ref && entry.normalized_url === unitUrl(x.unit_key)));
674
+ const home = homeOf(audited.map(x => unitUrl(x.unit_key)));
675
+ // What the gates of an absence fail need, derived here and not read from the queue: the lines most audited pages repeat, and (below) the names the owner confirmed in the intake answers, the code identity check and the host.
676
+ const chrome = siteChrome([...new Map(audited.map(x => [unitUrl(x.unit_key), requireRef(x.evidence_ref).bytes])).values()]);
677
+ return judgments.filter(obs => obs.state !== 'unknown').map(obs => {
678
+ if (obs.state === 'not_applicable' && !isModelJudged(obs.family_id)) fail(obs, `${obs.family_id} is not a family a model may declare not applicable`);
679
+ if (obs.family_id === 'answer-clarity') {
680
+ const named = obs.measurement?.question_id;
681
+ if (obs.state === 'not_applicable' ? named !== undefined : !(input.run_context.protocol.question_panel ?? []).some(question => question.id === named))
682
+ fail(obs, obs.state === 'not_applicable' ? 'a not applicable answer-clarity verdict names no question' : 'an answer-clarity verdict names a question of the fixed panel');
683
+ // The other questions it says the page answers: other, distinct questions of the fixed panel, on a pass or partial only.
684
+ const also = obs.measurement?.also_question_ids;
685
+ if (also !== undefined && (!['pass', 'partial'].includes(obs.state) || alsoQuestionIds({ question_id: named, also_question_ids: also }, new Set(input.run_context.protocol.question_panel.map(question => question.id))) === null))
686
+ fail(obs, 'its other questions are not other, distinct questions of the fixed panel on a pass or partial verdict');
687
+ }
688
+ if (isEntityFallback(obs.family_id, obs.unit_key) && obs.measurement?.identity_basis !== identityBasisOf(obs.state)) fail(obs, 'its identity basis is not the one its state records');
689
+ const raw = (audit.raw ?? []).find(x => x.evidence_ref === obs.evidence_ref && x.normalized_url === (isEntityFallback(obs.family_id, obs.unit_key) ? home : unitUrl(obs.unit_key)));
690
+ if (!raw) fail(obs, 'its evidence is not the audit raw entry of the unit page');
691
+ // Candidates are recomputed from the raw bytes, so a consistently forged queue cannot widen what a model may cite.
692
+ const candidates = candidateSpans(requireRef(obs.evidence_ref).bytes, raw.content_hash);
693
+ // A verdict that rests on something missing from the page needs a queue that offered enough of it and a page that does not show it; a fail of an absence family names its basis; the empty-page mark is true of the page.
694
+ const bytes = requireRef(obs.evidence_ref).bytes;
695
+ if (!basisAllowed({ family_id: obs.family_id, state: obs.state, basis: obs.measurement?.basis })) fail(obs, 'its basis is not the one this verdict must carry');
696
+ const cited = (obs.evidence_span ?? []).map(span => candidates.find(x => x.start === span.start && x.end === span.end)).filter(Boolean);
697
+ const unaccepted = acceptance({ family_id: obs.family_id, state: obs.state, condition: obs.applicability_decision?.condition, basis: obs.measurement?.basis }, bytes, cited, candidates,
698
+ { chrome, ...operatorNames({ confirmed: confirmedNames, observations: input.observations, url: unitUrl(obs.unit_key) }) });
699
+ if (unaccepted) fail(obs, `its verdict is not accepted: ${unaccepted.reason}`);
700
+ // What the owner reads for a fail is the catalog sentence of its family and basis with the text it cites; any other verdict states no problem.
701
+ const failed = { family_id: obs.family_id, state: obs.state, basis: obs.measurement?.basis };
702
+ const question = obs.family_id === 'answer-clarity' ? input.run_context.protocol.question_panel.find(item => item.id === obs.measurement?.question_id)?.question : null;
703
+ if (!same(obs.problem_messages ?? null, obs.state === 'fail' ? modelFailMessages({ family_id: obs.family_id, basis: sentenceBasis(failed, bytes, chrome) }, cited, chrome, question) : null))
704
+ fail(obs, 'its problem message is not the one its family, basis and cited text give');
705
+ if (Boolean(obs.measurement?.empty_page) !== isEmptyPage(bytes)) fail(obs, 'its empty-page mark differs from the page');
706
+ for (const span of obs.evidence_span ?? []) {
707
+ if (span.evidence_sha256 !== raw.content_hash) fail(obs, 'a span names evidence other than the unit page');
708
+ try { checkSpan(requireRef(obs.evidence_ref).bytes, span); } catch (error) { fail(obs, error.message); }
709
+ const candidate = candidates.find(x => x.start === span.start && x.end === span.end);
710
+ if (!candidate) fail(obs, `span ${span.start}-${span.end} is not a text run of the page`);
711
+ if (!queues.some(queue => queuedSpan(queue, obs, span))) fail(obs, `span ${span.start}-${span.end} is not a candidate of the stored judge queue`);
712
+ // The text the model read is the text of that range, with inline tags left out: the queue cannot show it something else.
713
+ const offered = queues.flatMap(queue => queue.items.filter(x => x.unit_key === obs.unit_key && x.family_id === obs.family_id && x.evidence_ref === obs.evidence_ref))
714
+ .flatMap(x => x.spans).find(x => x.evidence_sha256 === span.evidence_sha256 && x.start === span.start && x.end === span.end);
715
+ if (offered.text !== candidate.text) fail(obs, `the queued text of span ${span.start}-${span.end} differs from the text of the page`);
716
+ }
717
+ return { phase, family_id: obs.family_id, unit_key: obs.unit_key, state: obs.state, evidence_ref: obs.evidence_ref, evidence_span: obs.evidence_span, label: MODEL_JUDGMENT_LABEL,
718
+ ...(obs.measurement?.basis ? { basis: obs.measurement.basis, sentence_basis: sentenceBasis(failed, bytes, chrome) } : {}),
719
+ ...(obs.measurement?.identity_basis ? { identity_basis: obs.measurement.identity_basis } : {}) };
720
+ });
721
+ }
722
+
723
+ /**
724
+ * The audited pages that show no text, for the phase the report describes: the pages the judge queue lists as empty, each checked
725
+ * against the raw bytes the audit stored, so a queue cannot name a page that has text.
726
+ */
727
+ function emptyPagesOf(snapshot, requireRef) {
728
+ if (!snapshot) return [];
729
+ const audit = JSON.parse(requireRef(snapshot.audit_ref).text);
730
+ const listed = new Set((audit.judge_queue ?? []).flatMap(entry => JSON.parse(requireRef(entry.evidence_ref).text).empty_pages ?? []));
731
+ return [...listed].filter(url => {
732
+ const raw = (audit.raw ?? []).find(entry => entry.content_hash && entry.normalized_url === url);
733
+ return raw && isEmptyPage(requireRef(raw.evidence_ref).bytes);
734
+ }).sort();
735
+ }
736
+
530
737
  /** Recompute scores and metrics from contained run evidence; render four design tables. */
531
738
  export function buildReport(packet) {
739
+ assertCurrentEvidence(packet, 'report packet');
740
+ assertCurrentEvidence(packet?.after?.input, 'report packet (follow-up phase)');
532
741
  validateEntity('ReportPacket', packet);
533
742
  if (sensitive.test(JSON.stringify(packet))) throw new ContractError('private identifier or secret in report packet');
743
+ for (const id of packet.answered_runbooks ?? []) if (!runbookEntry(id)) throw new ContractError(`answered_runbooks names an unknown runbook: ${id}`);
534
744
  if (packet.baseline.input.run_context.run_id !== packet.run_context.run_id ||
535
745
  !same(packet.baseline.input.run_context, packet.run_context) || packet.costs.run_id !== packet.run_context.run_id)
536
746
  throw new ContractError('report run context or cost ledger mismatch');
@@ -541,9 +751,12 @@ export function buildReport(packet) {
541
751
  snapshots.some(x => !['baseline', 'after'].includes(x.phase) || !x.audit_ref || !x.context_ref))
542
752
  throw new ContractError('audit snapshot phase or reference invalid');
543
753
  const snapshotFor = phase => snapshots.find(x => x.phase === phase);
544
- // Dropping the audit reference must not downgrade collector evidence to an unaudited packet.
545
- if (!snapshotFor('baseline') && packet.evidence_index.some(x => x.source_ref === 'optbrew:collector'))
546
- throw new ContractError('collector evidence is indexed but the baseline audit snapshot is missing');
754
+ // Each phase's run context, hashed as run evidence, records whether the phase was audited. A packet cannot add or drop a
755
+ // snapshot to change how its phase is judged: the snapshot must exist exactly when the context says the phase was audited.
756
+ for (const [phase, input] of [['baseline', packet.baseline.input], ['after', packet.after?.input]]) {
757
+ if (input && (input.run_context.audited === true) !== Boolean(snapshotFor(phase)))
758
+ throw new ContractError(`${phase} audit snapshot differs from the audit recorded in its run context`);
759
+ }
547
760
  const baselineScores = score(packet.baseline.input);
548
761
  if (!same(packet.baseline.scores, baselineScores)) throw new ContractError('baseline score differs from raw observations');
549
762
  const collectedIn = phase => collectedResponses(snapshotFor(phase) ? [snapshotFor(phase)] : [], requireRef);
@@ -551,7 +764,8 @@ export function buildReport(packet) {
551
764
  let afterScores = null;
552
765
  if (packet.after) {
553
766
  if (packet.after.input.run_context.run_id !== packet.run_context.run_id) throw new ContractError('after run ID mismatch');
554
- if (!same(JSON.parse(requireRef(packet.after.input.run_context.evidence_ref).text), packet.after.input.run_context))
767
+ const afterContext = requireRef(packet.after.input.run_context.evidence_ref);
768
+ if (afterContext.kind !== 'context' || !same(JSON.parse(afterContext.text), packet.after.input.run_context))
555
769
  throw new ContractError('after context evidence differs from input');
556
770
  afterScores = score(packet.after.input);
557
771
  if (!same(packet.after.scores, afterScores)) throw new ContractError('after score differs from raw observations');
@@ -559,11 +773,24 @@ export function buildReport(packet) {
559
773
  }
560
774
  const comparison = compare(packet.baseline.input, packet.after?.input ?? null);
561
775
  if (!same(packet.comparison, comparison)) throw new ContractError('comparison differs from raw inputs');
562
- const metricOriginLimitations = [...new Set(packet.metrics.map(metric => checkMetricEvidence(metric, packet, requireRef)))];
776
+ const originNotes = [...new Map(packet.metrics.map(metric => checkMetricEvidence(metric, packet, requireRef))).entries()];
777
+ const metricOriginLimitations = originNotes.map(([text]) => text);
563
778
  if (!packet.costs.evidence_ref || !same(JSON.parse(requireRef(packet.costs.evidence_ref).text), packet.costs))
564
779
  throw new ContractError('cost ledger snapshot evidence differs from report');
780
+ // Model verdicts are replayed first, so a host_model observation never reaches the collector replay under a collector source.
781
+ const intakeEntry = packet.evidence_index.find(x => x.kind === 'intake');
782
+ const intake = intakeEntry ? JSON.parse(requireRef(intakeEntry.ref).text) : null;
783
+ const confirmedNames = (intake?.answers?.facts ?? []).filter(fact => fact.key === 'name').map(fact => fact.value);
784
+ const modelRows = [['baseline', packet.baseline.input], ['after', packet.after?.input]].flatMap(([phase, input]) => modelJudgmentRows(phase, input, snapshotFor(phase), requireRef, confirmedNames));
785
+ const emptyPages = emptyPagesOf(snapshotFor(packet.after ? 'after' : 'baseline'), requireRef);
565
786
  checkAuditPhase(packet.baseline.input, 'baseline', snapshotFor('baseline'), requireRef);
566
787
  checkAuditPhase(packet.after?.input, 'after', snapshotFor('after'), requireRef);
788
+ // An adopted start address is said in the run conditions, so it must match the intake record and the run's target.
789
+ const startRedirect = packet.start_redirect ?? null;
790
+ if (startRedirect || intake?.selection?.start_redirect) {
791
+ if (startRedirect?.to !== packet.run_context.target || !same(intake?.selection?.start_redirect ?? null, startRedirect))
792
+ throw new ContractError('start redirect differs from the intake record or the run target');
793
+ }
567
794
  const merged = mergeFindings(packet.findings);
568
795
  const observations = [...packet.baseline.input.observations, ...(packet.after?.input.observations ?? [])];
569
796
  const collectedAny = collectedResponses(snapshots, requireRef);
@@ -574,8 +801,27 @@ export function buildReport(packet) {
574
801
  if (finding.blocker && !observations.some(obs => obs.check_id === finding.check_id &&
575
802
  obs.unit_key === finding.unit_key && obs.state === 'fail'))
576
803
  throw new ContractError('blocker requires confirmed failed check observation');
804
+ // A finding that carries the model's source must rest on a model verdict of its unit; its evidence is the page the collector stored.
805
+ const modelFinding = finding.source_ref === OBSERVATION_SOURCES.host_model;
806
+ // A finding that groups several pages names each of them, and each must itself be a failed verdict of the same problem (the same text
807
+ // once its own address is left out, among the problems the verdict states) by the same kind of judge: a listed page cannot be one
808
+ // that was not measured.
809
+ for (const unit of finding.unit_keys ?? []) if (!observations.some(obs => obs.check_id === finding.check_id && obs.unit_key === unit && obs.state === 'fail' &&
810
+ problemsOf(obs).some(problem => pageNeutral(problem, unit) === pageNeutral(finding.message, finding.unit_key)) &&
811
+ (obs.evaluator_type === 'host_model') === modelFinding))
812
+ throw new ContractError(`finding names a page that is not a failed verdict of the same problem: ${unit}`);
813
+ // The Korean text rests on the same verdict as the English message: its key and values are the ones the verdict recorded for this problem.
814
+ if (finding.message_key !== undefined || finding.message_values !== undefined) {
815
+ const stated = { message_key: finding.message_key ?? null, message_values: finding.message_values ?? {} };
816
+ if (!observations.some(obs => obs.check_id === finding.check_id && obs.unit_key === finding.unit_key && obs.state === 'fail' &&
817
+ problemsOf(obs).some((problem, index) => pageNeutral(problem, obs.unit_key) === pageNeutral(finding.message, finding.unit_key) && same(obs.problem_messages?.[index] ?? null, stated))))
818
+ throw new ContractError(`finding message key differs from its verdict: ${finding.finding_id}`);
819
+ }
820
+ if (modelFinding && !observations.some(obs => obs.check_id === finding.check_id && obs.unit_key === finding.unit_key &&
821
+ obs.evaluator_type === 'host_model' && obs.state === 'fail'))
822
+ throw new ContractError(`model finding has no model verdict: ${finding.finding_id}`);
577
823
  for (const source of finding.sources) {
578
- const evidence = requireRef(source.evidence_ref, source.source_ref);
824
+ const evidence = requireRef(source.evidence_ref, [OBSERVATION_SOURCES.host_model, OBSERVATION_SOURCES.derived].includes(source.source_ref) ? { anySource: true } : source.source_ref);
579
825
  // The same HTML plus response-header replay as the observation, bound to the finding's own unit.
580
826
  if (finding.check_id === 'index-policy' && finding.blocker &&
581
827
  !evidenceBlocksIndexing(evidence, packet.run_context.protocol.engines, collectedAny, unitUrl(finding.unit_key)))
@@ -595,7 +841,7 @@ export function buildReport(packet) {
595
841
  const resolution = resolving.length ? { observation_id: resolving[0].observation_id,
596
842
  evaluator_type: [...new Set(resolving.map(obs => obs.evaluator_type))].join(', '),
597
843
  source_ref: [...new Set(resolving.map(obs => obs.source_ref))].join(', ') } : null;
598
- return { ...finding, remediation: selected ?? null, status, resolution };
844
+ return { ...finding, remediation: selected ?? null, status, resolution, ...(modelFinding ? { label: MODEL_JUDGMENT_LABEL } : {}) };
599
845
  });
600
846
  for (const change of packet.changes) {
601
847
  validateEntity('ChangeRecord', change);
@@ -652,91 +898,71 @@ export function buildReport(packet) {
652
898
  problem: `Production application and observation pending for ${finding.unit_key}`,
653
899
  reason: 'production_deployment_pending', required_input_or_authority: 'site owner deployment authority and production target',
654
900
  next_action: 'Deploy the locally verified change, then fetch the production response',
655
- reobserve_when: 'after authorized production deployment and a new raw response' });
656
- } else remaining.push({ finding_id: finding.finding_id, problem: `${finding.message} ${finding.unit_key}`,
901
+ reobserve_when: 'after authorized production deployment and a new raw response', ...messageOf('follow-up.production-pending', { pages: pagesLabel(finding) }) });
902
+ } else remaining.push({ finding_id: finding.finding_id, problem: `${finding.message} ${pagesLabel(finding)}`,
657
903
  reason: finding.status, required_input_or_authority: finding.remediation?.required_authority ?? 'source evidence and owner review',
658
904
  next_action: finding.remediation?.mechanism ?? 'Confirm the issue from raw evidence',
659
- reobserve_when: finding.remediation?.validation ?? 'after new evidence is collected' });
905
+ reobserve_when: finding.remediation?.validation ?? 'after new evidence is collected', ...messageOf('follow-up.finding') });
660
906
  }
661
- const costIssues = [
907
+ // Each cost note keeps its English text beside the catalog key and values of its Korean sentence.
908
+ const costNote = (text, message) => ({ text, ...message });
909
+ const costNotes = [
662
910
  ...packet.costs.reservations.filter(x => ['unknown', 'overrun'].includes(x.status)).map(x => {
663
911
  const settlement = packet.costs.settlements.find(item => item.reservation_id === x.reservation_id);
664
- const format = amounts => amounts?.map(item => `${item.amount} ${item.currency ?? item.unit}`).join(', ') ?? 'unknown';
665
- return `${x.provider}: ${x.status} (reserved ${format(x.upperBounds)}; actual ${format(settlement?.actual)})`;
912
+ const format = amounts => amounts?.map(item => `${item.amount} ${item.currency ?? item.unit}`).join(', ');
913
+ return costNote(`${x.provider}: ${x.status} (reserved ${format(x.upperBounds) ?? 'unknown'}; actual ${format(settlement?.actual) ?? 'unknown'})`,
914
+ messageOf('cost.reservation', { provider: x.provider, status: x.status, reserved: format(x.upperBounds), actual: format(settlement?.actual) }));
666
915
  }),
667
- ...(packet.capabilities?.selected ?? []).filter(x => x.cost_enforcement === 'host_policy').map(x => `${x.provider}: host_policy`),
668
- ...(packet.capabilities?.limitations ?? []),
916
+ ...(packet.capabilities?.selected ?? []).filter(x => x.cost_enforcement === 'host_policy').map(x => costNote(`${x.provider}: host_policy`, messageOf('cost.host-policy', { provider: x.provider }))),
917
+ ...(packet.capabilities?.limitations ?? []).map((text, index) => costNote(text, packet.capabilities.limitation_messages?.[index] ?? messageOf(null))),
669
918
  ];
670
919
  if (packet.costs.reservations.some(x => x.provider === 'anthropic') ||
671
920
  (packet.capabilities?.selected ?? []).some(x => x.provider === 'anthropic'))
672
- costIssues.push('Anthropic direct v0.1.0: text-only; claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5; max output 128000; one token reserve >=1000000+max_tokens per attempt; tools/search and currency-only or mixed bounds unsupported; reservation is capacity, not actual billing');
673
- const result = { schema_version: '1.0.0', run_context: packet.run_context,
921
+ // The direct adapter ships with the release, so this label is the release version (the panel label in metrics.mjs is the evaluation version).
922
+ costNotes.push(costNote(`Anthropic direct v${versions().release}: text-only; claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5; max output 128000; one token reserve >=1000000+max_tokens per attempt; tools/search and currency-only or mixed bounds unsupported; reservation is capacity, not actual billing`,
923
+ messageOf('cost.anthropic', { version: versions().release })));
924
+ const verified = { schema_version: versions().contract_schema, run_context: packet.run_context,
674
925
  data_class: packet.data_class ?? 'unknown', baseline: { input: packet.baseline.input, scores: baselineScores },
675
926
  after: packet.after ? { input: packet.after.input, scores: afterScores } : null, comparison,
676
927
  metrics: packet.metrics, findings, changes: packet.changes, costs: packet.costs,
677
- capabilities: packet.capabilities ?? null, cost_issues: costIssues, metric_origin_limitations: metricOriginLimitations, remaining,
928
+ capabilities: packet.capabilities ?? null, cost_issues: costNotes.map(item => item.text),
929
+ cost_issue_messages: costNotes.map(({ message_key, message_values }) => ({ message_key, message_values })), metric_origin_limitations: metricOriginLimitations,
930
+ metric_origin_limitation_messages: originNotes.map(([, key]) => messageOf(key)), follow_ups: remaining,
678
931
  evidence_root: packet.evidence_root, evidence_index: packet.evidence_index,
679
- audit_ref: packet.audit_ref ?? null, audit_snapshots: snapshots, source_ref: packet.source_ref };
680
- const byProfile = new Map(afterScores?.map(x => [x.profile, x]) ?? []);
681
- const scoreRows = baselineScores.map(before => {
682
- const after = byProfile.get(before.profile);
683
- const delta = comparison.score_deltas.find(x => x.profile === before.profile);
684
- const cautious = scoreHeadline(before).status !== 'sufficient' || (after && scoreHeadline(after).status !== 'sufficient');
685
- return [before.profile, headlineCell(before), after ? headlineCell(after) : 'unknown',
686
- `${delta?.percentage_point_delta ?? 'unknown'}${cautious && delta ? ' (between measured subsets)' : ''}`,
687
- `${before.coverage ?? 'unknown'} → ${after?.coverage ?? 'unknown'}`,
688
- `${before.missing_bounds?.join('–') ?? 'unknown'} → ${after?.missing_bounds?.join('–') ?? 'unknown'}`,
689
- `${before.readiness_status} → ${after?.readiness_status ?? 'unknown'}`];
690
- });
691
- const headlines = baselineScores.map(scoreHeadline);
692
- result.headline = headlines;
693
- const headlineText = headlines.map(h => h.status === 'sufficient' ? `${h.profile}: ${h.headline_score} (coverage ${h.coverage}%)` :
694
- h.status === 'insufficient_core' ? `${h.profile}: insufficient core checks (${h.unresolved_core} access or indexing check(s) have no verdict, although ${h.coverage}% is measured); measured subset ${h.measured_subset_score ?? 'unknown'}, bounds ${h.missing_bounds?.join('–') ?? 'unknown'}` :
695
- `${h.profile}: insufficient coverage (${floorTenth(h.coverage_exact)}% of the applicable weight is measured; at least ${h.coverage_threshold}% is needed); measured subset ${h.measured_subset_score ?? 'unknown'}, bounds ${h.missing_bounds?.join('–') ?? 'unknown'}`);
696
- const remediationRows = findings.map(f => [f.priority, `${f.message} ${f.unit_key}`, f.sources.map(x => `${x.source_ref} ${x.evidence_ref}`).join('; '),
697
- f.remediation ? `${f.remediation.mechanism} (${f.remediation.source_url}, checked ${f.remediation.checked_date})` : 'unknown',
698
- packet.changes.filter(x => x.finding_ids.some(id => f.finding_ids.includes(id))).map(x => x.path).join('; ') || f.remediation?.location_hint || 'owner implementation location required',
699
- f.remediation ? `${f.remediation.cost}; ${f.remediation.effort}` : 'unknown', f.remediation?.validation ?? 'unknown',
700
- f.resolution ? `${f.status} (resolved by ${f.resolution.evaluator_type} observation from ${f.resolution.source_ref})` : f.status]);
701
- const outcomeRows = metricRows(packet.metrics);
702
- const remainingRows = remaining.map(x => [x.problem, x.reason, x.required_input_or_authority, x.next_action, x.reobserve_when]);
703
- const markdown = `# optbrew report\n\n${packet.data_class === 'synthetic' ? '**Synthetic example — no real site or provider outcome.**\n\n' : ''}` +
704
- `Target: ${html(packet.run_context.target)} \nEnvironment: ${html(packet.run_context.environment)} \nGoals: ${html(packet.run_context.goals.join(', '))} \n` +
705
- `Comparison: ${html(comparison.status)} (${html(comparison.reasons.join(', ') || 'none')}) \n` +
706
- `Headline: \n${headlineText.map(line => `- ${html(line)} \n`).join('')}` +
707
- `Baseline blockers: ${html(baselineScores[0]?.blockers.length ?? 0)} (${html(baselineScores[0]?.blockers.map(x => `${x.family_id} ${x.unit_key}`).join('; ') || 'none')}) → ` +
708
- `After blockers: ${afterScores ? html(afterScores[0]?.blockers.length ?? 0) : 'unknown'} (${afterScores ? html(afterScores[0]?.blockers.map(x => `${x.family_id} ${x.unit_key}`).join('; ') || 'none') : 'after not observed'}); ` +
709
- `after core unknown: ${afterScores ? html(afterScores[0]?.unknown_core.length ?? 0) : 'unknown'}; actual outcomes: ${packet.metrics.length ? 'see measured and missing states below' : 'not_connected / unknown'} \n` +
710
- `Cost and capability limitations: ${html(costIssues.join('; ') || 'none recorded')} \n` +
711
- `Metric source limitations: ${html(metricOriginLimitations.join('; ') || 'no measured provider input')}\n\n` +
712
- `## Score\n\n${table(['Area', 'Before', 'After', 'Difference', 'Coverage before → after', 'Missing bounds', 'Status'], scoreRows)}\n\n` +
713
- `## Remediation\n\n${table(['Priority', 'Problem / URL', 'Evidence', 'Selected remedy', 'Change path or owner location', 'Cost / effort', 'Verification', 'Status'], remediationRows)}\n\n` +
714
- `## Actual outcomes\n\n${table(['Engine / property', 'Metric', 'Window / sample', 'Before raw', 'After raw', 'Difference', 'Completion', 'Limitations'], outcomeRows)}\n\n` +
715
- `## Remaining work\n\n${table(['Problem', 'Reason', 'Needed input / authority', 'Next action', 'Reobserve when'], remainingRows)}\n\n` +
716
- `Evidence root: ${html(packet.evidence_root)}; run ID: ${html(packet.run_context.run_id)}. Read report.json to replay raw score inputs.\n`;
717
- result.tables = { score: scoreRows, remediation: remediationRows, actual_outcomes: outcomeRows, remaining_work: remainingRows };
718
- return { json: result, markdown };
932
+ audit_ref: packet.audit_ref ?? null, audit_snapshots: snapshots, model_judgments: modelRows, empty_pages: emptyPages, source_ref: packet.source_ref,
933
+ outcome_rows: metricRows(packet.metrics), lab_cwv: packet.lab_cwv ?? null, start_redirect: startRedirect, answered_runbooks: packet.answered_runbooks ?? [] };
934
+ const composed = composeReport(verified);
935
+ const { markdown, tables } = renderReport(composed, verified);
936
+ // The composed report leads; the checked data it was built from follows. `findings` is the sorted fixes again, for readers of earlier reports.
937
+ const checked = { ...verified };
938
+ for (const key of ['schema_version', 'data_class', 'outcome_rows', 'lab_cwv', 'answered_runbooks']) delete checked[key];
939
+ return { json: { schema_version: verified.schema_version, data_class: verified.data_class, composed, ...checked,
940
+ findings: composed.fixes, tables }, markdown };
719
941
  }
720
942
 
721
- /** Write both outputs after all validation; temporary files stay in the destination. */
722
- export async function writeReport(packet, outputDir) {
943
+ /**
944
+ * Write the outputs after all validation; temporary files stay in the destination. `renderHtml`, when given, turns the composed
945
+ * report into the page written as `report.html` beside the other two. All files appear or none does.
946
+ */
947
+ export async function writeReport(packet, outputDir, { renderHtml = null } = {}) {
723
948
  const built = buildReport(packet);
949
+ const html = renderHtml ? renderHtml(built.json.composed) : null;
724
950
  await mkdir(outputDir, { recursive: true });
725
951
  const jsonPath = path.resolve(outputDir, 'report.json'), markdownPath = path.resolve(outputDir, 'report.md');
726
- for (const file of [jsonPath, markdownPath]) {
952
+ const htmlPath = html === null ? null : path.resolve(outputDir, 'report.html');
953
+ const outputs = [[jsonPath, `${JSON.stringify(built.json, null, 2)}\n`], [markdownPath, built.markdown], ...(html === null ? [] : [[htmlPath, html]])];
954
+ for (const [file] of outputs) {
727
955
  try { await access(file); throw new ContractError(`report output already exists: ${file}`); }
728
956
  catch (error) { if (error.code !== 'ENOENT') throw error; }
729
957
  }
730
- const temporary = [`${jsonPath}.${process.pid}.tmp`, `${markdownPath}.${process.pid}.tmp`];
731
- let jsonWritten = false;
958
+ const temporary = outputs.map(([file]) => `${file}.${process.pid}.tmp`);
959
+ const written = [];
732
960
  try {
733
- await writeFile(temporary[0], `${JSON.stringify(built.json, null, 2)}\n`, { flag: 'wx' });
734
- await writeFile(temporary[1], built.markdown, { flag: 'wx' });
735
- await rename(temporary[0], jsonPath); jsonWritten = true;
736
- await rename(temporary[1], markdownPath);
961
+ for (const [index, [, text]] of outputs.entries()) await writeFile(temporary[index], text, { flag: 'wx' });
962
+ for (const [index, [file]] of outputs.entries()) { await rename(temporary[index], file); written.push(file); }
737
963
  } catch (error) {
738
- if (jsonWritten) await rm(jsonPath, { force: true });
964
+ await Promise.all(written.map(file => rm(file, { force: true })));
739
965
  throw error;
740
966
  } finally { await Promise.all(temporary.map(file => rm(file, { force: true }))); }
741
- return { jsonPath, markdownPath };
967
+ return { jsonPath, markdownPath, ...(htmlPath ? { htmlPath } : {}) };
742
968
  }