@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,1964 @@
1
+ /**
2
+ * scripts/enrich/build-render-input.ts
3
+ * Wave-5 R1.4: deterministic enricher — (tier0-out, slice-plan, findings,
4
+ * trace-metadata) → a fully-populated RenderInput for the gold-standard renderer.
5
+ * Type A — Pure Script (DETERMINISTIC + IDEMPOTENT — no network, no LLM, no random,
6
+ * no clock beyond an injected `generatedAt`).
7
+ *
8
+ * Ported from wave-5-refs/build-render-input.ts (the hand-authored
9
+ * gold-standard body builder). The reference hardcoded its numbers; this enricher
10
+ * DERIVES them from the inputs so the pipeline never hand-authors a report again.
11
+ *
12
+ * Fail-loud (R1 §9.3): if ≥3 of the 4 internal render shapes (diagnosedEntity /
13
+ * bigStat / hourlyHeatmap / signalCensus) cannot be built, THROW — never emit a
14
+ * silently-degraded RenderInput (the regression's root cause).
15
+ *
16
+ * R1.7 (APPENDIX-A): `diagnosedEntity` is PULLED FROM the normalizer's
17
+ * EntityContext (passed in via opts.entityContext), NOT re-synthesized here.
18
+ */
19
+
20
+ import type {
21
+ Finding,
22
+ TraceMetadata,
23
+ EntityContext,
24
+ Assumption,
25
+ } from "../normalize/trace.ts";
26
+ import type {
27
+ RenderInput,
28
+ Entity,
29
+ BigStat,
30
+ SignalCensusRow,
31
+ ScanFunnel,
32
+ HourlyHeatmap,
33
+ HourlyHeatCell,
34
+ DecisionLogRow,
35
+ RunMeta,
36
+ } from "../report/render.ts";
37
+ import { entityFromContext } from "../report/render.ts";
38
+ import { rankRemedies } from "./rank-remedies.ts";
39
+ // ② G-2 over-alarming fix (SL-8 / DS-02): score numeric-anomaly severity against the
40
+ // corpus's OWN distribution (FI-LAT recipe) instead of absolute cutoffs. See
41
+ // .meta/prd.yaml KP-023 (proposed PR-056, operator-locked).
42
+ import {
43
+ computeCorpusBaseline,
44
+ calibrateFromSpans,
45
+ type CorpusBaseline,
46
+ type NameValue,
47
+ } from "./corpus-severity.ts";
48
+ import type {
49
+ TrajectoryCorroboration,
50
+ TrajectorySignal,
51
+ } from "../scan/trajectory.ts";
52
+ import type { DeepReadLedgerEntry, VerdictLedgerEntry } from "../library/types.ts";
53
+ // DISMISSAL FINAL-CHECK: the PURE partition (severity-escalation guard → semantic match).
54
+ // The reasoning that produces `verdicts` runs UPSTREAM (host-runtime, pinned); this module
55
+ // only applies the deterministic split. Keeps the enricher's no-LLM/no-network contract.
56
+ import { partitionByDismissal } from "./dismissal-match.ts";
57
+ import type { VerdictLookup } from "./dismissal-match.ts";
58
+
59
+ // ── Input shapes ──────────────────────────────────────────────────────────────
60
+
61
+ /** Subset of tier0-scan.ts output the enricher reads. */
62
+ export interface Tier0Input {
63
+ totalTraces: number;
64
+ withError?: number;
65
+ withFeedback?: number;
66
+ withLowScore?: number;
67
+ withHighLatency?: number;
68
+ hasPrimarySignal?: boolean;
69
+ recommendedSlicing?: string;
70
+ estimatedSlots?: number;
71
+ patterns?: Array<{ patternId: string; name: string; matchCount: number }>;
72
+ }
73
+
74
+ /**
75
+ * W11-01: PrimarySignal — the reconciled primary signal for a diagnostic run.
76
+ * Emitted by buildSignalCensus and threaded to RunMeta.primarySignal for
77
+ * census badge · heatmap · funnel to read from a single authoritative source.
78
+ */
79
+ export interface PrimarySignal {
80
+ /** The selected primary signal name (e.g. "latency-spike"). */
81
+ name: string;
82
+ /** One-sentence rationale for selection (impact×prevalence + deep-read corroboration). */
83
+ why: string;
84
+ /** Signals ruled out BEFORE scoring (e.g. benign observability artifacts). */
85
+ ruledOut: string[];
86
+ /** Confidence level based on deep-read corroboration. */
87
+ confidence: "high" | "medium" | "low";
88
+ }
89
+
90
+ /** Subset of slicer.ts output the enricher reads. */
91
+ export interface SlicePlanInput {
92
+ totalSlices?: number;
93
+ slices?: Array<{ sliceId?: string; traceIds?: string[] }>;
94
+ rationale?: string;
95
+ }
96
+
97
+ /**
98
+ * The findings document (already RenderInput-shaped from the RCA layer): carries
99
+ * sessionId, findings, runMeta, audience, isMetaReport, plus story-led copy.
100
+ */
101
+ export interface FindingsInput {
102
+ sessionId: string;
103
+ diagnosedAt: string;
104
+ sourcePlatform: string;
105
+ targetPlatform: string;
106
+ totalTraces: number;
107
+ findings: Finding[];
108
+ audience?: "client" | "internal";
109
+ isMetaReport?: boolean;
110
+ runMeta?: RunMeta;
111
+ entities?: Entity[];
112
+ // Story-led overview copy (optional — passed through verbatim).
113
+ headerTitle?: string;
114
+ overviewTitle?: string;
115
+ overviewSub?: string;
116
+ overviewHeadline?: string;
117
+ overviewLeverage?: string;
118
+ decisionsBundle?: string;
119
+ mermaidSequence?: string;
120
+ decisionLog?: DecisionLogRow[];
121
+ }
122
+
123
+ /** Trace metadata as emitted by the normalizers (+ optional cost). */
124
+ export interface MetadataInput extends TraceMetadata {
125
+ totalCostUsd?: number;
126
+ }
127
+
128
+ export interface EnricherInputs {
129
+ tier0: Tier0Input;
130
+ slicePlan: SlicePlanInput;
131
+ findings: FindingsInput;
132
+ metadata: MetadataInput[];
133
+ }
134
+
135
+ export interface EnricherOptions {
136
+ /** Injected ISO8601 timestamp — keeps the enricher deterministic in tests. */
137
+ generatedAt: string;
138
+ /** R1.7: EntityContext from the normalizer (preferred over findings.entities). */
139
+ entityContext?: EntityContext;
140
+ /**
141
+ * SD self-diag: set when `config.self_diagnostics.enabled`. Forces isMetaReport,
142
+ * audience=internal, a `[INTERNAL]` sessionId prefix, and a skill-typed entity.
143
+ * When supplied, `selfDiag.skillEntity` is the EntityContext built from the
144
+ * skill's own SKILL.md + scripts/ (via buildSkillSelfEntityContext).
145
+ */
146
+ selfDiag?: {
147
+ enabled: boolean;
148
+ /** Skill-typed EntityContext (entityType:"skill", codeAccess:true). */
149
+ skillEntity?: EntityContext;
150
+ };
151
+ /**
152
+ * W17-C (SELECTION HUB integration seam): the discovered-signal context threaded into
153
+ * buildSignalCensus — trajectory corroborations (R1 floor), folded ledger digests (R2)
154
+ * + the sampled-trace count (R6 honest prevalence). OPTIONAL: Block B (ledger fold) and
155
+ * Block D1 (trajectory scan) populate it upstream; until then the enricher runs the
156
+ * legacy Tier-0-only census (discovered signals, if any, surface as suspected-unconfirmed
157
+ * — never silently crowned, because no corroboration = no PRIMARY eligibility).
158
+ */
159
+ signalCtx?: SignalCensusContext;
160
+ /**
161
+ * DISMISSAL FINAL-CHECK (injected). When present, findings that semantically match a
162
+ * previously-dismissed verdict for this entity are REMOVED from renderInput.findings
163
+ * (hidden — there is NO visible "Suppressed" section; the verdict ledger is the audit
164
+ * trail). The reasoning that produced `verdicts` runs UPSTREAM (host-runtime, pinned
165
+ * temp-0 — see enrich/dismissal-match.ts + orchestrator-protocol.md); the enricher only
166
+ * applies the PURE deterministic partition. Absent ⇒ no suppression (fail-safe no-op,
167
+ * behaviour byte-unchanged vs pre-dismissal-loop).
168
+ */
169
+ dismissalContext?: {
170
+ /** The diagnosed entity's slug — EXACT-match gate for the final-check. */
171
+ entitySlug: string;
172
+ /** The folded VALID dismissed verdicts (fingerprint-invalidation already applied). */
173
+ dismissedEntries: VerdictLedgerEntry[];
174
+ /** Injected reasoning verdicts, keyed by verdictLookupKey(). */
175
+ verdicts: VerdictLookup;
176
+ };
177
+ }
178
+
179
+ // ── Deterministic numeric helpers ─────────────────────────────────────────────
180
+
181
+ /** Nearest-rank percentile over a numeric array (deterministic). */
182
+ function percentile(values: number[], p: number): number {
183
+ if (values.length === 0) return 0;
184
+ const sorted = values.slice().sort((a, b) => a - b);
185
+ const rank = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
186
+ return sorted[rank];
187
+ }
188
+
189
+ function sum(values: number[]): number {
190
+ return values.reduce((s, v) => s + v, 0);
191
+ }
192
+
193
+ /** Round to N decimals deterministically. */
194
+ function round(n: number, dp = 0): number {
195
+ const f = 10 ** dp;
196
+ return Math.round(n * f) / f;
197
+ }
198
+
199
+ /** Format a millisecond latency as a compact seconds string ("39s"). */
200
+ function ms2s(ms: number): string {
201
+ return `${Math.round(ms / 1000)}s`;
202
+ }
203
+
204
+ // ── Heatmap aggregation (the core R1.4 deterministic computation) ──────────────
205
+
206
+ /**
207
+ * F3 (UR-2): the heatmap metric MODE, keyed to the primary signal. The cell metric
208
+ * + colour FOLLOWS the primary failure mode rather than being latency-locked.
209
+ *
210
+ * latency-spike → avg latency (absolute bands, the historic default)
211
+ * cost-overshoot → avg $/trace (relative bands)
212
+ * error-WHAT → error rate (relative bands)
213
+ * else → volume (relative bands)
214
+ *
215
+ * Default (no primarySignal / unrecognized) = "latency" so a latency-primary run is
216
+ * byte-identical to the pre-F3 behaviour.
217
+ */
218
+ type HeatMetricMode = "latency" | "cost" | "error" | "volume";
219
+
220
+ /** WHAT-categories that map to the error-rate heatmap mode. */
221
+ const ERROR_WHATS = new Set([
222
+ "wrong-output",
223
+ "hallucination",
224
+ "missing-output",
225
+ "format-violation",
226
+ ]);
227
+
228
+ /**
229
+ * W17-C (R6): discovered WHY-style signals (from the trajectory map — tool-misuse ·
230
+ * handoff-loss · prompt-underspec) have NO direct per-trace numeric metric (unlike
231
+ * latency/cost/error). When one of these is the discovery-only primary, the heatmap
232
+ * falls back to the MECHANICAL metric the enricher can ALWAYS compute from metadata:
233
+ * trace VOLUME. This keeps the heatmap honest (a real per-hour mechanical metric) rather
234
+ * than fabricating a per-hour "tool-misuse rate" the metadata cannot support.
235
+ */
236
+ const DISCOVERY_ONLY_VOLUME_SIGNALS = new Set([
237
+ "tool-misuse",
238
+ "handoff-loss",
239
+ "prompt-underspec",
240
+ "loop",
241
+ ]);
242
+
243
+ /** Resolve the heatmap metric mode from a primary-signal name (deterministic). */
244
+ export function resolveHeatMetricMode(primarySignalName?: string): HeatMetricMode {
245
+ if (primarySignalName === "cost-overshoot") return "cost";
246
+ if (primarySignalName && ERROR_WHATS.has(primarySignalName)) return "error";
247
+ if (primarySignalName === "latency-spike" || !primarySignalName) return "latency";
248
+ // W17-C (R6): discovery-only WHY-style primaries have no per-trace numeric metric →
249
+ // mechanical-metric fallback = volume (the metric metadata can always supply).
250
+ if (primarySignalName && DISCOVERY_ONLY_VOLUME_SIGNALS.has(primarySignalName)) return "volume";
251
+ // volume / no-signal / any other admissible signal → volume view.
252
+ return "volume";
253
+ }
254
+
255
+ /**
256
+ * F3: classify a value into an l0..l4 bucket using RELATIVE bands (quintiles of the
257
+ * observed max). Used for cost / error / volume modes where there are no absolute
258
+ * gold-standard boundaries (latency keeps its absolute bands via classifyHeatLevel).
259
+ * Deterministic. maxValue 0 → all cells l0.
260
+ */
261
+ function classifyRelativeLevel(value: number, maxValue: number): number {
262
+ if (maxValue <= 0 || value <= 0) return 0;
263
+ const frac = value / maxValue;
264
+ if (frac >= 0.8) return 4;
265
+ if (frac >= 0.6) return 3;
266
+ if (frac >= 0.4) return 2;
267
+ if (frac >= 0.2) return 1;
268
+ return 0;
269
+ }
270
+
271
+ /** Absolute latency→l0..l4 bands (mirrors render.ts classifyHeatLevel for parity). */
272
+ function classifyLatencyLevel(avgS: number): number {
273
+ if (avgS < 50) return 0;
274
+ if (avgS < 65) return 1;
275
+ if (avgS < 85) return 2;
276
+ if (avgS < 100) return 3;
277
+ return 4;
278
+ }
279
+
280
+ /**
281
+ * Aggregate a 24h heatmap from per-trace startTime + latencyMs (+ cost + error).
282
+ * Each cell = one hour bucket: trace count, avg latency (s), max latency (s), plus
283
+ * F3 dynamic-metric fields (level / metricValue / metricLabel) keyed to the primary
284
+ * signal. Cells with zero traces are still emitted (count 0) so the 24-cell grid is
285
+ * complete. DETERMINISTIC — pure function of the metadata + the (data-derived)
286
+ * primary-signal name.
287
+ *
288
+ * F3 default = latency: when primarySignalName is latency/absent the cell colour is
289
+ * the historic absolute-band latency colour, so a latency-primary run is unchanged.
290
+ */
291
+ export function aggregateHourlyHeatmap(
292
+ metadata: MetadataInput[],
293
+ primarySignalName?: string
294
+ ): HourlyHeatmap {
295
+ const buckets: Array<{ latencies: number[]; costs: number[]; errors: number }> =
296
+ Array.from({ length: 24 }, () => ({ latencies: [], costs: [], errors: 0 }));
297
+
298
+ for (const t of metadata) {
299
+ if (!t.startTime || t.latencyMs === undefined) continue;
300
+ const hour = new Date(t.startTime).getUTCHours();
301
+ if (Number.isNaN(hour)) continue;
302
+ buckets[hour].latencies.push(t.latencyMs);
303
+ buckets[hour].costs.push(t.totalCostUsd ?? 0);
304
+ if (t.hasError) buckets[hour].errors += 1;
305
+ }
306
+
307
+ const mode = resolveHeatMetricMode(primarySignalName);
308
+
309
+ // First pass: latency stats + the raw active-metric value per cell.
310
+ const interim = buckets.map((b, hour) => {
311
+ const count = b.latencies.length;
312
+ const avgS = count > 0 ? round(sum(b.latencies) / count / 1000) : 0;
313
+ const maxS = count > 0 ? round(Math.max(...b.latencies) / 1000) : 0;
314
+ const avgCost = count > 0 ? round(sum(b.costs) / count, 4) : 0;
315
+ const errorRate = count > 0 ? round((b.errors / count) * 100) : 0;
316
+ const metricValue =
317
+ mode === "cost" ? avgCost : mode === "error" ? errorRate : mode === "volume" ? count : avgS;
318
+ return { hour, count, avgS, maxS, avgCost, errorRate, metricValue };
319
+ });
320
+
321
+ // Relative-band modes need the observed max across cells.
322
+ const maxMetric = Math.max(0, ...interim.map((c) => c.metricValue));
323
+
324
+ const cells: HourlyHeatCell[] = interim.map((c) => {
325
+ const level =
326
+ mode === "latency"
327
+ ? classifyLatencyLevel(c.avgS)
328
+ : classifyRelativeLevel(c.metricValue, maxMetric);
329
+ const metricLabel =
330
+ mode === "cost"
331
+ ? `avg $${c.avgCost.toFixed(2)}`
332
+ : mode === "error"
333
+ ? `${c.errorRate}% errors`
334
+ : mode === "volume"
335
+ ? `${c.count} traces`
336
+ : `avg ${c.avgS}s`;
337
+ return {
338
+ hour: c.hour,
339
+ count: c.count,
340
+ avgS: c.avgS,
341
+ maxS: c.maxS,
342
+ level,
343
+ metricValue: c.metricValue,
344
+ metricLabel,
345
+ };
346
+ });
347
+
348
+ // Narrative: top volume + top active-metric hours.
349
+ const byVol = cells.filter((c) => c.count > 0).slice().sort((a, b) => b.count - a.count);
350
+ const byMetric = cells
351
+ .filter((c) => c.count > 0)
352
+ .slice()
353
+ .sort((a, b) => (b.metricValue ?? 0) - (a.metricValue ?? 0));
354
+ const fmt = (c: HourlyHeatCell) => `${c.hour.toString().padStart(2, "0")}h`;
355
+ const metricWord =
356
+ mode === "cost" ? "cost" : mode === "error" ? "error rate" : mode === "volume" ? "volume" : "latency";
357
+ const narrative =
358
+ byVol.length > 0 && byMetric.length > 0
359
+ ? `Volume peaks at ${byVol.slice(0, 3).map(fmt).join(", ")}; ${metricWord} peaks at ${byMetric.slice(0, 3).map(fmt).join(", ")}.`
360
+ : undefined;
361
+
362
+ const metric: HourlyHeatmap["metric"] = {
363
+ signal: primarySignalName ?? "latency-spike",
364
+ label:
365
+ mode === "cost" ? "avg cost" : mode === "error" ? "error rate" : mode === "volume" ? "volume" : "avg latency",
366
+ unit: mode === "cost" ? "$" : mode === "error" ? "%" : mode === "volume" ? "" : "s",
367
+ };
368
+
369
+ const heatmap: HourlyHeatmap = { cells, metric };
370
+ if (narrative) heatmap.narrative = narrative;
371
+ return heatmap;
372
+ }
373
+
374
+ // ── Big-stat row ───────────────────────────────────────────────────────────────
375
+
376
+ /** Build the 6-tile big-stat row from latency percentiles + cost + counts. */
377
+ export function buildBigStat(
378
+ metadata: MetadataInput[],
379
+ tier0: Tier0Input
380
+ ): BigStat[] {
381
+ // Truly starved: no traces at all → no big-stat row to build (fail-loud signal).
382
+ if (metadata.length === 0) return [];
383
+
384
+ const latencies = metadata
385
+ .map((t) => t.latencyMs)
386
+ .filter((v): v is number => v !== undefined);
387
+ const cost = round(sum(metadata.map((t) => t.totalCostUsd ?? 0)), 2);
388
+ const errors = tier0.withError ?? metadata.filter((t) => t.hasError).length;
389
+
390
+ // PRD-SO-07: cost tile color-coded: red > $50, yellow > $10, muted <= $10.
391
+ const costColor = cost > 50 ? "var(--r)" : cost > 10 ? "var(--y)" : "var(--muted)";
392
+
393
+ return [
394
+ { value: latencies.length ? ms2s(percentile(latencies, 50)) : "—", label: "latency p50", color: "var(--y)" },
395
+ { value: latencies.length ? ms2s(percentile(latencies, 95)) : "—", label: "latency p95", color: "var(--y)" },
396
+ { value: latencies.length ? ms2s(Math.max(...latencies)) : "—", label: "latency max", color: "var(--r)" },
397
+ { value: cost > 0 ? `$${cost.toFixed(2)}` : "—", label: "cost / window", color: costColor },
398
+ { value: metadata.length.toLocaleString("en-US"), label: "traces", color: "var(--muted)" },
399
+ { value: String(errors), label: "errors", color: errors > 0 ? "var(--r)" : "var(--g)" },
400
+ ];
401
+ }
402
+
403
+ // ── Signal census ──────────────────────────────────────────────────────────────
404
+
405
+ /**
406
+ * W11-01: Impact weight per WHAT category.
407
+ * Signals that map to user-facing failure WHATs (correctness / cost / latency)
408
+ * score higher than observability artifacts (missing-metadata, etc.).
409
+ * Scale: 3 = direct user harm, 2 = indirect / degraded UX, 1 = mild / uncertain.
410
+ *
411
+ * W17-C (R3 — default impact ≥ 2): the DISCOVERED taxonomy (deep-read-found WHATs
412
+ * + ledger-folded digests + trajectory-corroborated signals) is keyed here so a
413
+ * discovered signal NEVER falls back to the impact-1 default — at impact 1 a fresh,
414
+ * genuine discovery would silently lose to latency-spike (impact 2) on prevalence
415
+ * tiebreak. The discovered WHY-style signals come from the trajectory map
416
+ * (loop/latency · tool-misuse · handoff-loss · prompt-underspec); `correctness` and
417
+ * `loop` carry impact 3. `hallucination` is already 3 (a correctness WHAT).
418
+ */
419
+ const SIGNAL_IMPACT: Record<string, number> = {
420
+ "wrong-output": 3,
421
+ "hallucination": 3,
422
+ "missing-output": 3,
423
+ "loop": 3,
424
+ "cost-overshoot": 3,
425
+ "latency-spike": 2,
426
+ "format-violation": 2,
427
+ "user-complaint": 2,
428
+ "low-score": 2,
429
+ "missing-context": 1,
430
+
431
+ // ── W17-C (R3): discovered taxonomy — impact ≥ 2 floor (never the default 1) ──
432
+ "correctness": 3, // umbrella correctness WHAT (≥ latency on a tie)
433
+ "handoff-loss": 2, // abandoned-call → degraded multi-step UX
434
+ "prompt-underspec": 2, // oscillation → wasted turns / wrong path
435
+ "tool-misuse": 2, // tool-error → broken action, degraded UX
436
+ };
437
+
438
+ /**
439
+ * W17-C (R3): DISCOVERED-signal default impact. Any discovered signal NOT explicitly
440
+ * keyed in SIGNAL_IMPACT falls back to 2 (NOT the cheap-signal default of 1), so an
441
+ * unknown but genuine discovered category cannot silently lose to latency-spike on the
442
+ * prevalence tiebreak. Tier-0 cheap signals keep the impact-1 default (DEFAULT_IMPACT).
443
+ */
444
+ const DISCOVERED_DEFAULT_IMPACT = 2;
445
+
446
+ /** Tier-0 cheap-signal default impact (unchanged — mild / uncertain). */
447
+ const DEFAULT_IMPACT = 1;
448
+
449
+ /**
450
+ * W11-01: Benign observability artifacts that NEVER qualify as a primary signal
451
+ * (failure-validity gate). These patterns may appear in the Tier-0 census but are
452
+ * ruled out before impact×prevalence scoring because they do not map to a user-
453
+ * visible WHAT failure category.
454
+ *
455
+ * W12-05 (PR-051 propose): `low-tagging-rate` was REMOVED — its Tier-0 emitter
456
+ * (LF-002) is deleted, so it is now dead. The BENIGN_SIGNALS mechanism is RETAINED
457
+ * as a forward-looking guard for genuine future observability artifacts.
458
+ */
459
+ const BENIGN_SIGNALS = new Set([
460
+ "missing-metadata",
461
+ "missing-score",
462
+ ]);
463
+
464
+ /**
465
+ * W12-05 (PR-051 propose): signal source-allowlist. The census admits only
466
+ * MECHANICAL signals that map to a user-visible failure WHAT. Any Tier-0 pattern
467
+ * whose name is neither a known mechanical signal NOR a deep-read failure-WHAT
468
+ * category is observability hygiene by construction and is ruled out BEFORE
469
+ * scoring — observability hygiene can never leak in as a signal/census row even
470
+ * if a future Tier-0 module emits one. Deep-read `failureOrigin.what` categories
471
+ * are admitted via SIGNAL_IMPACT (which keys them) at the corroboration step.
472
+ */
473
+ const MECHANICAL_SIGNALS = new Set([
474
+ "error-spike",
475
+ "latency-spike",
476
+ "feedback-cluster",
477
+ "low-score-concentration",
478
+ "api-exhaustion",
479
+ "context-compaction-cluster",
480
+ "high-teammate-ratio",
481
+ ]);
482
+
483
+ /**
484
+ * True when a Tier-0 pattern name is an admissible census signal — i.e. a
485
+ * mechanical signal, a deep-read failure-WHAT category, OR a recognized benign
486
+ * artifact (which earns a visible "ruled out" row rather than a silent drop).
487
+ * Anything else is observability hygiene and is dropped silently (W12-05).
488
+ */
489
+ function isAdmissibleSignal(name: string): boolean {
490
+ return MECHANICAL_SIGNALS.has(name) || name in SIGNAL_IMPACT || BENIGN_SIGNALS.has(name);
491
+ }
492
+
493
+ // ── W17-C SELECTION HUB — discovered-signal pool + the R1 evidence floor ──────────
494
+ //
495
+ // Wave-17 makes deep-read-DISCOVERED signals FIRST-CLASS census candidates (the old
496
+ // `validPatterns.some(p => p.name === llmWhat)` gate is deleted). A discovered signal
497
+ // is any failure-WHAT surfaced by the LLM analyzer (findings[].failureOrigin.what) OR
498
+ // folded from a still-valid cross-run ledger digest (Block G). But first-class ≠ free:
499
+ // R1 — a discovered signal may be PRIMARY only if MECHANICALLY corroborated by an
500
+ // analyzeTrajectory() corroboration whose signal (via PATTERN_SIGNAL_MAP) maps
501
+ // to the discovered WHAT, with a RESOLVABLE evidenceRef. LLM-asserted citation
502
+ // alone is NOT enough. Unevidenced discovered → capped at SECONDARY.
503
+ // R2 — a ledger-folded digest must RE-PASS the SAME floor (its evidenceRef must still
504
+ // resolve + corroborate). A digest is never trusted blindly.
505
+
506
+ /**
507
+ * W17-C (R1/R4): does a trajectory corroboration `signal` corroborate a discovered
508
+ * WHAT? The trajectory layer emits coarse WHY-style signal labels (loop/latency ·
509
+ * tool-misuse · handoff-loss · prompt-underspec — see PATTERN_SIGNAL_MAP); the
510
+ * discovered WHAT may be a WhatCategory (loop · latency-spike · hallucination …) OR a
511
+ * WhyCategory-style label (tool-misuse · handoff-loss · prompt-underspec). The compound
512
+ * `loop/latency` corroborates EITHER `loop` OR `latency-spike`. Exact-label matches
513
+ * (tool-misuse / handoff-loss / prompt-underspec) pass through. `hallucination` has no
514
+ * mechanical trajectory signal → never corroborated here (it is content-only; R1 caps
515
+ * it at SECONDARY unless an exact-named trajectory signal ever maps to it).
516
+ */
517
+ function trajectorySignalMatchesWhat(signal: TrajectorySignal, what: string): boolean {
518
+ if (signal === "loop/latency") return what === "loop" || what === "latency-spike";
519
+ // The remaining trajectory signals are exact WHY-style labels.
520
+ return signal === what;
521
+ }
522
+
523
+ /**
524
+ * W17-C (R1): a single resolved mechanical corroboration for a discovered WHAT.
525
+ * Carries the concrete evidenceRef so the census/selection card can CITE it (not just
526
+ * assert it). `undefined` from resolveCorroboration() means "unevidenced → cap at
527
+ * SECONDARY".
528
+ */
529
+ interface ResolvedCorroboration {
530
+ signal: TrajectorySignal;
531
+ evidenceRef: string;
532
+ }
533
+
534
+ /**
535
+ * W17-C (R1 — evidence-cited floor): find a trajectory corroboration that mechanically
536
+ * evidences `what`. A corroboration counts ONLY when:
537
+ * (a) its signal maps (via PATTERN_SIGNAL_MAP semantics) to `what`, AND
538
+ * (b) its evidenceRef RESOLVES — i.e. is a non-empty, well-formed trace span pointer
539
+ * (`trace:<id>#msg[...]`, the makeEvidenceRef shape). An empty/garbled ref is NOT
540
+ * resolvable, so a corroboration carrying one does not satisfy the floor.
541
+ * Returns the first matching resolvable corroboration (deterministic — input order),
542
+ * or undefined when none corroborates `what` (→ discovered signal capped at SECONDARY).
543
+ */
544
+ function resolveCorroboration(
545
+ what: string,
546
+ corroborations: ReadonlyArray<TrajectoryCorroboration>
547
+ ): ResolvedCorroboration | undefined {
548
+ for (const c of corroborations) {
549
+ if (!trajectorySignalMatchesWhat(c.signal, what)) continue;
550
+ if (!isResolvableEvidenceRef(c.evidenceRef)) continue;
551
+ return { signal: c.signal, evidenceRef: c.evidenceRef };
552
+ }
553
+ return undefined;
554
+ }
555
+
556
+ /**
557
+ * W17-C (R1/R2): an evidenceRef is RESOLVABLE when it matches the deterministic
558
+ * trajectory-evidence shape `trace:<traceId>#msg[<indices>]` (makeEvidenceRef in
559
+ * scan/trajectory.ts) with at least one span index. This is the mechanical "does the
560
+ * citation point at a real span" check the floor demands — an LLM that merely asserts
561
+ * a signal without a span pointer, or a stale ledger digest whose evidenceRef no longer
562
+ * conforms, fails here and cannot reach PRIMARY. Pure + deterministic.
563
+ */
564
+ export function isResolvableEvidenceRef(ref: string | undefined): boolean {
565
+ return typeof ref === "string" && /^trace:[^#]+#msg\[\d+(?:,\d+)*\]$/.test(ref);
566
+ }
567
+
568
+ /**
569
+ * W17-C (selection hub): one merged candidate in the discovered-signal pool. `origin`
570
+ * records WHERE the candidate came from (deep-read finding vs folded ledger digest) so
571
+ * the selection card + residual-surfacing copy can be honest. `seenCount` is the count
572
+ * of distinct traces the signal was observed on (deep-read finding sourceTraceIds OR
573
+ * 1-per-folded-digest) — the basis for the HONEST prevalence measure (R6: "seen in k/n
574
+ * sampled", never a fabricated corpus rate).
575
+ */
576
+ interface DiscoveredCandidate {
577
+ what: string;
578
+ origin: "deep-read" | "ledger";
579
+ /** Distinct traces this discovered signal was observed on (honest prevalence numerator). */
580
+ seenCount: number;
581
+ /** R1: the resolved mechanical corroboration (undefined → cap at SECONDARY). */
582
+ corroboration?: ResolvedCorroboration;
583
+ }
584
+
585
+ /**
586
+ * W12-F1 (UI-2op): the auditable signal-selection metadata the enricher derives
587
+ * from the SAME census scoring data. Threaded into runMeta so the Methodology tab
588
+ * can SHOW THE WORK (per-signal score cards + a mermaid decision-path trace)
589
+ * instead of falling back to the generic 5-step blurb. DETERMINISTIC — no LLM,
590
+ * no clock, no random; purely a function of the impact×prevalence scoring already
591
+ * computed for the census + the deep-read corroboration outcome (PR-049 / PR-038).
592
+ */
593
+ export interface SignalSelectionMeta {
594
+ /** Per-signal selection-rule cards (RunMeta.selectionRules shape). */
595
+ selectionRules: NonNullable<RunMeta["selectionRules"]>;
596
+ /** Mermaid decision-path trace (RunMeta.signalSelectionTrace shape). */
597
+ signalSelectionTrace: string;
598
+ }
599
+
600
+ /**
601
+ * W17-C (R7 — residual surfacing): emitted when the top DISCOVERED signal is capped at
602
+ * SECONDARY for lack of mechanical evidence (R1 floor) AND primary falls back to a cheap
603
+ * signal. Block E (the renderer) reads this to show "suspected primary — unconfirmed"
604
+ * instead of silently crowning the cheap signal (e.g. latency-spike). When the discovered
605
+ * signal IS evidence-floored (passes R1) this is absent — there is nothing unconfirmed.
606
+ */
607
+ export interface SuspectedPrimary {
608
+ /** The discovered WHAT that was suspected-but-uncorroborated. */
609
+ signal: string;
610
+ /** Distinct sampled traces it was seen on (honest prevalence numerator). */
611
+ seenCount: number;
612
+ /** Sampled-trace denominator (R6 — "seen in k/n sampled"). */
613
+ sampledCount: number;
614
+ /** Why it was capped at SECONDARY (the R1-floor reason). */
615
+ reason: string;
616
+ }
617
+
618
+ /**
619
+ * W17-C (R7): RunMeta + the residual-surfacing field. The canonical `RunMeta` lives in
620
+ * report/render.ts (Block E's scope) and does not yet declare `suspectedPrimaryUnconfirmed`;
621
+ * the enricher (Block C) is the PRODUCER of the field. We model it locally as a superset
622
+ * so the enricher stays type-safe WITHOUT editing render.ts (out of this block's scope).
623
+ * Block E adds the canonical slot to RunMeta when it lands the renderer consumption; the
624
+ * shape here is the contract Block E reads. Structurally a RunMetaW17 IS a RunMeta, so it
625
+ * threads transparently into RenderInput.runMeta.
626
+ */
627
+ export type RunMetaW17 = RunMeta & {
628
+ /** R7: present only when a discovered top signal was capped at SECONDARY (unevidenced). */
629
+ suspectedPrimaryUnconfirmed?: SuspectedPrimary;
630
+ };
631
+
632
+ /**
633
+ * W17-C (SELECTION HUB): the discovered-signal wiring the census reads to make
634
+ * deep-read findings + folded ledger digests FIRST-CLASS candidates under the R1
635
+ * evidence floor. All OPTIONAL — a caller that passes only `tier0`/`total`/`findings`
636
+ * gets the legacy Tier-0-only behaviour (backward-compat: existing 3-arg callers and
637
+ * tests are unaffected). Block C (this file) owns this object; Block B threads the
638
+ * folded ledger in, Block D1 threads the trajectory corroborations in.
639
+ */
640
+ export interface SignalCensusContext {
641
+ /**
642
+ * R1 evidence floor surface: mechanical corroborations from analyzeTrajectory()
643
+ * across the sampled traces. A discovered signal reaches PRIMARY only if one of
644
+ * these corroborates it (signal maps via PATTERN_SIGNAL_MAP + resolvable evidenceRef).
645
+ */
646
+ corroborations?: ReadonlyArray<TrajectoryCorroboration>;
647
+ /**
648
+ * R2 surface: ledger digests folded down to the still-VALID set by
649
+ * store.foldValidDigests(). Each is re-admitted as a discovered candidate and MUST
650
+ * re-pass the SAME evidence floor (its evidenceRef must still resolve + corroborate)
651
+ * — never trusted blindly.
652
+ */
653
+ foldedDigests?: ReadonlyArray<DeepReadLedgerEntry>;
654
+ /**
655
+ * R6 honest-prevalence denominator: how many traces were actually SAMPLED (deep-read).
656
+ * A discovered primary's measure reads "seen in k/n sampled" against THIS n — never a
657
+ * fabricated corpus rate over `total`. Defaults to `total` when absent (best-effort).
658
+ */
659
+ sampledCount?: number;
660
+ }
661
+
662
+ /**
663
+ * Finding subset the census reads (widened W17-C: + sourceTraceIds for honest
664
+ * prevalence; widened D3: + the RCA-narrative fields the dataset-candidate cards
665
+ * derive from — failureOrigin.{why,where,whatHappened} · severity · problem ·
666
+ * title · subDesc · whyChain origin). All optional — the real Finding is a strict
667
+ * superset, so passing normalizedFindings (full Finding[]) satisfies this type.
668
+ */
669
+ type CensusFinding = {
670
+ findingId?: string;
671
+ failureOrigin?: { what?: string; why?: string; where?: string; whatHappened?: string };
672
+ sourceTraceIds?: string[];
673
+ severity?: "crit" | "high" | "med" | "info";
674
+ problem?: string;
675
+ title?: string;
676
+ subDesc?: string;
677
+ whyChain?: Array<{ why?: string; isOrigin?: boolean }>;
678
+ };
679
+
680
+ /**
681
+ * W11-01 + W17-C: Build the signal census as the SELECTION HUB. Pipeline:
682
+ * 1. Failure-validity gate — drop non-admissible signals; benign → ruled-out row.
683
+ * 2. Tier-0 cheap-signal scoring: impact × (matchCount/total), default impact 1.
684
+ * 3. DISCOVERED pool (W17-C, first-class): findings[].failureOrigin.what ∪ folded
685
+ * ledger digests. Impact-dominant ranking (impact first, prevalence tiebreak),
686
+ * default impact 2 (R3). The OLD gate `validPatterns.some(p => p.name === llmWhat)`
687
+ * is DELETED — a discovered signal no longer needs a matching Tier-0 pattern.
688
+ * 4. R1 evidence floor: a discovered signal is PRIMARY-eligible ONLY when mechanically
689
+ * corroborated (trajectory corroboration maps to its WHAT + resolvable evidenceRef).
690
+ * R2: folded digests re-pass the SAME floor. Unevidenced discovered → SECONDARY.
691
+ * 5. PRIMARY selection: an evidence-floored discovered signal beats the top cheap
692
+ * signal (deep-read mechanical > static frequency). Else primary falls back to the
693
+ * top cheap signal; if a discovered top signal was capped for lack of evidence,
694
+ * `suspectedPrimaryUnconfirmed` is emitted (R7) so Block E shows "suspected primary
695
+ * — unconfirmed" instead of silently crowning latency.
696
+ *
697
+ * Returns rows AND attaches primarySignal + selectionRules + signalSelectionTrace +
698
+ * (W17-C) suspectedPrimaryUnconfirmed to the array so callers thread them into RunMeta.
699
+ *
700
+ * @param tier0 - Tier-0 scan output
701
+ * @param total - Denominator for prevalence (total traces in window)
702
+ * @param findings - Optional findings (deep-read discovered WHATs + sourceTraceIds)
703
+ * @param ctx - W17-C selection-hub context (corroborations · foldedDigests · sampledCount)
704
+ */
705
+ export function buildSignalCensus(
706
+ tier0: Tier0Input,
707
+ total: number,
708
+ findings?: ReadonlyArray<CensusFinding>,
709
+ ctx?: SignalCensusContext
710
+ ): SignalCensusRow[] &
711
+ { primarySignal?: PrimarySignal } &
712
+ Partial<SignalSelectionMeta> &
713
+ { suspectedPrimaryUnconfirmed?: SuspectedPrimary } {
714
+ const rawPatterns = (tier0.patterns ?? []).slice();
715
+ const corroborations = ctx?.corroborations ?? [];
716
+ const foldedDigests = ctx?.foldedDigests ?? [];
717
+ // R6: honest prevalence denominator = sampled count (falls back to total).
718
+ const sampledN = ctx?.sampledCount ?? total;
719
+
720
+ // ── W17-C: assemble the DISCOVERED candidate pool (first-class) ───────────────
721
+ // Source A: deep-read findings — every failureOrigin.what is a discovered signal.
722
+ // Source B (R2): folded ledger digests — each digest.signal re-admits as discovered.
723
+ // Both are merged + deduped by WHAT; seenCount sums distinct evidence (honest prev).
724
+ const discovered = buildDiscoveredPool(findings ?? [], foldedDigests, corroborations);
725
+
726
+ // Truly starved: no detected patterns AND no signal counts AND no discovered pool →
727
+ // no census to build (fail-loud signal). A census with only rule-out rows requires
728
+ // that tier0 at least measured the signals (or a discovery exists).
729
+ const hasSignalData =
730
+ rawPatterns.length > 0 ||
731
+ discovered.length > 0 ||
732
+ tier0.withError !== undefined ||
733
+ tier0.withLowScore !== undefined ||
734
+ tier0.withFeedback !== undefined;
735
+ if (!hasSignalData)
736
+ return Object.assign([], {
737
+ primarySignal: undefined,
738
+ selectionRules: undefined,
739
+ signalSelectionTrace: undefined,
740
+ suspectedPrimaryUnconfirmed: undefined,
741
+ });
742
+
743
+ // ── Step 1: failure-validity gate (Tier-0 cheap signals) ─────────────────────
744
+ // 1a. Signal source-allowlist (W12-05 / PR-051 propose): drop non-admissible
745
+ // signals SILENTLY (no census row at all — not even ruled-out). Observability
746
+ // hygiene must never surface anywhere in the census.
747
+ // 1b. BENIGN_SIGNALS: admissible-but-benign artifacts get a visible "ruled out"
748
+ // row (forward-looking guard; currently {missing-metadata, missing-score}).
749
+ const ruledOut: string[] = [];
750
+ const validPatternsAll = rawPatterns.filter((p) => {
751
+ if (!isAdmissibleSignal(p.name)) {
752
+ // Not a mechanical signal and not a deep-read failure WHAT → observability
753
+ // hygiene. Dropped silently; never rendered (W12-05).
754
+ return false;
755
+ }
756
+ if (BENIGN_SIGNALS.has(p.name)) {
757
+ ruledOut.push(p.name);
758
+ return false;
759
+ }
760
+ return true;
761
+ });
762
+
763
+ // ── W17-C: dedupe overlapping signals across the cheap + discovered pools ──────
764
+ // A signal name may appear in BOTH Tier-0 (cheap pattern) AND the discovered pool
765
+ // (same failure surfaced by deep-read). It is ONE signal — render ONE row. Ownership:
766
+ // - CORROBORATED discovered name → owned by the discovered pool (it can be PRIMARY
767
+ // via the evidence floor). Suppress the cheap row so there is no duplicate.
768
+ // - UNCORROBORATED discovered name that ALSO has a cheap Tier-0 pattern → owned by
769
+ // the cheap pool (normal prevalence scoring; the discovered duplicate is dropped —
770
+ // no extra "suspected-unconfirmed" row for a signal Tier-0 already measures).
771
+ // A discovered name with NO cheap pattern always stays in the discovered pool.
772
+ const corroboratedDiscoveredNames = new Set(
773
+ discovered.filter((d) => d.corroboration !== undefined).map((d) => d.what)
774
+ );
775
+ const cheapPatternNames = new Set(validPatternsAll.map((p) => p.name));
776
+
777
+ // Cheap pool: drop any pattern whose name is owned by a CORROBORATED discovered signal.
778
+ const validPatterns = validPatternsAll.filter((p) => !corroboratedDiscoveredNames.has(p.name));
779
+
780
+ // Discovered pool: drop an UNCORROBORATED discovered signal that a cheap pattern already
781
+ // covers (avoid a duplicate suspected-unconfirmed row for a Tier-0-measured signal).
782
+ const discoveredDeduped = discovered.filter(
783
+ (d) => d.corroboration !== undefined || !cheapPatternNames.has(d.what)
784
+ );
785
+
786
+ // ── Step 2: Tier-0 cheap-signal scoring (UNCHANGED real invariant) ────────────
787
+ // score = impact * (matchCount / total). Ties broken by matchCount, then name.
788
+ // Cheap signals keep the impact-1 DEFAULT_IMPACT fallback.
789
+ const scored = validPatterns
790
+ .map((p) => {
791
+ const impact = SIGNAL_IMPACT[p.name] ?? DEFAULT_IMPACT;
792
+ const prevalence = p.matchCount / Math.max(1, total);
793
+ return { ...p, score: impact * prevalence };
794
+ })
795
+ .sort((a, b) => {
796
+ if (b.score !== a.score) return b.score - a.score;
797
+ if (b.matchCount !== a.matchCount) return b.matchCount - a.matchCount;
798
+ return a.name.localeCompare(b.name);
799
+ });
800
+
801
+ // ── Step 3: DISCOVERED impact-dominant ranking (R3 default impact ≥ 2) ────────
802
+ // Rank discovered signals by IMPACT first (impact-dominant), prevalence (seenCount)
803
+ // tiebreak, then name. Unknown discovered categories get DISCOVERED_DEFAULT_IMPACT
804
+ // (2) — never the cheap-signal default of 1 (else a fresh discovery silently loses
805
+ // to latency-spike). This is intentionally NOT impact×prevalence: a low-prevalence
806
+ // but high-impact freshly-discovered failure should not be buried.
807
+ const discoveredScored = discoveredDeduped
808
+ .map((d) => ({
809
+ ...d,
810
+ impact: SIGNAL_IMPACT[d.what] ?? DISCOVERED_DEFAULT_IMPACT,
811
+ }))
812
+ .sort((a, b) => {
813
+ if (b.impact !== a.impact) return b.impact - a.impact; // impact-dominant
814
+ if (b.seenCount !== a.seenCount) return b.seenCount - a.seenCount; // prevalence tiebreak
815
+ return a.what.localeCompare(b.what);
816
+ });
817
+
818
+ // ── Step 4 + 5: R1 evidence floor → PRIMARY selection ────────────────────────
819
+ // The top discovered signal that PASSES the evidence floor (has a resolved mechanical
820
+ // corroboration) is PRIMARY-eligible and beats the top cheap signal. If the top
821
+ // discovered signal exists but is UNEVIDENCED, it is capped at SECONDARY and the
822
+ // residual flag is raised (R7).
823
+ const topDiscovered = discoveredScored[0];
824
+ const evidencedDiscovered = discoveredScored.find((d) => d.corroboration !== undefined);
825
+
826
+ const cheapPrimary = scored[0]?.name;
827
+ let primaryName: string | undefined;
828
+ let corroborationNote = "";
829
+ let confidence: PrimarySignal["confidence"] = "medium";
830
+ let primaryEvidenceRef: string | undefined;
831
+ let primaryIsDiscovered = false;
832
+ // R7: when the top discovered signal is capped at SECONDARY for lack of evidence and
833
+ // primary falls back to a cheap signal, surface it so Block E can render "suspected
834
+ // primary — unconfirmed" instead of silently crowning the cheap signal.
835
+ let suspected: SuspectedPrimary | undefined;
836
+
837
+ if (evidencedDiscovered) {
838
+ // R1 PASS — an evidence-floored discovered signal wins (deep-read mechanical >
839
+ // static frequency).
840
+ primaryName = evidencedDiscovered.what;
841
+ primaryIsDiscovered = true;
842
+ primaryEvidenceRef = evidencedDiscovered.corroboration!.evidenceRef;
843
+ confidence = "high";
844
+ corroborationNote =
845
+ evidencedDiscovered.origin === "ledger"
846
+ ? `discovered (ledger-folded) — mechanically corroborated (${primaryEvidenceRef})`
847
+ : `discovered (deep-read) — mechanically corroborated (${primaryEvidenceRef})`;
848
+ } else {
849
+ // No evidence-floored discovered signal → primary is the top cheap signal.
850
+ primaryName = cheapPrimary;
851
+ if (cheapPrimary) {
852
+ corroborationNote = "impact×prevalence (no deep-read corroboration)";
853
+ confidence = scored[0].score > 0.5 ? "medium" : "low";
854
+ }
855
+ // R7: a discovered signal WAS surfaced but failed the floor → suspected-unconfirmed.
856
+ if (topDiscovered) {
857
+ suspected = {
858
+ signal: topDiscovered.what,
859
+ seenCount: topDiscovered.seenCount,
860
+ sampledCount: sampledN,
861
+ reason:
862
+ "discovered but UNCONFIRMED — no mechanical trajectory corroboration with a resolvable evidenceRef (R1 evidence floor); capped at SECONDARY",
863
+ };
864
+ }
865
+ }
866
+
867
+ // ── Build census rows ─────────────────────────────────────────────────────────
868
+ const rows: SignalCensusRow[] = [];
869
+
870
+ // Tier-0 cheap-signal rows. A cheap signal is PRIMARY only when it actually won.
871
+ for (const p of scored) {
872
+ const isPrimary = !primaryIsDiscovered && p.name === primaryName;
873
+ rows.push({
874
+ signal: p.name,
875
+ present: "YES",
876
+ presentColor: "var(--r)",
877
+ measure: `${p.matchCount}/${total} (${round((p.matchCount / total) * 100)}%)`,
878
+ decision: isPrimary
879
+ ? `<span class="badge b-crit">★ PRIMARY</span>`
880
+ : `<span class="badge b-med">SECONDARY</span>`,
881
+ primary: isPrimary,
882
+ });
883
+ }
884
+
885
+ // W17-C discovered-signal rows. Honest prevalence (R6): "seen in k/n sampled" — NOT a
886
+ // corpus rate. The evidence-floored winner is ★ PRIMARY; the rest (incl. the capped
887
+ // suspected one) are SECONDARY with an explicit reason.
888
+ for (const d of discoveredScored) {
889
+ const isPrimary = primaryIsDiscovered && d.what === primaryName;
890
+ const evidenced = d.corroboration !== undefined;
891
+ rows.push({
892
+ signal: d.what,
893
+ present: "YES",
894
+ presentColor: evidenced ? "var(--r)" : "var(--y)",
895
+ // R6 honest prevalence — sampled denominator, never a fabricated corpus rate.
896
+ measure: `seen in ${d.seenCount}/${sampledN} sampled`,
897
+ decision: isPrimary
898
+ ? `<span class="badge b-crit">★ PRIMARY</span>`
899
+ : evidenced
900
+ ? `<span class="badge b-med">SECONDARY</span>`
901
+ : `<span class="badge b-med">SECONDARY — discovered, unconfirmed (no mechanical evidence)</span>`,
902
+ primary: isPrimary,
903
+ });
904
+ }
905
+
906
+ // Ruled-out rows (benign observability artifacts).
907
+ for (const name of ruledOut) {
908
+ const p = rawPatterns.find((x) => x.name === name)!;
909
+ const rate = p.matchCount / Math.max(1, total);
910
+ rows.push({
911
+ signal: name,
912
+ present: "YES",
913
+ presentColor: rate >= 0.2 ? "var(--y)" : "var(--g)",
914
+ measure: `${p.matchCount}/${total} (${round(rate * 100)}%)`,
915
+ decision:
916
+ rate >= 0.2
917
+ ? "ruled out — benign observability artifact (>20% rate)"
918
+ : "ruled out — benign observability artifact",
919
+ });
920
+ }
921
+
922
+ // W11-04: Always-present rule-out rows for absent/low error+score signals.
923
+ const withErrorVal = tier0.withError ?? 0;
924
+ const withErrorRate = withErrorVal / Math.max(1, total);
925
+ rows.push({
926
+ signal: "wrong-output / hallucination",
927
+ present: String(withErrorVal),
928
+ presentColor: withErrorVal === 0
929
+ ? "var(--g)"
930
+ : withErrorRate >= 0.2
931
+ ? "var(--r)"
932
+ : "var(--y)",
933
+ measure: `${withErrorVal} errors`,
934
+ decision:
935
+ withErrorVal === 0
936
+ ? "excluded — no signal"
937
+ : withErrorRate >= 0.2
938
+ ? `<span class="badge b-crit">potential signal — check rate ${round(withErrorRate * 100)}%</span>`
939
+ : "potential signal — ruled out (benign success:false / <20% rate)",
940
+ });
941
+ rows.push({
942
+ signal: "low-score",
943
+ present: String(tier0.withLowScore ?? 0),
944
+ presentColor: "var(--g)",
945
+ measure: `${tier0.withFeedback ?? 0} feedback`,
946
+ decision: "excluded — no signal",
947
+ });
948
+
949
+ // ── Attach primarySignal to result array ─────────────────────────────────────
950
+ const primarySignal: PrimarySignal | undefined = primaryName
951
+ ? {
952
+ name: primaryName,
953
+ why: corroborationNote
954
+ ? primaryIsDiscovered
955
+ ? `${primaryName} selected as ${corroborationNote}`
956
+ : `${primaryName} selected by impact×prevalence; ${corroborationNote}`
957
+ : `${primaryName} selected by impact×prevalence`,
958
+ ruledOut,
959
+ confidence,
960
+ }
961
+ : undefined;
962
+
963
+ // ── W12-F1 (UI-2op) + W17-C: auditable selection metadata ─────────────────────
964
+ // selectionRules: one card per cheap-signal + discovered candidate — score + verdict.
965
+ // The primary card cites WHY it won (impact×prevalence corroboration OR mechanical
966
+ // discovered corroboration); discovered-unevidenced cards cite the floor cap.
967
+ // signalSelectionTrace: a deterministic mermaid of the decision path. Pure functions
968
+ // of the scoring + floor outcome — NO LLM, NO clock, NO random (PR-038 / PR-049).
969
+ // D3: index the deep-read findings by failureOrigin.what so each selection card can
970
+ // link back to its finding's RCA narrative (first finding per WHAT wins — deterministic
971
+ // input order). Used by buildSelectionMeta to DERIVE the dataset-candidate copy.
972
+ const findingsByWhat = new Map<string, CensusFinding>();
973
+ for (const f of findings ?? []) {
974
+ const what = f.failureOrigin?.what;
975
+ if (what && !findingsByWhat.has(what)) findingsByWhat.set(what, f);
976
+ }
977
+
978
+ const selection = primaryName
979
+ ? buildSelectionMeta(scored, discoveredScored, {
980
+ primaryName,
981
+ primaryIsDiscovered,
982
+ corroborationNote,
983
+ ruledOut,
984
+ total,
985
+ sampledN,
986
+ suspected,
987
+ findingsByWhat,
988
+ })
989
+ : undefined;
990
+
991
+ return Object.assign(rows, {
992
+ primarySignal,
993
+ selectionRules: selection?.selectionRules,
994
+ signalSelectionTrace: selection?.signalSelectionTrace,
995
+ suspectedPrimaryUnconfirmed: suspected,
996
+ });
997
+ }
998
+
999
+ /**
1000
+ * W17-C: merge the discovered-signal pool from deep-read findings + folded ledger
1001
+ * digests, deduped by WHAT. For each distinct WHAT:
1002
+ * - seenCount = distinct traces it was observed on (findings.sourceTraceIds ∪ one per
1003
+ * folded digest) — the HONEST-prevalence numerator (R6).
1004
+ * - origin = "deep-read" when any finding contributed it, else "ledger".
1005
+ * - corroboration = the resolved mechanical trajectory corroboration (R1), if any.
1006
+ * R2: ledger-folded WHATs re-pass the SAME floor here — never trusted blindly.
1007
+ * Deterministic: output ordering is the first-seen order; callers re-sort.
1008
+ */
1009
+ function buildDiscoveredPool(
1010
+ findings: ReadonlyArray<CensusFinding>,
1011
+ foldedDigests: ReadonlyArray<DeepReadLedgerEntry>,
1012
+ corroborations: ReadonlyArray<TrajectoryCorroboration>
1013
+ ): DiscoveredCandidate[] {
1014
+ // WHAT → set of distinct trace ids (for honest prevalence) + origin tracking.
1015
+ const byWhat = new Map<string, { traces: Set<string>; fromDeepRead: boolean; idx: number }>();
1016
+ let order = 0;
1017
+ const note = (what: string, traceIds: string[], fromDeepRead: boolean): void => {
1018
+ if (!what) return;
1019
+ let rec = byWhat.get(what);
1020
+ if (!rec) {
1021
+ rec = { traces: new Set<string>(), fromDeepRead: false, idx: order++ };
1022
+ byWhat.set(what, rec);
1023
+ }
1024
+ for (const t of traceIds) if (t) rec.traces.add(t);
1025
+ rec.fromDeepRead = rec.fromDeepRead || fromDeepRead;
1026
+ };
1027
+
1028
+ // Source A: deep-read findings.
1029
+ for (const f of findings) {
1030
+ const what = f.failureOrigin?.what;
1031
+ if (!what) continue;
1032
+ note(what, f.sourceTraceIds ?? [], true);
1033
+ }
1034
+ // Source B (R2): folded ledger digests. Each digest = one (entity, trace) observation.
1035
+ for (const d of foldedDigests) {
1036
+ note(d.signal, [d.traceId], false);
1037
+ }
1038
+
1039
+ return Array.from(byWhat.entries())
1040
+ .sort((a, b) => a[1].idx - b[1].idx) // stable first-seen order
1041
+ .map(([what, rec]) => ({
1042
+ what,
1043
+ origin: rec.fromDeepRead ? ("deep-read" as const) : ("ledger" as const),
1044
+ // seenCount is the distinct-trace count; ≥1 even when sourceTraceIds were absent
1045
+ // (a finding with no listed traces still counts as one observation).
1046
+ seenCount: Math.max(1, rec.traces.size),
1047
+ // R1/R2 evidence floor: resolve a mechanical corroboration for this WHAT.
1048
+ corroboration: resolveCorroboration(what, corroborations),
1049
+ }));
1050
+ }
1051
+
1052
+ /** W17-C: the resolved-scoring inputs buildSelectionMeta reads (keeps the arg list sane). */
1053
+ interface SelectionMetaContext {
1054
+ primaryName: string;
1055
+ /** True when the winning primary is a DISCOVERED signal (evidence-floored). */
1056
+ primaryIsDiscovered: boolean;
1057
+ corroborationNote: string;
1058
+ ruledOut: string[];
1059
+ total: number;
1060
+ /** R6: sampled-trace denominator for discovered prevalence cards. */
1061
+ sampledN: number;
1062
+ /** R7: the suspected-but-uncorroborated discovered signal, if any. */
1063
+ suspected?: SuspectedPrimary;
1064
+ /** D3: findings indexed by failureOrigin.what — source for dataset-candidate card copy. */
1065
+ findingsByWhat?: Map<string, CensusFinding>;
1066
+ }
1067
+
1068
+ /** D3: severity → human label for the "why high-value" rationale. */
1069
+ const SEVERITY_LABEL: Record<string, string> = {
1070
+ crit: "critical",
1071
+ high: "high",
1072
+ med: "medium",
1073
+ info: "low",
1074
+ };
1075
+
1076
+ /**
1077
+ * D3: derive the dataset-candidate enrichment fields for ONE selection card from its
1078
+ * linked finding (matched by failureOrigin.what). Pure + deterministic. Each field is
1079
+ * DERIVED from an existing finding field; when the source is absent the field is left
1080
+ * undefined (omitted gracefully by the renderer — never fabricated).
1081
+ *
1082
+ * scenario ← failureOrigin.whatHappened (plain-words trace narration)
1083
+ * useCase ← finding.subDesc ?? title (the edge-case it represents)
1084
+ * whyFailed ← why-chain ORIGIN entry .why ?? failureOrigin.why (root cause)
1085
+ * whyHighValue ← severity · prevalence · novelty (composed from real signals)
1086
+ * prevents ← finding.problem ?? title (the regression it guards against)
1087
+ */
1088
+ function deriveCandidateEnrichment(
1089
+ finding: CensusFinding | undefined,
1090
+ opts: {
1091
+ /** Honest prevalence phrase (e.g. "seen in 12/40 sampled"). */
1092
+ prevalence: string;
1093
+ /** Novelty phrase (deep-read fresh vs ledger-recurring); omitted for cheap signals. */
1094
+ novelty?: string;
1095
+ }
1096
+ ): {
1097
+ linkedFindingId?: string;
1098
+ scenario?: string;
1099
+ useCase?: string;
1100
+ whyFailed?: string;
1101
+ whyHighValue?: string;
1102
+ prevents?: string;
1103
+ } {
1104
+ if (!finding) return {};
1105
+ const scenario = finding.failureOrigin?.whatHappened?.trim() || undefined;
1106
+ const useCaseRaw = (finding.subDesc ?? finding.title)?.trim() || undefined;
1107
+ // Avoid echoing the scenario verbatim in the edge-case slot.
1108
+ const useCase = useCaseRaw && useCaseRaw !== scenario ? useCaseRaw : undefined;
1109
+ const originEntry = finding.whyChain?.find((w) => w.isOrigin);
1110
+ const whyFailed =
1111
+ originEntry?.why?.trim() || finding.failureOrigin?.why?.trim() || undefined;
1112
+ const preventsRaw = (finding.problem ?? finding.title)?.trim() || undefined;
1113
+ const prevents = preventsRaw && preventsRaw !== scenario ? preventsRaw : undefined;
1114
+
1115
+ // whyHighValue — compose ONLY from signals we actually have (severity · prevalence ·
1116
+ // novelty). Empty when none are available.
1117
+ const valueParts: string[] = [];
1118
+ if (finding.severity) valueParts.push(`${SEVERITY_LABEL[finding.severity] ?? finding.severity} severity`);
1119
+ if (opts.prevalence) valueParts.push(opts.prevalence);
1120
+ if (opts.novelty) valueParts.push(opts.novelty);
1121
+ const whyHighValue = valueParts.length > 0 ? valueParts.join(" · ") : undefined;
1122
+
1123
+ return {
1124
+ linkedFindingId: finding.findingId,
1125
+ scenario,
1126
+ useCase,
1127
+ whyFailed,
1128
+ whyHighValue,
1129
+ prevents,
1130
+ };
1131
+ }
1132
+
1133
+ /**
1134
+ * W12-F1 (UI-2op / PR-038 / PR-049) + W17-C: deterministically derive the per-signal
1135
+ * selection-rule cards + the mermaid decision-path trace from the cheap-signal scoring
1136
+ * AND the discovered-pool ranking. PURE — same inputs → byte-identical output.
1137
+ *
1138
+ * Cheap signals: `score` = "impact × prevalence = score" (the auditable arithmetic).
1139
+ * Discovered signals: `score` = "impact (discovered) · seen k/n" (impact-dominant, R3).
1140
+ * Verdict cites the floor outcome: ★ PRIMARY (mechanically corroborated) for the
1141
+ * evidence-floored winner, "secondary — discovered, unconfirmed (no mechanical evidence)"
1142
+ * for capped discovered signals (R7), "secondary" otherwise, "ruled-out" for benign.
1143
+ */
1144
+ export function buildSelectionMeta(
1145
+ scored: Array<{ name: string; matchCount: number; score: number }>,
1146
+ discoveredScored: Array<{ what: string; impact: number; seenCount: number; corroboration?: ResolvedCorroboration; origin?: "deep-read" | "ledger" }>,
1147
+ ctx: SelectionMetaContext
1148
+ ): SignalSelectionMeta {
1149
+ const { primaryName, primaryIsDiscovered, corroborationNote, ruledOut, total, sampledN, findingsByWhat } = ctx;
1150
+ const safeTotal = Math.max(1, total);
1151
+
1152
+ // Cheap-signal cards (impact × prevalence = score).
1153
+ const selectionRules: NonNullable<RunMeta["selectionRules"]> = scored.map((p) => {
1154
+ const impact = SIGNAL_IMPACT[p.name] ?? DEFAULT_IMPACT;
1155
+ const prevalencePct = round((p.matchCount / safeTotal) * 100);
1156
+ const isPrimary = !primaryIsDiscovered && p.name === primaryName;
1157
+ const verdict = isPrimary
1158
+ ? corroborationNote.includes("deep-read")
1159
+ ? "★ PRIMARY (corroborated by deep-read)"
1160
+ : "★ PRIMARY"
1161
+ : "secondary";
1162
+ // D3: link the cheap signal to its finding (by WHAT) and derive the candidate copy.
1163
+ const enrich = deriveCandidateEnrichment(findingsByWhat?.get(p.name), {
1164
+ prevalence: `seen in ${p.matchCount}/${safeTotal} traces (${prevalencePct}%)`,
1165
+ });
1166
+ return {
1167
+ signal: p.name,
1168
+ // "impact × prevalence = score" — the auditable arithmetic.
1169
+ score: `${impact} × ${prevalencePct}% = ${round(p.score, 2)}`,
1170
+ verdict,
1171
+ ...enrich,
1172
+ };
1173
+ });
1174
+
1175
+ // W17-C discovered-signal cards (impact-dominant, R3; honest prevalence, R6).
1176
+ for (const d of discoveredScored) {
1177
+ const isPrimary = primaryIsDiscovered && d.what === primaryName;
1178
+ const evidenced = d.corroboration !== undefined;
1179
+ const verdict = isPrimary
1180
+ ? "★ PRIMARY (discovered, mechanically corroborated)"
1181
+ : evidenced
1182
+ ? "secondary (discovered, corroborated)"
1183
+ : "secondary — discovered, unconfirmed (no mechanical evidence — R1 floor)";
1184
+ // D3: link the discovered signal to its finding (by WHAT) and derive the candidate
1185
+ // copy. Novelty reflects WHERE it surfaced (fresh deep-read vs recurring ledger fold).
1186
+ const enrich = deriveCandidateEnrichment(findingsByWhat?.get(d.what), {
1187
+ prevalence: `seen in ${d.seenCount}/${sampledN} sampled`,
1188
+ novelty:
1189
+ d.origin === "ledger"
1190
+ ? "recurring across runs (ledger-folded)"
1191
+ : "newly surfaced via deep-read (not a Tier-0 pattern)",
1192
+ });
1193
+ selectionRules.push({
1194
+ signal: d.what,
1195
+ // impact-dominant card: impact (discovered default ≥2) · honest sampled prevalence.
1196
+ score: `impact ${d.impact} (discovered) · seen ${d.seenCount}/${sampledN}`,
1197
+ verdict,
1198
+ discoveredByAwareness: true,
1199
+ ...enrich,
1200
+ });
1201
+ }
1202
+
1203
+ // Ruled-out signals get an explicit "ruled-out" card so the gate is visible.
1204
+ for (const name of ruledOut) {
1205
+ selectionRules.push({
1206
+ signal: name,
1207
+ score: "n/a (gated)",
1208
+ verdict: "ruled-out: benign observability artifact (failure-validity gate)",
1209
+ });
1210
+ }
1211
+
1212
+ // Mermaid decision path. W17-C extends the historic chain with the discovered pool +
1213
+ // the R1 evidence floor + the R7 residual branch. Deterministic string.
1214
+ const candidateList = scored.map((p) => p.name).join(", ") || "(none)";
1215
+ const discoveredList = discoveredScored.map((d) => d.what).join(", ") || "(none)";
1216
+ const gateNote = ruledOut.length > 0 ? `ruled out: ${ruledOut.join(", ")}` : "none ruled out";
1217
+ const floorNote = primaryIsDiscovered
1218
+ ? `discovered PASSED floor: ${corroborationNote}`
1219
+ : ctx.suspected
1220
+ ? `discovered CAPPED (suspected-unconfirmed: ${ctx.suspected.signal})`
1221
+ : "no discovered candidate";
1222
+ const signalSelectionTrace = [
1223
+ "graph TD",
1224
+ ` A["Tier-0 candidates: ${candidateList}"] --> B["failure-validity gate (${gateNote})"]`,
1225
+ " B --> C[\"impact × prevalence (cheap signals)\"]",
1226
+ ` C --> D["discovered pool: ${discoveredList}"]`,
1227
+ ` D --> F["R1 evidence floor (${floorNote})"]`,
1228
+ ` F --> E["★ PRIMARY: ${primaryName}"]`,
1229
+ ].join("\n");
1230
+
1231
+ return { selectionRules, signalSelectionTrace };
1232
+ }
1233
+
1234
+ // ── Scan funnel ────────────────────────────────────────────────────────────────
1235
+
1236
+ /**
1237
+ * W11-03: Build the 4-stage scan-coverage funnel from tier0 + slice-plan.
1238
+ * Stages: total → Tier-0 scan → representative sample N → deep-read 6/N.
1239
+ *
1240
+ * The deep-read denominator is the SAMPLE size (honest: "6 of N sampled"),
1241
+ * not the population ("6 of total"), which was misleadingly low before.
1242
+ * When no representative sample metadata is available, the sample segment is
1243
+ * omitted and we fall back to the 3-stage display.
1244
+ */
1245
+ export function buildScanFunnel(
1246
+ tier0: Tier0Input,
1247
+ slicePlan: SlicePlanInput,
1248
+ runMeta?: RunMeta
1249
+ ): ScanFunnel {
1250
+ const total = tier0.totalTraces;
1251
+ const scanned = runMeta?.tier0ScannedCount ?? total;
1252
+ const llm = runMeta?.llmReadCount ?? 0;
1253
+ const sliceNote = slicePlan.totalSlices
1254
+ ? `${slicePlan.totalSlices} slices · ${tier0.recommendedSlicing ?? "window-based"}`
1255
+ : tier0.recommendedSlicing ?? "window-based";
1256
+
1257
+ // W11-03: representative sample N (from deepRead metadata when available).
1258
+ // deepRead.tierReached is the LLM read ceiling; population is total scope.
1259
+ const sampleN = runMeta?.deepRead?.tierReached ?? 0;
1260
+ const hasSample = sampleN > 0;
1261
+
1262
+ // Coverage confidence annotation for the deep-read segment.
1263
+ const coverage = runMeta?.deepRead?.coverageConfidence;
1264
+ const coverageNote = coverage ? ` · coverage: ${coverage}` : "";
1265
+
1266
+ // Deep-read detail: "6 of N sampled" when sample present, else "X% of population".
1267
+ const llmDetail = hasSample
1268
+ ? `${llm} of ${sampleN} sampled (100% of sample)${coverageNote}`
1269
+ : `${round((llm / Math.max(1, total)) * 100)}%${coverageNote}`;
1270
+
1271
+ const funnel: ScanFunnel = {
1272
+ total: { value: total.toLocaleString("en-US"), label: "traces in window", detail: sliceNote },
1273
+ code: {
1274
+ value: scanned.toLocaleString("en-US"),
1275
+ label: "code-level scan (Tier 0)",
1276
+ detail: `${round((scanned / Math.max(1, total)) * 100)}% · no LLM`,
1277
+ },
1278
+ llm: {
1279
+ value: llm.toLocaleString("en-US"),
1280
+ label: "LLM deep-read (analyzers)",
1281
+ detail: llmDetail,
1282
+ },
1283
+ };
1284
+
1285
+ // W11-03: attach sample segment when deepRead metadata is present.
1286
+ if (hasSample) {
1287
+ funnel.sample = {
1288
+ value: sampleN.toLocaleString("en-US"),
1289
+ label: "representative sample",
1290
+ detail: `${round((sampleN / Math.max(1, total)) * 100)}% of window · worst/med/best/rand`,
1291
+ };
1292
+ }
1293
+
1294
+ return funnel;
1295
+ }
1296
+
1297
+ // ── Diagnosed entity ───────────────────────────────────────────────────────────
1298
+
1299
+ /**
1300
+ * Resolve the diagnosed entity. R1.7: prefer the normalizer's EntityContext
1301
+ * (passed through transparently); fall back to findings.entities[0] only when no
1302
+ * EntityContext is supplied (legacy path).
1303
+ *
1304
+ * W9-08 portability: when neither EntityContext nor findings.entities is available
1305
+ * but findings ARE present, this is the "Path-B missing" case. The function returns
1306
+ * undefined and the caller's assertShapesBuildable will catch it. The silent jq
1307
+ * Path-B fallback (auto-construct entity from jq-extracted fields) is RETIRED —
1308
+ * callers MUST pass --entity-context or populate findings.entities explicitly.
1309
+ */
1310
+ function resolveDiagnosedEntity(
1311
+ findings: FindingsInput,
1312
+ entityContext?: EntityContext
1313
+ ): Entity | undefined {
1314
+ if (entityContext) return entityFromContext(entityContext);
1315
+ // Path-B (legacy): findings.entities[0]. Retained for backward-compat with pre-W9 inputs.
1316
+ // NOTE: silent jq Path-B fallback (auto-synthesize entity from jq field extraction) is
1317
+ // RETIRED (W9-08 portability). Provide --entity-context or populate findings.entities.
1318
+ return findings.entities?.[0];
1319
+ }
1320
+
1321
+ // ── Assumption normalization (R1.3: legacy free-text → structured) ─────────────
1322
+
1323
+ /**
1324
+ * Parse a single inline VERIFIED/UNVERIFIED/HYPOTHESIS marker out of a free-text
1325
+ * assumption. Deterministic. When no marker is present, defaults to "unverified"
1326
+ * (the conservative status — an un-annotated assumption is not yet confirmed).
1327
+ */
1328
+ function parseAssumptionStatus(text: string): Assumption["status"] {
1329
+ const upper = text.toUpperCase();
1330
+ if (/\bHYPOTHESIS\b/.test(upper)) return "hypothesis-pending";
1331
+ if (/\bUNVERIFIED\b|\bASSUMED\b/.test(upper)) return "unverified";
1332
+ if (/\bVERIFIED\b|\bCONFIRMED\b/.test(upper)) return "verified";
1333
+ return "unverified";
1334
+ }
1335
+
1336
+ /**
1337
+ * Normalize a finding's assumptions into the structured Assumption[] shape.
1338
+ * Already-structured entries pass through unchanged; legacy free-text strings are
1339
+ * converted (status parsed from any inline marker, basis = the marker tail or a
1340
+ * default note). Idempotent. Strips inline HTML pills so the renderer re-adds them.
1341
+ */
1342
+ export function normalizeAssumptions(
1343
+ raw: ReadonlyArray<string | Assumption> | undefined
1344
+ ): Assumption[] | undefined {
1345
+ if (!raw || raw.length === 0) return undefined;
1346
+ return raw.map((a) => {
1347
+ if (typeof a !== "string") return a; // already structured
1348
+ const stripped = a.replace(/<[^>]*>/g, "").trim();
1349
+ const status = parseAssumptionStatus(stripped);
1350
+ // basis = text after an em-dash marker, else a generic note.
1351
+ const dash = stripped.indexOf("—");
1352
+ const basis =
1353
+ dash >= 0 && dash < stripped.length - 1
1354
+ ? stripped.slice(dash + 1).trim()
1355
+ : "derived during RCA — not directly confirmed";
1356
+ const text = dash >= 0 ? stripped.slice(0, dash).trim() : stripped;
1357
+ return { text, status, basis };
1358
+ });
1359
+ }
1360
+
1361
+ /**
1362
+ * PRD-CC-10: Inject a hypothesis-pending no-code-access disclaimer assumption
1363
+ * when entity.codeAccess === false AND finding.assumptions is null/empty.
1364
+ * Never overrides analyzer-emitted assumptions.
1365
+ */
1366
+ export function synthesizeNoCodeAccessAssumption(
1367
+ finding: Finding,
1368
+ entity: EntityContext | undefined
1369
+ ): Assumption[] | undefined {
1370
+ if (!entity || entity.codeAccess !== false) return finding.assumptions;
1371
+ const existing = finding.assumptions;
1372
+ if (existing && existing.length > 0) return existing;
1373
+ return [
1374
+ {
1375
+ text: `Source code for ${entity.name} was not provided; findings are evidence-only.`,
1376
+ status: "hypothesis-pending",
1377
+ basis: "entity.codeAccess === false — source code unavailable for direct verification",
1378
+ },
1379
+ ];
1380
+ }
1381
+
1382
+ /** Apply assumption normalization + remedy-rank backfill across all findings (immutably). */
1383
+ /**
1384
+ * ② WHAT-categories whose severity is a NUMERIC anomaly and must be scored corpus-relative
1385
+ * rather than by absolute cutoffs (SL-8 / DS-02 G-2 fix). Scoped to latency today (the
1386
+ * proven DS-02 case); cost-overshoot can join once cost is a first-class corpus metric.
1387
+ */
1388
+ const NUMERIC_ANOMALY_WHATS = new Set(["latency-spike"]);
1389
+
1390
+ /**
1391
+ * ② Build the corpus latency baseline (per-agent p90) from the trace metadata. Groups by
1392
+ * `agentId` (the span/operation name the FI-LAT recipe compares WITHIN); traces with no
1393
+ * agentId share a single "__unnamed__" cohort so they still get a corpus-relative read.
1394
+ * Returns null when the corpus carries no latency samples → callers skip calibration.
1395
+ */
1396
+ function buildLatencyBaseline(metadata: MetadataInput[]): CorpusBaseline | null {
1397
+ const samples: NameValue[] = [];
1398
+ for (const t of metadata) {
1399
+ if (t.latencyMs === undefined) continue;
1400
+ samples.push({ name: t.agentId ?? "__unnamed__", value: t.latencyMs });
1401
+ }
1402
+ if (samples.length === 0) return null;
1403
+ return computeCorpusBaseline(samples);
1404
+ }
1405
+
1406
+ /**
1407
+ * ② Re-score a numeric-anomaly finding's severity against the corpus baseline. Gathers the
1408
+ * finding's source spans (agentId + latencyMs from metadata) and takes the MAX-severity
1409
+ * corpus-relative verdict. Leaves severity untouched when the finding is not a numeric
1410
+ * anomaly, has no scorable source spans, or there is no corpus baseline. This DOWNGRADES a
1411
+ * latency that IS the corpus baseline (the G-2 over-alarm) and still promotes a genuine
1412
+ * 3×-p90+floor outlier.
1413
+ */
1414
+ function calibrateNumericSeverity(
1415
+ finding: Finding,
1416
+ baseline: CorpusBaseline | null,
1417
+ latencyByTraceId: Map<string, number>,
1418
+ agentByTraceId: Map<string, string>
1419
+ ): Finding {
1420
+ if (!baseline) return finding;
1421
+ const what = finding.failureOrigin?.what;
1422
+ if (!what || !NUMERIC_ANOMALY_WHATS.has(what)) return finding;
1423
+
1424
+ const spans: NameValue[] = [];
1425
+ for (const id of finding.sourceTraceIds ?? []) {
1426
+ const value = latencyByTraceId.get(id);
1427
+ if (value === undefined) continue;
1428
+ spans.push({ name: agentByTraceId.get(id) ?? "__unnamed__", value });
1429
+ }
1430
+ const verdict = calibrateFromSpans(spans, baseline);
1431
+ if (!verdict) return finding;
1432
+ return { ...finding, severity: verdict.severity };
1433
+ }
1434
+
1435
+ function normalizeFindings(
1436
+ findings: Finding[],
1437
+ entityContext?: EntityContext,
1438
+ metadata: MetadataInput[] = []
1439
+ ): Finding[] {
1440
+ // ② Precompute the corpus latency baseline + per-trace lookups once for the whole batch.
1441
+ const latencyBaseline = buildLatencyBaseline(metadata);
1442
+ const latencyByTraceId = new Map<string, number>();
1443
+ const agentByTraceId = new Map<string, string>();
1444
+ for (const t of metadata) {
1445
+ if (t.latencyMs !== undefined) latencyByTraceId.set(t.traceId, t.latencyMs);
1446
+ if (t.agentId !== undefined) agentByTraceId.set(t.traceId, t.agentId);
1447
+ }
1448
+
1449
+ return findings.map((original) => {
1450
+ // ② Corpus-relative severity FIRST so downstream derivations read the calibrated grade.
1451
+ const f = calibrateNumericSeverity(
1452
+ original,
1453
+ latencyBaseline,
1454
+ latencyByTraceId,
1455
+ agentByTraceId
1456
+ );
1457
+ const assumptions = normalizeAssumptions(
1458
+ f.assumptions as ReadonlyArray<string | Assumption> | undefined
1459
+ );
1460
+ const withNormalized = assumptions ? { ...f, assumptions } : f;
1461
+ // PRD-CC-10: inject no-code-access disclaimer when needed.
1462
+ // W12-08: Finding.assumptions is now required (≥1). synthesizeNoCodeAccessAssumption
1463
+ // may still return undefined for the legacy/empty path; coerce to the original
1464
+ // (now-required) array so the enriched Finding keeps a non-undefined assumptions.
1465
+ const synthesized =
1466
+ synthesizeNoCodeAccessAssumption(withNormalized, entityContext) ??
1467
+ withNormalized.assumptions;
1468
+ const withAssumptions =
1469
+ synthesized !== withNormalized.assumptions
1470
+ ? { ...withNormalized, assumptions: synthesized }
1471
+ : withNormalized;
1472
+
1473
+ // W13-C (D-1): deterministically derive remedy.rank from cost × correctness
1474
+ // (orchestrator-protocol §8 — finally implemented in code). This closes the
1475
+ // last leg of the D-1 contract triad: cost/correctness are analyzer-required
1476
+ // (gate-enforced at Step 7.1), rank is ALWAYS backfilled here. So a remedy can
1477
+ // never reach the renderer with rank/cost/correctness undefined. The backfill
1478
+ // is reproducible (no LLM judgment), removing an agent-discretion variance source.
1479
+ return Array.isArray(withAssumptions.remedies) && withAssumptions.remedies.length > 0
1480
+ ? { ...withAssumptions, remedies: rankRemedies(withAssumptions.remedies) }
1481
+ : withAssumptions;
1482
+ });
1483
+ }
1484
+
1485
+ // ── Fail-loud predicate ────────────────────────────────────────────────────────
1486
+
1487
+ /**
1488
+ * R1 §9.3 + W12-06: fail-loud guard over the 4 internal render shapes.
1489
+ *
1490
+ * TWO-tier threshold — the shapes are NOT all equivalent:
1491
+ *
1492
+ * - `diagnosedEntity` is INDIVIDUALLY REQUIRED. A run WITH findings always has a
1493
+ * diagnosed subject; a missing entity yields an empty entity card despite the
1494
+ * "no silent placeholder" promise (W12-06 / OP-7). So a lone missing entity
1495
+ * (1-of-4) MUST refuse — it can never be a legitimate empty on a findings run.
1496
+ *
1497
+ * - `bigStat` / `hourlyHeatmap` / `signalCensus` can be LEGITIMATELY empty on a
1498
+ * clean no-signal run (no latencies → no big-stat; no startTimes → empty
1499
+ * heatmap; no patterns → empty census). These keep the COLLECTIVE ≥3-of-4
1500
+ * threshold: 3+ simultaneously empty signals a genuinely starved input, but any
1501
+ * one alone is tolerated.
1502
+ *
1503
+ * Mirrors the renderer's guard so the enricher fails BEFORE handing a starved
1504
+ * input downstream.
1505
+ */
1506
+ function assertShapesBuildable(
1507
+ shapes: {
1508
+ diagnosedEntity?: Entity;
1509
+ bigStat: BigStat[];
1510
+ hourlyHeatmap: HourlyHeatmap;
1511
+ signalCensus: SignalCensusRow[];
1512
+ },
1513
+ hasFindings: boolean
1514
+ ): void {
1515
+ if (!hasFindings) return;
1516
+
1517
+ // W12-06: diagnosedEntity is individually required — refuse on its own absence,
1518
+ // independent of the ≥3-of-4 count. A findings run always has a diagnosed entity;
1519
+ // its absence is never a legitimate empty.
1520
+ if (!shapes.diagnosedEntity) {
1521
+ throw new Error(
1522
+ `build-render-input: refusing to enrich — diagnosedEntity is missing on a run ` +
1523
+ `that HAS findings. A findings run always has a diagnosed subject; an empty ` +
1524
+ `entity card is forbidden (no silent placeholder). Pass --entity-context or ` +
1525
+ `populate findings.entities. Fail-loud (W12-06 / OP-7).`
1526
+ );
1527
+ }
1528
+
1529
+ // R1 §9.3: the other three may each be legitimately empty on a clean run; only
1530
+ // a ≥3-of-4 collective miss signals a genuinely starved input.
1531
+ const missing: string[] = [];
1532
+ if (shapes.bigStat.length === 0) missing.push("bigStat");
1533
+ if (shapes.hourlyHeatmap.cells.every((c) => c.count === 0)) missing.push("hourlyHeatmap");
1534
+ if (shapes.signalCensus.length === 0) missing.push("signalCensus");
1535
+ // diagnosedEntity is present here (guarded above) — count it among the 4 so the
1536
+ // threshold semantics ("3 of 4") stay intact even though it can't be the missing one.
1537
+ if (missing.length >= 3) {
1538
+ throw new Error(
1539
+ `build-render-input: refusing to enrich — ${missing.length} of 4 internal render ` +
1540
+ `shapes could not be built (${missing.join(", ")}). Inputs are starved: ` +
1541
+ `check tier0/metadata/findings. Fail-loud (R1 §9.3) — no silent placeholder.`
1542
+ );
1543
+ }
1544
+ }
1545
+
1546
+
1547
+ /**
1548
+ * W9-08 (R-CP-2): Assert RunMeta required fields are present before enrichment.
1549
+ * Fails loud when findings are present but runMeta is absent or missing key fields.
1550
+ * Required fields: totalTraces, tier0ScannedCount, llmReadCount, scopeFilter,
1551
+ * samplingStrategy, decisions, deepRead.
1552
+ *
1553
+ * NOTE: all RunMeta fields are optional for backward-compat. This check only fires
1554
+ * when findings are present — empty-findings runs are exempt (no run = no meta).
1555
+ */
1556
+ function assertRunMetaPresent(runMeta: RunMeta | undefined, hasFindings: boolean): void {
1557
+ if (!hasFindings) return;
1558
+ if (!runMeta) {
1559
+ // Soft warning — RunMeta absent on pre-Wave-9 runs is expected; not a hard fail.
1560
+ // Hard fail only when a specific required field is missing on a run that SHOULD have it.
1561
+ return;
1562
+ }
1563
+ const missing: string[] = [];
1564
+ if (runMeta.totalTraces === undefined) missing.push("runMeta.totalTraces");
1565
+ if (runMeta.tier0ScannedCount === undefined) missing.push("runMeta.tier0ScannedCount");
1566
+ if (runMeta.llmReadCount === undefined) missing.push("runMeta.llmReadCount");
1567
+ if (missing.length > 0) {
1568
+ // These three are mandatory on Wave-9+ runs — fail loud to catch misconfigured enrichers.
1569
+ throw new Error(
1570
+ `build-render-input: RunMeta is present but missing required fields ` +
1571
+ `(${missing.join(", ")}). Check the orchestrator's Step 8.5 enricher wiring. ` +
1572
+ `Fail-loud (W9-08 R-CP-2) — no silent placeholder fallback.`
1573
+ );
1574
+ }
1575
+ }
1576
+
1577
+ // ── S7-render: enricher-output self-validation ───────────────────────────────────
1578
+
1579
+ /** Local non-empty-string check (enricher-scoped; mirrors the validators' helper). */
1580
+ function isNonEmptyEnrichString(v: unknown): boolean {
1581
+ return typeof v === "string" && v.trim() !== "";
1582
+ }
1583
+
1584
+ /**
1585
+ * S7-render (Wave-15 Block B, operator option B): self-validate the assembled
1586
+ * RenderInput BEFORE it is returned to the orchestrator.
1587
+ *
1588
+ * WHY: previously the only RenderInput-shape gates were render.ts's
1589
+ * `validatePreRender` / `assertRenderShapesPresent`, which run AFTER 23×
1590
+ * `template.replaceAll()`. A field the enricher dropped (or built with the wrong
1591
+ * type) therefore reached broken markup before any gate fired. This guard moves
1592
+ * the catch UPSTREAM to the PRODUCER: the enricher asserts its own output shape —
1593
+ * every required RenderInput field present with the correct type — and THROWS
1594
+ * before returning, so a dropped field is caught pre-template at the producer.
1595
+ *
1596
+ * SCOPE: asserts the type-level required RenderInput contract (the six
1597
+ * non-optional members) plus, on a findings-bearing run, the internal Overview
1598
+ * shapes render.ts dereferences without a guard (diagnosedEntity / bigStat /
1599
+ * signalCensus / hourlyHeatmap.cells). It COMPLEMENTS — never replaces —
1600
+ * `assertShapesBuildable` (input-starvation predicate) and `assertRunMetaPresent`
1601
+ * (runMeta field presence): those gate the INPUTS, this gates the OUTPUT.
1602
+ *
1603
+ * DETERMINISTIC: pure structural assertion, no I/O.
1604
+ */
1605
+ function assertRenderInputShape(input: RenderInput): void {
1606
+ const missing: string[] = [];
1607
+
1608
+ // ── Type-required RenderInput fields (the six non-optional members) ──────────
1609
+ if (!isNonEmptyEnrichString(input.sessionId)) missing.push("sessionId (non-empty string)");
1610
+ if (!isNonEmptyEnrichString(input.diagnosedAt)) missing.push("diagnosedAt (non-empty string)");
1611
+ if (!isNonEmptyEnrichString(input.sourcePlatform)) missing.push("sourcePlatform (non-empty string)");
1612
+ if (!isNonEmptyEnrichString(input.targetPlatform)) missing.push("targetPlatform (non-empty string)");
1613
+ if (typeof input.totalTraces !== "number" || Number.isNaN(input.totalTraces)) {
1614
+ missing.push("totalTraces (number)");
1615
+ }
1616
+ if (!Array.isArray(input.findings)) missing.push("findings (array)");
1617
+
1618
+ // ── Findings-bearing runs: the internal shapes render.ts dereferences raw ────
1619
+ // Empty-findings runs are exempt — a zero-finding report legitimately omits the
1620
+ // Overview tiles (mirrors assertShapesBuildable / completeness-check semantics).
1621
+ if (Array.isArray(input.findings) && input.findings.length > 0) {
1622
+ if (!input.diagnosedEntity) missing.push("diagnosedEntity (Entity)");
1623
+ if (!Array.isArray(input.bigStat)) missing.push("bigStat (BigStat[])");
1624
+ if (!Array.isArray(input.signalCensus)) missing.push("signalCensus (SignalCensusRow[])");
1625
+ if (!input.hourlyHeatmap || !Array.isArray(input.hourlyHeatmap.cells)) {
1626
+ missing.push("hourlyHeatmap.cells (HourlyHeatCell[])");
1627
+ }
1628
+ }
1629
+
1630
+ if (missing.length > 0) {
1631
+ throw new Error(
1632
+ `build-render-input: refusing to return a malformed RenderInput — ` +
1633
+ `${missing.length} required field(s) absent or wrong-typed (${missing.join(", ")}). ` +
1634
+ `A producer-side drop must be caught HERE (S7-render), before the renderer's ` +
1635
+ `template.replaceAll() reaches broken markup. Fail-loud — no silent placeholder.`
1636
+ );
1637
+ }
1638
+ }
1639
+
1640
+ // ── Main entry ─────────────────────────────────────────────────────────────────
1641
+
1642
+ /**
1643
+ * Build a fully-populated RenderInput from the 4 deterministic inputs.
1644
+ * DETERMINISTIC + IDEMPOTENT: same inputs (+ same opts.generatedAt) → byte-identical
1645
+ * RenderInput. Fail-loud when the inputs are starved.
1646
+ */
1647
+ export function buildRenderInput(
1648
+ inputs: EnricherInputs,
1649
+ opts: EnricherOptions
1650
+ ): RenderInput {
1651
+ const { tier0, slicePlan, findings, metadata } = inputs;
1652
+
1653
+ // SD self-diag: when config.self_diagnostics.enabled, the report diagnoses the
1654
+ // diagnostics skill ITSELF — force meta-report mode (cluster-grouped findings),
1655
+ // internal-only audience (PR-022), a [INTERNAL] sessionId prefix, and a
1656
+ // skill-typed entity. These overrides happen BEFORE the shapes are built so the
1657
+ // whole render input is consistent.
1658
+ const isSelfDiag = opts.selfDiag?.enabled === true;
1659
+ const isMetaReport = isSelfDiag ? true : findings.isMetaReport;
1660
+ const sessionId = isSelfDiag && !findings.sessionId.startsWith("[INTERNAL]")
1661
+ ? `[INTERNAL] ${findings.sessionId}`
1662
+ : findings.sessionId;
1663
+
1664
+ // R1.3: normalize any legacy free-text assumptions → structured Assumption[].
1665
+ // PRD-CC-10: pass entityContext for no-code-access disclaimer synthesis.
1666
+ const entityContextForNorm = isSelfDiag && opts.selfDiag?.skillEntity
1667
+ ? opts.selfDiag.skillEntity
1668
+ : opts.entityContext;
1669
+ // ② pass metadata so numeric-anomaly severity is calibrated corpus-relative (G-2 fix).
1670
+ const normalizedFindings = normalizeFindings(findings.findings, entityContextForNorm, metadata);
1671
+
1672
+ // ── DISMISSAL FINAL-CHECK (hidden suppression) ────────────────────────────────
1673
+ // When a dismissalContext is injected, findings that semantically match a valid prior
1674
+ // DISMISSAL for this entity are REMOVED here (never rendered — no "Suppressed" section;
1675
+ // the verdict ledger is the audit trail). The severity-escalation HARD GUARD inside the
1676
+ // partition runs BEFORE the semantic match (a dismissed-but-now-worse finding always
1677
+ // re-surfaces). PURE + deterministic: the reasoning verdicts are pre-computed upstream
1678
+ // (host-runtime, pinned temp-0). Absent ⇒ no-op (activeFindings === normalizedFindings).
1679
+ const activeFindings = opts.dismissalContext
1680
+ ? partitionByDismissal(
1681
+ normalizedFindings,
1682
+ opts.dismissalContext.dismissedEntries,
1683
+ opts.dismissalContext.verdicts,
1684
+ { entitySlug: opts.dismissalContext.entitySlug }
1685
+ ).active
1686
+ : normalizedFindings;
1687
+
1688
+ const bigStat = buildBigStat(metadata, tier0);
1689
+ // W11-01: pass the ACTIVE (post-dismissal) findings for deep-read corroboration
1690
+ // (PR-049 step 3). activeFindings === normalizedFindings when no dismissal suppression
1691
+ // fired, so this is byte-unchanged for every non-dismissal run.
1692
+ // F3: build the census BEFORE the heatmap so the resolved primarySignal can key
1693
+ // the heatmap's metric + colour (cell metric FOLLOWS the primary signal).
1694
+ const signalCensus = buildSignalCensus(
1695
+ tier0,
1696
+ tier0.totalTraces || metadata.length || 1,
1697
+ activeFindings,
1698
+ // W17-C: discovered-signal context (corroborations · foldedDigests · sampledCount).
1699
+ // Absent until Block B/D1 wire it upstream → discovered signals stay capped at
1700
+ // SECONDARY (suspected-unconfirmed), never silently crowned.
1701
+ opts.signalCtx
1702
+ );
1703
+ // F3 (UR-2): heatmap metric follows the primary signal (default = latency, so a
1704
+ // latency-primary run is byte-identical to the pre-F3 behaviour).
1705
+ const hourlyHeatmap = aggregateHourlyHeatmap(
1706
+ metadata,
1707
+ signalCensus.primarySignal?.name
1708
+ );
1709
+ const scanFunnel = buildScanFunnel(tier0, slicePlan, findings.runMeta);
1710
+ // R1.7 + SD: self-diag uses the skill-typed EntityContext; otherwise the
1711
+ // normalizer's EntityContext (or legacy findings.entities[0]).
1712
+ const diagnosedEntity = isSelfDiag && opts.selfDiag?.skillEntity
1713
+ ? entityFromContext(opts.selfDiag.skillEntity)
1714
+ : resolveDiagnosedEntity(findings, opts.entityContext);
1715
+
1716
+ assertShapesBuildable(
1717
+ { diagnosedEntity, bigStat, hourlyHeatmap, signalCensus },
1718
+ activeFindings.length > 0
1719
+ );
1720
+
1721
+ // W9-08 (R-CP-2): assert RunMeta required fields when present + findings non-empty.
1722
+ // Uses the ACTIVE set: an all-suppressed run renders as a clean (zero-finding) report.
1723
+ assertRunMetaPresent(findings.runMeta, activeFindings.length > 0);
1724
+
1725
+ // ── W12-F1/F4 + W17-C: assemble the enriched runMeta ──────────────────────────
1726
+ // PRESERVE all upstream/LLM-produced fields from findings.runMeta — including the
1727
+ // threaded methodology widgets (tierBreakdown · blindSpots · awarenessSample) the
1728
+ // orchestrator assigned pre-enrich (codified in orchestrator-protocol Step 8.5).
1729
+ // The enricher NEVER recomputes those (they need an LLM); it only adds the fields
1730
+ // it can derive deterministically from the census scoring: primarySignal +
1731
+ // selectionRules + signalSelectionTrace (F1 / PR-038 / PR-049) + (W17-C, R7)
1732
+ // suspectedPrimaryUnconfirmed. The spread is unconditional so widget fields are
1733
+ // never dropped on a no-primary run.
1734
+ const enrichedRunMeta: RunMetaW17 | undefined = findings.runMeta
1735
+ ? {
1736
+ ...findings.runMeta,
1737
+ ...(signalCensus.primarySignal
1738
+ ? { primarySignal: signalCensus.primarySignal }
1739
+ : {}),
1740
+ // F1: enricher-computed selection metadata wins over any stale upstream copy
1741
+ // (they are deterministically re-derived from this run's census scoring).
1742
+ ...(signalCensus.selectionRules
1743
+ ? { selectionRules: signalCensus.selectionRules }
1744
+ : {}),
1745
+ ...(signalCensus.signalSelectionTrace !== undefined
1746
+ ? { signalSelectionTrace: signalCensus.signalSelectionTrace }
1747
+ : {}),
1748
+ // W17-C (R7): residual surfacing. When the top discovered signal is capped at
1749
+ // SECONDARY for lack of mechanical evidence and primary fell back to a cheap
1750
+ // signal, thread the flag so Block E (renderer) shows "suspected primary —
1751
+ // unconfirmed" instead of silently crowning the cheap signal.
1752
+ ...(signalCensus.suspectedPrimaryUnconfirmed
1753
+ ? { suspectedPrimaryUnconfirmed: signalCensus.suspectedPrimaryUnconfirmed }
1754
+ : {}),
1755
+ }
1756
+ : findings.runMeta;
1757
+
1758
+ const renderInput: RenderInput = {
1759
+ sessionId,
1760
+ diagnosedAt: findings.diagnosedAt,
1761
+ sourcePlatform: findings.sourcePlatform,
1762
+ targetPlatform: findings.targetPlatform,
1763
+ totalTraces: findings.totalTraces || metadata.length,
1764
+ // ACTIVE findings only — semantically-dismissed findings are removed (hidden). When no
1765
+ // dismissalContext is injected this is byte-identical to the normalized set.
1766
+ findings: activeFindings,
1767
+ generatedAt: opts.generatedAt,
1768
+ // SD/PR-022: meta-reports are ALWAYS internal — never strippable.
1769
+ audience: isMetaReport ? "internal" : findings.audience,
1770
+ isMetaReport,
1771
+ // Internal render shapes (R1.3/R1.7):
1772
+ diagnosedEntity,
1773
+ bigStat,
1774
+ signalCensus,
1775
+ scanFunnel,
1776
+ hourlyHeatmap,
1777
+ // Story-led copy (passed through verbatim when present):
1778
+ // W11-01/F1/F4: enriched runMeta — primarySignal + selectionRules +
1779
+ // signalSelectionTrace injected; upstream widget fields preserved (see above).
1780
+ runMeta: enrichedRunMeta,
1781
+ entities: findings.entities,
1782
+ decisionLog: findings.decisionLog ?? mapDecisionsToLog(findings.runMeta?.decisions),
1783
+ mermaidSequence: findings.mermaidSequence ?? findings.runMeta?.mermaidTopology,
1784
+ headerTitle: findings.headerTitle,
1785
+ overviewTitle: findings.overviewTitle,
1786
+ overviewSub: findings.overviewSub,
1787
+ overviewHeadline: findings.overviewHeadline,
1788
+ overviewLeverage: findings.overviewLeverage,
1789
+ decisionsBundle: findings.decisionsBundle,
1790
+ };
1791
+
1792
+ // S7-render: self-validate the assembled output shape BEFORE returning, so a
1793
+ // dropped/wrong-typed field is caught at the producer — never reaching the
1794
+ // renderer's template.replaceAll() and broken markup.
1795
+ assertRenderInputShape(renderInput);
1796
+
1797
+ return renderInput;
1798
+ }
1799
+
1800
+ /**
1801
+ * Minimal escaper for the decision-log grade badge text.
1802
+ * W13-C (D-5): hardened with `String(s ?? "")` — a decision row with an absent
1803
+ * rationale/reason field previously crashed on `s.slice` (hard render-input failure).
1804
+ * Now an absent field degrades to an empty badge instead of crashing.
1805
+ */
1806
+ function escapeBadge(s: unknown): string {
1807
+ return String(s ?? "")
1808
+ .slice(0, 48)
1809
+ .replace(/</g, "&lt;")
1810
+ .replace(/>/g, "&gt;");
1811
+ }
1812
+
1813
+ /**
1814
+ * W13-C (D-5): map RunMeta.decisions[] → DecisionLogRow[] for the Methodology tab.
1815
+ *
1816
+ * Tolerates the producer/consumer field-name desync between the canonical decision
1817
+ * shape `{step, choice, rationale}` (scripts/normalize/trace.ts RunMeta.decisions)
1818
+ * and the alias names `{stepId, decision, reason}` that appeared in some
1819
+ * orchestrator-protocol examples. An orchestrator that followed those examples
1820
+ * literally would otherwise produce all-undefined rows (and crash escapeBadge).
1821
+ * We read the canonical name first, then the alias, so BOTH shapes render correctly.
1822
+ *
1823
+ * Pure + deterministic. Returns undefined when there are no decisions (preserves the
1824
+ * prior `?.map` semantics so the field stays absent rather than an empty array).
1825
+ */
1826
+ export function mapDecisionsToLog(
1827
+ decisions: ReadonlyArray<Record<string, unknown>> | undefined
1828
+ ): DecisionLogRow[] | undefined {
1829
+ if (!decisions || decisions.length === 0) return undefined;
1830
+ return decisions.map((d) => ({
1831
+ // canonical `step` → alias `stepId`
1832
+ decision: String(d.step ?? d.stepId ?? ""),
1833
+ // canonical `choice` → alias `decision`
1834
+ what: String(d.choice ?? d.decision ?? ""),
1835
+ // canonical `rationale` → alias `reason`; escapeBadge guards undefined either way
1836
+ grade: `<span class="badge b-info">${escapeBadge(d.rationale ?? d.reason)}</span>`,
1837
+ }));
1838
+ }
1839
+
1840
+ // ── CLI entrypoint ─────────────────────────────────────────────────────────────
1841
+
1842
+ if (import.meta.main) {
1843
+ const { readFileSync, writeFileSync } = await import("fs");
1844
+ const { resolve } = await import("path");
1845
+
1846
+ const argv = process.argv.slice(2);
1847
+ const get = (flag: string): string | undefined => {
1848
+ const i = argv.indexOf(flag);
1849
+ return i >= 0 ? argv[i + 1] : undefined;
1850
+ };
1851
+
1852
+ const tier0Path = get("--tier0");
1853
+ const slicePath = get("--slice-plan");
1854
+ const findingsPath = get("--findings");
1855
+ const metadataPath = get("--metadata");
1856
+ const outPath = get("--output");
1857
+ const generatedAt = get("--generated-at") ?? "1970-01-01T00:00:00.000Z";
1858
+ // PRD-CC-10: --entity-context takes precedence over findings.entities[0].
1859
+ const entityContextPath = get("--entity-context");
1860
+ // SD-3: --self-diag turns on the meta-report path (isMetaReport + internal
1861
+ // audience + [INTERNAL] sessionId prefix + skill-typed entity). It pairs with
1862
+ // --skill-entity <path>, an EntityContext JSON produced by the claude-code
1863
+ // normalizer's self-diag CLI (SD-2: `--mode self-diag --out-entity`). Without
1864
+ // the CLI flag this path was programmatic-only (EnricherOptions.selfDiag),
1865
+ // so the self-diag report could not run end-to-end via run.sh.
1866
+ const selfDiag = argv.includes("--self-diag");
1867
+ const skillEntityPath = get("--skill-entity");
1868
+ // W17-WIRING: --signal-ctx <path> threads the discovered-signal SELECTION-HUB
1869
+ // context (SignalCensusContext) into buildSignalCensus end-to-end. The orchestrator
1870
+ // assembles this JSON at Step 6.5 from THREE deterministic sources:
1871
+ // • corroborations — scripts/scan/trajectory.ts CLI over the sampled deep-read bodies
1872
+ // • foldedDigests — store.foldValidDigests(entity, …) over the entity's ledger
1873
+ // • sampledCount — the honest deep-read denominator (R6)
1874
+ // Additive + safe-by-default (Zone-1.5): flag ABSENT → signalCtx undefined → legacy
1875
+ // Tier-0-only census → discovered signals stay suspected-unconfirmed (never crowned).
1876
+ const signalCtxPath = get("--signal-ctx");
1877
+ // DISMISSAL FINAL-CHECK wiring: --dismissal-context <path> threads the Step 8.5b
1878
+ // suppression context — a JSON `{ entitySlug, dismissedEntries, verdicts }` where the
1879
+ // reasoning verdicts were computed UPSTREAM (host-runtime, pinned temp-0). Additive +
1880
+ // safe-by-default: flag ABSENT → no suppression → byte-unchanged output.
1881
+ const dismissalContextPath = get("--dismissal-context");
1882
+
1883
+ if (!tier0Path || !slicePath || !findingsPath || !metadataPath || !outPath) {
1884
+ process.stderr.write(
1885
+ "Usage: bun scripts/enrich/build-render-input.ts --tier0 <f> --slice-plan <f> " +
1886
+ "--findings <f> --metadata <f> --output <f> [--generated-at <iso>] [--entity-context <f>] " +
1887
+ "[--signal-ctx <f>] [--dismissal-context <f>] [--self-diag --skill-entity <f>]\n"
1888
+ );
1889
+ process.exit(1);
1890
+ }
1891
+
1892
+ // SD-3: --self-diag and --skill-entity are paired — neither is useful alone.
1893
+ // --skill-entity without --self-diag would load a skill entity the enricher
1894
+ // ignores (selfDiag gate is off); --self-diag without --skill-entity would
1895
+ // synthesize a skill-typed card from findings only, defeating the SD-2 → SD-3
1896
+ // entity handoff. Fail loud rather than silently degrade.
1897
+ if (selfDiag && !skillEntityPath) {
1898
+ process.stderr.write("Error: --self-diag requires --skill-entity <path>\n");
1899
+ process.exit(1);
1900
+ }
1901
+ if (skillEntityPath && !selfDiag) {
1902
+ process.stderr.write("Error: --skill-entity requires --self-diag\n");
1903
+ process.exit(1);
1904
+ }
1905
+
1906
+ try {
1907
+ const inputs: EnricherInputs = {
1908
+ tier0: JSON.parse(readFileSync(resolve(tier0Path), "utf8")),
1909
+ slicePlan: JSON.parse(readFileSync(resolve(slicePath), "utf8")),
1910
+ findings: JSON.parse(readFileSync(resolve(findingsPath), "utf8")),
1911
+ metadata: JSON.parse(readFileSync(resolve(metadataPath), "utf8")),
1912
+ };
1913
+ // PRD-CC-10: load entity context from file when --entity-context is supplied.
1914
+ // When both file and findings.entities[0] differ, file takes precedence (Q1: override + warn).
1915
+ let entityContext: EntityContext | undefined;
1916
+ if (entityContextPath) {
1917
+ entityContext = JSON.parse(readFileSync(resolve(entityContextPath), "utf8")) as EntityContext;
1918
+ if (inputs.findings.entities && inputs.findings.entities.length > 0) {
1919
+ process.stderr.write(
1920
+ `[warn] --entity-context overrides findings.entities[0] (Q1 recommendation: override + warn)\n`
1921
+ );
1922
+ }
1923
+ }
1924
+ // SD-3: load the skill-typed EntityContext (SD-2 self-diag output) and wire
1925
+ // the EnricherOptions.selfDiag path so the meta-report renders end-to-end.
1926
+ let selfDiagOpt: EnricherOptions["selfDiag"];
1927
+ if (selfDiag) {
1928
+ const skillEntity = JSON.parse(
1929
+ readFileSync(resolve(skillEntityPath as string), "utf8")
1930
+ ) as EntityContext;
1931
+ selfDiagOpt = { enabled: true, skillEntity };
1932
+ }
1933
+ // W17-WIRING: load the SELECTION-HUB context when --signal-ctx is supplied.
1934
+ // Block C owns the consuming logic (buildSignalCensus); the CLI only threads the
1935
+ // orchestrator-assembled JSON through. Absent flag → undefined → safe-by-default.
1936
+ let signalCtx: SignalCensusContext | undefined;
1937
+ if (signalCtxPath) {
1938
+ signalCtx = JSON.parse(
1939
+ readFileSync(resolve(signalCtxPath), "utf8")
1940
+ ) as SignalCensusContext;
1941
+ }
1942
+ // DISMISSAL FINAL-CHECK: load the injected suppression context (Step 8.5b). Absent →
1943
+ // undefined → no suppression (the partition is a no-op, output byte-unchanged).
1944
+ let dismissalContext: EnricherOptions["dismissalContext"];
1945
+ if (dismissalContextPath) {
1946
+ dismissalContext = JSON.parse(
1947
+ readFileSync(resolve(dismissalContextPath), "utf8")
1948
+ ) as EnricherOptions["dismissalContext"];
1949
+ }
1950
+ const renderInput = buildRenderInput(inputs, {
1951
+ generatedAt,
1952
+ entityContext,
1953
+ selfDiag: selfDiagOpt,
1954
+ signalCtx,
1955
+ dismissalContext,
1956
+ });
1957
+ writeFileSync(resolve(outPath), JSON.stringify(renderInput, null, 2), "utf8");
1958
+ process.stdout.write(`RenderInput written to: ${outPath}\n`);
1959
+ process.exit(0);
1960
+ } catch (err) {
1961
+ process.stderr.write(`Error: ${err}\n`);
1962
+ process.exit(1);
1963
+ }
1964
+ }