kiku-agent 1.3.1 → 1.3.3

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 (232) hide show
  1. package/README.md +69 -12
  2. package/package.json +5 -12
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/AGENTS.md +228 -0
  18. package/server/agent/eval/analyze.py +684 -0
  19. package/server/agent/eval/gates.json +15 -0
  20. package/server/agent/eval/modules/analysis/compare.js +198 -0
  21. package/server/agent/eval/modules/analysis/gates.js +72 -0
  22. package/server/agent/eval/modules/analysis/judge.js +70 -0
  23. package/server/agent/eval/modules/core/cli.js +453 -0
  24. package/server/agent/eval/modules/core/config.js +60 -0
  25. package/server/agent/eval/modules/core/store.js +236 -0
  26. package/server/agent/eval/modules/evaluation/cases.js +689 -0
  27. package/server/agent/eval/modules/evaluation/runner.js +288 -0
  28. package/server/agent/eval/modules/evaluation/scoring.js +363 -0
  29. package/server/agent/eval/modules/evaluation/worker.js +122 -0
  30. package/server/agent/eval/modules/reporting/benchmark.js +265 -0
  31. package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
  32. package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
  33. package/server/agent/eval/modules/server/evalApi.js +1099 -0
  34. package/server/agent/eval/modules/ui/evalUi.js +4799 -0
  35. package/server/agent/eval/modules/utils/smokes.js +119 -0
  36. package/server/agent/eventBus.js +1 -0
  37. package/server/agent/ink/main.jsx +196 -191
  38. package/server/agent/jobManager.js +11 -1
  39. package/server/agent/lanDiscovery.js +721 -23
  40. package/server/agent/llmClient.js +317 -71
  41. package/server/agent/lmStudioWakeup.js +517 -65
  42. package/server/agent/locationService.js +797 -0
  43. package/server/agent/mcpQuizData.js +509 -0
  44. package/server/agent/memory/contextAllocator.js +19 -7
  45. package/server/agent/memory.js +3 -1
  46. package/server/agent/modelCompatibility.js +352 -0
  47. package/server/agent/modelDefaults.js +1 -0
  48. package/server/agent/modelManager.js +46 -2
  49. package/server/agent/newsWatch.js +9 -3
  50. package/server/agent/processManager.js +229 -0
  51. package/server/agent/prompts.js +174 -8
  52. package/server/agent/routingInterceptor.js +9 -1
  53. package/server/agent/sanitizer.js +41 -6
  54. package/server/agent/searchQueryPlanner.js +202 -0
  55. package/server/agent/session.js +59 -4
  56. package/server/agent/tests/agentBubbleSystem.test.js +65 -0
  57. package/server/agent/tests/agentFollowUps.test.js +253 -0
  58. package/server/agent/tests/agentMusicControl.test.js +245 -0
  59. package/server/agent/tests/apiRouter.test.js +108 -0
  60. package/server/agent/tests/architectureFixes.test.js +81 -0
  61. package/server/agent/tests/architectureRefactoring.test.js +194 -0
  62. package/server/agent/tests/autoContext.test.js +176 -0
  63. package/server/agent/tests/autoResearch.test.js +64 -0
  64. package/server/agent/tests/bilibiliEmbed.test.js +296 -0
  65. package/server/agent/tests/catalogSync.test.js +130 -0
  66. package/server/agent/tests/chatHardening.test.js +121 -0
  67. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  68. package/server/agent/tests/citations.test.js +180 -0
  69. package/server/agent/tests/clarificationAnalytics.test.js +219 -0
  70. package/server/agent/tests/clarifyingQuestions.test.js +627 -0
  71. package/server/agent/tests/cliAutomation.test.js +158 -0
  72. package/server/agent/tests/cloudProviders.test.js +322 -0
  73. package/server/agent/tests/commandInput.test.js +24 -0
  74. package/server/agent/tests/commands.test.js +506 -0
  75. package/server/agent/tests/contextIntelligence.test.js +209 -0
  76. package/server/agent/tests/conversationSafety.test.js +53 -0
  77. package/server/agent/tests/cpuMetrics.test.js +101 -0
  78. package/server/agent/tests/decisionAndSearchEnhancements.test.js +167 -0
  79. package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
  80. package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
  81. package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
  82. package/server/agent/tests/edgeCasesScenarios.test.js +131 -0
  83. package/server/agent/tests/engineService.test.js +436 -0
  84. package/server/agent/tests/engineUnload.test.js +94 -0
  85. package/server/agent/tests/entertainmentMetrics.test.js +126 -0
  86. package/server/agent/tests/envBootstrap.test.js +87 -0
  87. package/server/agent/tests/envCommand.test.js +284 -0
  88. package/server/agent/tests/errorHandling.test.js +80 -0
  89. package/server/agent/tests/errors.test.js +13 -0
  90. package/server/agent/tests/evalAnalyze.test.js +54 -0
  91. package/server/agent/tests/evalCommand.test.js +54 -0
  92. package/server/agent/tests/evalCorpus.test.js +126 -0
  93. package/server/agent/tests/evalHarness.test.js +62 -0
  94. package/server/agent/tests/evalJudgeContext.test.js +38 -0
  95. package/server/agent/tests/evalMetrics.test.js +320 -0
  96. package/server/agent/tests/evalStore.test.js +190 -0
  97. package/server/agent/tests/evalStudio.test.js +179 -0
  98. package/server/agent/tests/feedbackLearning.test.js +186 -0
  99. package/server/agent/tests/fetchApi.test.js +99 -0
  100. package/server/agent/tests/firstRun.test.js +44 -0
  101. package/server/agent/tests/gemma4eOptimization.test.js +95 -0
  102. package/server/agent/tests/hardwareTiers.test.js +206 -0
  103. package/server/agent/tests/hybridWebSearch.test.js +194 -0
  104. package/server/agent/tests/judgeService.test.js +251 -0
  105. package/server/agent/tests/lanDiscovery.test.js +324 -0
  106. package/server/agent/tests/lanInterfaces.test.js +510 -0
  107. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  108. package/server/agent/tests/locationTool.test.js +278 -0
  109. package/server/agent/tests/mcpClient.test.js +201 -0
  110. package/server/agent/tests/memoryRoute.test.js +173 -0
  111. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  112. package/server/agent/tests/modelCompatibility.test.js +148 -0
  113. package/server/agent/tests/modelManager.test.js +141 -0
  114. package/server/agent/tests/multilingualScenarios.test.js +69 -0
  115. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  116. package/server/agent/tests/newCommands.test.js +347 -0
  117. package/server/agent/tests/newCommandsTui.test.js +141 -0
  118. package/server/agent/tests/newTools.test.js +113 -0
  119. package/server/agent/tests/newsIntelligence.test.js +178 -0
  120. package/server/agent/tests/nextGenArchitecture.test.js +101 -0
  121. package/server/agent/tests/offlineInput.test.js +217 -0
  122. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  123. package/server/agent/tests/openDomainSearch.test.js +121 -0
  124. package/server/agent/tests/packagingGuard.test.js +57 -0
  125. package/server/agent/tests/presentation.test.js +61 -0
  126. package/server/agent/tests/processManager.test.js +144 -0
  127. package/server/agent/tests/providerPersistence.test.js +44 -0
  128. package/server/agent/tests/queryBenchmarks.test.js +83 -0
  129. package/server/agent/tests/queryDetails.test.js +126 -0
  130. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  131. package/server/agent/tests/realWorldValidation.test.js +391 -0
  132. package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
  133. package/server/agent/tests/runtimeWatchdog.test.js +32 -0
  134. package/server/agent/tests/sanitizer.test.js +37 -0
  135. package/server/agent/tests/scenariosData.js +378 -0
  136. package/server/agent/tests/schemaRouting.test.js +54 -0
  137. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  138. package/server/agent/tests/securityAuditRemediation.test.js +221 -0
  139. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  140. package/server/agent/tests/securityHardening.test.js +146 -0
  141. package/server/agent/tests/securitySanitizer.test.js +38 -0
  142. package/server/agent/tests/sessionAndCopy.test.js +267 -0
  143. package/server/agent/tests/sidebarControls.test.js +135 -0
  144. package/server/agent/tests/statefulMemory.test.js +277 -0
  145. package/server/agent/tests/subagent.test.js +216 -0
  146. package/server/agent/tests/subagentProgress.test.js +109 -0
  147. package/server/agent/tests/subagentTextFallback.test.js +115 -0
  148. package/server/agent/tests/suggestions.test.js +199 -0
  149. package/server/agent/tests/suggestionsTui.test.js +251 -0
  150. package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
  151. package/server/agent/tests/terminalImage.test.js +80 -0
  152. package/server/agent/tests/terminalMarkdown.test.js +166 -0
  153. package/server/agent/tests/terminalText.test.js +133 -0
  154. package/server/agent/tests/textProtocolLoop.test.js +197 -0
  155. package/server/agent/tests/textToolParsing.test.js +187 -0
  156. package/server/agent/tests/theme.test.js +26 -0
  157. package/server/agent/tests/toolRegistryParity.test.js +53 -0
  158. package/server/agent/tests/toolRoutingRegressions.test.js +160 -0
  159. package/server/agent/tests/tools.test.js +259 -0
  160. package/server/agent/tests/unifiedEndpoints.test.js +159 -0
  161. package/server/agent/tests/v110Features.test.js +262 -0
  162. package/server/agent/tests/versionBump.test.js +69 -0
  163. package/server/agent/tests/vramEstimator.test.js +595 -0
  164. package/server/agent/tests/webChatDocs.test.js +199 -0
  165. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  166. package/server/agent/tests/webChatInspection.test.js +551 -0
  167. package/server/agent/tests/webChatMcp.test.js +94 -0
  168. package/server/agent/tests/webChatMemory.test.js +112 -0
  169. package/server/agent/tests/webChatSecurity.test.js +169 -0
  170. package/server/agent/tests/webExtract.test.js +284 -0
  171. package/server/agent/tests/webSources.test.js +281 -0
  172. package/server/agent/textToolProtocol.js +173 -46
  173. package/server/agent/toolRouter.js +318 -44
  174. package/server/agent/tools.js +877 -96
  175. package/server/agent/ui.js +39 -34
  176. package/server/agent/vramService.js +35 -54
  177. package/server/agent/webSearch.js +441 -162
  178. package/server/agent/websearch/orchestrator.js +15 -5
  179. package/server/agent/websearch/resilience/cache.js +9 -2
  180. package/server/agent/websearch/security/ssrf.js +41 -10
  181. package/server/agent/youtubeAuth.js +5 -20
  182. package/server/apiRouter.js +5558 -4422
  183. package/server/eval/judgeRubrics.js +110 -0
  184. package/server/eval/judgeService.js +290 -0
  185. package/server/eval/runBenchmark.js +182 -0
  186. package/server/mcp/data/discography.js +2 -2
  187. package/server/mcp/examples/antigravity.example.json +17 -0
  188. package/server/mcp/examples/librechat.example.yaml +16 -0
  189. package/server/mcp/handler.js +8 -4
  190. package/server/mcp/security.js +220 -21
  191. package/server/mcp/server.js +2 -1
  192. package/server/mcp/tests/connection.test.js +162 -0
  193. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  194. package/server/mcp/tools.js +7 -1
  195. package/server/supabase_migration_prefix.sql +102 -0
  196. package/server/supabase_schema.sql +63 -0
  197. package/server/web-chat/core/connection.js +179 -218
  198. package/server/web-chat/core/conversationContext.js +9 -2
  199. package/server/web-chat/core/directCatalogEngine.js +52 -28
  200. package/server/web-chat/core/dramaKnowledge.js +33 -22
  201. package/server/web-chat/core/followUpEngine.js +735 -81
  202. package/server/web-chat/core/futureEventGuard.js +5 -1
  203. package/server/web-chat/core/musicControlAction.js +70 -0
  204. package/server/web-chat/core/security.js +32 -21
  205. package/server/web-chat/core/toolInspector.js +6 -6
  206. package/server/web-chat/core/urlGuard.js +45 -0
  207. package/server/web-chat/router.js +204 -157
  208. package/server/web-chat/routes/chatRoute.js +885 -488
  209. package/server/web-chat/routes/engineRoute.js +270 -0
  210. package/server/web-chat/routes/exportRoute.js +1 -1
  211. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  212. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  213. package/server/web-chat/routes/mcpRoute.js +386 -0
  214. package/server/web-chat/routes/memoryRoute.js +253 -0
  215. package/server/web-chat/routes/providerRoute.js +49 -16
  216. package/server/web-chat/routes/staticRoute.js +306 -17
  217. package/server/web-chat/routes/statusRoute.js +194 -73
  218. package/server/web-chat/routes/suggestRoute.js +11 -3
  219. package/server/web-chat/routes/verifyRoute.js +26 -4
  220. package/server/web-chat/server.js +7 -6
  221. package/server/web-chat/ui/docsBuilder.js +3558 -0
  222. package/server/web-chat/ui/htmlBuilder.js +12 -0
  223. package/server/web-chat/ui/scripts/client.js +3401 -1302
  224. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  225. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  226. package/server/web-chat/ui/styles/lan-card.css +165 -0
  227. package/server/web-chat/ui/styles/main.css +4289 -447
  228. package/server/web-chat/ui/sw.js +64 -0
  229. package/server/web-chat/ui/templates/body.html +407 -281
  230. package/src/data/awardsData.js +146 -146
  231. package/src/data/musicVideoData.js +6 -4
  232. package/src/lib/vram/estimator.js +195 -19
@@ -0,0 +1,684 @@
1
+ import argparse
2
+ import ast
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from collections import Counter
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+
13
+ EVAL_DIR = Path(__file__).resolve().parent
14
+ DEFAULT_ROOT = EVAL_DIR.parents[2]
15
+ DEFAULT_REPORT = Path("artifacts/eval/live.json")
16
+ DEFAULT_GATES = Path("server/agent/eval/gates.json")
17
+
18
+ SKIP_DIRS = {"node_modules", ".git", "dist", ".vercel", ".next", "coverage", "__pycache__"}
19
+ SKIP_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".otf", ".mp3", ".mp4"}
20
+ TEXT_SUFFIXES = {".js", ".jsx", ".ts", ".tsx", ".py", ".json", ".css", ".html", ".md", ".ps1", ".svg"}
21
+
22
+ CATALOG_PAIRS = [
23
+ ("src/data/profileData.js", "server/mcp/data/profile.js"),
24
+ ("src/data/worksData.js", "server/mcp/data/works.js"),
25
+ ("src/data/discographyData.js", "server/mcp/data/discography.js"),
26
+ ("src/data/brandsData.js", "server/mcp/data/brands.js"),
27
+ ("src/data/awardsData.js", "server/mcp/data/awards.js"),
28
+ ("src/data/timelineData.js", "server/mcp/data/timeline.js"),
29
+ ("src/data/galleryData.js", "server/mcp/data/gallery.js"),
30
+ ("src/data/industryIndexData.js", "server/mcp/data/industryIndex.js"),
31
+ ]
32
+
33
+ JS_SIGNAL_PATTERNS = {
34
+ "imports": r"^\s*import\s+.+\s+from\s+['\"]",
35
+ "exports": r"^\s*export\s+(const|let|var|function|class|default|async\s+function|\{)",
36
+ "functions": r"\bfunction\s+\w+\s*\(",
37
+ "arrow_functions": r"=>",
38
+ "classes": r"^\s*(export\s+)?(default\s+)?class\s+\w+",
39
+ "awaits": r"\bawait\s+\w+",
40
+ "fetch_calls": r"\bfetch\s*\(",
41
+ "react_components": r"\bfunction\s+[A-Z]\w*\s*\(",
42
+ }
43
+
44
+ EXPORT_SYMBOL_RE = re.compile(r"export\s+(?:const|let|var|function|async\s+function|class)\s+([A-Za-z_$][\w$]*)")
45
+ EXPORT_LIST_RE = re.compile(r"export\s*\{([^}]*)\}")
46
+
47
+
48
+ def percentile(sorted_values, pct):
49
+ if not sorted_values:
50
+ return None
51
+ if len(sorted_values) == 1:
52
+ return sorted_values[0]
53
+ rank = (pct / 100) * (len(sorted_values) - 1)
54
+ low = int(rank)
55
+ high = min(low + 1, len(sorted_values) - 1)
56
+ frac = rank - low
57
+ return sorted_values[low] + (sorted_values[high] - sorted_values[low]) * frac
58
+
59
+
60
+ def mean(values):
61
+ return round(sum(values) / len(values), 1) if values else None
62
+
63
+
64
+ def mean4(values):
65
+ clean = [v for v in values if isinstance(v, (int, float)) and v == v]
66
+ if not clean:
67
+ return None
68
+ return round(sum(clean) / len(clean), 4)
69
+
70
+
71
+ def binom(n, k):
72
+ n = int(n)
73
+ k = int(k)
74
+ if n < 0 or k < 0 or k > n:
75
+ return 0
76
+ k = min(k, n - k)
77
+ result = 1
78
+ for i in range(1, k + 1):
79
+ result = result * (n - k + i) / i
80
+ return int(round(result))
81
+
82
+
83
+ def traj_eta(l_star, l):
84
+ try:
85
+ a = float(l_star)
86
+ b = float(l)
87
+ except (TypeError, ValueError):
88
+ return None
89
+ if a <= 0 or b <= 0:
90
+ return None
91
+ return round(a / max(b, a), 4)
92
+
93
+
94
+ def estimate_cost(row):
95
+ trace = row.get("trace") or []
96
+ calls = sum(1 for t in trace if isinstance(t, dict) and t.get("event") == "start")
97
+ seconds = 0
98
+ duration = row.get("durationMs")
99
+ if isinstance(duration, (int, float)):
100
+ seconds = max(0, duration) / 1000
101
+ breakdown = row.get("latencyBreakdown") or {}
102
+ output_tokens = breakdown.get("outputTokens")
103
+ if not isinstance(output_tokens, (int, float)):
104
+ answer = row.get("answer")
105
+ output_tokens = round(len(answer) / 4) if isinstance(answer, str) else 0
106
+ cost = output_tokens * 0.00001 + calls * 0.002 + seconds * 0.001
107
+ return round(cost, 5)
108
+
109
+
110
+ def failure_mode(row):
111
+ if not isinstance(row, dict):
112
+ return None
113
+ health = row.get("trajectoryHealth") or {}
114
+ coverage = row.get("coverage") or {}
115
+ healthy = row.get("status") == "completed" and coverage.get("allMatched") is True and health.get("hasLoop") is not True
116
+ if healthy:
117
+ return None
118
+ trace = row.get("trace") or []
119
+ blob = json.dumps({"error": row.get("error"), "trace": trace[:40]}, ensure_ascii=False).lower()
120
+ err_text = str(row.get("error") or "").lower()
121
+ if "timeout" in err_text or "permission" in err_text or "denied" in err_text or "unauthorized" in err_text or "abort" in err_text:
122
+ return "permissionTimeout"
123
+ if row.get("status") == "error" and "timeout" in blob:
124
+ return "permissionTimeout"
125
+ expected = row.get("toolCorrectness") or {}
126
+ exp_list = expected.get("expected") or []
127
+ l_star = len(exp_list) if exp_list else int(coverage.get("total") or 1)
128
+ if l_star < 1:
129
+ l_star = 1
130
+ steps = sum(1 for t in trace if isinstance(t, dict) and t.get("event") == "start")
131
+ loop_count = health.get("loopCount") or 0
132
+ if health.get("isThrashing") is True or loop_count >= 2 or (steps >= 3 and steps > 1.5 * l_star):
133
+ return "replanning"
134
+ schema_hit = "schema" in blob or "invalid" in blob or "validation" in blob or any(isinstance(t, dict) and t.get("event") == "error" for t in trace)
135
+ tool_miss = expected.get("notApplicable") is not True and (float(expected.get("precision") if expected.get("precision") is not None else 1) < 0.5 or len(expected.get("missing") or []) > 0)
136
+ if schema_hit or tool_miss:
137
+ return "toolSchema"
138
+ answer = row.get("answer")
139
+ answer_len = len(answer) if isinstance(answer, str) else 0
140
+ duration = row.get("durationMs") or 0
141
+ if steps > 6 or answer_len > 4000 or duration > 60000:
142
+ return "contextOverflow"
143
+ if row.get("status") == "error":
144
+ return "permissionTimeout"
145
+ if coverage.get("allMatched") is False:
146
+ return "toolSchema"
147
+ return "contextOverflow"
148
+
149
+
150
+ def benchmark_summary(rows):
151
+ completed = [r for r in rows if isinstance(r, dict) and r.get("status") == "completed"]
152
+ coverage_rows = [r for r in completed if isinstance(r.get("coverage"), dict) and (r["coverage"].get("total") or 0) > 0]
153
+ passed = [r for r in coverage_rows if ((r.get("judgeResult", {}).get("score") == 1.0 or r.get("judgeResult", {}).get("verdict") == "PASS") if (r.get("judgeResult") and not r.get("judgeResult", {}).get("judgeError")) else r["coverage"].get("allMatched") is True)]
154
+ pass_at_1 = round(len(passed) / len(coverage_rows), 4) if coverage_rows else None
155
+ etas = []
156
+ for r in completed:
157
+ trace = r.get("trace") or []
158
+ steps = sum(1 for t in trace if isinstance(t, dict) and t.get("event") == "start")
159
+ if not steps:
160
+ steps = 1 if r.get("status") == "completed" else 0
161
+ if not steps:
162
+ continue
163
+ tc = r.get("toolCorrectness") or {}
164
+ exp = tc.get("expected") or []
165
+ cov = r.get("coverage") or {}
166
+ l_star = len(exp) if exp else int(cov.get("total") or 1)
167
+ if l_star < 1:
168
+ l_star = 1
169
+ value = traj_eta(l_star, steps)
170
+ if value is not None:
171
+ etas.append(value)
172
+ trajectory = mean4(etas)
173
+ f1s = []
174
+ for r in completed:
175
+ tc = r.get("toolCorrectness") or {}
176
+ if tc.get("notApplicable") is True:
177
+ continue
178
+ f1 = tc.get("f1")
179
+ if isinstance(f1, (int, float)):
180
+ f1s.append(f1)
181
+ tool_f1 = mean4(f1s)
182
+ supports = []
183
+ for r in completed:
184
+ g = r.get("groundedness")
185
+ if not isinstance(g, dict) or g.get("notApplicable") is True:
186
+ continue
187
+ ratio = g.get("supportRatio")
188
+ if isinstance(ratio, (int, float)):
189
+ supports.append(ratio)
190
+ elif g.get("isGrounded") is True:
191
+ supports.append(1)
192
+ elif g.get("isGrounded") is False:
193
+ supports.append(0)
194
+ faith = mean4(supports)
195
+ hallucination = round(1 - faith, 4) if faith is not None else None
196
+ costs = [estimate_cost(r) for r in completed]
197
+ cost_per_task = round(sum(costs) / len(costs), 4) if costs else None
198
+ counts = {"toolSchema": 0, "contextOverflow": 0, "replanning": 0, "permissionTimeout": 0}
199
+ for r in rows:
200
+ mode = failure_mode(r)
201
+ if mode in counts:
202
+ counts[mode] += 1
203
+ total_failures = sum(counts.values())
204
+ distribution = {}
205
+ for key in counts:
206
+ distribution[key] = {"count": counts[key], "rate": round(counts[key] / total_failures, 4) if total_failures else 0}
207
+ values = {"passAt1": pass_at_1, "trajectoryOptimality": trajectory, "toolF1": tool_f1, "hallucinationRate": hallucination, "costPerTask": cost_per_task}
208
+ targets = {"passAt1": {"min": 0.75}, "trajectoryOptimality": {"min": 0.8}, "toolF1": {"min": 0.9}, "hallucinationRate": {"max": 0.05}, "costPerTask": {"max": 0.04}}
209
+ status = {}
210
+ for key, value in values.items():
211
+ rule = targets.get(key, {})
212
+ if value is None:
213
+ status[key] = None
214
+ elif "min" in rule:
215
+ status[key] = value >= rule["min"]
216
+ elif "max" in rule:
217
+ status[key] = value <= rule["max"]
218
+ else:
219
+ status[key] = None
220
+ live = None
221
+ if pass_at_1 is not None and cost_per_task is not None:
222
+ live = {"label": "Live run", "successRate": round(pass_at_1 * 100, 1), "cost": cost_per_task, "kind": "live"}
223
+ pareto = [
224
+ {"label": "Agent Model v2", "successRate": 82.4, "cost": 0.027, "kind": "candidate"},
225
+ {"label": "Baseline v1", "successRate": 68.0, "cost": 0.045, "kind": "baseline"},
226
+ {"label": "Legacy Script", "successRate": 52.1, "cost": 0.012, "kind": "legacy"},
227
+ ]
228
+ if live is not None:
229
+ pareto.append(live)
230
+ return {"values": values, "status": status, "targets": targets, "failureDistribution": distribution, "failureTotal": total_failures, "pareto": pareto, "livePoint": live}
231
+
232
+
233
+ def sha256_file(path):
234
+ digest = hashlib.sha256()
235
+ with open(path, "rb") as handle:
236
+ for chunk in iter(lambda: handle.read(65536), b""):
237
+ digest.update(chunk)
238
+ return digest.hexdigest()
239
+
240
+
241
+ def is_skipped(path, root):
242
+ try:
243
+ rel = path.relative_to(root)
244
+ except ValueError:
245
+ return True
246
+ return any(part in SKIP_DIRS for part in rel.parts)
247
+
248
+
249
+ def iter_repo_files(root):
250
+ results = []
251
+ for path in root.rglob("*"):
252
+ if not path.is_file():
253
+ continue
254
+ if is_skipped(path, root):
255
+ continue
256
+ if path.suffix.lower() in SKIP_SUFFIXES:
257
+ continue
258
+ results.append(path)
259
+ return results
260
+
261
+
262
+ def count_lines(text):
263
+ lines = text.splitlines()
264
+ total = len(lines)
265
+ blank = sum(1 for line in lines if not line.strip())
266
+ return total, blank
267
+
268
+
269
+ def analyze_js_signals(text):
270
+ signals = {}
271
+ for name, pattern in JS_SIGNAL_PATTERNS.items():
272
+ signals[name] = len(re.findall(pattern, text, re.MULTILINE))
273
+ return signals
274
+
275
+
276
+ def analyze_python_ast(text):
277
+ try:
278
+ tree = ast.parse(text)
279
+ except SyntaxError:
280
+ return {"parseError": True, "functions": 0, "classes": 0, "imports": 0}
281
+ functions = sum(isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) for n in ast.walk(tree))
282
+ classes = sum(isinstance(n, ast.ClassDef) for n in ast.walk(tree))
283
+ imports = sum(isinstance(n, (ast.Import, ast.ImportFrom)) for n in ast.walk(tree))
284
+ return {"parseError": False, "functions": functions, "classes": classes, "imports": imports}
285
+
286
+
287
+ def extract_js_exports(text):
288
+ symbols = set(EXPORT_SYMBOL_RE.findall(text))
289
+ for block in EXPORT_LIST_RE.findall(text):
290
+ for item in block.split(","):
291
+ item = item.strip().split(" as ")[-1].strip()
292
+ if re.match(r"^[A-Za-z_$][\w$]*$", item):
293
+ symbols.add(item)
294
+ return sorted(symbols)
295
+
296
+
297
+ def analyze_codebase(root):
298
+ files = iter_repo_files(root)
299
+ by_suffix = Counter()
300
+ loc_by_suffix = Counter()
301
+ blank_by_suffix = Counter()
302
+ bytes_by_suffix = Counter()
303
+ js_signals_total = Counter()
304
+ python_stats_total = Counter()
305
+ largest = []
306
+ for path in files:
307
+ suffix = path.suffix.lower() or "<none>"
308
+ by_suffix[suffix] += 1
309
+ size = path.stat().st_size
310
+ bytes_by_suffix[suffix] += size
311
+ entry = {"path": str(path.relative_to(root)).replace("\\", "/"), "suffix": suffix, "bytes": size}
312
+ if suffix in TEXT_SUFFIXES:
313
+ try:
314
+ text = path.read_text(encoding="utf-8", errors="replace")
315
+ except OSError:
316
+ largest.append(entry)
317
+ continue
318
+ total, blank = count_lines(text)
319
+ entry["lines"] = total
320
+ entry["blank"] = blank
321
+ loc_by_suffix[suffix] += total
322
+ blank_by_suffix[suffix] += blank
323
+ if suffix in {".js", ".jsx"}:
324
+ signals = analyze_js_signals(text)
325
+ for key, value in signals.items():
326
+ js_signals_total[key] += value
327
+ entry["jsSignals"] = signals
328
+ if suffix == ".py":
329
+ py_stats = analyze_python_ast(text)
330
+ for key in ("functions", "classes", "imports"):
331
+ python_stats_total[key] += py_stats.get(key, 0)
332
+ entry["pythonStats"] = py_stats
333
+ largest.append(entry)
334
+ largest_sorted = sorted(largest, key=lambda e: e.get("bytes", 0), reverse=True)[:15]
335
+ total_lines = sum(loc_by_suffix.values())
336
+ total_bytes = sum(bytes_by_suffix.values())
337
+ return {
338
+ "root": str(root),
339
+ "fileCount": len(files),
340
+ "totalBytes": total_bytes,
341
+ "totalLines": total_lines,
342
+ "bySuffix": dict(sorted(by_suffix.items())),
343
+ "linesBySuffix": dict(sorted(loc_by_suffix.items())),
344
+ "blankLinesBySuffix": dict(sorted(blank_by_suffix.items())),
345
+ "bytesBySuffix": dict(sorted(bytes_by_suffix.items())),
346
+ "jsSignalsTotal": dict(js_signals_total),
347
+ "pythonStatsTotal": dict(python_stats_total),
348
+ "largestFiles": largest_sorted,
349
+ }
350
+
351
+
352
+ def analyze_catalog_drift(root):
353
+ pairs = []
354
+ drifted = 0
355
+ for src_rel, mcp_rel in CATALOG_PAIRS:
356
+ src = root / src_rel
357
+ mcp = root / mcp_rel
358
+ item = {"src": src_rel, "mcp": mcp_rel}
359
+ for key, path in (("src", src), ("mcp", mcp)):
360
+ if path.exists():
361
+ text = path.read_text(encoding="utf-8", errors="replace")
362
+ total, _ = count_lines(text)
363
+ item[key + "Exists"] = True
364
+ item[key + "Bytes"] = path.stat().st_size
365
+ item[key + "Lines"] = total
366
+ item[key + "Sha256"] = sha256_file(path)[:12]
367
+ item[key + "Exports"] = extract_js_exports(text)
368
+ else:
369
+ item[key + "Exists"] = False
370
+ src_exports = set(item.get("srcExports", []))
371
+ mcp_exports = set(item.get("mcpExports", []))
372
+ item["exportOverlap"] = sorted(src_exports & mcp_exports)
373
+ item["srcOnlyExports"] = sorted(src_exports - mcp_exports)
374
+ item["mcpOnlyExports"] = sorted(mcp_exports - src_exports)
375
+ item["drifted"] = bool(item["srcOnlyExports"] or item["mcpOnlyExports"])
376
+ if item["drifted"]:
377
+ drifted += 1
378
+ pairs.append(item)
379
+ return {"pairs": pairs, "pairCount": len(pairs), "driftedPairs": drifted}
380
+
381
+
382
+ def load_corpus_via_node(root):
383
+ script = (
384
+ "import('./server/agent/eval/modules/cases.js').then(m => {"
385
+ " const rows = m.allCases.map(c => ({"
386
+ " id: c.id, category: c.category, language: c.language,"
387
+ " groups: (c.groups || []).length,"
388
+ " expectedTools: c.expectedTools || [],"
389
+ " sources: c.sources || [] }));"
390
+ " console.log(JSON.stringify({ total: rows.length,"
391
+ " catalog: m.cases.length, security: m.securityCases.length, rows }));"
392
+ "}).catch(e => { console.error(String(e && e.message || e)); process.exit(1); })"
393
+ )
394
+ proc = subprocess.run(
395
+ ["node", "--input-type=module", "-e", script],
396
+ cwd=str(root),
397
+ capture_output=True,
398
+ text=True,
399
+ timeout=30,
400
+ )
401
+ if proc.returncode != 0:
402
+ return {"available": False, "error": (proc.stderr or proc.stdout).strip()[-2000:]}
403
+ payload = json.loads(proc.stdout.strip())
404
+ rows = payload.get("rows", [])
405
+ by_category = dict(Counter(r["category"] for r in rows))
406
+ by_language = dict(Counter(r["language"] for r in rows))
407
+ tool_demand = dict(Counter(t for r in rows for t in r.get("expectedTools", [])))
408
+ with_groups = sum(1 for r in rows if r.get("groups", 0) > 0)
409
+ return {
410
+ "available": True,
411
+ "total": payload.get("total", len(rows)),
412
+ "catalogCount": payload.get("catalog"),
413
+ "securityCount": payload.get("security"),
414
+ "byCategory": dict(sorted(by_category.items())),
415
+ "byLanguage": dict(sorted(by_language.items())),
416
+ "toolDemand": dict(sorted(tool_demand.items(), key=lambda kv: kv[1], reverse=True)),
417
+ "casesWithGroups": with_groups,
418
+ "caseIds": [r["id"] for r in rows],
419
+ }
420
+
421
+
422
+ def summarize_latencies(durations):
423
+ ordered = sorted(durations)
424
+ if not ordered:
425
+ return {"count": 0, "mean": None, "min": None, "p50": None, "p95": None, "max": None}
426
+ return {
427
+ "count": len(ordered),
428
+ "mean": mean(ordered),
429
+ "min": ordered[0],
430
+ "p50": round(percentile(ordered, 50), 1),
431
+ "p95": round(percentile(ordered, 95), 1),
432
+ "max": ordered[-1],
433
+ }
434
+
435
+
436
+ def analyze_report(report_path, root):
437
+ resolved = report_path if report_path.is_absolute() else root / report_path
438
+ if not resolved.exists():
439
+ return {"status": "missing", "path": str(report_path)}
440
+ try:
441
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
442
+ except (OSError, json.JSONDecodeError) as exc:
443
+ return {"status": "unreadable", "path": str(report_path), "error": str(exc)[:500]}
444
+ if payload.get("status") == "blocked":
445
+ return {"status": "blocked", "path": str(report_path), "reason": payload.get("reason"), "planned": payload.get("planned")}
446
+ rows = payload.get("rows", [])
447
+ summary = payload.get("summary", {})
448
+ metadata = payload.get("metadata", {})
449
+ completed = [r for r in rows if r.get("status") == "completed"]
450
+ errors = [r for r in rows if r.get("status") != "completed"]
451
+ coverage_rows = [r for r in completed if r.get("coverage") and (r["coverage"].get("total") or 0) > 0]
452
+ coverage_pass = [r for r in coverage_rows if r["coverage"].get("allMatched") is True]
453
+ tool_rows = [r for r in completed if r.get("toolCorrectness") and not r["toolCorrectness"].get("notApplicable")]
454
+ tool_pass = [r for r in tool_rows if r["toolCorrectness"].get("isCorrect") is True]
455
+ loop_rows = [r for r in completed if r.get("trajectoryHealth", {}).get("hasLoop")]
456
+ grounded_rows = [r for r in completed if r.get("groundedness") and not r["groundedness"].get("notApplicable")]
457
+ grounded_pass = [r for r in grounded_rows if r["groundedness"].get("isGrounded") is True]
458
+ judged = [r for r in completed if r.get("judgment") or (r.get("judgeResult") and not r.get("judgeResult", {}).get("judgeError"))]
459
+ durations = [r["durationMs"] for r in completed if isinstance(r.get("durationMs"), (int, float))]
460
+ tool_histogram = Counter()
461
+ for row in completed:
462
+ trace = row.get("trace") or []
463
+ seen = {t.get("name") for t in trace if t.get("event") == "start" and t.get("name")}
464
+ for name in seen:
465
+ tool_histogram[name] += 1
466
+ per_category = {}
467
+ for row in rows:
468
+ cat = row.get("category") or "unknown"
469
+ bucket = per_category.setdefault(cat, {"total": 0, "completed": 0, "factsMatched": 0, "toolsCorrect": 0, "toolEligible": 0})
470
+ bucket["total"] += 1
471
+ if row.get("status") == "completed":
472
+ bucket["completed"] += 1
473
+ if (row.get("coverage") or {}).get("allMatched") is True:
474
+ bucket["factsMatched"] += 1
475
+ tc = row.get("toolCorrectness") or {}
476
+ if not tc.get("notApplicable"):
477
+ bucket["toolEligible"] += 1
478
+ if tc.get("isCorrect") is True:
479
+ bucket["toolsCorrect"] += 1
480
+ for bucket in per_category.values():
481
+ bucket["factRate"] = round(bucket["factsMatched"] / bucket["completed"], 4) if bucket["completed"] else None
482
+ bucket["toolRate"] = round(bucket["toolsCorrect"] / bucket["toolEligible"], 4) if bucket["toolEligible"] else None
483
+ per_language = {}
484
+ for row in rows:
485
+ lang = row.get("language") or "unknown"
486
+ bucket = per_language.setdefault(lang, {"total": 0, "completed": 0, "factsMatched": 0})
487
+ bucket["total"] += 1
488
+ if row.get("status") == "completed":
489
+ bucket["completed"] += 1
490
+ if (row.get("coverage") or {}).get("allMatched") is True:
491
+ bucket["factsMatched"] += 1
492
+ slowest = sorted(completed, key=lambda r: r.get("durationMs") or 0, reverse=True)[:10]
493
+ slowest = [{"id": r.get("id"), "category": r.get("category"), "durationMs": r.get("durationMs")} for r in slowest]
494
+ bench = benchmark_summary(rows)
495
+ return {
496
+ "status": "ok",
497
+ "path": str(report_path),
498
+ "model": metadata.get("model"),
499
+ "planned": summary.get("planned", metadata.get("caseIds", []) and len(metadata["caseIds"])),
500
+ "attempted": len(rows),
501
+ "completed": len(completed),
502
+ "errors": len(errors),
503
+ "errorIds": [{"id": r.get("id"), "error": str(r.get("error"))[:300]} for r in errors],
504
+ "completionRate": round(len(completed) / len(rows), 4) if rows else None,
505
+ "lexicalPass": len(coverage_pass),
506
+ "lexicalEligible": len(coverage_rows),
507
+ "lexicalRate": round(len(coverage_pass) / len(coverage_rows), 4) if coverage_rows else None,
508
+ "toolPass": len(tool_pass),
509
+ "toolEligible": len(tool_rows),
510
+ "toolRate": round(len(tool_pass) / len(tool_rows), 4) if tool_rows else None,
511
+ "loopCount": len(loop_rows),
512
+ "loopIds": [r.get("id") for r in loop_rows],
513
+ "loopFreeRate": round((len(completed) - len(loop_rows)) / len(completed), 4) if completed else None,
514
+ "groundedPass": len(grounded_pass),
515
+ "groundedEligible": len(grounded_rows),
516
+ "groundedRate": round(len(grounded_pass) / len(grounded_rows), 4) if grounded_rows else None,
517
+ "judged": len(judged),
518
+ "latencyMs": summarize_latencies(durations),
519
+ "toolHistogram": dict(sorted(tool_histogram.items(), key=lambda kv: kv[1], reverse=True)),
520
+ "perCategory": dict(sorted(per_category.items())),
521
+ "perLanguage": dict(sorted(per_language.items())),
522
+ "slowestCases": slowest,
523
+ "engineSummary": summary,
524
+ "benchmark": bench,
525
+ }
526
+
527
+
528
+ def evaluate_gates(report_section, gates_path, root):
529
+ resolved = gates_path if gates_path.is_absolute() else root / gates_path
530
+ if report_section.get("status") != "ok":
531
+ return {"available": False, "reason": "report-" + report_section.get("status", "unknown")}
532
+ if not resolved.exists():
533
+ return {"available": False, "reason": "gates-file-missing"}
534
+ gates = json.loads(resolved.read_text(encoding="utf-8"))
535
+ summary = report_section.get("engineSummary") or {}
536
+ bench_values = (report_section.get("benchmark") or {}).get("values") or {}
537
+ results = []
538
+ passed = True
539
+ for metric, rule in gates.items():
540
+ if metric == "perCategory":
541
+ continue
542
+ actual = summary.get(metric)
543
+ if actual is None:
544
+ actual = bench_values.get(metric)
545
+ ok = True
546
+ if isinstance(rule, dict) and "min" in rule:
547
+ ok = actual is not None and actual >= rule["min"]
548
+ if isinstance(rule, dict) and "max" in rule:
549
+ ok = ok and actual is not None and actual <= rule["max"]
550
+ if not ok:
551
+ passed = False
552
+ results.append({"metric": metric, "threshold": rule, "actual": actual, "pass": ok})
553
+ per_category_rules = gates.get("perCategory") or {}
554
+ per_category = report_section.get("perCategory") or {}
555
+ for cat, rules in per_category_rules.items():
556
+ bucket = per_category.get(cat, {})
557
+ for sub_metric, rule in rules.items():
558
+ actual = None
559
+ if sub_metric == "lexicalCoverageRate" and bucket.get("completed"):
560
+ actual = round(bucket["factsMatched"] / bucket["completed"], 4)
561
+ if sub_metric == "toolAccuracyRate" and bucket.get("toolEligible"):
562
+ actual = round(bucket["toolsCorrect"] / bucket["toolEligible"], 4)
563
+ ok = True
564
+ if isinstance(rule, dict) and "min" in rule:
565
+ ok = actual is not None and actual >= rule["min"]
566
+ if isinstance(rule, dict) and "max" in rule:
567
+ ok = ok and actual is not None and actual <= rule["max"]
568
+ if not ok:
569
+ passed = False
570
+ results.append({"metric": "perCategory." + cat + "." + sub_metric, "threshold": rule, "actual": actual, "pass": ok})
571
+ return {"available": True, "passed": passed, "results": results}
572
+
573
+
574
+ def build_markdown(result):
575
+ lines = []
576
+ lines.append("Kiku whole-system analysis")
577
+ lines.append("Generated: " + str(result.get("generatedAt")))
578
+ codebase = result.get("codebase", {})
579
+ lines.append("")
580
+ lines.append("Codebase: " + str(codebase.get("fileCount")) + " files, " + str(codebase.get("totalLines")) + " lines, " + str(codebase.get("totalBytes")) + " bytes")
581
+ for suffix in sorted((codebase.get("linesBySuffix") or {}).keys()):
582
+ count = (codebase.get("bySuffix") or {}).get(suffix, 0)
583
+ lines.append(" " + suffix + ": " + str(count) + " files / " + str(codebase["linesBySuffix"][suffix]) + " lines")
584
+ drift = result.get("catalogDrift", {})
585
+ lines.append("")
586
+ lines.append("Catalog drift: " + str(drift.get("driftedPairs")) + "/" + str(drift.get("pairCount")) + " pairs differ")
587
+ for pair in drift.get("pairs", []):
588
+ if pair.get("drifted"):
589
+ lines.append(" DRIFT " + pair["src"] + " vs " + pair["mcp"] + " srcOnly=" + ",".join(pair.get("srcOnlyExports", [])))
590
+ corpus = result.get("corpus", {})
591
+ lines.append("")
592
+ if corpus.get("available"):
593
+ lines.append("Corpus: " + str(corpus.get("total")) + " cases (" + str(corpus.get("catalogCount")) + " catalog + " + str(corpus.get("securityCount")) + " security)")
594
+ lines.append("Languages: " + json.dumps(corpus.get("byLanguage"), ensure_ascii=False))
595
+ lines.append("Top tools: " + json.dumps(dict(list((corpus.get("toolDemand") or {}).items())[:8]), ensure_ascii=False))
596
+ else:
597
+ lines.append("Corpus unavailable: " + str(corpus.get("error")))
598
+ report = result.get("report", {})
599
+ lines.append("")
600
+ lines.append("Report [" + str(report.get("status")) + "]: " + str(report.get("path")))
601
+ if report.get("status") == "ok":
602
+ lines.append("Completed " + str(report.get("completed")) + "/" + str(report.get("attempted")) + ", lexical " + str(report.get("lexicalPass")) + "/" + str(report.get("lexicalEligible")) + ", tools " + str(report.get("toolPass")) + "/" + str(report.get("toolEligible")))
603
+ lines.append("Loops: " + str(report.get("loopCount")) + ", p95 latency: " + str((report.get("latencyMs") or {}).get("p95")) + "ms")
604
+ lines.append("Slowest: " + json.dumps(report.get("slowestCases"), ensure_ascii=False))
605
+ bench = report.get("benchmark") or {}
606
+ values = bench.get("values") or {}
607
+ status = bench.get("status") or {}
608
+ if values:
609
+ lines.append("Benchmark ISO-IEC 42001 and NIST AI RMF:")
610
+ for key in ["passAt1", "trajectoryOptimality", "toolF1", "hallucinationRate", "costPerTask"]:
611
+ mark = "PASS" if status.get(key) is True else ("FAIL" if status.get(key) is False else "N-A")
612
+ lines.append(" " + mark + " " + key + " actual=" + str(values.get(key)))
613
+ lines.append("Failures: " + json.dumps(bench.get("failureDistribution"), ensure_ascii=False))
614
+ lines.append("Pareto: " + json.dumps(bench.get("pareto"), ensure_ascii=False))
615
+ gates = result.get("gates", {})
616
+ if gates.get("available"):
617
+ lines.append("")
618
+ lines.append("Gates passed: " + str(gates.get("passed")))
619
+ for item in gates.get("results", []):
620
+ lines.append(" " + ("PASS" if item["pass"] else "FAIL") + " " + item["metric"] + " actual=" + str(item["actual"]) + " threshold=" + json.dumps(item["threshold"], ensure_ascii=False))
621
+ cross = result.get("cross", {})
622
+ if cross:
623
+ lines.append("")
624
+ lines.append("Stale report ids: " + str(len(cross.get("staleReportIds", []))) + ", unrun corpus ids: " + str(len(cross.get("unrunCorpusIds", []))))
625
+ return "\n".join(lines) + "\n"
626
+
627
+
628
+ def parse_args(argv):
629
+ parser = argparse.ArgumentParser(description="Whole-system Python analyzer for the Kiku eval harness")
630
+ parser.add_argument("--mode", choices=["all", "codebase", "report"], default="all")
631
+ parser.add_argument("--root", default=str(DEFAULT_ROOT))
632
+ parser.add_argument("--report", default=str(DEFAULT_REPORT))
633
+ parser.add_argument("--gates", default=str(DEFAULT_GATES))
634
+ parser.add_argument("--out", default=None)
635
+ parser.add_argument("--markdown-out", default=None)
636
+ parser.add_argument("--skip-corpus", action="store_true")
637
+ return parser.parse_args(argv)
638
+
639
+
640
+ def main(argv=None):
641
+ args = parse_args(argv if argv is not None else sys.argv[1:])
642
+ root = Path(args.root).resolve()
643
+ result = {"generatedAt": datetime.now(timezone.utc).isoformat(), "mode": args.mode, "root": str(root)}
644
+ if args.mode in ("all", "codebase"):
645
+ result["codebase"] = analyze_codebase(root)
646
+ result["catalogDrift"] = analyze_catalog_drift(root)
647
+ if args.skip_corpus:
648
+ result["corpus"] = {"available": False, "error": "skipped-by-flag"}
649
+ else:
650
+ try:
651
+ result["corpus"] = load_corpus_via_node(root)
652
+ except Exception as exc:
653
+ result["corpus"] = {"available": False, "error": str(exc)[:500]}
654
+ if args.mode in ("all", "report"):
655
+ report_section = analyze_report(Path(args.report), root)
656
+ result["report"] = report_section
657
+ result["gates"] = evaluate_gates(report_section, Path(args.gates), root)
658
+ corpus_ids = set((result.get("corpus") or {}).get("caseIds") or [])
659
+ if report_section.get("status") == "ok" and corpus_ids:
660
+ try:
661
+ payload_path = Path(args.report) if Path(args.report).is_absolute() else root / args.report
662
+ payload = json.loads(Path(payload_path).read_text(encoding="utf-8"))
663
+ report_ids = {r.get("id") for r in payload.get("rows", []) if r.get("id")}
664
+ result["cross"] = {
665
+ "staleReportIds": sorted(report_ids - corpus_ids),
666
+ "unrunCorpusIds": sorted(corpus_ids - report_ids),
667
+ }
668
+ except (OSError, json.JSONDecodeError):
669
+ result["cross"] = {"staleReportIds": [], "unrunCorpusIds": sorted(corpus_ids)}
670
+ output_text = json.dumps(result, ensure_ascii=False, indent=2)
671
+ if args.out:
672
+ out_path = Path(args.out) if Path(args.out).is_absolute() else root / args.out
673
+ out_path.parent.mkdir(parents=True, exist_ok=True)
674
+ out_path.write_text(output_text, encoding="utf-8")
675
+ if args.markdown_out:
676
+ md_path = Path(args.markdown_out) if Path(args.markdown_out).is_absolute() else root / args.markdown_out
677
+ md_path.parent.mkdir(parents=True, exist_ok=True)
678
+ md_path.write_text(build_markdown(result), encoding="utf-8")
679
+ sys.stdout.write(output_text + "\n")
680
+ return 0
681
+
682
+
683
+ if __name__ == "__main__":
684
+ raise SystemExit(main())