@mutagent/helix 0.1.0-alpha.14

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 (357) hide show
  1. package/.claude/skills/mutagent-helix/SKILL.md +113 -0
  2. package/.claude/skills/mutagent-helix/scripts/cli/codex-transcode.ts +143 -0
  3. package/.claude/skills/mutagent-helix/scripts/cli/doctor.ts +111 -0
  4. package/.claude/skills/mutagent-helix/scripts/cli/init.ts +382 -0
  5. package/.claude/skills/mutagent-helix/scripts/cli/run.sh +61 -0
  6. package/CLAUDE.md +193 -0
  7. package/LICENSE +201 -0
  8. package/NOTICE +22 -0
  9. package/README.md +209 -0
  10. package/assets/adl-loop.svg +162 -0
  11. package/assets/system-status.svg +422 -0
  12. package/bin/mutagent-cli.mjs +9950 -0
  13. package/bin/mutagent-helix.mjs +78 -0
  14. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/CLAUDE.md +28 -0
  15. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/SKILL.md +138 -0
  16. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/assets/templates/agentspec.yaml.tpl +247 -0
  17. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/references/frameworks/doc-pins.md +53 -0
  18. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/references/principles.md +91 -0
  19. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/references/requirements.yaml +295 -0
  20. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/references/workflows/orchestrator-protocol.md +302 -0
  21. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/scripts/cli/doctor.ts +116 -0
  22. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/scripts/cli/init.ts +230 -0
  23. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/scripts/cli/run.sh +60 -0
  24. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/scripts/config/resolve-spec-dir.ts +199 -0
  25. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/scripts/contract/agentspec.schema.ts +455 -0
  26. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/scripts/setup/detect.ts +67 -0
  27. package/mutagent-agentspec/.claude/skills/mutagent-agentspec/scripts/validate/validate-spec.ts +93 -0
  28. package/mutagent-agentspec/LICENSE +201 -0
  29. package/mutagent-agentspec/NOTICE +19 -0
  30. package/mutagent-agentspec/README.md +44 -0
  31. package/mutagent-builder/.claude/skills/mutagent-builder/SKILL.md +100 -0
  32. package/mutagent-builder/.claude/skills/mutagent-builder/assets/agents/ai-architect.md +312 -0
  33. package/mutagent-builder/.claude/skills/mutagent-builder/assets/agents/ai-engineer.md +357 -0
  34. package/mutagent-builder/.claude/skills/mutagent-builder/assets/templates/build-report.md.tpl +39 -0
  35. package/mutagent-builder/.claude/skills/mutagent-builder/references/principles.md +31 -0
  36. package/mutagent-builder/.claude/skills/mutagent-builder/references/workflows/build-protocol.md +61 -0
  37. package/mutagent-builder/.claude/skills/mutagent-builder/references/workflows/improve-handoff.md +23 -0
  38. package/mutagent-builder/.claude/skills/mutagent-builder/scripts/cli/run.sh +60 -0
  39. package/mutagent-builder/.claude/skills/mutagent-builder/scripts/handoff/validate-build-input.ts +72 -0
  40. package/mutagent-builder/.claude/skills/mutagent-builder/scripts/sync-spec/check-sync-spec.ts +499 -0
  41. package/mutagent-builder/.claude/skills/mutagent-builder/scripts/verify/spec-impl-coverage.ts +182 -0
  42. package/mutagent-builder/CLAUDE.md +25 -0
  43. package/mutagent-builder/README.md +26 -0
  44. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/README.md +192 -0
  45. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/SKILL.md +352 -0
  46. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/agents/ai-architect.md +256 -0
  47. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/agents/ai-engineer.md +344 -0
  48. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/agents/diagnostics-analyzer.md +576 -0
  49. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/agents/discovery.md +425 -0
  50. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/audit.json.tpl +24 -0
  51. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/audit.md.tpl +49 -0
  52. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/config.yaml.tpl +100 -0
  53. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/pr-body.md.tpl +56 -0
  54. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/report.html.tpl +327 -0
  55. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/shared/README.md +27 -0
  56. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/shared/iter-N-handover.md.tpl +125 -0
  57. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/shared/self-diagnosis-contract.v0.1.0.yaml.tpl +234 -0
  58. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/shared/spec.yaml.tpl +152 -0
  59. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/shared/team.yaml.tpl +127 -0
  60. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/templates/shared/wave-N-dashboard.html.tpl +1712 -0
  61. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/diagnostics/WF-2.1-nl-query.md +59 -0
  62. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/diagnostics/WF-2.2-tier0-progress.md +58 -0
  63. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/diagnostics/WF-2.3-analysis-progress.md +44 -0
  64. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/diagnostics/WF-2.4-html-report.md +61 -0
  65. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/diagnostics/WF-2.5-rca-detail.md +69 -0
  66. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/diagnostics/WF-2.6-stale-warning.md +53 -0
  67. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.1-welcome.md +60 -0
  68. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.2-source-platform-picker.md +41 -0
  69. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.3-source-auth.md +75 -0
  70. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.4-target-picker.md +48 -0
  71. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.5-ask-tool.md +43 -0
  72. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.6-config-review.md +57 -0
  73. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.7-filter-defaults.md +52 -0
  74. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/assets/wireframes/onboarding/WF-1.8-first-run.md +59 -0
  75. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/examples/sample-findings.json +92 -0
  76. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/examples/sample-meta.json +47 -0
  77. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/adapter-strategy.md +75 -0
  78. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/config-migration.md +84 -0
  79. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/config.md +159 -0
  80. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/filter-search-matrix.md +66 -0
  81. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/harness-knowledge.md +22 -0
  82. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/memory-format.md +78 -0
  83. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/onboarding-decisions.yaml +187 -0
  84. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/operation-inventory.md +59 -0
  85. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/overview.md +167 -0
  86. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/reference.md +119 -0
  87. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/script-index.md +101 -0
  88. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-adapter-contract.md +93 -0
  89. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/claude-code-transcripts.md +66 -0
  90. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/codex-transcripts.md +78 -0
  91. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/codex.md +145 -0
  92. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/install-paths.md +57 -0
  93. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/langfuse.md +178 -0
  94. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/local-jsonl.md +55 -0
  95. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/openobserve.md +102 -0
  96. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/source-platforms/otel.md +63 -0
  97. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/target-platforms/cloud-rest.md +84 -0
  98. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/target-platforms/local-claude.md +65 -0
  99. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/target-platforms/local-cloud-agent-sdk.md +92 -0
  100. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/target-platforms/local-codex.md +72 -0
  101. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/target-platforms/local-cursor.md +108 -0
  102. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/target-platforms/local-mastra.md +77 -0
  103. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/target-platforms/local-opencode.md +70 -0
  104. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/apply-pr-comment-format.md +82 -0
  105. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/diagnostics.md +35 -0
  106. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/handover-contract.md +247 -0
  107. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/onboarding.md +300 -0
  108. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/orchestrator-protocol.md +1861 -0
  109. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/rca.md +331 -0
  110. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/rendering-anatomy.md +314 -0
  111. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/schedule-prep.md +100 -0
  112. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/scope-model.md +128 -0
  113. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/references/workflows/verification-methodology.md +174 -0
  114. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/aggregate/sort-findings.ts +0 -0
  115. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/awareness/blind-spots.ts +167 -0
  116. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/awareness/llm-sample.ts +681 -0
  117. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/cli/doctor.ts +215 -0
  118. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/cli/init.ts +1013 -0
  119. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/cli/install-agents.ts +350 -0
  120. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/cli/run.sh +61 -0
  121. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/cli/shared-templates.ts +79 -0
  122. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/config/load.ts +392 -0
  123. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/config/resolve-credential.ts +120 -0
  124. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/config/schema.ts +641 -0
  125. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/config/validate.ts +134 -0
  126. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/context/build-diagnosis-context.ts +417 -0
  127. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/context/load-context.ts +105 -0
  128. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/contract/types.ts +166 -0
  129. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/enrich/build-render-input.ts +1964 -0
  130. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/enrich/corpus-severity.ts +172 -0
  131. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/enrich/dismissal-match.ts +270 -0
  132. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/enrich/rank-remedies.ts +75 -0
  133. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/hooks/install-hooks.sh +46 -0
  134. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/hooks/pre-push.sh +78 -0
  135. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/invocation/parse-brief.ts +188 -0
  136. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/library/match.ts +114 -0
  137. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/library/paths.ts +95 -0
  138. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/library/store.ts +535 -0
  139. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/library/types.ts +181 -0
  140. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/lint/template-inline-js.ts +267 -0
  141. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/memory/append.ts +188 -0
  142. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/memory/read.ts +218 -0
  143. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/normalize/platforms/entity-context.ts +686 -0
  144. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/normalize/read-unitf.ts +267 -0
  145. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/normalize/trace.ts +1078 -0
  146. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/normalize/unitf-adapter.ts +0 -0
  147. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/normalize/unitf-types.ts +213 -0
  148. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/report/persist-selections.ts +174 -0
  149. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/report/render.ts +3254 -0
  150. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/run/diagnose.ts +64 -0
  151. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/run/session.ts +167 -0
  152. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/sample/caps.ts +345 -0
  153. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/sample/deep-read-gate.ts +424 -0
  154. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/sample/representative.ts +584 -0
  155. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/scan/objection.ts +224 -0
  156. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/scan/trajectory.ts +489 -0
  157. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/self-diagnostics/dispatch.ts +135 -0
  158. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/self-diagnostics/probe.ts +170 -0
  159. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/setup/detect.ts +307 -0
  160. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/setup/ensure-cli.ts +315 -0
  161. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/setup/reconfigure.ts +77 -0
  162. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/setup/verify-agents.ts +207 -0
  163. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/slicer.ts +166 -0
  164. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/adapters/claude-code.ts +17 -0
  165. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/adapters/codex.ts +11 -0
  166. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/adapters/langfuse.ts +17 -0
  167. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/adapters/local-jsonl.ts +12 -0
  168. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/adapters/otel.ts +12 -0
  169. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/index.ts +54 -0
  170. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/registry.ts +45 -0
  171. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/source/types.ts +86 -0
  172. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/stale-detector.ts +74 -0
  173. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/test/render-playwright.cli.ts +477 -0
  174. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/tier0/claude-code.ts +133 -0
  175. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/tier0/langfuse.ts +143 -0
  176. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/tier0-scan.ts +423 -0
  177. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/completeness-check.ts +436 -0
  178. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/doc-refs.ts +190 -0
  179. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/finalize-gate.ts +417 -0
  180. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/findings-contract.ts +628 -0
  181. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/render-js-syntax.ts +188 -0
  182. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/report-checklist.yaml +217 -0
  183. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/trace-shape.ts +262 -0
  184. package/mutagent-diagnostics/.claude/skills/mutagent-diagnostics/scripts/validate/wave6-checklist.ts +326 -0
  185. package/mutagent-diagnostics/CLAUDE.md +41 -0
  186. package/mutagent-diagnostics/LICENSE +201 -0
  187. package/mutagent-diagnostics/NOTICE +18 -0
  188. package/mutagent-diagnostics/README.md +343 -0
  189. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/SKILL.md +548 -0
  190. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/assets/agents/audit-executor.md +169 -0
  191. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +160 -0
  192. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/assets/agents/discovery.md +425 -0
  193. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +1108 -0
  194. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/assets/brand/theme.css +213 -0
  195. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/assets/brand/wordmark.html +9 -0
  196. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/lenses/context-flow-lens.md +85 -0
  197. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/lenses/data-lens.md +47 -0
  198. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/lenses/decision-lens.md +48 -0
  199. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +53 -0
  200. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/lenses/trajectory-lens.md +44 -0
  201. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/build-review-interface.md +83 -0
  202. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/edd-loop.md +134 -0
  203. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/error-analysis.md +113 -0
  204. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/eval-audit.md +154 -0
  205. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/eval-stage.md +168 -0
  206. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/generate-synthetic-data.md +81 -0
  207. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/grounded-adjudication.md +221 -0
  208. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/memory-format.md +65 -0
  209. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/methodology.md +201 -0
  210. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/operation-inventory.md +197 -0
  211. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/validate-evaluator.md +125 -0
  212. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/workflows/orchestrator-protocol.md +300 -0
  213. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/references/write-judge-prompt.md +123 -0
  214. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/behavior-tree.schema.yaml +73 -0
  215. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/dataset.schema.yaml +66 -0
  216. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/edd-change-request.schema.yaml +114 -0
  217. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/eval-matrix.schema.yaml +74 -0
  218. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/flow-graph.schema.yaml +69 -0
  219. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/flow-profile.schema.yaml +49 -0
  220. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +40 -0
  221. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/schemas/scorecard.schema.yaml +85 -0
  222. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  223. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +543 -0
  224. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/artifact-paths.ts +115 -0
  225. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/assemble-scorecard.ts +172 -0
  226. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/build-dataset.ts +186 -0
  227. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/build-evals.ts +93 -0
  228. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +393 -0
  229. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/check-method-router.ts +170 -0
  230. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/aggregate.ts +112 -0
  231. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/audit-run.ts +182 -0
  232. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/doctor.ts +211 -0
  233. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/dogfood.ts +133 -0
  234. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/init.ts +601 -0
  235. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/methodology-review.ts +122 -0
  236. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +295 -0
  237. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +165 -0
  238. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/run.sh +56 -0
  239. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cli/variance-check.ts +105 -0
  240. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/code-eval.ts +248 -0
  241. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +285 -0
  242. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/codegen-evals.ts +173 -0
  243. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cold-start-project.ts +151 -0
  244. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/cold-start-sampler.ts +267 -0
  245. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/config/load.ts +343 -0
  246. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +380 -0
  247. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +85 -0
  248. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/dataset.ts +149 -0
  249. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/eval-engine.ts +118 -0
  250. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +577 -0
  251. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +1074 -0
  252. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/flow-graph.ts +194 -0
  253. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/types.ts +303 -0
  254. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/contracts/validation.ts +193 -0
  255. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/derive-dataset.ts +152 -0
  256. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +219 -0
  257. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/diff-discriminate.ts +160 -0
  258. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/discover-criteria.ts +348 -0
  259. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +232 -0
  260. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +210 -0
  261. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/edd/variance-gate.ts +186 -0
  262. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/emit-completeness.ts +111 -0
  263. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/eval-engine.ts +160 -0
  264. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/evaluate.ts +333 -0
  265. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/flow-graph.ts +230 -0
  266. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +253 -0
  267. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/judge-provider.ts +135 -0
  268. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/lint-grounding.ts +229 -0
  269. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/lint-uniformity.ts +168 -0
  270. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/living-suite.ts +109 -0
  271. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/load-bundle.ts +203 -0
  272. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/load-profile-vocab.ts +64 -0
  273. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/load-profile.ts +106 -0
  274. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/mask.ts +138 -0
  275. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +113 -0
  276. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +750 -0
  277. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +215 -0
  278. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/memory/read.ts +168 -0
  279. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/persist-eval-criteria.ts +232 -0
  280. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +125 -0
  281. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/profile-subject.ts +310 -0
  282. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/publish-report.ts +131 -0
  283. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +81 -0
  284. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +195 -0
  285. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/render-discover-report.ts +1640 -0
  286. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +3838 -0
  287. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/render-report.ts +212 -0
  288. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/resolve-credential.ts +110 -0
  289. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/resolve-ref.ts +98 -0
  290. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/result-verify.ts +129 -0
  291. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +330 -0
  292. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/run-deterministic.ts +367 -0
  293. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +719 -0
  294. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/run-judge.ts +155 -0
  295. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +175 -0
  296. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/sample-traces.ts +210 -0
  297. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/self-audit.ts +387 -0
  298. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/source-map.ts +106 -0
  299. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +134 -0
  300. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/substrate.ts +162 -0
  301. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +244 -0
  302. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/ui-slots.ts +119 -0
  303. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +284 -0
  304. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/validate-judge.ts +358 -0
  305. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/scripts/variance-compare.ts +177 -0
  306. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/subjects/mutagent-diagnostics/behavior-tree.yaml +140 -0
  307. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/subjects/mutagent-diagnostics/eval-matrix.yaml +1270 -0
  308. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/subjects/mutagent-diagnostics/methodology-review.yaml +105 -0
  309. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/workflows/audit.workflow.js +82 -0
  310. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/workflows/data-leak.workflow.js +236 -0
  311. package/mutagent-evaluator/.claude/skills/mutagent-evaluator/workflows/variance.workflow.js +163 -0
  312. package/mutagent-evaluator/CLAUDE.md +45 -0
  313. package/mutagent-evaluator/LICENSE +201 -0
  314. package/mutagent-evaluator/NOTICE +23 -0
  315. package/mutagent-evaluator/README.md +88 -0
  316. package/mutagent-optimize/.claude/skills/mutagent-optimize/SKILL.md +287 -0
  317. package/mutagent-optimize/.claude/skills/mutagent-optimize/scripts/contracts/amend-request.ts +503 -0
  318. package/mutagent-optimize/.claude/skills/mutagent-optimize/scripts/improve-loop-run.ts +304 -0
  319. package/mutagent-optimize/.claude/skills/mutagent-optimize/scripts/loop-state-cli.ts +300 -0
  320. package/mutagent-optimize/.claude/skills/mutagent-optimize/scripts/loop-state.ts +440 -0
  321. package/mutagent-optimize/CLAUDE.md +49 -0
  322. package/mutagent-optimize/README.md +26 -0
  323. package/mutagent-orchestrator/CLAUDE.md +102 -0
  324. package/mutagent-orchestrator/README.md +20 -0
  325. package/mutagent-orchestrator/assets/agents/discovery.md +425 -0
  326. package/mutagent-orchestrator/assets/agents/dogfood-monitor.md +266 -0
  327. package/mutagent-orchestrator/assets/agents/monitor.md +258 -0
  328. package/mutagent-orchestrator/orchestrator.md +782 -0
  329. package/mutagent-orchestrator/references/MONITORING.md +209 -0
  330. package/mutagent-orchestrator/references/config-fields.md +132 -0
  331. package/mutagent-orchestrator/references/config-migration.md +132 -0
  332. package/mutagent-orchestrator/references/index-registry.md +92 -0
  333. package/mutagent-orchestrator/routing.yaml +391 -0
  334. package/mutagent-orchestrator/scripts/config-schema.ts +1010 -0
  335. package/mutagent-orchestrator/scripts/dispatch.ts +439 -0
  336. package/mutagent-orchestrator/scripts/dogfood/extract-feedback.ts +203 -0
  337. package/mutagent-orchestrator/scripts/dogfood/reconstruct-trajectory.ts +327 -0
  338. package/mutagent-orchestrator/scripts/dogfood/render-dogfood-report.ts +347 -0
  339. package/mutagent-orchestrator/scripts/dogfood/resolve-subagents.ts +160 -0
  340. package/mutagent-orchestrator/scripts/dogfood/run-report.ts +264 -0
  341. package/mutagent-orchestrator/scripts/dogfood/slack-thread.ts +106 -0
  342. package/mutagent-orchestrator/scripts/dogfood/types.ts +257 -0
  343. package/mutagent-orchestrator/scripts/eval-routing.ts +753 -0
  344. package/mutagent-orchestrator/scripts/gate.ts +219 -0
  345. package/mutagent-orchestrator/scripts/handover-contract.ts +362 -0
  346. package/mutagent-orchestrator/scripts/index/build-index.ts +351 -0
  347. package/mutagent-orchestrator/scripts/index/render-index.ts +110 -0
  348. package/mutagent-orchestrator/scripts/monitor/slack-notify.ts +111 -0
  349. package/mutagent-orchestrator/scripts/monitor/triggers.ts +162 -0
  350. package/mutagent-orchestrator/scripts/onboarding-check.ts +369 -0
  351. package/mutagent-orchestrator/scripts/render-roster.ts +308 -0
  352. package/mutagent-orchestrator/scripts/resolve-credential.ts +185 -0
  353. package/mutagent-orchestrator/scripts/resolve-paths.ts +114 -0
  354. package/mutagent-orchestrator/scripts/slack/format.ts +63 -0
  355. package/mutagent-orchestrator/scripts/slack/post.ts +115 -0
  356. package/mutagent-orchestrator/scripts/sync-index.ts +311 -0
  357. package/package.json +64 -0
@@ -0,0 +1,3254 @@
1
+ /**
2
+ * scripts/report/render.ts
3
+ * Template + findings data → report.html (4-layer HITL report)
4
+ * Type A — Pure Script (template interpolation — no LLM, no I/O side effects except file write)
5
+ *
6
+ * R-SELF-14-b: Renderer emits semantic JSON-LD (<script type="application/ld+json">) for
7
+ * the full findings graph. Template embeds it via {{FINDINGS_JSONLD}} placeholder.
8
+ * Web-component slots in the default template can read this data without re-parsing HTML.
9
+ *
10
+ * Phase 3-B (Wave-3): flat iter9 anatomy, audience flag (FU-INT-1), Methodology + Trajectory
11
+ * internal tabs (I-024/I-030/I-041), entity cards (I-026/I-038), funnel/heatmap (I-027),
12
+ * meta-report cluster layout (I-029/I-034/I-036), copyDecisions handoff (I-033).
13
+ *
14
+ * RISK-05 (template-stamp fallback): procedural HTML-building is preserved as the primary
15
+ * rendering path. The template-stamp engine (template file + {{PLACEHOLDER}} substitution)
16
+ * was introduced in P4 and is the canonical rendering surface. Both coexist.
17
+ *
18
+ * Usage:
19
+ * bun scripts/report/render.ts --findings <findings.json> --output <report.html> [options]
20
+ * Options:
21
+ * --template <path> Override template file (default: assets/templates/report.html.tpl)
22
+ * --audience client|internal Audience mode (default: client — leak-safe). NODE-STRIP removes
23
+ * class="internal" nodes when audience=client.
24
+ */
25
+
26
+ import { readFileSync, writeFileSync, existsSync } from "fs";
27
+ import { resolve, dirname as pathDirname, join } from "path";
28
+ import { parse as parseYaml } from "yaml";
29
+ import { Value } from "@sinclair/typebox/value";
30
+ import type {
31
+ Finding,
32
+ Remedy,
33
+ DiffStatus,
34
+ FailureOrigin,
35
+ EntityContext,
36
+ SizedText,
37
+ ToolInventoryEntry,
38
+ FeedbackSource,
39
+ TranslatedFeedback,
40
+ FeedbackOnFix,
41
+ } from "../normalize/trace.ts";
42
+ import {
43
+ SelfDiagnosisContractSchema,
44
+ type SelfDiagnosisContract,
45
+ } from "../contract/types.ts";
46
+
47
+ // ── Audience type (FU-INT-1) ─────────────────────────────────────────────────
48
+
49
+ /** FU-INT-1: Audience for this render pass. Default: "internal" (safe). */
50
+ export type Audience = "client" | "internal";
51
+
52
+ // ── Run metadata (I-024, I-030 — Methodology tab) ───────────────────────────
53
+
54
+ /**
55
+ * W17-E (R7 — residual surfacing): the canonical "suspected primary — unconfirmed"
56
+ * record. PRODUCED by buildSignalCensus in build-render-input.ts (Block C, which
57
+ * modelled it locally as a superset of this type because render.ts was out of its
58
+ * scope); CONSUMED here by the renderer. Present ONLY when the top DISCOVERED signal
59
+ * was capped at SECONDARY for lack of mechanical evidence (R1 evidence floor) AND
60
+ * primary fell back to a cheap signal. The renderer reads this to show a clear
61
+ * "suspected primary — unconfirmed" note instead of silently crowning the cheap
62
+ * fallback. Absent when the discovered signal passed the evidence floor (nothing
63
+ * unconfirmed) or no discovered signal was surfaced. All-primitive shape, so the
64
+ * enricher's local copy threads into RunMeta.suspectedPrimaryUnconfirmed structurally.
65
+ */
66
+ export interface SuspectedPrimary {
67
+ /** The discovered WHAT that was suspected-but-uncorroborated. */
68
+ signal: string;
69
+ /** Distinct sampled traces it was seen on (honest prevalence numerator). */
70
+ seenCount: number;
71
+ /** Sampled-trace denominator (R6 — "seen in k/n sampled"). */
72
+ sampledCount: number;
73
+ /** Why it was capped at SECONDARY (the R1-floor reason). */
74
+ reason: string;
75
+ }
76
+
77
+ export interface RunMeta {
78
+ /** Total traces in the session (may differ from totalTraces in RenderInput when filtered) */
79
+ totalTraces?: number;
80
+ /** How many traces were scanned by tier-0 static patterns */
81
+ tier0ScannedCount?: number;
82
+ /** How many traces were sent to LLM for deep analysis */
83
+ llmReadCount?: number;
84
+ /** Scope filter expression applied before sampling */
85
+ scopeFilter?: string;
86
+ /** Sampling strategy description */
87
+ samplingStrategy?: string;
88
+ /** Decision log: key choices made during the diagnostic run */
89
+ decisions?: Array<{ step: string; choice: string; rationale: string }>;
90
+ /** Signal census: how many of each signal type were surfaced */
91
+ signalCensus?: Array<{ signal: string; count: number }>;
92
+ /** Mermaid topology diagram source (agent/tool graph) */
93
+ mermaidTopology?: string;
94
+ /**
95
+ * W11-01 (PR-049): The reconciled primary signal for this run.
96
+ * Selected by: failure-validity gate → impact×prevalence → deep-read corroboration.
97
+ * Authoritative source for census badge · heatmap signal flag · funnel coverage note.
98
+ * Set by buildSignalCensus in build-render-input.ts.
99
+ */
100
+ primarySignal?: {
101
+ /** Selected primary signal name (e.g. "latency-spike"). */
102
+ name: string;
103
+ /** One-sentence rationale (impact×prevalence + deep-read corroboration). */
104
+ why: string;
105
+ /** Signals ruled out by the failure-validity gate before scoring. */
106
+ ruledOut: string[];
107
+ /** Confidence level based on deep-read corroboration. */
108
+ confidence: "high" | "medium" | "low";
109
+ };
110
+
111
+ // ── Wave-6 R2.1 (+D1) — mandatory LLM deep-read accounting ────────────────
112
+ /**
113
+ * R2.1/D1: a cap-exceeded banner line. When an active cap tripped (max_trace /
114
+ * time / cost), the run STOPS, emits findings, and this banner is rendered at
115
+ * the top of the Methodology tab. Absent when no cap tripped.
116
+ */
117
+ capBanner?: string;
118
+ /**
119
+ * R2.1: the deep-read gate verdict + reason. When the gate REFUSED, this carries
120
+ * the refusal banner (rendered prominently). Present on every fresh run.
121
+ */
122
+ deepReadGate?: { verdict: "refuse" | "proceed" | "proceed-with-priors"; reason: string };
123
+
124
+ // ── Wave-6 R2.2 — awareness-layer LLM mini-sample ─────────────────────────
125
+ /**
126
+ * R2.2: a 5-trace LLM mini-sample fired BEFORE primary-signal selection to
127
+ * surface signals Tier-0 cannot MEASURE (the measurement-layer fix). Absent
128
+ * (with blindSpots placeholder) when library priors already exist (SKIP).
129
+ */
130
+ awarenessSample?: {
131
+ traces: string[];
132
+ findings: string[];
133
+ firedAt: string;
134
+ };
135
+ /**
136
+ * R2.2: blind-spots table — signals + whether Tier-0 can measure them + how they
137
+ * were checked + the result. Rendered as Methodology Step 1.5.
138
+ */
139
+ blindSpots?: Array<{
140
+ signal: string;
141
+ measurable: "Tier-0" | "No";
142
+ checkedBy: string;
143
+ result: string;
144
+ }>;
145
+
146
+ // ── Wave-6 R2.4 — methodology widgets ─────────────────────────────────────
147
+ /**
148
+ * R2.4: tier-coverage breakdown for the SVG arc pie. Each entry = one tier with
149
+ * a finding count. The pie handles the 0-finding-tier case (renders an empty arc).
150
+ */
151
+ tierBreakdown?: Array<{ tier: string; count: number; color?: string }>;
152
+ /**
153
+ * R2.4: per-signal selection-rule cards. Each card shows the signal's score +
154
+ * verdict; `discoveredByAwareness` marks signals surfaced by R2.2 (badge).
155
+ */
156
+ selectionRules?: Array<{
157
+ signal: string;
158
+ score: string;
159
+ verdict: string;
160
+ discoveredByAwareness?: boolean;
161
+ /**
162
+ * D3 (dataset-candidate enrichment): when this signal links to a finding (by
163
+ * failureOrigin.what), these fields are DERIVED from that finding so each
164
+ * candidate card answers — in plain language — which scenario it is, what
165
+ * edge-case it represents, why it failed, why it is a high-value dataset
166
+ * candidate, and what regression it would guard against. All OPTIONAL and
167
+ * omitted gracefully when the source field is absent (never fabricated).
168
+ */
169
+ /** The linked finding's id (for citation). */
170
+ linkedFindingId?: string;
171
+ /** SCENARIO — plain-words narration of what happened in the trace (failureOrigin.whatHappened). */
172
+ scenario?: string;
173
+ /** USE-CASE / EDGE-CASE this candidate represents (finding subDesc / title). */
174
+ useCase?: string;
175
+ /** WHY it failed — the root cause (why-chain origin / failureOrigin.why). */
176
+ whyFailed?: string;
177
+ /** WHY it is a high-value dataset candidate (severity · prevalence · novelty — derived). */
178
+ whyHighValue?: string;
179
+ /** What regression it would PREVENT (the finding's problem statement). */
180
+ prevents?: string;
181
+ }>;
182
+ /**
183
+ * R2.4: mermaid decision-tree source for the signal-selection trace. Rendered
184
+ * with partial data (the renderer tolerates an empty/short trace).
185
+ */
186
+ signalSelectionTrace?: string;
187
+
188
+ // ── W17-E (R7) — residual surfacing (canonical slot) ──────────────────────
189
+ /**
190
+ * W17-E (R7): set ONLY when the top DISCOVERED signal was capped at SECONDARY for
191
+ * lack of mechanical evidence (R1 evidence floor) and primary fell back to a cheap
192
+ * signal. The renderer surfaces a "suspected primary — unconfirmed" note so the
193
+ * cheap fallback is NOT silently presented as the confident primary. Absent when
194
+ * the discovered signal passed the floor, or none was discovered. Produced by
195
+ * buildSignalCensus (build-render-input.ts, Block C).
196
+ */
197
+ suspectedPrimaryUnconfirmed?: SuspectedPrimary;
198
+
199
+ // ── Wave-6 R2.6 (+D2) — operator invocation ────────────────────────────────
200
+ /**
201
+ * D2: the VERBATIM operator brief string. Stored even when parsing succeeds, so
202
+ * the brief can be re-parsed later + for library authenticity. Rendered as
203
+ * Methodology Step 0 ("Operator invocation (verbatim)").
204
+ */
205
+ operatorInvocation?: string;
206
+ /**
207
+ * R2.6/D2: the parsed invocation shape (agent / timeWindow / focus / residual).
208
+ * Drives the 🎯 Guided tab + tooltip when `focus` is set.
209
+ */
210
+ parsedInvocation?: {
211
+ agent?: string;
212
+ timeWindow?: string;
213
+ focus?: string;
214
+ /**
215
+ * W12-03: optional — a bare invocation on a neutral-survey run leaves
216
+ * `residual` undefined. The renderer guards with a `—` fallback; the type now
217
+ * reflects the runtime reality (was `string`, which masked the crash path).
218
+ */
219
+ residual?: string;
220
+ };
221
+
222
+ // ── W9-09 (PR-048): Deep-read escalation telemetry (mirrors trace.ts RunMeta.deepRead) ──
223
+ /**
224
+ * W9-07/W9-09: Deep-read coverage record populated by the trace-hungry escalation
225
+ * loop. Surfaced in the report header bigStat tile (tier reached · tier0 scanned ·
226
+ * llmReadCount · coverageConfidence · stopReason). Backward-compatible — absent on
227
+ * pre-Wave-9 runs.
228
+ */
229
+ deepRead?: {
230
+ population: number;
231
+ tierReached: number;
232
+ llmReadCount: number;
233
+ coverageConfidence: "high" | "medium" | "low";
234
+ stopReason: "evidence-sufficient" | "ceiling-reached" | "time-budget";
235
+ batches: Array<{
236
+ tier: number;
237
+ newFailureCategories: number;
238
+ coverageConfidence: string;
239
+ }>;
240
+ };
241
+
242
+ // ── W9-07: Coverage warning flags (from deep-read-gate) ───────────────────
243
+ /**
244
+ * W9-07: Set by the deep-read-gate when trace pool is too thin for high confidence.
245
+ * Triggers a low-confidence banner in the coverage tile.
246
+ */
247
+ coverageWarning?: boolean;
248
+ /**
249
+ * W9-07: Set by representative.ts when the sample is below the minimum useful size.
250
+ */
251
+ tooThin?: boolean;
252
+ }
253
+
254
+ // ── Entity type (I-026+I-038 — entity-definition card) ──────────────────────
255
+
256
+ export interface Entity {
257
+ /** Entity identifier / display name */
258
+ name: string;
259
+ /** Category: agent, tool, skill, or model */
260
+ entityType: "agent" | "tool" | "skill" | "model";
261
+ /** Whether source code is accessible for diagnostics */
262
+ codeAccess: boolean;
263
+ /** Short summary of entity purpose */
264
+ summary?: string;
265
+ /** Expandable definition content: system prompt / SKILL.md / source snippet */
266
+ definition?: string;
267
+
268
+ // ── Wave-5 R1.2 — gold-standard entity-card rows (all OPTIONAL) ────────────
269
+ /** Type-row text: "Multi-step agentic email-drafting agent (…)". */
270
+ typeLabel?: string;
271
+ /** Model identifier (rendered with <code>). */
272
+ model?: string;
273
+ /** System-prompt row (may contain HTML, e.g. char count + READABLE pill). */
274
+ systemPrompt?: string;
275
+ /** Tool names — rendered as `.b-tool` chips. */
276
+ tools?: string[];
277
+ /** Code-access row prose (overrides the boolean for richer client copy). */
278
+ codeAccessNote?: string;
279
+ /** Apply-target row prose (e.g. "client-side (client codebase)…"). */
280
+ applyTarget?: string;
281
+ /** Expandable agent-input / prompt sample (gold-standard `details.expand > pre`). */
282
+ inputSample?: string;
283
+ /** Summary text for the input-sample <summary> (defaults to a generic label). */
284
+ inputSampleSummary?: string;
285
+ /**
286
+ * SD self-diag: host runtime for the diagnosed skill ("claude-code" | "codex" | "cursor").
287
+ * Rendered as an extra entity-card row when entityType === "skill".
288
+ */
289
+ hostRuntime?: "claude-code" | "codex" | "cursor";
290
+
291
+ // ── Wave-5 R1.7 (APPENDIX-A) — rich EntityContext fields (extracted at ingest) ─
292
+ // These make Entity a strict SUPERSET of EntityContext. When present, the
293
+ // renderer prefers them over the legacy string fields and wraps any field
294
+ // > 1 KB in an ExpandableSection. The normalizer populates these deterministically.
295
+ /** Rich system prompt with size metadata — ALWAYS rendered expandable + collapsed (PII). */
296
+ systemPromptCtx?: SizedText;
297
+ /** Aggregated per-tool usage stats — chip strip visible + nested expandable per-tool stats. */
298
+ toolInventory?: ToolInventoryEntry[];
299
+ /** Rich input sample with size metadata — expandable when > 1 KB. */
300
+ inputSampleCtx?: SizedText & { sanitized: boolean };
301
+ /** Provenance string from the normalizer (e.g. "langfuse-export"). */
302
+ source?: string;
303
+ }
304
+
305
+ /**
306
+ * Wave-5 R1.7: lift a normalizer-emitted EntityContext into the render-layer
307
+ * Entity shape. The rich fields land on the *Ctx-suffixed slots so the renderer's
308
+ * ExpandableSection logic engages, while legacy display fields stay untouched.
309
+ */
310
+ export function entityFromContext(ctx: EntityContext): Entity {
311
+ const e: Entity = {
312
+ name: ctx.name,
313
+ entityType: ctx.entityType,
314
+ codeAccess: ctx.codeAccess,
315
+ source: ctx.source,
316
+ };
317
+ if (ctx.model) e.model = ctx.model;
318
+ if (ctx.applyTarget) e.applyTarget = ctx.applyTarget;
319
+ if (ctx.systemPrompt) e.systemPromptCtx = ctx.systemPrompt;
320
+ if (ctx.toolInventory) e.toolInventory = ctx.toolInventory;
321
+ if (ctx.inputSample) e.inputSampleCtx = ctx.inputSample;
322
+ return e;
323
+ }
324
+
325
+ // ── Wave-5 R1.3 — gold-standard render-only shapes ───────────────────────────
326
+
327
+ /** One tile in the 6-tile `.big-stat` row (Overview). */
328
+ export interface BigStat {
329
+ /** Display value (e.g. "54s", "$156.74", "1,946", "0"). */
330
+ value: string;
331
+ /** Small uppercase label under the value (e.g. "latency p50"). */
332
+ label: string;
333
+ /** Optional inline colour CSS var (e.g. "var(--y)", "var(--r)", "var(--g)"). */
334
+ color?: string;
335
+ }
336
+
337
+ /** A row in the signal-census table (Overview). */
338
+ export interface SignalCensusRow {
339
+ /** Signal / failure-mode name (e.g. "latency-spike"). Rendered bold if `primary`. */
340
+ signal: string;
341
+ /** "Present?" cell prose (e.g. "YES", "0", "?"). */
342
+ present: string;
343
+ /** Optional colour for the present cell. */
344
+ presentColor?: string;
345
+ /** Measure cell prose (may contain <code>). */
346
+ measure: string;
347
+ /** Decision cell prose (may contain a badge). */
348
+ decision: string;
349
+ /** Marks the ★ PRIMARY row. */
350
+ primary?: boolean;
351
+ }
352
+
353
+ /**
354
+ * W11-03: 4-segment scan-coverage funnel (Overview).
355
+ * Stages: total → tier0-scan → representative-sample N → deep-read 6/N.
356
+ * Denominator for the deep-read segment is the SAMPLE (not population) to be
357
+ * honest about what was actually read. Backward-compat: sample is optional;
358
+ * absent → funnel falls back to 3-segment display (total/code/llm).
359
+ */
360
+ export interface ScanFunnel {
361
+ /** s-total segment. */
362
+ total: { value: string; label: string; detail: string };
363
+ /** s-code segment (Tier-0 static scan). */
364
+ code: { value: string; label: string; detail: string };
365
+ /**
366
+ * W11-03: s-sample segment (representative sample N drawn from tier0 output).
367
+ * Optional for backward-compat: absent → funnel renders without this segment.
368
+ */
369
+ sample?: { value: string; label: string; detail: string };
370
+ /** s-llm segment (deep-read LLM analyzers; denominator = sample when present). */
371
+ llm: { value: string; label: string; detail: string };
372
+ }
373
+
374
+ /** One hourly cell in the 24h latency heatmap. */
375
+ export interface HourlyHeatCell {
376
+ /** Hour 0..23. */
377
+ hour: number;
378
+ /** Trace count for the hour (rendered as the cell number). */
379
+ count: number;
380
+ /** Average latency in seconds — drives the l0..l4 colour bucket. */
381
+ avgS: number;
382
+ /** Max latency in seconds (shown in the cell tooltip). */
383
+ maxS: number;
384
+ /** Optional extra tooltip note (e.g. "VOLUME SPIKE", "SLOWEST"). */
385
+ note?: string;
386
+ /**
387
+ * F3 (UR-2): the l0..l4 colour level for THIS cell's active metric. When present,
388
+ * the renderer uses it directly (dynamic-metric heatmap); when absent it falls
389
+ * back to classifying avgS (latency — backward-compat).
390
+ */
391
+ level?: number;
392
+ /** F3: the active-metric value for this cell (e.g. avg $/trace, error-rate). */
393
+ metricValue?: number;
394
+ /** F3: human label for the active metric value (e.g. "avg $0.04", "12% errors"). */
395
+ metricLabel?: string;
396
+ }
397
+
398
+ /** F3 (UR-2): which metric the heatmap cells are keyed to. */
399
+ export interface HourlyHeatmapMetric {
400
+ /** The primary-signal name driving the metric choice (e.g. "cost-overshoot"). */
401
+ signal: string;
402
+ /** Legend / caption label (e.g. "avg latency", "avg cost", "error rate"). */
403
+ label: string;
404
+ /** Unit suffix for tooltips (e.g. "s", "$", "%"). */
405
+ unit: string;
406
+ }
407
+
408
+ /** The 24h heatmap (Overview). Metric follows the primary signal (F3 / UR-2). */
409
+ export interface HourlyHeatmap {
410
+ /** 24 hourly cells (one per hour). */
411
+ cells: HourlyHeatCell[];
412
+ /** l0..l4 legend boundaries label (e.g. "<50s · 50–65s · …"). Optional. */
413
+ legendLabels?: string[];
414
+ /** Narrative paragraph under the heatmap. */
415
+ narrative?: string;
416
+ /**
417
+ * F3 (UR-2): the active metric the cells are coloured by. Default = latency (so a
418
+ * latency-primary run is byte-identical to the pre-F3 behaviour). Absent → latency.
419
+ */
420
+ metric?: HourlyHeatmapMetric;
421
+ }
422
+
423
+ /** A graded decision-log row (Methodology). */
424
+ export interface DecisionLogRow {
425
+ /** Decision name (e.g. "Source ingest"). */
426
+ decision: string;
427
+ /** What the skill did. */
428
+ what: string;
429
+ /** Grade HTML (e.g. a badge span). */
430
+ grade: string;
431
+ }
432
+
433
+ // ── Session trajectory (I-041 — Trajectory internal tab) ────────────────────
434
+
435
+ export interface SessionTrajectoryData {
436
+ /** Ordered steps in the session (tool calls, messages, etc.) */
437
+ steps?: Array<{ step: number; type: string; content: string; timestamp?: string }>;
438
+ /** Mermaid DAG source for the session flow diagram */
439
+ mermaidDag?: string;
440
+ }
441
+
442
+ // ── Render input ─────────────────────────────────────────────────────────────
443
+
444
+ export interface RenderInput {
445
+ sessionId: string;
446
+ diagnosedAt: string;
447
+ sourcePlatform: string;
448
+ targetPlatform: string;
449
+ totalTraces: number;
450
+ findings: Finding[];
451
+ discoveredChecks?: DiscoveredCheck[];
452
+
453
+ // ── Wave-5 R1.3 — gold-standard render shapes (built by the enricher) ──────
454
+ /** Header title text (e.g. "DIAGNOSTICS · SAMPLE-EMAIL-AGENT LATENCY"). */
455
+ headerTitle?: string;
456
+ /** Header meta line HTML (generated · source · traces · findings · model). */
457
+ headerMetaHtml?: string;
458
+ /** The diagnosed entity (gold-standard entity card on the Overview tab). */
459
+ diagnosedEntity?: Entity;
460
+ /** 6-tile big-stat row (Overview). */
461
+ bigStat?: BigStat[];
462
+ /** Signal-census table rows (Overview + Methodology). */
463
+ signalCensus?: SignalCensusRow[];
464
+ /** Scan-coverage funnel (Overview). */
465
+ scanFunnel?: ScanFunnel;
466
+ /** 24h latency heatmap (Overview). */
467
+ hourlyHeatmap?: HourlyHeatmap;
468
+ /** Graded decision-log rows (Methodology). */
469
+ decisionLog?: DecisionLogRow[];
470
+ /** Overview headline callout (gold-standard `.crit` box). */
471
+ overviewHeadline?: string;
472
+ /** Overview leverage callout (gold-standard `.alert` box). */
473
+ overviewLeverage?: string;
474
+ /** Overview h2 title + sub (story-led). */
475
+ overviewTitle?: string;
476
+ overviewSub?: string;
477
+ /** Decisions-tab recommended-bundle callout (`.alert`). */
478
+ decisionsBundle?: string;
479
+ /** Mermaid sequence-diagram source for the Methodology topology. */
480
+ mermaidSequence?: string;
481
+ /**
482
+ * FU-INT-1: Audience for this render pass. Default: "internal" (safe — full report).
483
+ * When "client": NODE-STRIP removes all class="internal" nodes (Methodology tab,
484
+ * Trajectory tab, internal-banner) — they are not emitted into the HTML at all.
485
+ */
486
+ audience?: Audience;
487
+ /**
488
+ * I-024 + I-030: Run metadata for Methodology tab (INTERNAL).
489
+ * If absent, Methodology tab renders with a placeholder message.
490
+ */
491
+ runMeta?: RunMeta;
492
+ /**
493
+ * I-026 + I-038: Entities involved in the diagnostic session (agents, tools, skills, models).
494
+ * Rendered as entity cards in the Methodology tab.
495
+ */
496
+ entities?: Entity[];
497
+ /**
498
+ * I-041: Session trajectory data for the Trajectory tab (INTERNAL).
499
+ */
500
+ sessionTrajectory?: SessionTrajectoryData;
501
+ /**
502
+ * I-035: Override the generated-at timestamp (ISO8601).
503
+ * Defaults to the current time at render. Override useful in tests.
504
+ */
505
+ generatedAt?: string;
506
+ /**
507
+ * I-029 + I-034 + I-036: Meta-report mode — findings are grouped by cluster (failureOrigin.what)
508
+ * instead of one tab per finding. Used when diagnosing the diagnostics tool itself.
509
+ */
510
+ isMetaReport?: boolean;
511
+ /**
512
+ * D-8 (Wave-13 Block A): Methodology/variance self-audit variant.
513
+ *
514
+ * A self-diagnosis can be one of two shapes:
515
+ * (a) the TRACE-METRIC variant — the skill diagnosed against real runtime
516
+ * traces, so latency/cost tiles (bigStat / hourlyHeatmap / signalCensus)
517
+ * exist and the R1 §9.3 fail-loud predicate rightly requires them; or
518
+ * (b) the PROCESS/METHODOLOGY variant — a variance / operational-deviance
519
+ * audit of the skill's OWN behaviour. It has NO runtime traces, so the
520
+ * metric tiles legitimately do not exist. Requiring them would force the
521
+ * renderer to either refuse or fabricate metrics.
522
+ *
523
+ * When `methodologyAudit` is true the renderer treats this as variant (b):
524
+ * it EXEMPTS the report from the trace-metric tile requirement (the tiles are
525
+ * absent by nature, not starved) WITHOUT relaxing the predicate for ordinary
526
+ * reports. It implies `isMetaReport` semantics and therefore `audience` is
527
+ * forced to internal (PR-022). The metric-shaped Overview tiles simply omit
528
+ * (they are already conditional); a process-shaped report renders the entity
529
+ * card, findings table, methodology tab, and clustered finding panels.
530
+ */
531
+ methodologyAudit?: boolean;
532
+ }
533
+
534
+ export interface DiscoveredCheck {
535
+ checkId: string;
536
+ name: string;
537
+ description: string;
538
+ affectedTraceIds: string[];
539
+ }
540
+
541
+ // ── JSON-LD (R-SELF-14-b) ────────────────────────────────────────────────────
542
+
543
+ /**
544
+ * R-SELF-14-b: Build a JSON-LD graph for the full findings/remedies graph.
545
+ * Wrapped in <script type="application/ld+json"> in the final HTML.
546
+ */
547
+ export function buildFindingsJsonLd(input: RenderInput): string {
548
+ const graph = {
549
+ "@context": "https://schema.org",
550
+ "@type": "ItemList",
551
+ "name": "Diagnostic Findings",
552
+ "description": `Session ${safeSessionId(input.sessionId)} diagnosed at ${input.diagnosedAt}`,
553
+ "numberOfItems": input.findings.length,
554
+ "itemListElement": input.findings.map((f, i) => ({
555
+ "@type": "ListItem",
556
+ "position": i + 1,
557
+ "item": {
558
+ "@type": "CreativeWork",
559
+ "identifier": f.findingId,
560
+ "name": f.actionable,
561
+ "description": f.failureOrigin.evidence,
562
+ "keywords": [f.failureOrigin.what, f.failureOrigin.why, f.failureOrigin.where].join(" "),
563
+ "additionalProperty": [
564
+ { "@type": "PropertyValue", "name": "what", "value": f.failureOrigin.what },
565
+ { "@type": "PropertyValue", "name": "why", "value": f.failureOrigin.why },
566
+ { "@type": "PropertyValue", "name": "where", "value": f.failureOrigin.where },
567
+ { "@type": "PropertyValue", "name": "confidence", "value": f.failureOrigin.confidence },
568
+ ],
569
+ "hasPart": f.remedies.map((r) => ({
570
+ "@type": "CreativeWork",
571
+ "identifier": r.remedyId,
572
+ "name": r.title,
573
+ "additionalProperty": [
574
+ { "@type": "PropertyValue", "name": "cost", "value": r.cost },
575
+ { "@type": "PropertyValue", "name": "correctness", "value": r.correctness },
576
+ { "@type": "PropertyValue", "name": "rank", "value": r.rank },
577
+ ],
578
+ })),
579
+ },
580
+ })),
581
+ };
582
+ return `<script type="application/ld+json">\n${JSON.stringify(graph, null, 2)}\n</script>`;
583
+ }
584
+
585
+ // ── Wave-5 R1.7 — ExpandableSection widget (APPENDIX-A §A.1) ──────────────────
586
+
587
+ /** Threshold above which a field is wrapped in an ExpandableSection (1 KB). */
588
+ const EXPAND_THRESHOLD_BYTES = 1024;
589
+
590
+ /** Human-readable KB string (1 dp) for a byte count. */
591
+ function formatKb(bytes: number): string {
592
+ return `${(bytes / 1024).toFixed(1)} KB`;
593
+ }
594
+
595
+ /**
596
+ * Wave-5 R1.7 (APPENDIX-A §A.1): the ExpandableSection building block. Renders a
597
+ * default-collapsed `<details class="expand">` with a size+token summary and the
598
+ * content inside a scrollable `<pre>`. Set `forceExpandable` for fields that must
599
+ * always be collapsed regardless of size (e.g. system prompt — PII).
600
+ *
601
+ * The `details.expand` / `summary` / `pre` CSS already lives in the gold-standard
602
+ * report.html.tpl (max-height:340px, overflow:auto, white-space:pre-wrap).
603
+ */
604
+ export function renderExpandableSection(
605
+ label: string,
606
+ content: SizedText,
607
+ opts: { forceExpandable?: boolean } = {}
608
+ ): string {
609
+ const meta = `${formatKb(content.sizeBytes)}${content.tokensApprox ? ` · ${content.tokensApprox.toLocaleString("en-US")} tokens` : ""}`;
610
+ const big = content.sizeBytes > EXPAND_THRESHOLD_BYTES || opts.forceExpandable;
611
+ if (!big) {
612
+ // Small + not force-collapsed: render inline (no details wrapper).
613
+ return `<div class="inline-field">${escapeHtml(content.text)}</div>`;
614
+ }
615
+ // NOTE: never emit the `open` attribute — sections are collapsed by default.
616
+ return `<details class="expand"><summary>${escapeHtml(label)} (${meta} · click to expand)</summary><pre>${escapeHtml(content.text)}</pre></details>`;
617
+ }
618
+
619
+ // ── Wave-5 helpers — gold-standard class mappings ────────────────────────────
620
+
621
+ /** Map a finding severity → gold-standard badge class suffix (crit/high/med/info). */
622
+ function severityBadgeClass(sev: Finding["severity"] | undefined, confidence: FailureOrigin["confidence"]): "crit" | "high" | "med" | "info" {
623
+ if (sev) return sev;
624
+ // Fall back from confidence when severity is not explicitly set.
625
+ return confidence === "high" ? "crit" : confidence === "medium" ? "high" : "info";
626
+ }
627
+
628
+ /** Map a severity → gold-standard tab sev-dot class. */
629
+ function severityDotClass(sev: "crit" | "high" | "med" | "info"): string {
630
+ return sev === "crit" ? "sev-crit" : sev === "high" ? "sev-high" : sev === "med" ? "sev-med" : "sev-info";
631
+ }
632
+
633
+ /** Map a heat avg-latency level (already classified l0..l4) — defensive clamp. */
634
+ function heatLevelClass(level: number): string {
635
+ const l = Math.max(0, Math.min(4, Math.round(level)));
636
+ return `l${l}`;
637
+ }
638
+
639
+ /** Classify an avg-latency value (seconds) into an l0..l4 bucket using gold-standard boundaries. */
640
+ function classifyHeatLevel(avgS: number): number {
641
+ if (avgS < 50) return 0;
642
+ if (avgS < 65) return 1;
643
+ if (avgS < 85) return 2;
644
+ if (avgS < 100) return 3;
645
+ return 4;
646
+ }
647
+
648
+ // ── renderReport ─────────────────────────────────────────────────────────────
649
+
650
+
651
+ // ── W9-08: Pre-render validation (R-CP-1..4) ─────────────────────────────────
652
+
653
+ /**
654
+ * W9-08 (R-CP-1/2/3/4): Validate RenderInput before HTML is emitted.
655
+ *
656
+ * Checks (all throw on failure — NEVER emit partial HTML):
657
+ * 1. Template has zero unfilled {{PLACEHOLDER}} remnants vs known placeholder set.
658
+ * Reports any unrecognised {{...}} tokens left in the final rendered output.
659
+ * 2. Every Finding has required fields (findingId, actionable, failureOrigin.*,
660
+ * whyChain, remedies, sourceTraceIds, referenceIds).
661
+ * 3. isMetaReport implies audience === 'internal' (PR-022).
662
+ *
663
+ * This function is called from renderReport AFTER the template is stamped, BEFORE
664
+ * the HTML is returned. On any miss: throws with the full missing list (R-CP-1 —
665
+ * no partial HTML ever emitted).
666
+ */
667
+ export function validatePreRender(
668
+ renderedHtml: string,
669
+ input: RenderInput
670
+ ): void {
671
+ const errors: string[] = [];
672
+
673
+ // ── (1) Template placeholder check — no {{...}} remnants ──────────────────
674
+ const remnants = renderedHtml.match(/\{\{[A-Z_0-9]+\}\}/g);
675
+ if (remnants && remnants.length > 0) {
676
+ const unique = [...new Set(remnants)];
677
+ errors.push(
678
+ `Unresolved template placeholders (${unique.length}): ${unique.join(", ")}`
679
+ );
680
+ }
681
+
682
+ // ── (2) Finding shape validation ──────────────────────────────────────────
683
+ for (let i = 0; i < input.findings.length; i++) {
684
+ const f = input.findings[i];
685
+ const prefix = `findings[${i}] (${String(f.findingId ?? "?")})`;
686
+ if (!f.findingId || typeof f.findingId !== "string") {
687
+ errors.push(`${prefix}: findingId missing or not a string`);
688
+ }
689
+ if (!f.actionable || typeof f.actionable !== "string") {
690
+ errors.push(`${prefix}: actionable missing or not a string`);
691
+ }
692
+ if (!f.failureOrigin || typeof f.failureOrigin !== "object") {
693
+ errors.push(`${prefix}: failureOrigin missing`);
694
+ } else {
695
+ if (!f.failureOrigin.what) errors.push(`${prefix}: failureOrigin.what missing`);
696
+ if (!f.failureOrigin.why) errors.push(`${prefix}: failureOrigin.why missing`);
697
+ if (!f.failureOrigin.where) errors.push(`${prefix}: failureOrigin.where missing`);
698
+ if (!f.failureOrigin.evidence) errors.push(`${prefix}: failureOrigin.evidence missing`);
699
+ if (!f.failureOrigin.confidence) errors.push(`${prefix}: failureOrigin.confidence missing`);
700
+ }
701
+ if (!Array.isArray(f.whyChain)) {
702
+ errors.push(`${prefix}: whyChain is not an array`);
703
+ }
704
+ if (!Array.isArray(f.remedies)) {
705
+ errors.push(`${prefix}: remedies is not an array`);
706
+ }
707
+ if (!Array.isArray(f.sourceTraceIds)) {
708
+ errors.push(`${prefix}: sourceTraceIds is not an array`);
709
+ }
710
+ if (!f.referenceIds || typeof f.referenceIds !== "object") {
711
+ errors.push(`${prefix}: referenceIds missing`);
712
+ }
713
+ }
714
+
715
+ // ── (3) isMetaReport ⇒ audience === 'internal' ───────────────────────────
716
+ // D-8: methodologyAudit implies a self-diag (meta) report, same PR-022 rule.
717
+ if ((input.isMetaReport || input.methodologyAudit) && input.audience === "client") {
718
+ errors.push(
719
+ "isMetaReport is true but audience is 'client' — self-diagnosis reports MUST be internal-only (PR-022)"
720
+ );
721
+ }
722
+
723
+ if (errors.length > 0) {
724
+ throw new Error(
725
+ `[W9-08] pre-render validation failed — ${errors.length} error(s). REFUSING to emit HTML:\n` +
726
+ errors.map((e, i) => ` ${i + 1}. ${e}`).join("\n")
727
+ );
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Wave-5: Render the gold-standard HTML report from the ported template + a
733
+ * fully-populated RenderInput.
734
+ *
735
+ * Fail-loud predicate (R1 §9.3): the renderer REFUSES (throws) when ≥3 of the 4
736
+ * internal render shapes (diagnosedEntity / bigStat / hourlyHeatmap / signalCensus)
737
+ * are missing — no silent placeholder fallback. This is the guard that the whole
738
+ * regression hinged on (silent graceful-degradation hid the starved input).
739
+ *
740
+ * SD self-diag: when isMetaReport===true the renderer REFUSES audience="client"
741
+ * (PR-022 — self-diagnosis is always internal), force-renders the SELF-DIAGNOSIS
742
+ * banner, and groups findings into clusters by failureOrigin.what.
743
+ *
744
+ * Backward-compat: minimal templates (without the gold-standard slots) and the
745
+ * legacy {{FINDINGS_HTML}} / {{PANELS_HTML}} / {{TABS_NAV_HTML}} placeholders are
746
+ * still substituted, so the open-ended regression guard test keeps passing.
747
+ */
748
+ export function renderReport(template: string, input: RenderInput): string {
749
+ // D-8: a methodology/variance self-audit is a self-diag (meta) report by
750
+ // definition — it implies isMetaReport so PR-022 (internal-only) still holds.
751
+ const isMetaReport = (input.isMetaReport ?? false) || (input.methodologyAudit ?? false);
752
+
753
+ // SD: self-diag is ALWAYS internal — refuse client (PR-022).
754
+ if (isMetaReport && input.audience === "client") {
755
+ throw new Error(
756
+ "renderReport: refusing --audience client for a self-diagnosis (isMetaReport) report. " +
757
+ "Self-diagnosis output is INTERNAL-only (PR-022)."
758
+ );
759
+ }
760
+ const audience: Audience = isMetaReport ? "internal" : input.audience ?? "client";
761
+
762
+ // R1 §9.3 fail-loud predicate: ≥3 of 4 internal shapes missing → refuse.
763
+ assertRenderShapesPresent(input);
764
+
765
+ const generatedAt = input.generatedAt ?? new Date().toISOString();
766
+
767
+ // D-4 render guard: `input.sessionId` is typed required but the enricher does
768
+ // not always emit a top-level sessionId, so `.toUpperCase()` (and the slot
769
+ // interpolations below) crashed / printed `undefined` when it was absent.
770
+ // Resolve a safe sessionId ONCE and route every dereference through it.
771
+ const sessionId = safeSessionId(input.sessionId);
772
+
773
+ // ── Header ──
774
+ const headerTitle = input.headerTitle ?? `— DIAGNOSTICS · ${escapeHtml(sessionId.toUpperCase())}`;
775
+ const headerMeta = input.headerMetaHtml ?? defaultHeaderMeta(input, generatedAt);
776
+
777
+ // ── Tab nav ──
778
+ // R2.6: focus directive (from the parsed operator brief) swaps Overview → 🎯 Guided.
779
+ const guidedFocus = input.runMeta?.parsedInvocation?.focus;
780
+ const tabNavHtml = renderTabsNav(input.findings, audience, isMetaReport, guidedFocus);
781
+
782
+ // ── Panels ──
783
+ const overviewPanelHtml = renderOverviewTab(input);
784
+ const findingPanelsHtml = isMetaReport
785
+ ? renderMetaReportPanels(input.findings)
786
+ : input.findings.map((f, idx) => renderFindingPanel(f, idx)).join("\n");
787
+ const decisionsPanelHtml = renderDecisionsTab(input);
788
+
789
+ // Methodology panel (INTERNAL only — NODE-STRIP for client).
790
+ const methodologyPanelHtml = audience === "internal" ? renderMethodologyTab(input) : "";
791
+
792
+ // FU-INT-1: internal audience banner (NODE-STRIPPED for client). The
793
+ // class="internal-banner" string must NOT appear in client output.
794
+ const internalBannerHtml =
795
+ audience === "internal" && !isMetaReport
796
+ ? `<div class="internal-banner">⚙ INTERNAL — methodology + session-trajectory data included. NODE-STRIPPED for --audience client.</div>`
797
+ : "";
798
+
799
+ // SD: forced self-diagnosis banner (PR-022) — only when isMetaReport.
800
+ const internalBannerForce = isMetaReport
801
+ ? `<div class="internal-banner" style="text-align:center;border-radius:0;margin:0;">⚙ SELF-DIAGNOSIS — analyzing the diagnostics skill itself. Findings target config.yaml / skill assets, NEVER source code (PR-022).</div>`
802
+ : "";
803
+
804
+ // I-027 backward-compat slot (kept so the .tpl + minimal templates substitute cleanly).
805
+ const funnelHeatmapHtml = renderFunnelHeatmap();
806
+
807
+ // R-SELF-14-b: JSON-LD semantic block.
808
+ const findingsJsonLd = buildFindingsJsonLd(input);
809
+
810
+ // PRD-CC-07: Live preview script — wires .remedy-cb + .remedy-notes + #general-feedback.
811
+ // PRD-SD-06: self-diag banner discipline — included in the same slot.
812
+ const mermaidScriptHtml = renderLivePreviewScript();
813
+
814
+ // Legacy combined panels (for minimal templates using {{PANELS_HTML}} /
815
+ // {{FINDINGS_HTML}}). Gold-standard templates ignore these.
816
+ const legacyPanelsHtml =
817
+ overviewPanelHtml + "\n" + findingPanelsHtml + "\n" + decisionsPanelHtml +
818
+ (methodologyPanelHtml ? "\n" + methodologyPanelHtml : "");
819
+
820
+ const discoveredChecksHtml =
821
+ input.discoveredChecks && input.discoveredChecks.length > 0
822
+ ? renderDiscoveredChecks(input.discoveredChecks)
823
+ : "";
824
+
825
+ const denominator = countRemedies(input.findings);
826
+
827
+ // W9-08: Build HTML string then validate before returning (R-CP-1/2/3).
828
+ const html = template
829
+ .replaceAll("{{TITLE}}", escapeHtml(input.headerTitle ? stripTags(input.headerTitle) : `MUTAGENT-DIAGNOSTICS — ${sessionId}`))
830
+ .replaceAll("{{HEADER_TITLE}}", headerTitle)
831
+ .replaceAll("{{HEADER_META}}", headerMeta)
832
+ .replaceAll("{{TAB_NAV_HTML}}", tabNavHtml)
833
+ .replaceAll("{{METHODOLOGY_PANEL_HTML}}", methodologyPanelHtml)
834
+ .replaceAll("{{OVERVIEW_PANEL_HTML}}", overviewPanelHtml)
835
+ .replaceAll("{{FINDING_PANELS_HTML}}", findingPanelsHtml)
836
+ .replaceAll("{{DECISIONS_PANEL_HTML}}", decisionsPanelHtml)
837
+ .replaceAll("{{APPROVED_COUNT_DENOMINATOR}}", String(denominator))
838
+ .replaceAll("{{INTERNAL_BANNER_FORCE}}", internalBannerForce)
839
+ // Backward-compat + shared slots:
840
+ .replaceAll("{{SESSION_ID}}", escapeHtml(sessionId))
841
+ .replaceAll("{{DIAGNOSED_AT}}", escapeHtml(input.diagnosedAt))
842
+ .replaceAll("{{SOURCE_PLATFORM}}", escapeHtml(input.sourcePlatform))
843
+ .replaceAll("{{TARGET_PLATFORM}}", escapeHtml(input.targetPlatform))
844
+ .replaceAll("{{TOTAL_TRACES}}", String(input.totalTraces))
845
+ .replaceAll("{{FINDINGS_COUNT}}", String(input.findings.length))
846
+ .replaceAll("{{GENERATED_AT}}", escapeHtml(generatedAt))
847
+ .replaceAll("{{INTERNAL_BANNER_HTML}}", internalBannerHtml)
848
+ .replaceAll("{{MERMAID_SCRIPT_HTML}}", mermaidScriptHtml)
849
+ .replaceAll("{{FUNNEL_HEATMAP_HTML}}", funnelHeatmapHtml)
850
+ .replaceAll("{{TABS_NAV_HTML}}", tabNavHtml)
851
+ .replaceAll("{{PANELS_HTML}}", legacyPanelsHtml)
852
+ .replaceAll("{{FINDINGS_HTML}}", legacyPanelsHtml)
853
+ .replaceAll("{{DISCOVERED_CHECKS_HTML}}", discoveredChecksHtml)
854
+ .replaceAll("{{FINDINGS_JSONLD}}", findingsJsonLd);
855
+
856
+ // W9-08 (R-CP-1/2/3): validate placeholder + finding shape + isMetaReport => internal.
857
+ // THROW before emitting partial or invalid HTML — never write broken output.
858
+ validatePreRender(html, input);
859
+ return html;
860
+ }
861
+
862
+ /**
863
+ * R1 §9.3 fail-loud predicate. The 4 internal render shapes are diagnosedEntity,
864
+ * bigStat, hourlyHeatmap, signalCensus. When ≥3 are missing the renderer refuses,
865
+ * rather than silently degrading to placeholders (the root cause of the regression).
866
+ *
867
+ * Empty findings (e.g. the open-ended regression guard with findings:[]) are
868
+ * exempt — a report with no findings legitimately has no render shapes to build.
869
+ *
870
+ * D-8 (Wave-13 Block A): a `methodologyAudit` report (process/variance self-audit)
871
+ * is also exempt — it has NO runtime traces, so the latency/cost tiles legitimately
872
+ * do not exist. The exemption is narrow (gated on the explicit flag) so it cannot
873
+ * mask a genuinely STARVED trace report, which is what the predicate guards against.
874
+ */
875
+ function assertRenderShapesPresent(input: RenderInput): void {
876
+ if (input.findings.length === 0) return;
877
+ if (input.methodologyAudit) return;
878
+ const missing: string[] = [];
879
+ if (!input.diagnosedEntity) missing.push("diagnosedEntity");
880
+ if (!input.bigStat || input.bigStat.length === 0) missing.push("bigStat");
881
+ if (!input.hourlyHeatmap || input.hourlyHeatmap.cells.length === 0) missing.push("hourlyHeatmap");
882
+ if (!input.signalCensus || input.signalCensus.length === 0) missing.push("signalCensus");
883
+ if (missing.length >= 3) {
884
+ throw new Error(
885
+ `renderReport: refusing to render — ${missing.length} of 4 internal render shapes missing ` +
886
+ `(${missing.join(", ")}). The enricher (scripts/enrich/build-render-input.ts) must ` +
887
+ `populate diagnosedEntity, bigStat, hourlyHeatmap, signalCensus before render. ` +
888
+ `Fail-loud (R1 §9.3) — no silent placeholder fallback.`
889
+ );
890
+ }
891
+ }
892
+
893
+ /** Count total remedies across all findings (approved-count denominator). */
894
+ function countRemedies(findings: Finding[]): number {
895
+ return findings.reduce((sum, f) => sum + f.remedies.length, 0);
896
+ }
897
+
898
+ /** Default header meta line when the enricher doesn't supply one. */
899
+ function defaultHeaderMeta(input: RenderInput, generatedAt: string): string {
900
+ return (
901
+ `<span class="mk">generated</span> <span class="mv">${escapeHtml(generatedAt)}</span>` +
902
+ `<span class="sep">·</span><span class="mk">source</span> ${escapeHtml(input.sourcePlatform)}` +
903
+ `<span class="sep">·</span><span class="mv">${input.totalTraces.toLocaleString("en-US")} traces</span>` +
904
+ `<span class="sep">·</span><span class="mv">${input.findings.length} findings</span>`
905
+ );
906
+ }
907
+
908
+ /** Strip HTML tags from a string (for <title>, which must be text-only). */
909
+ function stripTags(html: string): string {
910
+ return html.replace(/<[^>]*>/g, "").replace(/^—\s*/, "MUTAGENT-DIAGNOSTICS — ").trim();
911
+ }
912
+
913
+ // ── renderTabsNav ─────────────────────────────────────────────────────────────
914
+
915
+ /**
916
+ * Render the gold-standard tabs navigation. Emits one button per finding (client
917
+ * mode) or one per cluster (self-diag meta-report mode), bookended by the
918
+ * Methodology [INTERNAL] tab (t0, internal-only) and the Decisions tab.
919
+ *
920
+ * Tab id scheme (matches gold-standard):
921
+ * t0 → Methodology [INTERNAL] (only when audience===internal)
922
+ * t1 → Overview
923
+ * t2..tN+1 → one per finding (client mode)
924
+ * tcluster-K → one per cluster (self-diag isMetaReport mode)
925
+ * tN+2 / last → Decisions
926
+ */
927
+ export function renderTabsNav(
928
+ findings: Finding[],
929
+ audience: Audience = "internal",
930
+ isMetaReport: boolean = false,
931
+ guidedFocus?: string
932
+ ): string {
933
+ const buttons: string[] = [];
934
+
935
+ // Methodology [INTERNAL] — t0 — only for internal audience.
936
+ if (audience === "internal") {
937
+ buttons.push(
938
+ `<button data-tab="t0" class="internal active">⚙ Methodology [INTERNAL]</button>`
939
+ );
940
+ }
941
+
942
+ // Overview — t1. Active when there is no internal methodology tab.
943
+ // R2.6: when a focus directive is present, 🎯 Guided REPLACES Overview (same t1
944
+ // slot) and carries a tooltip echoing the operator's focus.
945
+ const overviewActive = audience === "internal" ? "" : " class=\"active\"";
946
+ if (guidedFocus && guidedFocus.trim().length > 0) {
947
+ buttons.push(
948
+ `<button data-tab="t1"${overviewActive} title="Guided by operator focus: ${escapeHtml(guidedFocus)}">🎯 Guided</button>`
949
+ );
950
+ } else {
951
+ buttons.push(`<button data-tab="t1"${overviewActive}>① Overview</button>`);
952
+ }
953
+
954
+ if (isMetaReport && findings.length > 0) {
955
+ // SD: tab-per-cluster (group by failureOrigin.what).
956
+ const clusters = clusterFindings(findings);
957
+ let idx = 0;
958
+ for (const [what, group] of clusters) {
959
+ const sev = group.some((f) => f.failureOrigin.confidence === "high")
960
+ ? "crit"
961
+ : group.some((f) => f.failureOrigin.confidence === "medium")
962
+ ? "high"
963
+ : "info";
964
+ buttons.push(
965
+ `<button data-tab="tcluster-${idx}"><span class="sev-dot ${severityDotClass(sev)}"></span>${escapeHtml(what)} (${group.length})</button>`
966
+ );
967
+ idx++;
968
+ }
969
+ } else {
970
+ // Client: one tab per finding.
971
+ findings.forEach((f, idx) => {
972
+ const sev = severityBadgeClass(f.severity, f.failureOrigin.confidence);
973
+ const label = escapeHtml(`${f.findingId} ${shortTitle(f)}`);
974
+ buttons.push(
975
+ `<button data-tab="t${idx + 2}"><span class="sev-dot ${severityDotClass(sev)}"></span>${label}</button>`
976
+ );
977
+ });
978
+ }
979
+
980
+ // Decisions — last tab.
981
+ buttons.push(`<button data-tab="tdecisions">⑦ Decisions</button>`);
982
+
983
+ return buttons.join("\n ");
984
+ }
985
+
986
+ /** Short title for a finding tab (story-led title, falls back to actionable). */
987
+ function shortTitle(f: Finding): string {
988
+ const raw = f.title ?? f.actionable;
989
+ return raw.length > 32 ? raw.slice(0, 32) + "…" : raw;
990
+ }
991
+
992
+ // ── renderEntityCard (gold-standard .entity / .entity-grid) ──────────────────
993
+
994
+ /**
995
+ * Wave-5 R1.2: Render the gold-standard entity-definition card.
996
+ * Emits `.entity` → `.entity-head` + `.entity-grid` with type/model/system-prompt/
997
+ * tools/code-access/apply-target rows, plus an expandable input-sample `details.expand`.
998
+ *
999
+ * SD: when entityType==="skill" the card renders skill-specific rows
1000
+ * (Diagnostic skill (self-target) · source root · code access YES · apply target
1001
+ * config.yaml/skill assets NEVER source) + an optional host-runtime row.
1002
+ */
1003
+ export function renderEntityCard(entity: Entity): string {
1004
+ const isSkill = entity.entityType === "skill";
1005
+
1006
+ const rows: string[] = [];
1007
+
1008
+ // Type row.
1009
+ const typeText = entity.typeLabel
1010
+ ? entity.typeLabel
1011
+ : isSkill
1012
+ ? "Diagnostic skill (self-target)"
1013
+ : escapeHtml(entity.summary ?? entity.entityType);
1014
+ rows.push(`<div class="k">Type</div><div class="v">${entity.typeLabel ? entity.typeLabel : typeText}</div>`);
1015
+
1016
+ // Model row.
1017
+ if (entity.model) {
1018
+ rows.push(`<div class="k">Model</div><div class="v"><code>${escapeHtml(entity.model)}</code></div>`);
1019
+ }
1020
+
1021
+ // SD: source root for skill self-target.
1022
+ if (isSkill) {
1023
+ rows.push(
1024
+ `<div class="k">Source root</div><div class="v"><code>~/.claude/skills/mutagent-diagnostics/</code></div>`
1025
+ );
1026
+ }
1027
+
1028
+ // System-prompt / SKILL.md row.
1029
+ // R1.7: when the rich systemPromptCtx is present, ALWAYS render it as a
1030
+ // forced-collapsed ExpandableSection (PII — explicit click to view) regardless
1031
+ // of size. Fall back to the legacy inline string field otherwise.
1032
+ const sysLabel = isSkill ? "SKILL.md" : "System prompt";
1033
+ if (entity.systemPromptCtx) {
1034
+ rows.push(
1035
+ `<div class="k">${sysLabel}</div><div class="v">${renderExpandableSection(sysLabel, entity.systemPromptCtx, { forceExpandable: true })}</div>`
1036
+ );
1037
+ } else if (entity.systemPrompt) {
1038
+ rows.push(`<div class="k">${sysLabel}</div><div class="v">${entity.systemPrompt}</div>`);
1039
+ }
1040
+
1041
+ // Tools row.
1042
+ // R1.7: rich toolInventory → visible chip strip + a nested expandable per-tool
1043
+ // stats list (collapsed by default). Legacy `tools: string[]` → plain chips.
1044
+ if (entity.toolInventory && entity.toolInventory.length > 0) {
1045
+ rows.push(
1046
+ `<div class="k">Tools (${entity.toolInventory.length})</div><div class="v">${renderToolInventory(entity.toolInventory)}</div>`
1047
+ );
1048
+ } else if (entity.tools && entity.tools.length > 0) {
1049
+ const chips = entity.tools.map((t) => `<span class="b-tool">${escapeHtml(t)}</span>`).join("");
1050
+ rows.push(`<div class="k">Tools (${entity.tools.length})</div><div class="v">${chips}</div>`);
1051
+ }
1052
+
1053
+ // SD: host runtime row.
1054
+ if (entity.hostRuntime) {
1055
+ rows.push(`<div class="k">Host runtime</div><div class="v"><code>${escapeHtml(entity.hostRuntime)}</code></div>`);
1056
+ }
1057
+
1058
+ // Code-access row.
1059
+ const codeAccessText = entity.codeAccessNote
1060
+ ? entity.codeAccessNote
1061
+ : entity.codeAccess
1062
+ ? `<span class="access-yes">YES</span>`
1063
+ : `<span class="access-no">NO — remote export.</span>`;
1064
+ rows.push(`<div class="k">Code access</div><div class="v">${codeAccessText}</div>`);
1065
+
1066
+ // Apply-target row.
1067
+ const applyTargetText = entity.applyTarget
1068
+ ? entity.applyTarget
1069
+ : isSkill
1070
+ ? `<code>config.yaml</code> / skill assets (NEVER source)`
1071
+ : "";
1072
+ if (applyTargetText) {
1073
+ rows.push(`<div class="k">Apply target</div><div class="v">${applyTargetText}</div>`);
1074
+ }
1075
+
1076
+ // Expandable input / prompt sample.
1077
+ // R1.7: rich inputSampleCtx → ExpandableSection (expandable when > 1 KB).
1078
+ // Legacy `inputSample: string` → the original details.expand block.
1079
+ let inputSampleHtml = "";
1080
+ if (entity.inputSampleCtx) {
1081
+ inputSampleHtml = renderExpandableSection("Agent input / prompt sample", entity.inputSampleCtx);
1082
+ } else if (entity.inputSample) {
1083
+ inputSampleHtml = `<details class="expand"><summary>${escapeHtml(entity.inputSampleSummary ?? "View agent input / prompt sample")}</summary><pre>${escapeHtml(entity.inputSample)}</pre></details>`;
1084
+ }
1085
+
1086
+ return `<div class="entity">
1087
+ <div class="entity-head"><div class="entity-name">⬡ ${escapeHtml(entity.name)}</div><span class="badge b-info">DIAGNOSED ENTITY</span></div>
1088
+ <div class="entity-grid">
1089
+ ${rows.join("\n ")}
1090
+ </div>
1091
+ ${inputSampleHtml}
1092
+ </div>`;
1093
+ }
1094
+
1095
+ /**
1096
+ * Wave-5 R1.7: render the tool inventory as a visible chip strip plus a nested,
1097
+ * default-collapsed ExpandableSection listing per-tool stats (callCount,
1098
+ * callsPerTrace, avg/p95 latency, signature). The chip strip is always visible;
1099
+ * the detailed stats are one click away.
1100
+ */
1101
+ export function renderToolInventory(tools: ToolInventoryEntry[]): string {
1102
+ const chips = tools
1103
+ .map((t) => `<span class="b-tool">${escapeHtml(t.name)}</span>`)
1104
+ .join("");
1105
+ const statRows = tools
1106
+ .map((t) => {
1107
+ const lat =
1108
+ t.avgLatencyMs !== undefined
1109
+ ? `avg ${t.avgLatencyMs}ms${t.p95LatencyMs !== undefined ? ` · p95 ${t.p95LatencyMs}ms` : ""}`
1110
+ : "—";
1111
+ const sig = t.signature ? ` · <code>${escapeHtml(t.signature)}</code>` : "";
1112
+ return `<li><strong>${escapeHtml(t.name)}</strong> — ${t.callCount} call${t.callCount !== 1 ? "s" : ""} · ${t.callsPerTrace}/trace · ${lat}${sig}</li>`;
1113
+ })
1114
+ .join("\n ");
1115
+ // Nested expandable per-tool stats (collapsed by default — never `open`).
1116
+ const nested = `<details class="expand"><summary>Per-tool stats (${tools.length} tool${tools.length !== 1 ? "s" : ""} · click to expand)</summary><ul>
1117
+ ${statRows}
1118
+ </ul></details>`;
1119
+ return `<div class="tool-chips">${chips}</div>${nested}`;
1120
+ }
1121
+
1122
+ // ── renderBigStat (gold-standard .big-stat) ──────────────────────────────────
1123
+
1124
+ /** Wave-5 R1.2: Render the 6-tile `.big-stat` row. */
1125
+ export function renderBigStat(stats: BigStat[]): string {
1126
+ if (!stats || stats.length === 0) return "";
1127
+ const tiles = stats
1128
+ .map(
1129
+ (s) =>
1130
+ `<div class="s"><div class="v"${s.color ? ` style="color:${s.color}"` : ""}>${escapeHtml(s.value)}</div><div class="l">${escapeHtml(s.label)}</div></div>`
1131
+ )
1132
+ .join("");
1133
+ return `<div class="big-stat">${tiles}</div>`;
1134
+ }
1135
+
1136
+
1137
+ // ── W9-07: Deep-read coverage tile (PR-048) ──────────────────────────────────
1138
+
1139
+ /**
1140
+ * W9-07 (PR-048): Render the deep-read coverage tile block for the report header
1141
+ * / Overview tab. Shows tierReached · tier0ScannedCount · llmReadCount ·
1142
+ * coverageConfidence · stopReason sourced from RunMeta.deepRead (B1's telemetry).
1143
+ *
1144
+ * Low-confidence banner: rendered when coverageConfidence !== 'high' OR when
1145
+ * RunMeta signals coverageWarning or tooThin. Brand-vars-only CSS.
1146
+ *
1147
+ * Returns "" when RunMeta.deepRead is absent (backward-compatible pre-Wave-9 runs).
1148
+ */
1149
+ export function renderDeepReadTile(runMeta?: RunMeta): string {
1150
+ const dr = runMeta?.deepRead;
1151
+ if (!dr) return "";
1152
+
1153
+ const isLowConf = dr.coverageConfidence !== "high";
1154
+ // tooThin / coverageWarning come from the B1 deep-read-gate (optional fields)
1155
+ const tooThin = (runMeta as Record<string, unknown>).coverageWarning === true
1156
+ || (runMeta as Record<string, unknown>).tooThin === true;
1157
+ const showBanner = isLowConf || tooThin;
1158
+
1159
+ const confColor =
1160
+ dr.coverageConfidence === "high"
1161
+ ? "var(--g)"
1162
+ : dr.coverageConfidence === "medium"
1163
+ ? "var(--y)"
1164
+ : "var(--r)";
1165
+
1166
+ const stopReasonLabel = {
1167
+ "evidence-sufficient": "evidence sufficient",
1168
+ "ceiling-reached": "ceiling reached",
1169
+ "time-budget": "time budget exhausted",
1170
+ }[dr.stopReason as string] ?? dr.stopReason;
1171
+
1172
+ const tiles = [
1173
+ { value: String(dr.tierReached), label: "tier reached", color: "var(--p)" },
1174
+ { value: dr.population.toLocaleString("en-US"), label: "trace population", color: "var(--muted)" },
1175
+ { value: String(dr.llmReadCount), label: "llm-read", color: "var(--c)" },
1176
+ { value: dr.coverageConfidence, label: "coverage confidence", color: confColor },
1177
+ { value: stopReasonLabel, label: "stop reason", color: "var(--muted)" },
1178
+ ];
1179
+
1180
+ const tileHtml = tiles
1181
+ .map(
1182
+ (t) =>
1183
+ `<div class="s"><div class="v" style="color:${t.color}">${escapeHtml(t.value)}</div><div class="l">${escapeHtml(t.label)}</div></div>`
1184
+ )
1185
+ .join("");
1186
+
1187
+ const banner = showBanner
1188
+ ? `<div class="warn" style="margin-top:8px;">` +
1189
+ `<strong>Low read coverage</strong> — ` +
1190
+ `coverageConfidence: <strong>${escapeHtml(dr.coverageConfidence)}</strong>` +
1191
+ `${tooThin ? " · trace pool too thin for high confidence" : ""}` +
1192
+ `. Findings may under-represent the population. Re-run with a larger population or ` +
1193
+ `wider time window to improve coverage. (W9-07 / PR-048)</div>`
1194
+ : "";
1195
+
1196
+ return `<div class="deep-read-tile">
1197
+ <h3>Deep-read coverage — trace-hungry escalation telemetry</h3>
1198
+ <div class="big-stat deep-read-stat">${tileHtml}</div>
1199
+ ${banner}
1200
+ </div>`;
1201
+ }
1202
+
1203
+ // ── W9-F: Translated feedback block (PR-046 Layer 2) ─────────────────────────
1204
+
1205
+ /**
1206
+ * W9-F (PR-046 Layer 2): Render translated-feedback entries for a finding.
1207
+ * Bridges raw user symptom → component-level RCA target. Shows "what the user
1208
+ * said" (rawQuote) vs "what it means for the agent" (component + reasoning).
1209
+ *
1210
+ * Backward-compatible: returns "" when translatedFeedback is absent or empty.
1211
+ */
1212
+ export function renderTranslatedFeedbackBlock(finding: Finding): string {
1213
+ const entries: TranslatedFeedback[] = finding.translatedFeedback ?? [];
1214
+ if (entries.length === 0) return "";
1215
+
1216
+ const confPill = (c: TranslatedFeedback["confidence"]): string => {
1217
+ const cls = c === "high" ? "b-info" : c === "medium" ? "b-med" : "b-crit";
1218
+ return `<span class="badge ${cls}">${c.toUpperCase()}</span>`;
1219
+ };
1220
+
1221
+ const items = entries
1222
+ .map((e) => {
1223
+ const sourceLine = e.sourceIndex !== undefined
1224
+ ? `<span class="fb-source-type">source[${e.sourceIndex}]</span> · `
1225
+ : "";
1226
+ return `<div class="fb-item fb-translated">
1227
+ <div class="fb-head">${sourceLine}<strong>${escapeHtml(e.component)}</strong> <span class="tax-chip">${escapeHtml(e.affectedComponent)}</span> ${confPill(e.confidence)}</div>
1228
+ <blockquote class="fb-rawquote">"${escapeHtml(e.rawQuote)}"</blockquote>
1229
+ <p class="fb-reasoning">${escapeHtml(e.reasoning)}</p>
1230
+ </div>`;
1231
+ })
1232
+ .join("\n");
1233
+
1234
+ return `<div class="feedback-list feedback-translated">
1235
+ <h3>Translated feedback <span class="r-count">(${entries.length} translation${entries.length !== 1 ? "s" : ""})</span> <span class="badge b-med">L2</span></h3>
1236
+ ${items}
1237
+ </div>`;
1238
+ }
1239
+
1240
+ // ── W9-F: Fix-feedback block (PR-046 Layer 3) ─────────────────────────────────
1241
+
1242
+ /**
1243
+ * W9-F (PR-046 Layer 3): Render per-remedy fix-outcome history from
1244
+ * Remedy.feedbackOnFix[]. Shows a status pill per outcome record.
1245
+ *
1246
+ * Backward-compatible: returns "" when feedbackOnFix is absent or empty.
1247
+ */
1248
+ export function renderFeedbackOnFixBlock(remedy: Remedy): string {
1249
+ const entries: FeedbackOnFix[] = remedy.feedbackOnFix ?? [];
1250
+ if (entries.length === 0) return "";
1251
+
1252
+ const outcomeConfig = {
1253
+ closed: { label: "CLOSED", color: "var(--g)" },
1254
+ partial: { label: "PARTIAL", color: "var(--y)" },
1255
+ ineffective: { label: "INEFFECTIVE", color: "var(--r)" },
1256
+ regressed: { label: "REGRESSED", color: "var(--r)" },
1257
+ } as const;
1258
+
1259
+ const rows = entries
1260
+ .map((e) => {
1261
+ const cfg = outcomeConfig[e.outcome] ?? { label: e.outcome.toUpperCase(), color: "var(--muted)" };
1262
+ const pill = `<span style="font-family:var(--fm);font-size:11px;font-weight:700;text-transform:uppercase;padding:2px 7px;border-radius:3px;background:${cfg.color};color:var(--bg);">${escapeHtml(cfg.label)}</span>`;
1263
+ const nextSteps =
1264
+ e.nextSteps && e.nextSteps.length > 0
1265
+ ? `<ul class="fix-next-steps">${e.nextSteps.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul>`
1266
+ : "";
1267
+ const comment = e.comment ? `<p class="fix-comment">${escapeHtml(e.comment)}</p>` : "";
1268
+ return `<div class="fix-fb-row">
1269
+ <div class="fix-fb-head">${pill} <span class="fix-session"><code>${escapeHtml(e.testSessionId)}</code></span> · ${escapeHtml(e.testedAt)}</div>
1270
+ ${comment}
1271
+ ${nextSteps}
1272
+ </div>`;
1273
+ })
1274
+ .join("\n");
1275
+
1276
+ return `<div class="fix-feedback-list">
1277
+ <h4>Fix history <span class="r-count">(${entries.length} test${entries.length !== 1 ? "s" : ""})</span> <span class="badge b-med">L3</span></h4>
1278
+ ${rows}
1279
+ </div>`;
1280
+ }
1281
+
1282
+ // ── render24hHeatmap (gold-standard .heat / .cell l0..l4 / .heat-legend) ──────
1283
+
1284
+ /**
1285
+ * W11-02 (Wave-5 R1.2): Render the 24h latency heatmap as 24 `.cell` divs.
1286
+ * Colouring: when `runMeta.primarySignal` is present, cells where the primary
1287
+ * signal flag fires get a `sig` marker on the title tooltip; latency is the
1288
+ * fallback colour when no signal flag is available (pre-W11 backward-compat).
1289
+ *
1290
+ * W11-02: adds a window selector (default 24h, selectable 7d / 30d). The selector
1291
+ * is rendered as a small pill row above the heatmap; switching windows is
1292
+ * client-side only (the heatmap data in this render pass is always 24h — the
1293
+ * selector communicates to the operator which window they're viewing and links
1294
+ * back to a broader trace pull if needed).
1295
+ */
1296
+ export function render24hHeatmap(heatmap: HourlyHeatmap, runMeta?: RunMeta): string {
1297
+ if (!heatmap || heatmap.cells.length === 0) return "";
1298
+
1299
+ const primarySignalName = runMeta?.primarySignal?.name;
1300
+
1301
+ // F3 (UR-2): the active metric drives cell colour + legend + caption. Default =
1302
+ // latency (backward-compat: a latency-primary heatmap is byte-identical to pre-F3).
1303
+ const metricLabel = heatmap.metric?.label ?? "avg latency";
1304
+
1305
+ const cells = heatmap.cells
1306
+ .slice()
1307
+ .sort((a, b) => a.hour - b.hour)
1308
+ .map((c) => {
1309
+ // F3: prefer the enricher-supplied dynamic level; fall back to classifying
1310
+ // latency (pre-F3 cells that carry no `level`).
1311
+ const lvl = c.level !== undefined ? c.level : classifyHeatLevel(c.avgS);
1312
+ const level = heatLevelClass(lvl);
1313
+ const hh = c.hour.toString().padStart(2, "0");
1314
+ const signalNote = primarySignalName && c.note?.includes(primarySignalName)
1315
+ ? ` · ★ ${escapeHtml(primarySignalName)}`
1316
+ : "";
1317
+ // F3: tooltip leads with the active metric value (metricLabel), retaining
1318
+ // latency context (avg/max) for reference.
1319
+ const metricNote = c.metricLabel ? `${c.metricLabel} · ` : "";
1320
+ const title = `${hh}h ${metricNote}avg ${c.avgS}s max ${c.maxS}s${c.note ? " · " + c.note : ""}${signalNote}`;
1321
+ return `<div class="cell ${level}" title="${escapeHtml(title)}"><span class="ch">${hh}</span><span class="cn">${c.count}</span></div>`;
1322
+ })
1323
+ .join("\n ");
1324
+
1325
+ // F3: latency keeps its absolute-band legend; relative-metric modes get a
1326
+ // low→high relative legend (no spurious absolute boundaries).
1327
+ const isLatency = (heatmap.metric?.signal ?? "latency-spike") === "latency-spike" || heatmap.metric === undefined;
1328
+ const defaultLegend = isLatency
1329
+ ? ["<50s", "50–65s", "65–85s", "85–100s", ">100s"]
1330
+ : ["low", "", "mid", "", "high"];
1331
+ const legend = (heatmap.legendLabels ?? defaultLegend)
1332
+ .map((label, i) => `<span><span class="sw l${i}"></span>${escapeHtml(label)}</span>`)
1333
+ .join("");
1334
+
1335
+ const narrative = heatmap.narrative ? `<p style="font-size:12px;color:var(--muted);">${heatmap.narrative}</p>` : "";
1336
+
1337
+ // W11-02: window selector — default 24h, selectable 7d / 30d.
1338
+ // Data in this render pass is always 24h; the selector communicates the active
1339
+ // window and can be extended when multi-window data is available.
1340
+ const primaryNote = primarySignalName
1341
+ ? ` · primary signal: <strong style="color:var(--r);">${escapeHtml(primarySignalName)}</strong>`
1342
+ : "";
1343
+ const windowSelector = `<div class="heat-window-selector" style="font-size:11px;margin-bottom:4px;color:var(--muted);">
1344
+ <span class="heat-win active" data-win="24h" style="font-weight:700;color:var(--c);">24h</span>
1345
+ <span style="margin:0 4px;">·</span>
1346
+ <span class="heat-win" data-win="7d" style="cursor:pointer;">7d</span>
1347
+ <span style="margin:0 4px;">·</span>
1348
+ <span class="heat-win" data-win="30d" style="cursor:pointer;">30d</span>
1349
+ ${primaryNote}
1350
+ </div>`;
1351
+
1352
+ return `${windowSelector}<div class="heat" id="heat">
1353
+ ${cells}
1354
+ </div>
1355
+ <div class="heat-legend">${escapeHtml(metricLabel)}: ${legend} · hover a cell for detail</div>
1356
+ ${narrative}`;
1357
+ }
1358
+
1359
+ // ── renderSignalCensus (gold-standard signal-census table) ───────────────────
1360
+
1361
+ /** Wave-5 R1.2: Render the signal-census table. */
1362
+ export function renderSignalCensus(rows: SignalCensusRow[]): string {
1363
+ if (!rows || rows.length === 0) return "";
1364
+ const body = rows
1365
+ .map((r) => {
1366
+ const signalCell = r.primary ? `<strong>${escapeHtml(r.signal)}</strong>` : escapeHtml(r.signal);
1367
+ const presentStyle = r.presentColor ? ` style="color:${r.presentColor}"` : "";
1368
+ return `<tr><td>${signalCell}</td><td${presentStyle}>${escapeHtml(r.present)}</td><td>${r.measure}</td><td>${r.decision}</td></tr>`;
1369
+ })
1370
+ .join("\n ");
1371
+ return `<table>
1372
+ <thead><tr><th>Signal / failure-mode</th><th>Present?</th><th>Measure</th><th>Decision</th></tr></thead>
1373
+ <tbody>
1374
+ ${body}
1375
+ </tbody>
1376
+ </table>`;
1377
+ }
1378
+
1379
+ // ── renderAssumptionsBlock (gold-standard .assumptions w/ pills) ─────────────
1380
+
1381
+ /**
1382
+ * Wave-5 R1.2: Render the `.assumptions` block — one `<li>` per assumption with a
1383
+ * verified / unverified / hypothesis-pending pill.
1384
+ */
1385
+ export function renderAssumptionsBlock(finding: Finding): string {
1386
+ const assumptions = finding.assumptions ?? [];
1387
+ if (assumptions.length === 0) return "";
1388
+ const items = assumptions
1389
+ .map((a) => {
1390
+ const pillClass = a.status; // "verified" | "unverified" | "hypothesis-pending"
1391
+ const pillLabel =
1392
+ a.status === "verified" ? "VERIFIED" : a.status === "unverified" ? "UNVERIFIED" : "HYPOTHESIS-PENDING";
1393
+ return `<li>${escapeHtml(a.text)} <span class="${pillClass}">${pillLabel} — ${escapeHtml(a.basis)}</span></li>`;
1394
+ })
1395
+ .join("\n ");
1396
+ return `<div class="assumptions"><h4>⚠ Assumptions</h4><ul>
1397
+ ${items}
1398
+ </ul></div>`;
1399
+ }
1400
+
1401
+ // ── renderRemedyCard (gold-standard .remedy.recommended) ─────────────────────
1402
+
1403
+ /** Map a remedy applyTarget label → gold-standard apply-pill class. */
1404
+ function applyPillClass(label: string | undefined): string {
1405
+ if (!label) return "apply-none";
1406
+ const l = label.toLowerCase();
1407
+ if (l.includes("code")) return "apply-code";
1408
+ if (l.includes("prompt")) return "apply-prompt";
1409
+ if (l.includes("config")) return "apply-config";
1410
+ if (l.includes("no change") || l.includes("none")) return "apply-none";
1411
+ return "apply-config";
1412
+ }
1413
+
1414
+ /** Map a remedy targetClass → canonical tclass-pill class. */
1415
+ function tclassPillClass(targetClass: string | undefined): string {
1416
+ if (!targetClass) return "";
1417
+ const l = targetClass.toLowerCase();
1418
+ if (l.includes("local-agent")) return "tclass-local-agent";
1419
+ if (l.includes("local-code")) return "tclass-local-code-construct";
1420
+ if (l.includes("remote")) return "tclass-remote";
1421
+ return "tclass-local-agent";
1422
+ }
1423
+
1424
+ /** Map a remedy changeType → canonical ctype-pill class. */
1425
+ function ctypePillClass(changeType: Remedy["changeType"]): string {
1426
+ if (!changeType) return "";
1427
+ return `ctype-${changeType}`;
1428
+ }
1429
+
1430
+ /** Map a remedy correctness → canonical b-correctness class. */
1431
+ function correctnessPillClass(correctness: Remedy["correctness"]): string {
1432
+ // D-1 render guard: an absent correctness must NOT yield `b-correctness-undefined`.
1433
+ // Block C owns making the DATA always-present (type↔producer↔gate); this is the
1434
+ // render safety-net so a missing field can never reach the operator as `undefined`.
1435
+ return correctness ? `b-correctness-${correctness}` : "b-correctness-na";
1436
+ }
1437
+
1438
+ /**
1439
+ * D-1 render guard. The remedy badge fields (`rank` / `cost` / `correctness`) are
1440
+ * REQUIRED on the canonical Remedy type, yet the broken-report evidence showed
1441
+ * them rendering as the literal string `undefined` when a producer/contract
1442
+ * desync left them unset at runtime. This is the render-side safety-net: an
1443
+ * absent value becomes a neutral `n/a` marker, never `undefined`.
1444
+ *
1445
+ * `value` is `unknown` deliberately — the guard must survive a value the type
1446
+ * system claims cannot be missing (defense-in-depth; Block C fixes the data).
1447
+ */
1448
+ function badgeField(value: unknown): string {
1449
+ if (value === undefined || value === null || value === "") return "n/a";
1450
+ return String(value);
1451
+ }
1452
+
1453
+ /**
1454
+ * D-4 render guard. `RenderInput.sessionId` is typed required, but the enricher
1455
+ * does not always emit a top-level sessionId — when absent the renderer crashed
1456
+ * on `sessionId.toUpperCase()` (the report would not render at all). Resolve a
1457
+ * safe, non-empty session label so the header / title / footer never crash or
1458
+ * print `undefined`. The data-side fix (enricher populating sessionId) is
1459
+ * tracked separately; this is the render-side null-guard.
1460
+ *
1461
+ * `value` is `unknown` for defense-in-depth — the guard must survive a value
1462
+ * the type system claims cannot be missing.
1463
+ */
1464
+ function safeSessionId(value: unknown): string {
1465
+ if (typeof value === "string" && value.length > 0) return value;
1466
+ return "unknown-session";
1467
+ }
1468
+
1469
+ /**
1470
+ * PRD-CC-08: Build the pre-assembled markdown bundle for a remedy.
1471
+ * Emitted as a hidden <script type="text/plain"> so the Live Preview reader
1472
+ * can use it for deterministic, DOM-extraction-free bundle assembly.
1473
+ */
1474
+ export function buildRemedyMarkdownBundle(remedy: Remedy, finding: Finding): string {
1475
+ const lines: string[] = [];
1476
+ lines.push(`## ${escapeMarkdown(remedy.remedyId)} — ${escapeMarkdown(remedy.title)}`);
1477
+ lines.push(`**Finding:** ${escapeMarkdown(finding.findingId)} — ${escapeMarkdown(finding.title ?? finding.actionable)}`);
1478
+ lines.push(`**Severity:** ${finding.severity ?? finding.failureOrigin.confidence}`);
1479
+ if (remedy.applyTarget) lines.push(`**Apply target:** ${escapeMarkdown(remedy.applyTarget)}`);
1480
+ if (remedy.targetClass) lines.push(`**Target class:** ${escapeMarkdown(remedy.targetClass)}`);
1481
+ if (remedy.changeType) lines.push(`**Change type:** ${remedy.changeType}`);
1482
+ // D-1 render guard: the copy-back markdown bundle must not leak `undefined` either.
1483
+ lines.push(`**Cost:** ${badgeField(remedy.cost)} · **Correctness:** ${badgeField(remedy.correctness)} · **Rank:** ${badgeField(remedy.rank)}`);
1484
+ lines.push("");
1485
+
1486
+ if (remedy.rationale) {
1487
+ lines.push("### Why this remedy");
1488
+ lines.push(escapeMarkdown(remedy.rationale));
1489
+ lines.push("");
1490
+ }
1491
+
1492
+ if (remedy.whyWorks) {
1493
+ lines.push("### Why this works");
1494
+ lines.push(escapeMarkdown(remedy.whyWorks));
1495
+ lines.push("");
1496
+ }
1497
+
1498
+ if (remedy.diff) {
1499
+ lines.push("### Diff");
1500
+ lines.push("**Before:**");
1501
+ lines.push("```diff");
1502
+ lines.push(`- ${remedy.diff.before}`);
1503
+ lines.push("```");
1504
+ lines.push("**After:**");
1505
+ lines.push("```diff");
1506
+ lines.push(`+ ${remedy.diff.after}`);
1507
+ lines.push("```");
1508
+ lines.push("");
1509
+ }
1510
+
1511
+ if (remedy.plan) {
1512
+ lines.push("### Apply plan");
1513
+ for (const f of remedy.plan.files) {
1514
+ lines.push(`- [${f.action}] ${f.path}${f.lineRange ? ` (${f.lineRange})` : ""}`);
1515
+ }
1516
+ if (remedy.plan.verify.length > 0) {
1517
+ lines.push("");
1518
+ lines.push("**Verify:**");
1519
+ for (const v of remedy.plan.verify) {
1520
+ lines.push(`- \`${v}\``);
1521
+ }
1522
+ }
1523
+ if (remedy.plan.acceptance) {
1524
+ lines.push(`**Acceptance:** ${remedy.plan.acceptance}`);
1525
+ }
1526
+ if (remedy.plan.commitMessage) {
1527
+ lines.push(`**Commit:** \`${remedy.plan.commitMessage}\``);
1528
+ }
1529
+ lines.push("");
1530
+ }
1531
+
1532
+ if (remedy.applyInstructions && remedy.applyInstructions.length > 0) {
1533
+ lines.push("### Apply instructions");
1534
+ remedy.applyInstructions.forEach((step, i) => {
1535
+ lines.push(`${i + 1}. ${escapeMarkdown(step)}`);
1536
+ });
1537
+ lines.push("");
1538
+ }
1539
+
1540
+ return lines.join("\n");
1541
+ }
1542
+
1543
+ /** Minimal markdown escaper for inline text (not block-level). */
1544
+ function escapeMarkdown(s: string): string {
1545
+ return s.replace(/[*_`[\]]/g, "\\$&");
1546
+ }
1547
+
1548
+ /**
1549
+ * PRD-CC-03: Canonical remedy card anatomy (D1/D2/D3/D4/D7).
1550
+ * Render order:
1551
+ * checkbox (top-right) → header row (rank · id · title) →
1552
+ * meta strip (cost · correctness · targetClass · changeType · applyTarget pills) →
1553
+ * purple r-rationale → cyan r-why-works →
1554
+ * dashed r-target row (IFF applyTarget) →
1555
+ * r-diff-grid 2-col Before/After (IFF diff) →
1556
+ * r-apply-grid 2-col Apply plan + Apply instructions (IFF plan OR applyInstructions) →
1557
+ * remedy-notes textarea.
1558
+ * NO <details> collapsibles. NO per-remedy Copy button.
1559
+ * Hidden <script type="text/plain"> payload for Live Preview (CC-08).
1560
+ */
1561
+
1562
+ /**
1563
+ * W12-08 (PR-052 proposed): render the "source not found — hypothesis" caveat
1564
+ * block shown in place of the Before/After grid when a remedy carries a
1565
+ * `diffStatus` instead of a `diff`. Honors `feedback_model_intent_sacred` — a
1566
+ * remedy with no findable source surfaces an explicit marker, never a guessed diff.
1567
+ */
1568
+ export function renderDiffStatusCaveat(diffStatus: DiffStatus): string {
1569
+ const reason =
1570
+ diffStatus === "source-unavailable"
1571
+ ? "The apply target's current source is not accessible to the analyzer, so a Before/After cannot be cited."
1572
+ : "The failure origin could not be pinned to a concrete source location.";
1573
+ return `<div class="r-diff-caveat" data-diff-status="${escapeHtml(diffStatus)}">
1574
+ <div class="r-diff-caveat-label">Source not found — hypothesis</div>
1575
+ <p>${escapeHtml(reason)} No diff is shown; apply this remedy as a hypothesis and verify against the live target.</p>
1576
+ </div>`;
1577
+ }
1578
+
1579
+ export function renderRemedyCard(remedy: Remedy, findingId: string, finding?: Finding): string {
1580
+ const isRecommended = remedy.rank === 1;
1581
+ const rid = remedy.remedyId.toLowerCase().replace(/[^a-z0-9-]/g, "-");
1582
+
1583
+ // ── Checkbox (top-right, part of header row) ──
1584
+ const checkbox = `<input type="checkbox" class="remedy-cb" data-id="${escapeHtml(remedy.remedyId)}" data-finding="${escapeHtml(findingId)}"${isRecommended ? " checked" : ""}>`;
1585
+
1586
+ // ── Header row ──
1587
+ // W12-04: the checkbox is a direct child of `.remedy` (a flex ROW:
1588
+ // `[checkbox] [.remedy-body]`), NOT inside the header. The header is part of
1589
+ // the `.remedy-body` column. This restores the `[checkbox][.remedy-body]`
1590
+ // structure the orphaned `.remedy-body{flex:1}` rule + the template's
1591
+ // `.remedy-body`-targeting note-injection JS were written for.
1592
+ // D-1 render guard: never interpolate a bare `remedy.rank` — absent ⇒ "n/a", not "undefined".
1593
+ const rankPill = `<span class="rank-pill">RANK ${badgeField(remedy.rank)}</span>`;
1594
+ const header = `<div class="remedy-header"><div class="remedy-rank-id">${rankPill} <span class="remedy-id">${escapeHtml(remedy.remedyId)}</span></div><div class="remedy-what">${escapeHtml(remedy.title)}</div></div>`;
1595
+
1596
+ // ── Meta strip ──
1597
+ // D-1 render guard: cost/correctness drive BOTH the pill text and a CSS class.
1598
+ // Guard both surfaces so a missing field never prints `undefined` nor produces
1599
+ // a `b-cost-undefined` class. The class helper for cost is inlined here.
1600
+ const costClass = remedy.cost ? `b-cost-${remedy.cost}` : "b-cost-na";
1601
+ const costPill = `<span class="${costClass}">cost:${badgeField(remedy.cost)}</span>`;
1602
+ const correctnessPill = `<span class="${correctnessPillClass(remedy.correctness)}">correct:${badgeField(remedy.correctness)}</span>`;
1603
+ const applyLabel = remedy.applyTarget ?? "";
1604
+ const applyPill = applyLabel
1605
+ ? `<span class="apply-pill ${applyPillClass(applyLabel)}">${escapeHtml(applyLabel)}</span>`
1606
+ : "";
1607
+ const tclassVal = remedy.targetClass ?? "";
1608
+ const tclassPill = tclassVal
1609
+ ? `<span class="tclass-pill ${tclassPillClass(tclassVal)}">${escapeHtml(tclassVal)}</span>`
1610
+ : "";
1611
+ const ctypePill = remedy.changeType
1612
+ ? `<span class="ctype-pill ${ctypePillClass(remedy.changeType)}">${remedy.changeType}</span>`
1613
+ : "";
1614
+ const metaStrip = `<div class="remedy-meta">${costPill}${correctnessPill}${tclassPill}${ctypePill}${applyPill}</div>`;
1615
+
1616
+ // ── Purple r-rationale (D1) ──
1617
+ const rationaleText = remedy.rationale ?? `${escapeHtml(remedy.failureOrigin.what)}: ${escapeHtml(remedy.failureOrigin.why)} at ${escapeHtml(remedy.failureOrigin.where)}`;
1618
+ const rationaleBlock = `<div class="r-rationale"><span class="r-block-label">Why this remedy</span><p>${remedy.rationale ? escapeHtml(remedy.rationale) : rationaleText}</p></div>`;
1619
+
1620
+ // ── Cyan r-why-works (D1) ──
1621
+ const whyWorksText = remedy.whyWorks ?? "(causal mechanism not documented)";
1622
+ const whyWorksBlock = `<div class="r-why-works"><span class="r-block-label">Why this works</span><p>${escapeHtml(whyWorksText)}</p></div>`;
1623
+
1624
+ // ── Dashed r-target row (IFF applyTarget) ──
1625
+ const targetRow = applyLabel
1626
+ ? `<div class="r-target"><span class="r-target-label">Apply target</span><code>${escapeHtml(applyLabel)}</code></div>`
1627
+ : "";
1628
+
1629
+ // ── r-diff-grid 2-col Before/After (IFF diff) (D3) ──
1630
+ // W12-08: when diff is absent but diffStatus is set, render a labeled
1631
+ // "source not found — hypothesis" caveat block instead of the Before/After
1632
+ // grid (renderDiffStatusCaveat). NEVER a fabricated diff (PR-052 proposed).
1633
+ let diffGrid = "";
1634
+ if (remedy.diff) {
1635
+ diffGrid = `<div class="r-diff-grid">
1636
+ <div class="diff-col"><div class="diff-label label-before">Before</div><pre>${escapeHtml(remedy.diff.before)}</pre></div>
1637
+ <div class="diff-col"><div class="diff-label label-after">After</div><pre>${escapeHtml(remedy.diff.after)}</pre></div>
1638
+ </div>`;
1639
+ } else if (remedy.diffStatus) {
1640
+ diffGrid = renderDiffStatusCaveat(remedy.diffStatus);
1641
+ }
1642
+
1643
+ // ── r-apply-grid 2-col Apply plan + Apply instructions (IFF plan OR applyInstructions) (D4) ──
1644
+ let applyGrid = "";
1645
+ if (remedy.plan || (remedy.applyInstructions && remedy.applyInstructions.length > 0)) {
1646
+ const planCol = remedy.plan
1647
+ ? `<div class="apply-col">
1648
+ <div class="apply-col-label">Apply plan</div>
1649
+ <ul class="r-apply-plan">
1650
+ ${remedy.plan.files.map((f) => `<li><code>${escapeHtml(f.action)}</code> ${escapeHtml(f.path)}${f.lineRange ? ` <span class="r-line-range">(${escapeHtml(f.lineRange)})</span>` : ""}</li>`).join("\n ")}
1651
+ </ul>
1652
+ ${remedy.plan.verify.length > 0 ? `<div class="r-verify-label">Verify</div><ul class="r-apply-plan">${remedy.plan.verify.map((v) => `<li><code>${escapeHtml(v)}</code></li>`).join("")}</ul>` : ""}
1653
+ ${remedy.plan.acceptance ? `<div class="r-acceptance">${escapeHtml(remedy.plan.acceptance)}</div>` : ""}
1654
+ ${remedy.plan.commitMessage ? `<div class="r-commit"><code>${escapeHtml(remedy.plan.commitMessage)}</code></div>` : ""}
1655
+ </div>`
1656
+ : `<div class="apply-col apply-col-empty"><span class="r-block-label">Apply plan</span><p class="r-muted">No structured plan provided.</p></div>`;
1657
+
1658
+ const instrCol =
1659
+ remedy.applyInstructions && remedy.applyInstructions.length > 0
1660
+ ? `<div class="apply-col">
1661
+ <div class="apply-col-label">Apply instructions</div>
1662
+ <ol class="r-apply-instr">
1663
+ ${remedy.applyInstructions.map((step) => `<li>${escapeHtml(step)}</li>`).join("\n ")}
1664
+ </ol>
1665
+ </div>`
1666
+ : `<div class="apply-col apply-col-empty"><span class="r-block-label">Apply instructions</span><p class="r-muted">No step-by-step instructions provided.</p></div>`;
1667
+
1668
+ applyGrid = `<div class="r-apply-grid">${planCol}${instrCol}</div>`;
1669
+ }
1670
+
1671
+ // ── W9-F L3: fix-feedback history (feedbackOnFix[]) ──
1672
+ const fixFeedbackHtml = renderFeedbackOnFixBlock(remedy);
1673
+
1674
+ // ── Remedy notes textarea (D7) ──
1675
+ const notesArea = `<textarea class="remedy-notes" placeholder="Feedback on this remedy — overrides, conditions, why-not, modifications. Merged verbatim into the master Copy decisions bundle."></textarea>`;
1676
+
1677
+ // ── Hidden payload script (CC-08) ──
1678
+ const payloadScript = finding
1679
+ ? `<script type="text/plain" id="payload-${escapeHtml(rid)}">${escapeHtml(buildRemedyMarkdownBundle(remedy, finding))}</script>`
1680
+ : "";
1681
+
1682
+ // W12-04: `.remedy` is a flex ROW of [checkbox][.remedy-body]; `.remedy-body`
1683
+ // is a flex COLUMN that stacks the ~9 content blocks full-width. Without the
1684
+ // wrapper the blocks laid out as a horizontal row crushed into narrow columns.
1685
+ // The hidden text/plain payload stays outside `.remedy-body` (not visible).
1686
+ return `<div class="remedy${isRecommended ? " recommended" : ""}">
1687
+ ${payloadScript}
1688
+ ${checkbox}
1689
+ <div class="remedy-body">
1690
+ ${header}
1691
+ ${metaStrip}
1692
+ ${rationaleBlock}
1693
+ ${whyWorksBlock}
1694
+ ${targetRow}
1695
+ ${diffGrid}
1696
+ ${applyGrid}
1697
+ ${fixFeedbackHtml}
1698
+ ${notesArea}
1699
+ </div>
1700
+ </div>`;
1701
+ }
1702
+
1703
+ // ── renderFeedbackBlock (PRD-CC-04, D5/D9) ────────────────────────────────────
1704
+
1705
+ /**
1706
+ * PRD-CC-04 (D5): Render the feedback-grounding block for a finding.
1707
+ * Displayed between Problem and Evidence. Returns "" when no sources.
1708
+ * Legacy: when feedbackSources is absent, auto-promotes userFeedback to
1709
+ * a single chat source entry (PRD-CC-02 backward-compat).
1710
+ * NO emojis on the header or source-type labels (D9).
1711
+ */
1712
+ export function renderFeedbackBlock(finding: Finding): string {
1713
+ // Resolve sources: prefer feedbackSources, fall back to userFeedback promotion.
1714
+ let sources: FeedbackSource[] = finding.feedbackSources ?? [];
1715
+ if (sources.length === 0 && finding.userFeedback) {
1716
+ sources = [
1717
+ {
1718
+ sourceType: "chat",
1719
+ provenance: "legacy userFeedback field",
1720
+ body: finding.userFeedback,
1721
+ },
1722
+ ];
1723
+ }
1724
+ if (sources.length === 0) return "";
1725
+
1726
+ const prettyLabel = (s: FeedbackSource): string => {
1727
+ if (s.sourceType === "chat") return "Operator chat";
1728
+ if (s.sourceType === "trace-score") return "Langfuse trace score";
1729
+ return s.externalPlatform ? `External: ${s.externalPlatform}` : "External feedback platform";
1730
+ };
1731
+
1732
+ const items = sources
1733
+ .map((s) => {
1734
+ const scoreNote =
1735
+ s.score !== undefined
1736
+ ? ` <span class="fb-score-note">${escapeHtml(s.score.name)}: ${escapeHtml(String(s.score.value))}${s.score.scorerType ? ` (${escapeHtml(s.score.scorerType)})` : ""}</span>`
1737
+ : "";
1738
+ const traceNote = s.traceId
1739
+ ? ` · trace <code>${escapeHtml(s.traceId)}</code>`
1740
+ : "";
1741
+ const timeNote = s.capturedAt ? ` · ${escapeHtml(s.capturedAt)}` : "";
1742
+ return `<div class="fb-item fb-${s.sourceType}">
1743
+ <div class="fb-head"><span class="fb-source-type">${escapeHtml(prettyLabel(s))}</span> · ${escapeHtml(s.provenance)}${scoreNote}${traceNote}${timeNote}</div>
1744
+ <blockquote>${escapeHtml(s.body)}</blockquote>
1745
+ </div>`;
1746
+ })
1747
+ .join("\n");
1748
+
1749
+ return `<div class="feedback-list">
1750
+ <h3>Feedback grounding this finding <span class="r-count">(${sources.length} source${sources.length !== 1 ? "s" : ""})</span></h3>
1751
+ ${items}
1752
+ </div>`;
1753
+ }
1754
+
1755
+ // ── renderFindingPanel (gold-standard finding panel) ─────────────────────────
1756
+
1757
+ /**
1758
+ * Wave-5 R1.2: Render a single finding as a gold-standard panel.
1759
+ * Anatomy: severity-badged h2 + sub + worst-case callout + taxonomy chips +
1760
+ * Problem (.f-desc) + Evidence + Why-chain (.whychain w/ origin marker) +
1761
+ * Assumptions block + Remedies (ranked, rank-1 recommended).
1762
+ *
1763
+ * Panel id scheme: client findings get t2..tN+1 (Overview is t1).
1764
+ */
1765
+ /**
1766
+ * Wave-6 R2.5: Render the per-finding sampling-representativeness proof. Shown
1767
+ * BELOW the why-chain. WARN-only — a "low" coverageConfidence surfaces a caveat
1768
+ * banner but never blocks. Returns "" when the finding carries no coverageProof.
1769
+ *
1770
+ * Renders: a confidence headline (value + level pill), the 4-dimension coverage
1771
+ * table (latency · score · temporal · tool-trajectory), and a population-vs-sample
1772
+ * bias row (mean-badness proof). The colour cue derives from `level` only.
1773
+ *
1774
+ * W12-13 (OP-4): when the per-finding proof was NOT computed (sampler did not
1775
+ * attach `coverageProof`), render an EXPLICIT "representativeness not computed"
1776
+ * marker rather than a blank slot — so the absence is legible, not silent.
1777
+ */
1778
+ export function renderCoverageProof(f: Finding): string {
1779
+ const cp = f.coverageProof;
1780
+ if (!cp) {
1781
+ return `<div class="coverage-proof coverage-proof-absent">
1782
+ <h3>Sampling coverage — representativeness proof <span class="badge b-info">NOT COMPUTED</span></h3>
1783
+ <p class="sub">Per-finding representativeness was not computed for this finding (no per-finding sample proof attached). The run-level sampling methodology still applies; see the Methodology tab.</p>
1784
+ </div>`;
1785
+ }
1786
+
1787
+ const levelPill =
1788
+ cp.level === "high"
1789
+ ? `<span class="badge b-info" style="background:var(--g,#43c39a);">HIGH</span>`
1790
+ : cp.level === "medium"
1791
+ ? `<span class="badge b-med">MEDIUM</span>`
1792
+ : `<span class="badge b-crit">LOW</span>`;
1793
+
1794
+ const lowBanner =
1795
+ cp.level === "low"
1796
+ ? `<div class="warn">⚠ <strong>Low sampling coverage (${escapeHtml(cp.coverageConfidence.toFixed(1))}%).</strong> This finding's evidence may under-represent the population. Non-blocking — re-run with a wider sample or pass <code>--accept-low-confidence</code> to acknowledge. (R2.5)</div>`
1797
+ : "";
1798
+
1799
+ const dimRows = cp.dimensions
1800
+ .map(
1801
+ (d) =>
1802
+ `<tr><td>${escapeHtml(d.dimension)}</td><td>${d.coveredBuckets} / ${d.populationBuckets}</td><td>${escapeHtml(d.coveragePct.toFixed(1))}%</td></tr>`
1803
+ )
1804
+ .join("\n ");
1805
+
1806
+ const p = cp.population;
1807
+ const biasRow = `<p class="sub">Population bias proof — sampled <strong>${p.sampleSize}</strong> of <strong>${p.populationSize}</strong> traces (${escapeHtml((p.sampleFraction * 100).toFixed(1))}%). Mean-badness population <code>${escapeHtml(p.populationMeanBadness.toFixed(4))}</code> vs sample <code>${escapeHtml(p.sampleMeanBadness.toFixed(4))}</code>.</p>`;
1808
+
1809
+ return `<div class="coverage-proof">
1810
+ <h3>Sampling coverage — representativeness proof ${levelPill} <code>${escapeHtml(cp.coverageConfidence.toFixed(1))}%</code></h3>
1811
+ ${lowBanner}
1812
+ <table>
1813
+ <thead><tr><th>Dimension</th><th>Buckets covered</th><th>Coverage</th></tr></thead>
1814
+ <tbody>
1815
+ ${dimRows}
1816
+ </tbody>
1817
+ </table>
1818
+ ${biasRow}
1819
+ </div>`;
1820
+ }
1821
+
1822
+ /**
1823
+ * EV-1 (Wave-15): render a cited trace as a narration line rather than a bare
1824
+ * pointer. Shape: trace `<id>` — <whatHappened> with the optional `example`
1825
+ * appended as a quoted excerpt («…»). Block-0 (trace.ts) guarantees
1826
+ * `whatHappened` on every FailureOrigin / WhyChainEntry, but we degrade
1827
+ * gracefully if it is absent (e.g. legacy findings): fall back to the `evidence`
1828
+ * pointer alone so the panel never renders an empty narration.
1829
+ *
1830
+ * `traceId` is the cited trace for this slice (failureOrigin → referenceIds /
1831
+ * sourceTraceIds[0]; whyChain[] → the evidence pointer already carries it). When
1832
+ * no trace id is known the "trace `<id>` —" prefix is omitted, not faked.
1833
+ *
1834
+ * escapeHtml discipline (no raw interpolation) — every dynamic field is escaped.
1835
+ */
1836
+ function renderTraceNarration(
1837
+ whatHappened: string | undefined,
1838
+ evidence: string,
1839
+ example: string | undefined,
1840
+ traceId?: string,
1841
+ ): string {
1842
+ const narration =
1843
+ whatHappened && whatHappened.trim().length > 0
1844
+ ? escapeHtml(whatHappened)
1845
+ : escapeHtml(evidence);
1846
+ const prefix =
1847
+ traceId && traceId.trim().length > 0
1848
+ ? `trace <code>${escapeHtml(traceId)}</code> — `
1849
+ : "";
1850
+ const excerpt =
1851
+ example && example.trim().length > 0
1852
+ ? ` <q class="ev-example">«${escapeHtml(example)}»</q>`
1853
+ : "";
1854
+ return `${prefix}${narration}${excerpt}`;
1855
+ }
1856
+
1857
+ /**
1858
+ * DISMISSAL capture control — the per-finding "Valid / Invalid (not an issue)" verdict,
1859
+ * rendered at the FOOTER of each finding panel. It rides the EXISTING copy-back channel
1860
+ * (mirrors the remedy checkbox / notes / hidden-payload pattern) — NO new egress, NO new
1861
+ * button. Default UNCHECKED = Valid (a report never pre-dismisses a finding); checked =
1862
+ * Invalid → the operator's copy-back carries the dismissal, which the orchestrator folds
1863
+ * into the unified verdict ledger, suppressing the finding on the next run for this entity.
1864
+ *
1865
+ * The hidden `text/plain` payload carries the machine-readable record the copy-back reads
1866
+ * on paste-back (findingId + the failure-mode triple + severity) so the marks survive
1867
+ * without DOM scraping. `text/plain` scripts are SKIPPED by the render-js-syntax gate
1868
+ * (PR-050) — non-executable data, not JS. Placed at the footer (beyond the finalize-gate
1869
+ * finding-panel 1500-char window) so the taxonomy→Problem anchor scan is unaffected.
1870
+ */
1871
+ export function renderDismissControl(f: Finding): string {
1872
+ const fidNorm = f.findingId.toLowerCase().replace(/[^a-z0-9-]/g, "-");
1873
+ const payload = {
1874
+ findingId: f.findingId,
1875
+ verdict: "dismissed",
1876
+ what: f.failureOrigin.what,
1877
+ why: f.failureOrigin.why,
1878
+ where: f.failureOrigin.where,
1879
+ whatHappened: f.failureOrigin.whatHappened,
1880
+ severity: f.severity ?? "info",
1881
+ };
1882
+ const payloadScript = `<script type="text/plain" id="dismiss-payload-${escapeHtml(fidNorm)}">${escapeHtml(JSON.stringify(payload))}</script>`;
1883
+ const checkbox = `<input type="checkbox" class="finding-invalid" data-finding="${escapeHtml(f.findingId)}">`;
1884
+ const reason = `<textarea class="dismiss-reason" placeholder="Why is this not an issue? Folded verbatim into the Copy decisions bundle."></textarea>`;
1885
+ return `<div class="finding-verdict">
1886
+ ${payloadScript}
1887
+ <label class="fv-toggle">${checkbox} <span class="fv-label">Invalid — not an issue (dismiss &amp; suppress next run)</span></label>
1888
+ ${reason}
1889
+ </div>`;
1890
+ }
1891
+
1892
+ export function renderFindingPanel(f: Finding, panelIdx: number): string {
1893
+ const panelId = `t${panelIdx + 2}`;
1894
+ const sev = severityBadgeClass(f.severity, f.failureOrigin.confidence);
1895
+ const sevLabel = sev === "crit" ? "CRITICAL" : sev === "high" ? "HIGH" : sev === "med" ? "MEDIUM" : "RULE-OUT";
1896
+
1897
+ const title = escapeHtml(f.title ?? f.actionable.slice(0, 90));
1898
+ const sub = f.subDesc ? `<p class="sub">${escapeHtml(f.subDesc)}</p>` : "";
1899
+ const worstCase = f.worstCaseCallout ? `<div class="crit"><strong>Worst:</strong> ${f.worstCaseCallout}</div>` : "";
1900
+
1901
+ // Taxonomy chips (WHAT/WHY/WHERE + apply + confidence).
1902
+ const applyLabel = f.applyLabel ?? "";
1903
+ const taxonomy = `<div class="taxonomy">
1904
+ <span class="tax-chip"><strong>WHAT</strong>: ${escapeHtml(f.failureOrigin.what)}</span>
1905
+ <span class="tax-chip"><strong>WHY</strong>: ${escapeHtml(f.failureOrigin.why)}</span>
1906
+ <span class="tax-chip"><strong>WHERE</strong>: ${escapeHtml(f.failureOrigin.where)}</span>
1907
+ ${applyLabel ? `<span class="tax-chip"><strong>APPLY</strong>: ${escapeHtml(applyLabel)}</span>` : ""}
1908
+ <span class="tax-chip"><strong>Confidence</strong>: ${escapeHtml(f.failureOrigin.confidence)}</span>
1909
+ </div>`;
1910
+
1911
+ // Problem prose — PRIMARY block (W18-problem): ALWAYS rendered, at the TOP of the
1912
+ // panel (before Evidence / why-chain / remedies), from the descriptive `f.problem`
1913
+ // statement. The fallback to the action-biased `f.actionable` is KILLED: if `problem`
1914
+ // is somehow absent we emit a LOUD placeholder rather than silently showing a todo.
1915
+ const problemBody =
1916
+ f.problem && f.problem.trim().length > 0
1917
+ ? f.problem
1918
+ : `<strong class="problem-missing">PROBLEM STATEMENT MISSING</strong>`;
1919
+ const problem = `<h3>Problem</h3>
1920
+ <div class="f-desc">${problemBody}</div>`;
1921
+
1922
+ // PRD-CC-04: Feedback block between Problem and Evidence (D5).
1923
+ // L1: raw feedbackSources (or legacy userFeedback promotion)
1924
+ const feedbackBlock = renderFeedbackBlock(f);
1925
+ // W9-F L2: translated feedback (raw symptom → component-level RCA target)
1926
+ const translatedFeedbackBlock = renderTranslatedFeedbackBlock(f);
1927
+
1928
+ // Evidence. EV-1: when no pre-rendered evidenceHtml is supplied, narrate the
1929
+ // cited trace ("trace <id> — <whatHappened> «example»") instead of a bare pointer.
1930
+ const citedTraceId = f.referenceIds?.traceId ?? f.sourceTraceIds[0];
1931
+ const evidence = `<h3>Evidence</h3>
1932
+ ${
1933
+ f.evidenceHtml
1934
+ ? f.evidenceHtml
1935
+ : `<p class="ev-narration">${renderTraceNarration(
1936
+ f.failureOrigin.whatHappened,
1937
+ f.failureOrigin.evidence,
1938
+ f.failureOrigin.example,
1939
+ citedTraceId,
1940
+ )}</p>`
1941
+ }`;
1942
+
1943
+ // Why-chain. EV-1: each step narrates what happened in the cited trace via
1944
+ // whatHappened (+ optional «example»); the evidence pointer is the trace id.
1945
+ const whyChainItems = f.whyChain
1946
+ .map(
1947
+ (w) =>
1948
+ `<li class="${w.isOrigin ? "origin" : ""}">${escapeHtml(w.why)}<em>${renderTraceNarration(
1949
+ w.whatHappened,
1950
+ w.evidence,
1951
+ w.example,
1952
+ w.evidence,
1953
+ )}</em></li>`
1954
+ )
1955
+ .join("\n ");
1956
+ const whyChain = f.whyChain.length > 0
1957
+ ? `<h3>Why-chain</h3>
1958
+ <ol class="whychain">
1959
+ ${whyChainItems}
1960
+ </ol>`
1961
+ : "";
1962
+
1963
+ // R2.5: sampling-representativeness proof — rendered directly below the why-chain.
1964
+ const coverageProof = renderCoverageProof(f);
1965
+
1966
+ // Assumptions block.
1967
+ const assumptions = renderAssumptionsBlock(f);
1968
+
1969
+ // Remedies — pass finding ref to renderRemedyCard for hidden payload scripts (CC-08).
1970
+ const remedies = f.remedies.length > 0
1971
+ ? `<h3>Remedies</h3>
1972
+ ${f.remedies.map((r) => renderRemedyCard(r, f.findingId, f)).join("\n ")}`
1973
+ : "";
1974
+
1975
+ // W18-problem: Problem is the PRIMARY content block of the finding — rendered at the
1976
+ // TOP of the finding body, BEFORE feedback / Evidence / why-chain / remedies. It sits
1977
+ // immediately after the taxonomy chip-strip (head matter: title + sub + worstCase
1978
+ // callout + WHAT/WHY/WHERE chips). Keeping Problem adjacent-after the taxonomy anchor
1979
+ // also satisfies the finalize-gate `finding-panel` row, which scans forward from
1980
+ // `<div class="taxonomy">` for the required "Problem" marker.
1981
+ return `
1982
+ <section class="panel" id="${panelId}">
1983
+ <h2><span class="badge b-${sev}">${sevLabel}</span> ${escapeHtml(f.findingId)} — ${title}</h2>
1984
+ ${sub}
1985
+ ${worstCase}
1986
+ ${taxonomy}
1987
+ ${problem}
1988
+ ${feedbackBlock}
1989
+ ${translatedFeedbackBlock}
1990
+ ${evidence}
1991
+ ${whyChain}
1992
+ ${coverageProof}
1993
+ ${assumptions}
1994
+ ${remedies}
1995
+ ${renderDismissControl(f)}
1996
+ </section>`;
1997
+ }
1998
+
1999
+ // ── renderMetaReportPanels (SD cluster-grouped panels) ───────────────────────
2000
+
2001
+ /**
2002
+ * SD self-diag: render cluster panels — one panel per failureOrigin.what cluster.
2003
+ * Dedup findings within a cluster by (traceId, what+why+where). Each finding inside
2004
+ * the cluster renders the same gold-standard depth (taxonomy + evidence + assumptions
2005
+ * + remedies) as the per-finding client panels.
2006
+ */
2007
+ function renderMetaReportPanels(findings: Finding[]): string {
2008
+ const clusters = clusterFindings(findings);
2009
+ let html = "";
2010
+ let clusterIdx = 0;
2011
+ for (const [what, group] of clusters) {
2012
+ const panelId = `tcluster-${clusterIdx}`;
2013
+ const deduped = dedupFindings(group);
2014
+ const subPanels = deduped
2015
+ .map((f) => {
2016
+ const sev = severityBadgeClass(f.severity, f.failureOrigin.confidence);
2017
+ // SD-6: parity with renderFindingPanel — the meta sub-panel must carry the
2018
+ // WHAT chip (it previously had only WHY/WHERE/Confidence) so the
2019
+ // finding-panel checklist row (require: taxonomy + WHAT + Problem) passes
2020
+ // on a meta-report WITHOUT a checklist exemption.
2021
+ const taxonomy = `<div class="taxonomy">
2022
+ <span class="tax-chip"><strong>WHAT</strong>: ${escapeHtml(f.failureOrigin.what)}</span>
2023
+ <span class="tax-chip"><strong>WHY</strong>: ${escapeHtml(f.failureOrigin.why)}</span>
2024
+ <span class="tax-chip"><strong>WHERE</strong>: ${escapeHtml(f.failureOrigin.where)}</span>
2025
+ <span class="tax-chip"><strong>Confidence</strong>: ${escapeHtml(f.failureOrigin.confidence)}</span>
2026
+ </div>`;
2027
+ // SD-6 + W18-problem: Problem block parity. renderFindingPanel emits an
2028
+ // <h3>Problem</h3> PRIMARY block from the descriptive `f.problem` statement with
2029
+ // NO actionable fallback. Mirror it here: render ALWAYS from `f.problem`; if
2030
+ // absent, emit the same LOUD placeholder rather than silently showing a todo.
2031
+ const metaProblemBody =
2032
+ f.problem && f.problem.trim().length > 0
2033
+ ? f.problem
2034
+ : `<strong class="problem-missing">PROBLEM STATEMENT MISSING</strong>`;
2035
+ const problem = `<h3 style="font-size:13px;margin:8px 0 4px;">Problem</h3>
2036
+ <div class="f-desc">${metaProblemBody}</div>`;
2037
+ // EV-1: narrate the cited trace ("trace <id> — <whatHappened> «example»")
2038
+ // in the SD cluster panel too, instead of a bare evidence pointer.
2039
+ const sdTraceId = f.referenceIds?.traceId ?? f.sourceTraceIds[0];
2040
+ // W18-problem: Problem is the PRIMARY content block of the meta sub-panel —
2041
+ // rendered at the TOP of the finding body, BEFORE the evidence narration /
2042
+ // remedies. It sits immediately after the taxonomy chip-strip (after the
2043
+ // entity-head + title line), keeping it adjacent-after the `<div class="taxonomy">`
2044
+ // anchor so the finalize-gate `finding-panel` row finds the "Problem" marker.
2045
+ return `<div class="entity" style="margin-bottom:14px;">
2046
+ <div class="entity-head"><div class="entity-name" style="font-size:14px;">${escapeHtml(f.findingId)}</div><span class="badge b-${sev}">${escapeHtml(f.failureOrigin.confidence)}</span></div>
2047
+ <div class="f-desc">${escapeHtml(f.title ?? f.actionable)}</div>
2048
+ ${taxonomy}
2049
+ ${problem}
2050
+ <p class="ev-narration" style="font-size:12px;color:var(--muted);">${renderTraceNarration(
2051
+ f.failureOrigin.whatHappened,
2052
+ f.failureOrigin.evidence,
2053
+ f.failureOrigin.example,
2054
+ sdTraceId,
2055
+ )}</p>
2056
+ ${renderAssumptionsBlock(f)}
2057
+ ${f.remedies.map((r) => renderRemedyCard(r, f.findingId, f)).join("\n ")}
2058
+ </div>`;
2059
+ })
2060
+ .join("\n");
2061
+
2062
+ html += `
2063
+ <section class="panel" id="${panelId}">
2064
+ <h2>${escapeHtml(what)} <span style="font-size:13px;color:var(--dim);">(${deduped.length} finding${deduped.length !== 1 ? "s" : ""})</span></h2>
2065
+ ${subPanels}
2066
+ </section>`;
2067
+ clusterIdx++;
2068
+ }
2069
+ return html;
2070
+ }
2071
+
2072
+ /** Group findings by failureOrigin.what for the SD cluster layout. */
2073
+ function clusterFindings(findings: Finding[]): Map<string, Finding[]> {
2074
+ const clusters = new Map<string, Finding[]>();
2075
+ for (const f of findings) {
2076
+ const key = f.failureOrigin.what;
2077
+ if (!clusters.has(key)) clusters.set(key, []);
2078
+ clusters.get(key)!.push(f);
2079
+ }
2080
+ return clusters;
2081
+ }
2082
+
2083
+ /** Dedup findings within a cluster by (traceId, what+why+where). */
2084
+ function dedupFindings(findings: Finding[]): Finding[] {
2085
+ const seen = new Set<string>();
2086
+ const out: Finding[] = [];
2087
+ for (const f of findings) {
2088
+ const traceId = f.referenceIds?.traceId ?? f.sourceTraceIds[0] ?? "";
2089
+ const key = `${traceId}|${f.failureOrigin.what}+${f.failureOrigin.why}+${f.failureOrigin.where}`;
2090
+ if (seen.has(key)) continue;
2091
+ seen.add(key);
2092
+ out.push(f);
2093
+ }
2094
+ return out;
2095
+ }
2096
+
2097
+ // ── renderOverviewTab (gold-standard Overview) ───────────────────────────────
2098
+
2099
+ /**
2100
+ * Wave-5 R1.2: Render the Overview panel (t1).
2101
+ * Entity card + big-stat row + headline callout + signal census + scan funnel +
2102
+ * 24h heatmap + findings summary table + leverage callout.
2103
+ */
2104
+ export function renderOverviewTab(input: RenderInput): string {
2105
+ // R2.6: when the operator gave a focus, this panel leads as 🎯 Guided (replacing
2106
+ // the neutral Overview survey). The focus drives the heading + a sub-line tooltip.
2107
+ const focus = input.runMeta?.parsedInvocation?.focus;
2108
+ const guided = typeof focus === "string" && focus.trim().length > 0;
2109
+ const defaultTitle = guided ? `🎯 Guided — ${escapeHtml(focus)}` : "① Overview";
2110
+ const title = input.overviewTitle ? escapeHtml(input.overviewTitle) : defaultTitle;
2111
+ const guidedSub = guided
2112
+ ? `<p class="sub" title="Guided by operator focus: ${escapeHtml(focus)}">Operator focus: <code>${escapeHtml(focus)}</code> — this report leads with the focused view (R2.6).</p>`
2113
+ : "";
2114
+ const sub = (input.overviewSub ? `<p class="sub">${escapeHtml(input.overviewSub)}</p>` : "") + guidedSub;
2115
+
2116
+ const entityCard = input.diagnosedEntity ? renderEntityCard(input.diagnosedEntity) : "";
2117
+ const bigStat = input.bigStat ? renderBigStat(input.bigStat) : "";
2118
+ // W9-07: deep-read coverage tile sourced from RunMeta.deepRead (B1 telemetry)
2119
+ const deepReadTile = renderDeepReadTile(input.runMeta);
2120
+ const headline = input.overviewHeadline ? `<div class="crit"><strong>Headline:</strong> ${input.overviewHeadline}</div>` : "";
2121
+
2122
+ // W17-E (R7): residual surfacing — when the top discovered signal was capped at
2123
+ // SECONDARY for lack of mechanical evidence and primary fell back to a cheap signal,
2124
+ // render the caveat BEFORE the census so the reader sees it ahead of the ★ PRIMARY
2125
+ // badge (never silently present the cheap fallback as the confident primary).
2126
+ const suspectedNote = renderSuspectedPrimaryNote(input.runMeta);
2127
+
2128
+ // W11-01: annotate the census heading with primarySignal.why when available.
2129
+ const primaryWhy = input.runMeta?.primarySignal?.why;
2130
+ const censusSub = primaryWhy
2131
+ ? `<p style="font-size:12px;color:var(--muted);margin:2px 0 6px;">${escapeHtml(primaryWhy)}</p>`
2132
+ : "";
2133
+ const census = input.signalCensus
2134
+ ? `<h3>Signal census → primary selected by failure-validity gate + impact×prevalence + deep-read corroboration</h3>
2135
+ ${censusSub}
2136
+ ${renderSignalCensus(input.signalCensus)}`
2137
+ : "";
2138
+
2139
+ const funnel = input.scanFunnel ? renderScanFunnel(input.scanFunnel) : "";
2140
+ // W11-02: pass runMeta so heatmap can annotate primary-signal cells.
2141
+ // F3 (UR-2): caption follows the active metric (default = avg latency).
2142
+ const heatMetricLabel = input.hourlyHeatmap?.metric?.label ?? "avg latency";
2143
+ const heatmap = input.hourlyHeatmap
2144
+ ? `<h3>24h timeline heatmap (colour = ${escapeHtml(heatMetricLabel)}, number = trace count)</h3>
2145
+ ${render24hHeatmap(input.hourlyHeatmap, input.runMeta)}`
2146
+ : "";
2147
+
2148
+ const findingsTable = renderFindingsSummaryTable(input.findings);
2149
+ const leverage = input.overviewLeverage ? `<div class="alert"><strong>Highest-leverage:</strong> ${input.overviewLeverage}</div>` : "";
2150
+
2151
+ // Overview is the active tab only when there is no internal Methodology tab.
2152
+ // D-8: a methodologyAudit is a meta report, so treat it as such here.
2153
+ const effectiveMeta = (input.isMetaReport ?? false) || (input.methodologyAudit ?? false);
2154
+ const isActive = (input.audience ?? "client") === "internal" && !effectiveMeta ? "" : " active";
2155
+
2156
+ return `<section class="panel${isActive}" id="t1">
2157
+ <h2>${title}</h2>
2158
+ ${sub}
2159
+ ${entityCard}
2160
+ ${bigStat}
2161
+ ${deepReadTile}
2162
+ ${headline}
2163
+ ${suspectedNote}
2164
+ ${census}
2165
+ ${funnel}
2166
+ ${heatmap}
2167
+ ${findingsTable}
2168
+ ${leverage}
2169
+ </section>`;
2170
+ }
2171
+
2172
+ /**
2173
+ * W11-03: Render the scan-coverage funnel.
2174
+ * Supports 4 segments (total → tier0 → sample → deep-read) when `funnel.sample`
2175
+ * is present; falls back to 3-segment display for backward-compat.
2176
+ */
2177
+ function renderScanFunnel(funnel: ScanFunnel): string {
2178
+ const seg = (cls: string, s: { value: string; label: string; detail: string }) =>
2179
+ `<div class="seg ${cls}"><div class="fv">${escapeHtml(s.value)}</div><div class="fl">${escapeHtml(s.label)}</div><div class="fp">${escapeHtml(s.detail)}</div></div>`;
2180
+ const sampleSeg = funnel.sample ? `\n ${seg("s-sample", funnel.sample)}` : "";
2181
+ return `<h3>Scan coverage</h3>
2182
+ <div class="funnel">
2183
+ ${seg("s-total", funnel.total)}
2184
+ ${seg("s-code", funnel.code)}${sampleSeg}
2185
+ ${seg("s-llm", funnel.llm)}
2186
+ </div>`;
2187
+ }
2188
+
2189
+ /** Render the findings summary table on the Overview tab. */
2190
+ function renderFindingsSummaryTable(findings: Finding[]): string {
2191
+ if (findings.length === 0) return "";
2192
+ const rows = findings
2193
+ .map((f) => {
2194
+ const sev = severityBadgeClass(f.severity, f.failureOrigin.confidence);
2195
+ const sevLabel = sev === "crit" ? "CRIT" : sev === "high" ? "HIGH" : sev === "med" ? "MED" : "RULE-OUT";
2196
+ const applyLabel = f.applyLabel ?? "";
2197
+ const applyPill = applyLabel
2198
+ ? `<span class="apply-pill ${applyPillClass(applyLabel)}">${escapeHtml(applyLabel)}</span>`
2199
+ : "";
2200
+ const oneLiner = f.subDesc ?? f.title ?? f.actionable.slice(0, 80);
2201
+ return `<tr><td>${escapeHtml(f.findingId)}</td><td><span class="badge b-${sev}">${sevLabel}</span></td><td>${escapeHtml(f.failureOrigin.what)} / ${escapeHtml(f.failureOrigin.why)} / ${escapeHtml(f.failureOrigin.where)}</td><td>${applyPill}</td><td>${escapeHtml(oneLiner)}</td></tr>`;
2202
+ })
2203
+ .join("\n ");
2204
+ return `<table>
2205
+ <thead><tr><th>ID</th><th>Severity</th><th>WHAT/WHY/WHERE</th><th>Apply</th><th>One-liner</th></tr></thead>
2206
+ <tbody>
2207
+ ${rows}
2208
+ </tbody>
2209
+ </table>`;
2210
+ }
2211
+
2212
+ // ── renderDecisionsTab (gold-standard Decisions) ─────────────────────────────
2213
+
2214
+ /**
2215
+ * PRD-CC-06: Render the Decisions panel — recommended bundle + general feedback +
2216
+ * live-preview textarea (D11). The live preview updates in real-time as remedies
2217
+ * are checked/unchecked and feedback is typed. The sticky global Copy button
2218
+ * (#copy-decisions) reads #lp-body.value and writes to clipboard.
2219
+ */
2220
+ export function renderDecisionsTab(input: RenderInput): string {
2221
+ const bundle = input.decisionsBundle
2222
+ ? `<div class="alert"><strong>Recommended bundle:</strong> ${input.decisionsBundle}</div>`
2223
+ : "";
2224
+
2225
+ // SD-06: self-diag subject header row in Decisions
2226
+ // D-8: a methodologyAudit is also a self-diag report.
2227
+ const subjectRow = input.isMetaReport || input.methodologyAudit
2228
+ ? `<div class="decisions-subject-row"><span class="r-block-label">Subject</span> skill source (skill maintainer mode — PR-022)</div>`
2229
+ : "";
2230
+
2231
+ return `<section class="panel" id="tdecisions">
2232
+ <div class="decisions-cta-bar"><button id="copy-decisions" title="Copy the approved-remedies bundle as markdown for handoff">Copy decisions as markdown</button></div>
2233
+ <h2>⑦ Decisions — copy approvals</h2>
2234
+ <p class="sub">★ green = recommended (pre-checked). Apply-target tags show change-type per remedy.</p>
2235
+ ${subjectRow}
2236
+ ${bundle}
2237
+ <div class="gfeedback">
2238
+ <h4>General feedback (speech-to-text friendly)</h4>
2239
+ <textarea id="general-feedback" id="general-feedback-dr" placeholder="Voice-dump anything here — approve a remedy AND add conditions, reject with rationale, request a remedy not listed, scope overrides, questions, priorities… Folded into the Copy-decisions export verbatim."></textarea>
2240
+ </div>
2241
+ <div class="live-preview" id="live-preview">
2242
+ <div class="lp-head"><span class="lp-label">Live Preview — Master Plan markdown</span><span class="lp-meta" id="lp-meta">0 remedies · 0 chars</span></div>
2243
+ <textarea class="lp-body" id="lp-body" readonly placeholder="Check remedies above and type feedback — bundle assembles here live. Click the sticky Copy to Clipboard button at the bottom of the page to copy."></textarea>
2244
+ </div>
2245
+ <p>Each remedy also has its own notes box. The live preview above updates as you select remedies and type feedback.</p>
2246
+ </section>`;
2247
+ }
2248
+
2249
+ // ── renderLivePreviewScript (PRD-CC-07 — live preview + global Copy button) ───
2250
+
2251
+ /**
2252
+ * PRD-CC-07: Render the inline JS for the live preview + event wiring.
2253
+ * renderLivePreview() walks .remedy-cb:checked, prefers hidden payload-{id}
2254
+ * scripts over DOM extraction, appends .remedy-notes content (lines prefixed "> "),
2255
+ * appends general feedback, writes result to #lp-body and updates #lp-meta.
2256
+ * Wired to: .remedy-cb change, .remedy-notes input, #general-feedback input,
2257
+ * #general-feedback-dr input, DOMContentLoaded.
2258
+ * The #copy-decisions button reads #lp-body.value and writes to clipboard.
2259
+ */
2260
+ export function renderLivePreviewScript(): string {
2261
+ return `<script>
2262
+ (function(){
2263
+ function renderLivePreview(){
2264
+ var checked = Array.from(document.querySelectorAll('.remedy-cb:checked'));
2265
+ var invalids = Array.from(document.querySelectorAll('.finding-invalid:checked'));
2266
+ var lines = [];
2267
+ if (checked.length === 0 && invalids.length === 0) {
2268
+ document.getElementById('lp-body').value = '';
2269
+ document.getElementById('lp-meta').textContent = '0 remedies · 0 chars';
2270
+ return;
2271
+ }
2272
+ if (checked.length > 0) {
2273
+ lines.push('# Approved Remedies (handoff)');
2274
+ lines.push('');
2275
+ lines.push('Generated: ' + new Date().toISOString());
2276
+ lines.push('');
2277
+ lines.push('---');
2278
+ lines.push('');
2279
+ checked.forEach(function(cb){
2280
+ var rid = (cb.dataset.id || 'unknown').toLowerCase().replace(/[^a-z0-9-]/g,'-');
2281
+ var payloadEl = document.getElementById('payload-' + rid);
2282
+ if (payloadEl) {
2283
+ lines.push(payloadEl.textContent || '');
2284
+ } else {
2285
+ // DOM fallback
2286
+ var card = cb.closest('.remedy');
2287
+ if (!card) return;
2288
+ var fid = cb.dataset.finding || '?';
2289
+ var remedyId = cb.dataset.id || '?';
2290
+ var what = (card.querySelector('.remedy-what') || {textContent:''}).textContent.trim();
2291
+ lines.push('## ' + remedyId + ' (' + fid + ')');
2292
+ lines.push('**Remedy:** ' + what);
2293
+ lines.push('');
2294
+ }
2295
+ var note = card ? card.querySelector('.remedy-notes') : null;
2296
+ if (note && note.value.trim()) {
2297
+ note.value.trim().split(/\\r?\\n/).forEach(function(ln){ lines.push('> ' + ln); });
2298
+ lines.push('');
2299
+ }
2300
+ lines.push('---');
2301
+ lines.push('');
2302
+ });
2303
+ } // end if (checked.length > 0)
2304
+ // DISMISSAL capture — the SAME copy-back channel (no new egress). Each finding marked
2305
+ // "Invalid" folds its hidden dismiss-payload (findingId + failure-mode triple + severity)
2306
+ // + optional reason into the bundle; the orchestrator persists these to the unified
2307
+ // verdict ledger. This walk also lets a dismissal-only paste (0 remedies) still export.
2308
+ if (invalids.length > 0) {
2309
+ lines.push('## Invalid findings (dismissed — "not an issue")');
2310
+ lines.push('');
2311
+ invalids.forEach(function(cb){
2312
+ var fid = cb.dataset.finding || '?';
2313
+ var fidNorm = fid.toLowerCase().replace(/[^a-z0-9-]/g,'-');
2314
+ lines.push('- finding: ' + fid);
2315
+ var card = cb.closest('.panel');
2316
+ var reasonEl = card ? card.querySelector('.dismiss-reason') : null;
2317
+ if (reasonEl && reasonEl.value.trim()) {
2318
+ reasonEl.value.trim().split(/\\r?\\n/).forEach(function(ln){ lines.push(' reason: ' + ln); });
2319
+ }
2320
+ var payloadEl = document.getElementById('dismiss-payload-' + fidNorm);
2321
+ if (payloadEl) { lines.push(' ' + (payloadEl.textContent || '').trim()); }
2322
+ lines.push('');
2323
+ });
2324
+ lines.push('---');
2325
+ lines.push('');
2326
+ }
2327
+ var gf1 = document.getElementById('general-feedback');
2328
+ var gf2 = document.getElementById('general-feedback-dr');
2329
+ var gfVal = (gf1 && gf1.value.trim()) || (gf2 && gf2.value.trim()) || '';
2330
+ if (gfVal) {
2331
+ lines.push('## General feedback');
2332
+ lines.push('');
2333
+ lines.push(gfVal);
2334
+ lines.push('');
2335
+ }
2336
+ var text = lines.join('\\n');
2337
+ var lp = document.getElementById('lp-body');
2338
+ lp.value = text;
2339
+ document.getElementById('lp-meta').textContent = checked.length + ' remedies · ' + invalids.length + ' invalid · ' + text.length + ' chars';
2340
+ }
2341
+
2342
+ // Wire events
2343
+ function wireEvents(){
2344
+ document.querySelectorAll('.remedy-cb').forEach(function(cb){
2345
+ cb.addEventListener('change', renderLivePreview);
2346
+ });
2347
+ document.querySelectorAll('.remedy-notes').forEach(function(ta){
2348
+ ta.addEventListener('input', renderLivePreview);
2349
+ });
2350
+ document.querySelectorAll('.finding-invalid').forEach(function(cb){
2351
+ cb.addEventListener('change', renderLivePreview);
2352
+ });
2353
+ document.querySelectorAll('.dismiss-reason').forEach(function(ta){
2354
+ ta.addEventListener('input', renderLivePreview);
2355
+ });
2356
+ var gf1 = document.getElementById('general-feedback');
2357
+ var gf2 = document.getElementById('general-feedback-dr');
2358
+ if (gf1) gf1.addEventListener('input', renderLivePreview);
2359
+ if (gf2) gf2.addEventListener('input', renderLivePreview);
2360
+ renderLivePreview();
2361
+ }
2362
+
2363
+ if (document.readyState === 'loading') {
2364
+ document.addEventListener('DOMContentLoaded', wireEvents);
2365
+ } else {
2366
+ wireEvents();
2367
+ }
2368
+
2369
+ // Override #copy-decisions to read from #lp-body (D11)
2370
+ function overrideCopyBtn(){
2371
+ var btn = document.getElementById('copy-decisions');
2372
+ if (!btn) return;
2373
+ // Remove old listeners by cloning
2374
+ var newBtn = btn.cloneNode(true);
2375
+ btn.parentNode.replaceChild(newBtn, btn);
2376
+ newBtn.addEventListener('click', function(){
2377
+ var lp = document.getElementById('lp-body');
2378
+ var text = lp ? lp.value : '';
2379
+ if (!text) { text = '# No remedies selected'; }
2380
+ var originalText = newBtn.textContent;
2381
+ navigator.clipboard.writeText(text).then(function(){
2382
+ newBtn.textContent = '✓ Copied';
2383
+ // Log to changelog if present
2384
+ var cl = document.getElementById('changelog-log');
2385
+ if (cl) {
2386
+ var row = document.createElement('tr');
2387
+ row.innerHTML = '<td>' + new Date().toLocaleTimeString() + '</td><td>Copied ' + text.length + ' chars to clipboard</td>';
2388
+ cl.prepend(row);
2389
+ }
2390
+ setTimeout(function(){ newBtn.textContent = originalText; }, 2200);
2391
+ }).catch(function(){
2392
+ window.prompt('Copy:', text);
2393
+ });
2394
+ });
2395
+ }
2396
+
2397
+ if (document.readyState === 'loading') {
2398
+ document.addEventListener('DOMContentLoaded', overrideCopyBtn);
2399
+ } else {
2400
+ overrideCopyBtn();
2401
+ }
2402
+ })();
2403
+ </script>`;
2404
+ }
2405
+
2406
+ // ── renderFunnelHeatmap (legacy I-027 slot — backward compat) ────────────────
2407
+
2408
+ /**
2409
+ * I-027 backward-compat: the {{FUNNEL_HEATMAP_HTML}} slot. In the gold-standard
2410
+ * layout the funnel + heatmap live INSIDE the Overview tab, so this slot is
2411
+ * emitted empty unless a legacy template relies on it AND no Overview was built.
2412
+ * Returns "" for the gold-standard path (avoids double-render).
2413
+ */
2414
+ export function renderFunnelHeatmap(): string {
2415
+ return "";
2416
+ }
2417
+
2418
+ // ── sanitizeMermaid (F-SELF-011 — mermaid v10 compatibility) ─────────────────
2419
+
2420
+ /**
2421
+ * Sanitize orchestrator-authored mermaid before it lands in <div class="mermaid">.
2422
+ * Mermaid v10 lexer is strict: parens in participant aliases and raw HTML in Note
2423
+ * blocks cause silent render failure. This rewrites both into compatible forms.
2424
+ * Pure + deterministic. Emits a single console.warn listing what changed so the
2425
+ * author learns the constraints.
2426
+ */
2427
+ export function sanitizeMermaid(src: string): { sanitized: string; rewrites: string[] } {
2428
+ const rewrites: string[] = [];
2429
+ let out = src;
2430
+
2431
+ // 1) Strip parens from participant alias lines: `participant X as Foo (bar)` → `participant X as Foo bar`
2432
+ out = out.replace(/^(\s*participant\s+\S+\s+as\s+)([^\n]+)$/gm, (_, prefix, alias) => {
2433
+ if (alias.includes("(") || alias.includes(")")) {
2434
+ const clean = alias.replace(/[()]/g, "").replace(/\s+/g, " ").trim();
2435
+ rewrites.push(`participant alias "${alias.trim()}" → "${clean}"`);
2436
+ return prefix + clean;
2437
+ }
2438
+ return prefix + alias;
2439
+ });
2440
+
2441
+ // 2) Split `<br/?>` inside Note over X,Y: lines into consecutive Note over lines.
2442
+ out = out.replace(/^(\s*Note\s+over\s+[^:]+:\s*)([^\n]+)$/gm, (_, prefix, body) => {
2443
+ if (!/<br\s*\/?>/i.test(body)) return prefix + body;
2444
+ const parts = body.split(/<br\s*\/?>/i).map((s: string) => s.trim()).filter(Boolean);
2445
+ rewrites.push(`<br/> in Note → ${parts.length} consecutive Note lines`);
2446
+ return parts.map((p: string) => `${prefix}${p}`).join("\n");
2447
+ });
2448
+
2449
+ if (rewrites.length > 0) {
2450
+ console.warn(`[render] sanitizeMermaid rewrote ${rewrites.length} construct(s): ${rewrites.join("; ")}`);
2451
+ }
2452
+ return { sanitized: out, rewrites };
2453
+ }
2454
+
2455
+ // ── renderMethodologyTab (gold-standard Methodology [INTERNAL]) ──────────────
2456
+
2457
+ /**
2458
+ * Wave-5 R1.2: Render the Methodology [INTERNAL] panel (t0).
2459
+ * Mermaid sequence diagram + graded decision log + signal census table.
2460
+ * NODE-STRIPPED for client audience (FU-INT-1).
2461
+ */
2462
+ export function renderMethodologyTab(input: RenderInput): string {
2463
+ const mermaidRaw = input.mermaidSequence ?? input.runMeta?.mermaidTopology;
2464
+ // F-SELF-011: sanitize orchestrator-authored mermaid (parens in participant
2465
+ // aliases + raw <br/> in Notes) before embedding — mermaid v10 silently fails otherwise.
2466
+ const mermaid = mermaidRaw !== undefined ? sanitizeMermaid(mermaidRaw).sanitized : undefined;
2467
+ const sequenceHtml = mermaid
2468
+ ? `<h3>Sequence — orchestrator → scripts → analyzers</h3>
2469
+ <div class="mermaid">
2470
+ ${mermaid}
2471
+ </div>`
2472
+ : "";
2473
+
2474
+ const decisionLogHtml = input.decisionLog && input.decisionLog.length > 0
2475
+ ? `<h3>Decision log (graded)</h3>
2476
+ <table>
2477
+ <thead><tr><th>Decision</th><th>What the skill did</th><th>Grade</th></tr></thead>
2478
+ <tbody>
2479
+ ${input.decisionLog
2480
+ .map((d) => `<tr><td>${escapeHtml(d.decision)}</td><td>${escapeHtml(d.what)}</td><td>${d.grade}</td></tr>`)
2481
+ .join("\n ")}
2482
+ </tbody>
2483
+ </table>`
2484
+ : "";
2485
+
2486
+ // W11-01/PR-049: annotate Methodology census heading with primarySignal rationale.
2487
+ const primarySig = input.runMeta?.primarySignal;
2488
+ const primarySigNote = primarySig
2489
+ ? `<p style="font-size:12px;color:var(--muted);margin:2px 0 6px;">
2490
+ Selected: <strong>${escapeHtml(primarySig.name)}</strong> — ${escapeHtml(primarySig.why)}
2491
+ · confidence: <em>${escapeHtml(primarySig.confidence)}</em>
2492
+ ${primarySig.ruledOut.length > 0 ? `· ruled out: ${primarySig.ruledOut.map((r) => escapeHtml(r)).join(", ")}` : ""}
2493
+ </p>`
2494
+ : "";
2495
+ // W17-E (R7): residual surfacing inside Methodology too — the floor cap is part of
2496
+ // the selection story, so the caveat sits with Step 1's census + selection cards.
2497
+ const suspectedNoteHtml = renderSuspectedPrimaryNote(input.runMeta);
2498
+ const censusHtml = input.signalCensus
2499
+ ? `<h3>Step 1 — Primary-signal selection (PR-049: failure-validity gate → impact×prevalence → deep-read)</h3>
2500
+ ${primarySigNote}
2501
+ ${suspectedNoteHtml}
2502
+ ${renderSignalCensus(input.signalCensus)}`
2503
+ : "";
2504
+
2505
+ // R2.6/D2: Methodology Step 0 — verbatim operator invocation (rendered first).
2506
+ const step0Html = renderOperatorInvocationStep(input.runMeta);
2507
+
2508
+ // F5 (defense-in-depth): on INTERNAL / META-audience reports, a widget whose data
2509
+ // was NOT threaded into runMeta renders a LOUD marker instead of vanishing silently
2510
+ // — so the next threading regression is visible, not invisible. CLIENT / PRODUCT
2511
+ // reports stay clean (these are internal-only nodes, node-stripped at publish).
2512
+ const isInternalAudience = (input.audience ?? "client") !== "client";
2513
+ const loudOrEmpty = (html: string, widget: string, threaded: boolean): string => {
2514
+ if (html.length > 0) return html;
2515
+ if (isInternalAudience && !threaded) {
2516
+ return `<div class="warn loud-missing-widget" data-widget="${escapeHtml(widget)}">⚠ ${escapeHtml(widget)} not threaded into runMeta — see orchestrator-protocol Step 8.5 (F4 widget-threading table).</div>`;
2517
+ }
2518
+ return html;
2519
+ };
2520
+
2521
+ // R2.2: Methodology Step 1.5 — awareness-layer mini-sample + blind-spots.
2522
+ const awarenessThreaded =
2523
+ input.runMeta?.awarenessSample !== undefined ||
2524
+ (input.runMeta?.blindSpots !== undefined && input.runMeta.blindSpots.length > 0);
2525
+ // REQ-050: the empty-state must be HONEST. An absent awareness witness has TWO
2526
+ // distinct causes the generic "not threaded" marker conflates:
2527
+ // (a) awareness genuinely NOT RUN / NOT APPLICABLE — the orchestrator recorded a
2528
+ // skip/exemption decision (e.g. library priors exist, or the run shape makes
2529
+ // the 5-trace mini-sample inapplicable). This is correct behaviour, not a bug.
2530
+ // (b) awareness RAN but its witness was never threaded into runMeta — a real
2531
+ // threading regression that the loud F5 marker rightly surfaces.
2532
+ // We distinguish them via runMeta.decisions: an awareness skip/not-applicable
2533
+ // decision is the (a) signal. INTERNAL audience only (the whole tab is stripped for
2534
+ // clients — we never force-render anything here for client reports).
2535
+ const awarenessSkip = findAwarenessSkipDecision(input.runMeta);
2536
+ let step15Html: string;
2537
+ if (awarenessThreaded) {
2538
+ // Case: awareness ran AND was threaded → render the real Step 1.5 content.
2539
+ step15Html = renderAwarenessStep(input.runMeta);
2540
+ } else if (awarenessSkip && isInternalAudience) {
2541
+ // Case (a): awareness genuinely not run / not applicable — HONEST placeholder
2542
+ // that surfaces the recorded reason, not a misleading "not threaded" marker.
2543
+ step15Html = renderAwarenessNotRun(awarenessSkip);
2544
+ } else {
2545
+ // Case (b): awareness absent with no skip decision → the loud F5 not-threaded
2546
+ // marker (INTERNAL only; "" for client — the tab is stripped anyway).
2547
+ step15Html = loudOrEmpty(
2548
+ renderAwarenessStep(input.runMeta),
2549
+ "awareness sample + blind-spots",
2550
+ awarenessThreaded
2551
+ );
2552
+ }
2553
+
2554
+ // R2.4: three methodology widgets — tier pie, selection-rule cards, mermaid trace.
2555
+ const tierPieHtml = loudOrEmpty(
2556
+ renderTierPie(input.runMeta?.tierBreakdown),
2557
+ "tier-coverage pie (tierBreakdown)",
2558
+ input.runMeta?.tierBreakdown !== undefined
2559
+ );
2560
+ const selectionCardsHtml = renderSelectionRuleCards(input.runMeta?.selectionRules);
2561
+ const selectionTraceHtml = loudOrEmpty(
2562
+ renderSignalSelectionTrace(input.runMeta?.signalSelectionTrace),
2563
+ "signal-selection trace (signalSelectionTrace)",
2564
+ input.runMeta?.signalSelectionTrace !== undefined
2565
+ );
2566
+
2567
+ // R2.1 (+D1): cap-exceeded banner + deep-read gate banner (rendered FIRST).
2568
+ const capBannerHtml = input.runMeta?.capBanner
2569
+ ? `<div class="crit"><strong>${escapeHtml(input.runMeta.capBanner)}</strong></div>`
2570
+ : "";
2571
+ const gate = input.runMeta?.deepReadGate;
2572
+ const gateBannerHtml = gate
2573
+ ? gate.verdict === "refuse"
2574
+ ? `<div class="crit"><strong>${escapeHtml(gate.reason)}</strong></div>`
2575
+ : gate.verdict === "proceed-with-priors"
2576
+ ? `<div class="warn">${escapeHtml(gate.reason)}</div>`
2577
+ : ""
2578
+ : "";
2579
+
2580
+ return `<section class="panel active" id="t0">
2581
+ <div class="internal-banner">⚙ INTERNAL-ONLY — hidden in public/client release. Traces the topology of skill operations + sub-agent dispatch for this run.</div>
2582
+ <h2>⚙ Methodology — operation topology</h2>
2583
+ <p class="sub">How this diagnosis was produced: which orchestrator steps, scripts, and sub-agents ran, pulled from the run trajectory.</p>
2584
+ ${capBannerHtml}
2585
+ ${gateBannerHtml}
2586
+ ${step0Html}
2587
+ ${sequenceHtml}
2588
+ ${decisionLogHtml}
2589
+ ${censusHtml}
2590
+ ${step15Html}
2591
+ ${selectionCardsHtml}
2592
+ ${tierPieHtml}
2593
+ ${selectionTraceHtml}
2594
+ </section>`;
2595
+ }
2596
+
2597
+ /**
2598
+ * Wave-6 R2.6/D2: Render Methodology Step 0 — the VERBATIM operator invocation
2599
+ * brief that initiated the run, plus the parsed shape (agent / timeWindow / focus /
2600
+ * residual) when present. The verbatim is shown EXACTLY as the operator typed it
2601
+ * (D2 — never reworded/dropped). Returns "" when no invocation was recorded.
2602
+ */
2603
+ export function renderOperatorInvocationStep(runMeta?: RunMeta): string {
2604
+ const verbatim = runMeta?.operatorInvocation;
2605
+ const parsed = runMeta?.parsedInvocation;
2606
+ if (!verbatim && !parsed) return "";
2607
+
2608
+ const verbatimHtml = verbatim
2609
+ ? `<p class="sub">Operator invocation (verbatim):</p>
2610
+ <pre>${escapeHtml(verbatim)}</pre>`
2611
+ : "";
2612
+
2613
+ const parsedHtml = parsed
2614
+ ? `<table>
2615
+ <thead><tr><th>Parsed field</th><th>Value</th></tr></thead>
2616
+ <tbody>
2617
+ <tr><td>agent</td><td>${parsed.agent ? `<code>${escapeHtml(parsed.agent)}</code>` : "<em>—</em>"}</td></tr>
2618
+ <tr><td>timeWindow</td><td>${parsed.timeWindow ? `<code>${escapeHtml(parsed.timeWindow)}</code>` : "<em>—</em>"}</td></tr>
2619
+ <tr><td>focus</td><td>${parsed.focus ? `<code>${escapeHtml(parsed.focus)}</code>` : "<em>neutral survey</em>"}</td></tr>
2620
+ <tr><td>residual</td><td>${parsed.residual ? escapeHtml(parsed.residual) : "<em>—</em>"}</td></tr>
2621
+ </tbody>
2622
+ </table>`
2623
+ : "";
2624
+
2625
+ return `<h3>Step 0 — Operator invocation</h3>
2626
+ ${verbatimHtml}
2627
+ ${parsedHtml}`;
2628
+ }
2629
+
2630
+ // ── Wave-6 R2.4 — Methodology widgets (SVG pie · selection cards · mermaid trace) ──
2631
+
2632
+ /** Deterministic polar→cartesian for SVG arc math. cx/cy/r in px, angle in deg. */
2633
+ function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number): { x: number; y: number } {
2634
+ const a = ((angleDeg - 90) * Math.PI) / 180;
2635
+ return { x: round3(cx + r * Math.cos(a)), y: round3(cy + r * Math.sin(a)) };
2636
+ }
2637
+
2638
+ function round3(n: number): number {
2639
+ return Math.round(n * 1000) / 1000;
2640
+ }
2641
+
2642
+ /** Default tier colours (cycled deterministically when a tier omits `color`). */
2643
+ const TIER_PIE_COLORS = ["#45b8cc", "#7E47D7", "#e8a64d", "#43c39a", "#b794f4", "#8a8698"];
2644
+
2645
+ /**
2646
+ * Wave-6 R2.4: SVG arc-path tier-coverage pie. Reads runMeta.tierBreakdown.
2647
+ * Handles the 0-finding case (renders an empty ring + "no findings" note) and the
2648
+ * single-tier case (full circle). Deterministic: arc math is pure, colours cycle
2649
+ * by index. Returns "" when tierBreakdown is absent.
2650
+ */
2651
+ export function renderTierPie(tierBreakdown?: RunMeta["tierBreakdown"]): string {
2652
+ if (!tierBreakdown) return "";
2653
+
2654
+ const total = tierBreakdown.reduce((sum, t) => sum + Math.max(0, t.count), 0);
2655
+ const cx = 80;
2656
+ const cy = 80;
2657
+ const r = 70;
2658
+
2659
+ // 0-finding case: render an empty ring with a note (handle 0-tiers, plan §4 R2.4).
2660
+ if (total === 0) {
2661
+ return `<h3>Tier coverage</h3>
2662
+ <svg width="160" height="160" viewBox="0 0 160 160" role="img" aria-label="Tier coverage — no findings">
2663
+ <circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--border,#444)" stroke-width="2"/>
2664
+ </svg>
2665
+ <p class="sub">No findings across any tier — empty coverage ring.</p>`;
2666
+ }
2667
+
2668
+ // Build pie slices. A single tier with all the findings → full circle.
2669
+ const slices: string[] = [];
2670
+ const legend: string[] = [];
2671
+ let cursor = 0;
2672
+ tierBreakdown.forEach((t, i) => {
2673
+ const count = Math.max(0, t.count);
2674
+ const color = t.color ?? TIER_PIE_COLORS[i % TIER_PIE_COLORS.length];
2675
+ const frac = count / total;
2676
+ legend.push(
2677
+ `<span class="pie-leg"><span class="pie-sw" style="background:${escapeHtml(color)}"></span>${escapeHtml(t.tier)} (${count})</span>`
2678
+ );
2679
+ if (count === 0) return; // 0-finding tier contributes legend only, no slice.
2680
+
2681
+ const startAngle = cursor * 360;
2682
+ const endAngle = (cursor + frac) * 360;
2683
+ cursor += frac;
2684
+
2685
+ if (frac >= 1) {
2686
+ // Full circle — a path arc cannot draw 360°, use a circle element.
2687
+ slices.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${escapeHtml(color)}"/>`);
2688
+ return;
2689
+ }
2690
+ const start = polarToCartesian(cx, cy, r, endAngle);
2691
+ const end = polarToCartesian(cx, cy, r, startAngle);
2692
+ const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
2693
+ slices.push(
2694
+ `<path d="M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 0 ${end.x} ${end.y} Z" fill="${escapeHtml(color)}"/>`
2695
+ );
2696
+ });
2697
+
2698
+ return `<h3>Tier coverage</h3>
2699
+ <svg width="160" height="160" viewBox="0 0 160 160" role="img" aria-label="Tier coverage pie">
2700
+ ${slices.join("\n ")}
2701
+ </svg>
2702
+ <div class="pie-legend">${legend.join(" ")}</div>`;
2703
+ }
2704
+
2705
+ /**
2706
+ * W11-09 (Wave-6 R2.4 + PR-049): per-signal selection-rule cards.
2707
+ * Each card shows the signal's score + verdict. Signals discovered by the R2.2
2708
+ * awareness layer get a "discovered" badge.
2709
+ *
2710
+ * PR-049 5-step detect+select process (rendered as a sub-header when no cards present):
2711
+ * 1. Enumerate all signals from Tier-0 static scan (PR-001).
2712
+ * 2. Failure-validity gate — drop benign observability artifacts.
2713
+ * 3. Impact×prevalence scoring (impact = $/latency/correctness harm × trace rate).
2714
+ * 4. Deep-read corroboration — findings[0].failureOrigin.what confirms or overrides.
2715
+ * 5. Emit ONE RunMeta.primarySignal driving census·heatmap·funnel.
2716
+ *
2717
+ * Reads runMeta.selectionRules. Returns the 5-step methodology note when absent/empty.
2718
+ */
2719
+ /**
2720
+ * W17-E (R1 evidence-floor visibility): derive an explicit floor-outcome badge from a
2721
+ * selection-rule verdict string. The enricher (buildSelectionMeta) encodes the floor
2722
+ * outcome in the verdict prose; this lifts it into a visible badge so a reader can see
2723
+ * — without parsing prose — which discovered signals were CORROBORATED (admitted to
2724
+ * PRIMARY by the evidence floor) vs CAPPED at SECONDARY for lack of mechanical evidence.
2725
+ *
2726
+ * - "★ PRIMARY (… mechanically corroborated)" → corroborated-PRIMARY badge
2727
+ * - "secondary (discovered, corroborated)" → corroborated badge
2728
+ * - "… unconfirmed (no mechanical evidence …)" → capped-at-SECONDARY badge
2729
+ * - anything else (cheap-signal cards / ruled-out) → no floor badge ("")
2730
+ *
2731
+ * Deterministic, case-insensitive substring classification. Pure string.
2732
+ */
2733
+ export function floorVerdictBadge(verdict: string): string {
2734
+ const v = verdict.toLowerCase();
2735
+ // Unevidenced discovered signal: the floor capped it at SECONDARY. Most specific —
2736
+ // check first so a "corroborated" substring elsewhere can't mask it.
2737
+ if (v.includes("unconfirmed") || v.includes("no mechanical evidence")) {
2738
+ return `<span class="badge b-med floor-capped">⚠ capped at SECONDARY — unevidenced (R1 floor)</span>`;
2739
+ }
2740
+ // Discovered signal that passed the floor (corroborated).
2741
+ if (v.includes("corroborated")) {
2742
+ return `<span class="badge b-crit floor-corroborated">✓ evidence-floor: corroborated</span>`;
2743
+ }
2744
+ return "";
2745
+ }
2746
+
2747
+ export function renderSelectionRuleCards(rules?: RunMeta["selectionRules"]): string {
2748
+ const methodologyNote = `<div style="font-size:12px;color:var(--muted);background:var(--bg2);border:1px solid var(--border);border-radius:4px;padding:8px 12px;margin:4px 0;">
2749
+ <strong>PR-049 signal detect+select process (5 steps):</strong><br>
2750
+ 1. Enumerate all Tier-0 signals (PR-001 static scan).<br>
2751
+ 2. Failure-validity gate — admit only mechanical signals that map to a user-visible failure WHAT; rule out benign observability artifacts (e.g. missing-metadata).<br>
2752
+ 3. Impact×prevalence scoring: impact = $/latency/correctness harm × trace prevalence rate.<br>
2753
+ 4. Deep-read corroboration — LLM findings[0].failureOrigin.what confirms or overrides rank.<br>
2754
+ 5. Emit ONE <code>runMeta.primarySignal</code> driving census badge · heatmap · funnel.
2755
+ </div>`;
2756
+
2757
+ if (!rules || rules.length === 0) return methodologyNote;
2758
+ const cards = rules
2759
+ .map((rule) => {
2760
+ const badge = rule.discoveredByAwareness
2761
+ ? `<span class="badge b-info">★ discovered (awareness)</span>`
2762
+ : "";
2763
+ // W17-E (R1 floor visibility): make the evidence-floor outcome an explicit badge,
2764
+ // not text buried in the verdict prose. Derived deterministically from the verdict
2765
+ // the enricher emitted (build-render-input.ts buildSelectionMeta): a corroborated
2766
+ // discovered winner reads "★ PRIMARY (… corroborated)"; an unevidenced discovered
2767
+ // signal reads "… unconfirmed (no mechanical evidence …)" and is capped-at-SECONDARY.
2768
+ const floorBadge = floorVerdictBadge(rule.verdict);
2769
+ // D3: dataset-candidate detail rows — each is rendered ONLY when the enricher
2770
+ // derived it from the linked finding (omitted gracefully otherwise — never faked).
2771
+ const detail = (label: string, value: string | undefined, cls = ""): string =>
2772
+ value
2773
+ ? `<div class="sel-detail${cls ? " " + cls : ""}"><span class="sel-k">${escapeHtml(label)}</span>${escapeHtml(value)}</div>`
2774
+ : "";
2775
+ const findingLink = rule.linkedFindingId
2776
+ ? `<div class="sel-detail"><span class="sel-k">finding</span><code>${escapeHtml(rule.linkedFindingId)}</code></div>`
2777
+ : "";
2778
+ const candidateDetails = [
2779
+ detail("scenario", rule.scenario),
2780
+ detail("use-case / edge-case", rule.useCase),
2781
+ detail("why it failed", rule.whyFailed),
2782
+ detail("why high-value candidate", rule.whyHighValue, "sel-value"),
2783
+ detail("would prevent regression", rule.prevents, "sel-prevents"),
2784
+ findingLink,
2785
+ ].join("");
2786
+ return `<div class="sel-card">
2787
+ <div class="sel-card-head"><strong>${escapeHtml(rule.signal)}</strong> ${badge}${floorBadge}</div>
2788
+ <div class="sel-card-body">
2789
+ <span class="sel-k">score</span> <code>${escapeHtml(rule.score)}</code>
2790
+ <span class="sel-k">verdict</span> ${escapeHtml(rule.verdict)}
2791
+ </div>
2792
+ ${candidateDetails}
2793
+ </div>`;
2794
+ })
2795
+ .join("\n ");
2796
+ return `<h3>Selection rules — per-signal score + verdict (PR-049)</h3>
2797
+ ${methodologyNote}
2798
+ <div class="sel-cards">
2799
+ ${cards}
2800
+ </div>`;
2801
+ }
2802
+
2803
+ /**
2804
+ * W17-E (R7 — residual surfacing): render the "suspected primary — unconfirmed"
2805
+ * note from runMeta.suspectedPrimaryUnconfirmed.
2806
+ *
2807
+ * WHY: when the top DISCOVERED signal is capped at SECONDARY for lack of mechanical
2808
+ * evidence (R1 evidence floor) and primary falls back to a CHEAP signal, the report
2809
+ * must NOT silently present the cheap fallback as the confident primary. This note
2810
+ * makes the residual visible: it names the suspected signal, shows its HONEST sampled
2811
+ * prevalence ("seen in k/n sampled" — never a fabricated corpus rate), and states WHY
2812
+ * it could not be confirmed (the floor reason). Rendered as a `.warn` callout so it
2813
+ * reads as a caveat, not a finding.
2814
+ *
2815
+ * Returns "" when the field is absent — i.e. the discovered signal PASSED the floor
2816
+ * (nothing unconfirmed) or none was discovered. Deterministic; pure string.
2817
+ */
2818
+ export function renderSuspectedPrimaryNote(runMeta?: RunMeta): string {
2819
+ const sp = runMeta?.suspectedPrimaryUnconfirmed;
2820
+ if (!sp) return "";
2821
+ return `<div class="warn suspected-primary-unconfirmed" data-suspected="${escapeHtml(sp.signal)}">
2822
+ <strong>⚠ Suspected primary — unconfirmed:</strong>
2823
+ <code>${escapeHtml(sp.signal)}</code> was the highest-impact <em>discovered</em> signal
2824
+ (seen in ${sp.seenCount}/${sp.sampledCount} sampled) but is <strong>capped at SECONDARY</strong>
2825
+ — ${escapeHtml(sp.reason)}. The primary shown below is a cheaper fallback signal, NOT a
2826
+ confident root cause. Treat the suspected signal as a lead requiring mechanical corroboration.
2827
+ </div>`;
2828
+ }
2829
+
2830
+ /**
2831
+ * Wave-6 R2.4: signal-selection trace — a mermaid decision tree reading
2832
+ * runMeta.signalSelectionTrace. Renders with PARTIAL data (tolerates a short or
2833
+ * empty trace by falling back to a stub diagram). Returns "" when absent.
2834
+ */
2835
+ export function renderSignalSelectionTrace(trace?: string): string {
2836
+ if (trace === undefined) return "";
2837
+ const body = trace.trim().length > 0
2838
+ ? trace
2839
+ : "graph TD\n A[insufficient trace data] --> B[partial render]";
2840
+ return `<h3>Signal-selection trace</h3>
2841
+ <div class="mermaid">
2842
+ ${body}
2843
+ </div>`;
2844
+ }
2845
+
2846
+ /**
2847
+ * REQ-050: Detect an awareness "not run / not applicable" decision in the run's
2848
+ * decision log. The orchestrator records a skip/exemption decision (e.g. library
2849
+ * priors exist, or the run shape makes the 5-trace mini-sample inapplicable) in
2850
+ * runMeta.decisions. Its presence is the signal that an ABSENT awareness witness is
2851
+ * deliberate (case a) rather than a threading regression (case b).
2852
+ *
2853
+ * Match heuristic: a decision whose step marks an exemption/skip (or whose choice
2854
+ * text names a skip) AND whose text mentions the awareness layer. Returns the
2855
+ * matched decision (for its rationale) or undefined.
2856
+ */
2857
+ export function findAwarenessSkipDecision(
2858
+ runMeta?: RunMeta
2859
+ ): { step: string; choice: string; rationale: string } | undefined {
2860
+ const decisions = runMeta?.decisions;
2861
+ if (!decisions || decisions.length === 0) return undefined;
2862
+ return decisions.find((d) => {
2863
+ const haystack = `${d.step} ${d.choice} ${d.rationale}`.toLowerCase();
2864
+ const mentionsAwareness = haystack.includes("awareness");
2865
+ const isSkip =
2866
+ haystack.includes("skip") ||
2867
+ haystack.includes("exempt") ||
2868
+ haystack.includes("not applicable") ||
2869
+ haystack.includes("not run") ||
2870
+ haystack.includes("n/a");
2871
+ return mentionsAwareness && isSkip;
2872
+ });
2873
+ }
2874
+
2875
+ /**
2876
+ * REQ-050: Render the HONEST "awareness not run / not applicable" empty-state for the
2877
+ * Methodology Step 1.5 slot. Surfaces the recorded rationale so the reader can tell
2878
+ * this from the (b) "ran-but-not-threaded" regression marker. INTERNAL-audience only
2879
+ * (the caller guards on audience; the whole Methodology tab is stripped for clients).
2880
+ */
2881
+ export function renderAwarenessNotRun(decision: {
2882
+ step: string;
2883
+ choice: string;
2884
+ rationale: string;
2885
+ }): string {
2886
+ return `<h3>Step 1.5 — Awareness layer (measurement-gap check)</h3>
2887
+ <div class="warn awareness-not-run" data-awareness="not-run">⊘ Awareness layer not run / not applicable for this run — this is a recorded decision, not a missing widget. Reason: ${escapeHtml(decision.rationale || decision.choice)}.</div>`;
2888
+ }
2889
+
2890
+ /**
2891
+ * Wave-6 R2.2: Render Methodology Step 1.5 — the awareness-layer mini-sample +
2892
+ * blind-spots table. When the awareness layer SKIPPED (library priors exist), a
2893
+ * placeholder is rendered instead. Returns "" when no awareness data is present
2894
+ * at all (older runs / backward-compat).
2895
+ */
2896
+ export function renderAwarenessStep(runMeta?: RunMeta): string {
2897
+ const sample = runMeta?.awarenessSample;
2898
+ const blindSpots = runMeta?.blindSpots;
2899
+
2900
+ // No awareness data at all → omit the step (backward-compat).
2901
+ if (!sample && (!blindSpots || blindSpots.length === 0)) return "";
2902
+
2903
+ // SKIP placeholder: a blindSpots table with the "(all)" skipped marker and no sample.
2904
+ if (!sample && blindSpots && blindSpots.length > 0 && blindSpots.every((b) => b.checkedBy === "—")) {
2905
+ return `<h3>Step 1.5 — Awareness layer (measurement-gap check)</h3>
2906
+ <p class="sub">Library priors exist; awareness layer skipped. The discovered-signal priors from prior runs are reused instead of a fresh 5-trace mini-sample. (R2.2)</p>`;
2907
+ }
2908
+
2909
+ // SD-7: guard the .map at the call site. The documented shape is
2910
+ // `{ traces: string[]; findings; firedAt }`, but a malformed runtime witness
2911
+ // (e.g. traces threaded as undefined/null by a producer regression) would crash
2912
+ // the whole render with `sample.traces.map is not a function`. Render a SKIP
2913
+ // marker for the trace list instead of throwing — the rest of Step 1.5 (firedAt,
2914
+ // blind-spots) still renders.
2915
+ const sampleHtml = sample
2916
+ ? `<p class="sub">A 5-trace LLM mini-sample fired BEFORE primary-signal selection to surface signals Tier-0 cannot MEASURE (the measurement-layer fix). Traces: ${
2917
+ Array.isArray(sample.traces)
2918
+ ? sample.traces.map((t) => `<code>${escapeHtml(t)}</code>`).join(", ")
2919
+ : `<em>— trace list unavailable (malformed awareness witness)</em>`
2920
+ }. Fired at ${escapeHtml(sample.firedAt)}.</p>`
2921
+ : "";
2922
+
2923
+ const blindSpotsTable = blindSpots && blindSpots.length > 0
2924
+ ? `<table>
2925
+ <thead><tr><th>Signal</th><th>Measurable?</th><th>Checked by</th><th>Result</th></tr></thead>
2926
+ <tbody>
2927
+ ${blindSpots
2928
+ .map(
2929
+ (b) =>
2930
+ `<tr><td>${escapeHtml(b.signal)}</td><td>${escapeHtml(b.measurable)}</td><td>${escapeHtml(b.checkedBy)}</td><td>${escapeHtml(b.result)}</td></tr>`
2931
+ )
2932
+ .join("\n ")}
2933
+ </tbody>
2934
+ </table>`
2935
+ : "";
2936
+
2937
+ return `<h3>Step 1.5 — Awareness layer (measurement-gap check)</h3>
2938
+ ${sampleHtml}
2939
+ ${blindSpotsTable}`;
2940
+ }
2941
+
2942
+ // ── renderTrajectoryTab (legacy I-041 — retained, no longer in default nav) ──
2943
+
2944
+ /**
2945
+ * I-041 (retained): Session-trajectory tab. The gold-standard nav does not
2946
+ * surface a separate Trajectory tab (its sequence lives in Methodology), but the
2947
+ * function is retained for callers/tests that render it directly.
2948
+ */
2949
+ export function renderTrajectoryTab(input: RenderInput): string {
2950
+ const traj = input.sessionTrajectory;
2951
+ const dagHtml = traj?.mermaidDag
2952
+ ? `<h3>Session Flow (DAG)</h3>
2953
+ <div class="mermaid">${escapeHtml(traj.mermaidDag)}</div>`
2954
+ : `<h3>Session Flow (DAG)</h3><p class="sub">No trajectory DAG available.</p>`;
2955
+ const timelineHtml =
2956
+ traj?.steps && traj.steps.length > 0
2957
+ ? `<h3 style="margin-top:18px;">Timeline</h3>
2958
+ <div style="font-size:12px;">
2959
+ ${traj.steps
2960
+ .map(
2961
+ (s) =>
2962
+ `<div style="display:flex;gap:10px;align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--border);">
2963
+ <span style="min-width:28px;font-family:var(--fm);color:var(--dim);font-size:11px;">${s.step}</span>
2964
+ <span style="min-width:80px;color:var(--p);font-family:var(--fm);font-size:11px;font-weight:600;">${escapeHtml(s.type)}</span>
2965
+ <span style="flex:1;color:var(--muted);">${escapeHtml(s.content.slice(0, 140))}${s.content.length > 140 ? "…" : ""}</span>
2966
+ ${s.timestamp ? `<span style="color:var(--dim);font-size:11px;font-family:var(--fm);white-space:nowrap;">${escapeHtml(s.timestamp)}</span>` : ""}
2967
+ </div>`
2968
+ )
2969
+ .join("\n")}
2970
+ </div>`
2971
+ : `<p class="sub" style="margin-top:12px;">No step-by-step timeline available.</p>`;
2972
+ return `
2973
+ <section class="panel" id="ttraj">
2974
+ <h2>Session Trajectory <span class="badge b-info">INTERNAL</span></h2>
2975
+ ${dagHtml}
2976
+ ${timelineHtml}
2977
+ </section>`;
2978
+ }
2979
+
2980
+ // ── renderDiscoveredChecks ────────────────────────────────────────────────────
2981
+
2982
+ function renderDiscoveredChecks(checks: DiscoveredCheck[]): string {
2983
+ return `
2984
+ <div class="discovered-checks">
2985
+ ${checks
2986
+ .map(
2987
+ (c) => `
2988
+ <div class="check" id="${escapeHtml(c.checkId)}">
2989
+ <strong>${escapeHtml(c.name)}</strong>
2990
+ <p>${escapeHtml(c.description)}</p>
2991
+ <small>Affects: ${c.affectedTraceIds.map((id) => escapeHtml(id)).join(", ")}</small>
2992
+ </div>`
2993
+ )
2994
+ .join("\n")}
2995
+ </div>`;
2996
+ }
2997
+
2998
+ // ── Wave-4: Contract-aware rendering (opt-in structured-report mode) ──────────
2999
+
3000
+ /**
3001
+ * Detects whether a target declares a self-diagnosis contract.
3002
+ * Looks for <target-root>/self-diagnosis-contract.yaml.
3003
+ * Returns parsed + validated contract OR null if absent.
3004
+ *
3005
+ * Capability: opt-in structured-report mode. Targets that don't declare
3006
+ * a contract get open-ended reports unchanged (regression invariant).
3007
+ */
3008
+ export async function loadTargetContract(
3009
+ targetRoot: string
3010
+ ): Promise<SelfDiagnosisContract | null> {
3011
+ const contractPath = join(targetRoot, "self-diagnosis-contract.yaml");
3012
+ if (!existsSync(contractPath)) return null;
3013
+ const raw = readFileSync(contractPath, "utf8");
3014
+ const parsed: unknown = parseYaml(raw);
3015
+ if (!Value.Check(SelfDiagnosisContractSchema, parsed)) {
3016
+ const errors = [...Value.Errors(SelfDiagnosisContractSchema, parsed)];
3017
+ const detail = errors.slice(0, 3).map((e) => `${e.path}: ${e.message}`).join("; ");
3018
+ throw new Error(`Invalid self-diagnosis-contract.yaml at ${contractPath}: ${detail}`);
3019
+ }
3020
+ return parsed as SelfDiagnosisContract;
3021
+ }
3022
+
3023
+ /**
3024
+ * Open-ended report path (existing behaviour — unchanged).
3025
+ * Targets without a self-diagnosis-contract.yaml always use this path.
3026
+ * Regression invariant: output must be identical to renderReport for same inputs.
3027
+ */
3028
+ export function renderOpenEndedReport(template: string, input: RenderInput): string {
3029
+ return renderReport(template, input);
3030
+ }
3031
+
3032
+ // ── Structured render input ──────────────────────────────────────────────────
3033
+
3034
+ export interface StructuredRenderInput extends RenderInput {
3035
+ /** Parsed contract from the target's self-diagnosis-contract.yaml */
3036
+ contract: SelfDiagnosisContract;
3037
+ }
3038
+
3039
+ /**
3040
+ * Structured report path (Wave-4 new).
3041
+ * When a target declares a self-diagnosis-contract.yaml, diagnostics emits a
3042
+ * 10-category structured report against the declared success criteria.
3043
+ * Findings with `criterion` populated are matched to their category section.
3044
+ * Criteria with no matching finding default to "pending".
3045
+ */
3046
+ export function renderStructuredReport(input: StructuredRenderInput): string {
3047
+ const { contract, findings } = input;
3048
+
3049
+ // Build a lookup: criterion id → finding (for criterion-aware findings only)
3050
+ const criterionFindingMap = new Map<string, Finding>();
3051
+ for (const f of findings) {
3052
+ if (f.criterion) {
3053
+ criterionFindingMap.set(f.criterion.id, f);
3054
+ }
3055
+ }
3056
+
3057
+ // Status pill rendering
3058
+ const statusPill = (status: string): string => {
3059
+ const colorMap: Record<string, string> = {
3060
+ pass: "var(--g)",
3061
+ fail: "var(--r)",
3062
+ "not-applicable": "var(--dim)",
3063
+ pending: "var(--y)",
3064
+ };
3065
+ const color = colorMap[status] ?? "var(--muted)";
3066
+ return `<span style="font-family:var(--fm);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;padding:2px 8px;border-radius:4px;background:${color};color:var(--bg);">${escapeHtml(status)}</span>`;
3067
+ };
3068
+
3069
+ // Build 10-category sections
3070
+ const categorySections = contract.success_criteria
3071
+ .map((entry) => {
3072
+ const criteriaRows = entry.criteria
3073
+ .map((criterion) => {
3074
+ const matchedFinding = criterionFindingMap.get(criterion.id);
3075
+ const status = matchedFinding?.criterion?.status ?? "pending";
3076
+ const evidenceRef = matchedFinding?.criterion?.evidenceRef;
3077
+ const evidenceHtml = evidenceRef
3078
+ ? `<span style="font-size:11px;color:var(--dim);font-family:var(--fm);">[${escapeHtml(evidenceRef.kind)}] ${escapeHtml(evidenceRef.value)}</span>`
3079
+ : "";
3080
+ return `<tr style="border-bottom:1px solid var(--border);">
3081
+ <td style="padding:8px 12px;font-family:var(--fm);font-size:11px;color:var(--muted);white-space:nowrap;">${escapeHtml(criterion.id)}</td>
3082
+ <td style="padding:8px 12px;font-size:13px;color:var(--text);">${escapeHtml(criterion.statement)}</td>
3083
+ <td style="padding:8px 12px;">${statusPill(status)}</td>
3084
+ <td style="padding:8px 12px;">${evidenceHtml}</td>
3085
+ </tr>`;
3086
+ })
3087
+ .join("\n");
3088
+
3089
+ const notesHtml = entry.notes
3090
+ ? `<div style="font-size:11px;color:var(--dim);font-family:var(--fm);padding:6px 0 10px;">${escapeHtml(entry.notes)}</div>`
3091
+ : "";
3092
+
3093
+ return `<section style="margin-bottom:24px;">
3094
+ <h3 style="font-family:var(--fm);font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:var(--p);border-bottom:1px solid var(--border);padding-bottom:6px;margin-bottom:6px;">${escapeHtml(entry.category)}</h3>
3095
+ ${notesHtml}
3096
+ <table style="width:100%;border-collapse:collapse;font-size:12px;">
3097
+ <thead><tr style="border-bottom:2px solid var(--bstr);">
3098
+ <th style="padding:6px 12px;text-align:left;font-family:var(--fm);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">ID</th>
3099
+ <th style="padding:6px 12px;text-align:left;font-family:var(--fm);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">Criterion</th>
3100
+ <th style="padding:6px 12px;text-align:left;font-family:var(--fm);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">Status</th>
3101
+ <th style="padding:6px 12px;text-align:left;font-family:var(--fm);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">Evidence</th>
3102
+ </tr></thead>
3103
+ <tbody>${criteriaRows}</tbody>
3104
+ </table>
3105
+ </section>`;
3106
+ })
3107
+ .join("\n");
3108
+
3109
+ const generatedAt = input.generatedAt ?? new Date().toISOString();
3110
+ const skillName = escapeHtml(contract.skill.name);
3111
+ const skillVersion = escapeHtml(contract.skill.version);
3112
+ const skillClass = escapeHtml(contract.skill.class);
3113
+ const categoryCount = contract.success_criteria.length;
3114
+ const totalCriteria = contract.success_criteria.reduce(
3115
+ (sum, e) => sum + e.criteria.length,
3116
+ 0
3117
+ );
3118
+
3119
+ return `<!DOCTYPE html>
3120
+ <html lang="en" data-theme="dark">
3121
+ <head>
3122
+ <meta charset="UTF-8">
3123
+ <title>MUTAGENT — Structured Diagnostic Report · ${skillName}</title>
3124
+ <style>
3125
+ :root{--bg:#0a0a12;--bg2:#14141d;--surf:#1a1a25;--surf-e:#22222f;--text:#eef1f6;--muted:#a6a2b4;--dim:#6a6678;--border:rgba(255,255,255,0.09);--bstr:rgba(255,255,255,0.16);--p:#b794f4;--p-strong:#7E47D7;--c:#45b8cc;--g:#43c39a;--y:#e8a64d;--r:#e06666;--fm:'IBM Plex Mono',ui-monospace,monospace;}
3126
+ *,*::before,*::after{box-sizing:border-box;border-radius:0!important;} body{margin:0;font-family:'Space Grotesk',system-ui,sans-serif;color:var(--text);background:var(--bg);font-size:14px;line-height:1.55;}
3127
+ header{background:var(--bg2);border-bottom:1px solid var(--bstr);padding:20px 32px;}
3128
+ .logo{font-weight:700;font-size:20px;letter-spacing:0.18em;background:linear-gradient(135deg,var(--p) 0%,var(--c) 100%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
3129
+ main{padding:24px 32px;}
3130
+ h2{font-size:16px;color:var(--p);margin-bottom:16px;}
3131
+ </style>
3132
+ </head>
3133
+ <body>
3134
+ <header>
3135
+ <div class="logo">MUTAGENT</div>
3136
+ <div style="font-size:12px;color:var(--muted);margin-top:4px;">Structured Diagnostic Report · ${skillName} v${skillVersion} · <span style="color:var(--dim);">${skillClass}</span></div>
3137
+ <div style="font-size:11px;color:var(--dim);font-family:var(--fm);margin-top:4px;">session=${escapeHtml(safeSessionId(input.sessionId))} · diagnosed=${escapeHtml(input.diagnosedAt)} · generated=${escapeHtml(generatedAt)}</div>
3138
+ <div style="font-size:11px;color:var(--dim);font-family:var(--fm);">${categoryCount} categories · ${totalCriteria} criteria · ${findings.length} finding(s)</div>
3139
+ </header>
3140
+ <main>
3141
+ <h2>Success Criteria — 10-Category Structured Report</h2>
3142
+ ${categorySections}
3143
+ </main>
3144
+ </body>
3145
+ </html>`;
3146
+ }
3147
+
3148
+ // ── escapeHtml ───────────────────────────────────────────────────────────────
3149
+
3150
+ function escapeHtml(str: string | null | undefined): string {
3151
+ // W12-03: coerce defensively — optional parsed fields (e.g. parsedInvocation.residual)
3152
+ // reach here as undefined on neutral-survey runs; an unguarded .replace would throw
3153
+ // TypeError and crash the whole report (the product surface must never crash on an
3154
+ // absent optional field).
3155
+ const safe = String(str ?? "");
3156
+ return safe
3157
+ .replace(/&/g, "&amp;")
3158
+ .replace(/</g, "&lt;")
3159
+ .replace(/>/g, "&gt;")
3160
+ .replace(/"/g, "&quot;")
3161
+ .replace(/'/g, "&#39;");
3162
+ }
3163
+
3164
+ // ── defaultTemplatePath ──────────────────────────────────────────────────────
3165
+
3166
+ /**
3167
+ * Resolve the default template path: assets/templates/report.html.tpl
3168
+ * relative to this script's own directory (report/), so it works regardless
3169
+ * of CWD. The canonical path is <skill-root>/assets/templates/report.html.tpl.
3170
+ * R-SELF-02-a / R-003-A: when --template flag is omitted, default to this file.
3171
+ * Falls back to default.html if report.html.tpl does not exist (pre-P4 compatibility).
3172
+ */
3173
+ function defaultTemplatePath(): string {
3174
+ const scriptDir = import.meta.dirname ?? pathDirname(import.meta.url.replace("file://", ""));
3175
+ const tplPath = join(scriptDir, "..", "..", "assets", "templates", "report.html.tpl");
3176
+ if (existsSync(tplPath)) return tplPath;
3177
+ return join(scriptDir, "..", "..", "assets", "templates", "default.html");
3178
+ }
3179
+
3180
+ // ── parseCliArgs ─────────────────────────────────────────────────────────────
3181
+
3182
+ /**
3183
+ * R-003-A: Parse flag-based CLI arguments.
3184
+ * Supports: --findings <path> --output <path> [--template <path>] [--audience client|internal]
3185
+ * Returns null if required flags are missing.
3186
+ */
3187
+ function parseCliArgs(argv: string[]): {
3188
+ findingsPath: string;
3189
+ outputPath: string;
3190
+ templatePath: string | null;
3191
+ audience: Audience;
3192
+ } | null {
3193
+ let findingsPath: string | null = null;
3194
+ let outputPath: string | null = null;
3195
+ let templatePath: string | null = null;
3196
+ let audience: Audience = "client"; // W13: default to CLIENT (leak-safe for published runs); internal is opt-in (--audience internal); self-diag forces internal (PR-022)
3197
+
3198
+ for (let i = 0; i < argv.length; i++) {
3199
+ if (argv[i] === "--findings" && argv[i + 1]) {
3200
+ findingsPath = argv[i + 1];
3201
+ i++;
3202
+ } else if (argv[i] === "--output" && argv[i + 1]) {
3203
+ outputPath = argv[i + 1];
3204
+ i++;
3205
+ } else if (argv[i] === "--template" && argv[i + 1]) {
3206
+ templatePath = argv[i + 1];
3207
+ i++;
3208
+ } else if (argv[i] === "--audience" && argv[i + 1]) {
3209
+ const raw = argv[i + 1];
3210
+ audience = raw === "client" ? "client" : "internal";
3211
+ i++;
3212
+ }
3213
+ }
3214
+
3215
+ if (!findingsPath || !outputPath) return null;
3216
+ return { findingsPath, outputPath, templatePath, audience };
3217
+ }
3218
+
3219
+ // ── CLI entrypoint ───────────────────────────────────────────────────────────
3220
+
3221
+ if (import.meta.main) {
3222
+ const args = process.argv.slice(2);
3223
+ const parsed = parseCliArgs(args);
3224
+
3225
+ if (!parsed) {
3226
+ process.stderr.write(
3227
+ "Usage: bun scripts/report/render.ts --findings <findings.json> --output <report.html> [options]\n" +
3228
+ "Options:\n" +
3229
+ " --template <path> Template file (default: assets/templates/report.html.tpl)\n" +
3230
+ " --audience client|internal Audience mode (default: internal)\n" +
3231
+ " client: NODE-STRIP removes class=\"internal\" nodes (Methodology, Trajectory tabs)\n" +
3232
+ " internal: Full report with all internal tabs and banners\n"
3233
+ );
3234
+ process.exit(1);
3235
+ }
3236
+
3237
+ const { findingsPath, outputPath, templatePath: templatePathArg, audience } = parsed;
3238
+ const templatePath = templatePathArg ?? defaultTemplatePath();
3239
+
3240
+ try {
3241
+ const rawInput: RenderInput = JSON.parse(readFileSync(resolve(findingsPath), "utf8"));
3242
+ // FU-INT-1: CLI --audience flag overrides any audience in the JSON
3243
+ const input: RenderInput = { ...rawInput, audience };
3244
+ const template = readFileSync(resolve(templatePath), "utf8");
3245
+ const html = renderReport(template, input);
3246
+ writeFileSync(resolve(outputPath), html, "utf8");
3247
+ process.stdout.write(`Report written to: ${outputPath}\n`);
3248
+ process.exit(0);
3249
+ } catch (err) {
3250
+ const e = err instanceof Error ? err : new Error(String(err));
3251
+ process.stderr.write(`Error rendering ${findingsPath}:\n${e.stack ?? e.message}\n`);
3252
+ process.exit(1);
3253
+ }
3254
+ }