@sushanglewis/lincoln 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/dist/main.d.ts +3 -0
  2. package/dist/main.js +1231 -906
  3. package/dist/main.js.map +1 -1
  4. package/dist/postinstall.d.ts +3 -0
  5. package/dist/postinstall.js +18 -0
  6. package/dist/postinstall.js.map +1 -0
  7. package/framework/.claude/agents/_contract.md +79 -0
  8. package/framework/.claude/agents/architect.md +31 -0
  9. package/framework/.claude/agents/default.md +151 -0
  10. package/framework/.claude/agents/designer.md +33 -0
  11. package/framework/.claude/agents/engineer.md +42 -0
  12. package/framework/.claude/agents/external/NOTICES.md +12 -0
  13. package/framework/.claude/agents/external/everything-claude-code.manifest.yaml +23 -0
  14. package/framework/.claude/agents/external/oh-my-claudecode.manifest.yaml +26 -0
  15. package/framework/.claude/agents/external/wshobson-agents.manifest.yaml +23 -0
  16. package/framework/.claude/agents/frontend-engineer.md +32 -0
  17. package/framework/.claude/agents/knowledge-curator.md +28 -0
  18. package/framework/.claude/agents/pm.md +38 -0
  19. package/framework/.claude/agents/qa.md +28 -0
  20. package/framework/.claude/agents/release-coordinator.md +25 -0
  21. package/framework/.claude/agents/researcher.md +41 -0
  22. package/framework/.claude/harnesses/claude-code.yaml +15 -0
  23. package/framework/.claude/harnesses/codex.yaml +40 -0
  24. package/framework/.claude/harnesses/command-map.yaml +255 -0
  25. package/framework/.claude/harnesses/opencode.yaml +32 -0
  26. package/framework/.claude/harnesses/scenarios.yaml +45 -0
  27. package/framework/.claude/hooks/on-pr-event.sh +69 -0
  28. package/framework/.claude/hooks/on-session-start.sh +427 -0
  29. package/framework/.claude/hooks/on-stop.sh +104 -0
  30. package/framework/.claude/hooks/post-tool-use.sh +161 -0
  31. package/framework/.claude/hooks/pre-tool-use.sh +246 -0
  32. package/framework/.claude/schemas/agent-definition.schema.json +35 -0
  33. package/framework/.claude/schemas/benchmark-report.schema.json +148 -0
  34. package/framework/.claude/schemas/pm-to-ux-handoff.schema.yaml +128 -0
  35. package/framework/.claude/schemas/stage-definition.schema.json +233 -0
  36. package/framework/.claude/schemas/workflow-stage.schema.json +86 -0
  37. package/framework/.claude/schemas/workflow-template.schema.json +88 -0
  38. package/framework/.claude/settings.json +14 -0
  39. package/framework/.claude/skills/build-product-prototype/SKILL.md +35 -0
  40. package/framework/.claude/skills/build-product-prototype/prompts/main.md +78 -0
  41. package/framework/.claude/skills/clarify-requirements/SKILL.md +32 -0
  42. package/framework/.claude/skills/clarify-requirements/prompts/main.md +81 -0
  43. package/framework/.claude/skills/dependencies.yaml +139 -0
  44. package/framework/.claude/skills/draft-product-design/SKILL.md +40 -0
  45. package/framework/.claude/skills/draft-product-design/prompts/main.md +47 -0
  46. package/framework/.claude/skills/lc-analyze-frameworks/SKILL.md +43 -0
  47. package/framework/.claude/skills/lc-analyze-frameworks/prompts/main.md +95 -0
  48. package/framework/.claude/skills/lc-benchmark/SKILL.md +61 -0
  49. package/framework/.claude/skills/lc-build-codebase-knowledge/SKILL.md +38 -0
  50. package/framework/.claude/skills/lc-build-codebase-knowledge/prompts/build-codebase-knowledge.md +61 -0
  51. package/framework/.claude/skills/lc-collect-intelligence/SKILL.md +43 -0
  52. package/framework/.claude/skills/lc-collect-intelligence/prompts/main.md +72 -0
  53. package/framework/.claude/skills/lc-competitive-analysis/SKILL.md +43 -0
  54. package/framework/.claude/skills/lc-competitive-analysis/prompts/main.md +70 -0
  55. package/framework/.claude/skills/lc-explore-opensource/SKILL.md +36 -0
  56. package/framework/.claude/skills/lc-explore-opensource/prompts/explore-opensource.md +77 -0
  57. package/framework/.claude/skills/lc-first-principles/SKILL.md +43 -0
  58. package/framework/.claude/skills/lc-first-principles/prompts/main.md +73 -0
  59. package/framework/.claude/skills/lc-handoff/SKILL.md +34 -0
  60. package/framework/.claude/skills/lc-init-branch/SKILL.md +52 -0
  61. package/framework/.claude/skills/lc-market-research/SKILL.md +43 -0
  62. package/framework/.claude/skills/lc-market-research/prompts/main.md +79 -0
  63. package/framework/.claude/skills/lc-product-research/SKILL.md +43 -0
  64. package/framework/.claude/skills/lc-product-research/prompts/main.md +80 -0
  65. package/framework/.claude/skills/lc-research-report/SKILL.md +43 -0
  66. package/framework/.claude/skills/lc-research-report/prompts/main.md +90 -0
  67. package/framework/.claude/skills/lc-research-scope/SKILL.md +43 -0
  68. package/framework/.claude/skills/lc-research-scope/prompts/main.md +82 -0
  69. package/framework/.claude/skills/lc-setup/SKILL.md +68 -0
  70. package/framework/.claude/skills/lc-setup/prompts/main.md +88 -0
  71. package/framework/.claude/skills/lc-stage/SKILL.md +45 -0
  72. package/framework/.claude/skills/lc-stakeholder-research/SKILL.md +43 -0
  73. package/framework/.claude/skills/lc-stakeholder-research/prompts/main.md +65 -0
  74. package/framework/.claude/skills/lc-status/SKILL.md +25 -0
  75. package/framework/.claude/skills/lc-storytelling/SKILL.md +43 -0
  76. package/framework/.claude/skills/lc-storytelling/prompts/main.md +77 -0
  77. package/framework/.claude/skills/lc-wf/SKILL.md +54 -0
  78. package/framework/.claude/skills/lc-workflow/SKILL.md +48 -0
  79. package/framework/.claude/skills/lc-workflow/validators/validate.py +755 -0
  80. package/framework/.claude/skills/lc-workflow-router/SKILL.md +41 -0
  81. package/framework/.claude/skills/lc-workflow-router/prompts/intake-prompt.md +91 -0
  82. package/framework/.claude/skills/lc-workflow-router/prompts/router-prompt.md +44 -0
  83. package/framework/.claude/skills/plan-tdd-development/SKILL.md +31 -0
  84. package/framework/.claude/skills/plan-tdd-development/prompts/main.md +44 -0
  85. package/framework/.claude/skills/process-interview/SKILL.md +32 -0
  86. package/framework/.claude/skills/process-interview/prompts/main.md +47 -0
  87. package/framework/.claude/skills/propose-with-openspec/SKILL.md +37 -0
  88. package/framework/.claude/skills/propose-with-openspec/prompts/main.md +53 -0
  89. package/framework/.claude/skills/split-to-github/SKILL.md +32 -0
  90. package/framework/.claude/skills/split-to-github/prompts/main.md +42 -0
  91. package/framework/.claude/skills/sync-to-knowledge/SKILL.md +35 -0
  92. package/framework/.claude/skills/sync-to-knowledge/prompts/main.md +54 -0
  93. package/framework/.claude/skills/workflow-continue/SKILL.md +24 -0
  94. package/framework/.claude/skills/workflow-continue/prompts/main.md +25 -0
  95. package/framework/.claude/stages/build-codebase-knowledge.yaml +42 -0
  96. package/framework/.claude/stages/clarify.yaml +59 -0
  97. package/framework/.claude/stages/explore-opensource.yaml +40 -0
  98. package/framework/.claude/stages/implement.yaml +60 -0
  99. package/framework/.claude/stages/ingest.yaml +40 -0
  100. package/framework/.claude/stages/lc-analyze-frameworks.yaml +37 -0
  101. package/framework/.claude/stages/lc-collect-intelligence.yaml +37 -0
  102. package/framework/.claude/stages/lc-competitive-analysis.yaml +37 -0
  103. package/framework/.claude/stages/lc-first-principles.yaml +37 -0
  104. package/framework/.claude/stages/lc-market-research.yaml +37 -0
  105. package/framework/.claude/stages/lc-product-research.yaml +37 -0
  106. package/framework/.claude/stages/lc-research-report.yaml +38 -0
  107. package/framework/.claude/stages/lc-research-scope.yaml +38 -0
  108. package/framework/.claude/stages/lc-stakeholder-research.yaml +37 -0
  109. package/framework/.claude/stages/lc-storytelling.yaml +37 -0
  110. package/framework/.claude/stages/product-design-docs.yaml +66 -0
  111. package/framework/.claude/stages/product-prototype.yaml +53 -0
  112. package/framework/.claude/stages/propose.yaml +45 -0
  113. package/framework/.claude/stages/split.yaml +41 -0
  114. package/framework/.claude/stages/sync-knowledge.yaml +49 -0
  115. package/framework/.claude/stages/tdd-development-plan.yaml +43 -0
  116. package/framework/.claude/stages/workflow-router.yaml +45 -0
  117. package/framework/.claude/templates/solo-workflow-context.yaml +23 -0
  118. package/framework/.claude/workflows/README.md +93 -0
  119. package/framework/.claude/workflows/bug-fix.yaml +81 -0
  120. package/framework/.claude/workflows/design-spike.yaml +55 -0
  121. package/framework/.claude/workflows/existing-project-iteration.yaml +106 -0
  122. package/framework/.claude/workflows/interview-to-knowledge.yaml +189 -0
  123. package/framework/.claude/workflows/oss-first-design.yaml +100 -0
  124. package/framework/.claude/workflows/pm-research.yaml +115 -0
  125. package/framework/.claude-plugin/hooks.json +6 -0
  126. package/framework/.claude-plugin/marketplace.json +32 -0
  127. package/framework/.claude-plugin/plugin.json +57 -0
  128. package/framework/.version-bump.json +43 -0
  129. package/framework/CLAUDE.md +140 -0
  130. package/framework/CONTRIBUTING.md +219 -0
  131. package/framework/LICENSE +21 -0
  132. package/framework/README.en.md +139 -0
  133. package/framework/README.md +139 -0
  134. package/framework/RELEASE.md +334 -0
  135. package/framework/SKILL.md +24 -0
  136. package/framework/USAGE.md +356 -0
  137. package/framework/manifest.json +1214 -0
  138. package/framework/requirements.txt +4 -0
  139. package/framework/scripts/__init__.py +0 -0
  140. package/framework/scripts/build_codebase_knowledge.py +76 -0
  141. package/framework/scripts/bump_version.py +181 -0
  142. package/framework/scripts/check-dependency-drift.py +69 -0
  143. package/framework/scripts/check-harness-drift.sh +57 -0
  144. package/framework/scripts/check-main-merge-hygiene.py +128 -0
  145. package/framework/scripts/check-skill-dependencies.sh +124 -0
  146. package/framework/scripts/init-lincoln-branch.sh +300 -0
  147. package/framework/scripts/init-project.sh +184 -0
  148. package/framework/scripts/lc-benchmark-cli.py +66 -0
  149. package/framework/scripts/lincoln-audit.py +288 -0
  150. package/framework/scripts/lincoln-setup.py +651 -0
  151. package/framework/scripts/lincoln-status.py +448 -0
  152. package/framework/scripts/lincoln_benchmark.py +224 -0
  153. package/framework/scripts/lincoln_benchmark_eval.py +104 -0
  154. package/framework/scripts/lincoln_benchmark_metrics.py +818 -0
  155. package/framework/scripts/lincoln_benchmark_report.py +141 -0
  156. package/framework/scripts/lincoln_command_map.py +289 -0
  157. package/framework/scripts/lincoln_dependency_manager.py +335 -0
  158. package/framework/scripts/lincoln_documents.py +175 -0
  159. package/framework/scripts/lincoln_harness_adapter.py +319 -0
  160. package/framework/scripts/lincoln_index.py +255 -0
  161. package/framework/scripts/lincoln_paths.py +198 -0
  162. package/framework/scripts/lincoln_prd.py +227 -0
  163. package/framework/scripts/lincoln_role.py +46 -0
  164. package/framework/scripts/lincoln_scenario.py +59 -0
  165. package/framework/scripts/lincoln_session_start_metrics.py +71 -0
  166. package/framework/scripts/lincoln_skill_prompt.py +48 -0
  167. package/framework/scripts/lincoln_trace.py +335 -0
  168. package/framework/scripts/lincoln_workflow.py +199 -0
  169. package/framework/scripts/list-active-lincoln-branches.sh +87 -0
  170. package/framework/scripts/package-lincoln-plugin.py +340 -0
  171. package/framework/scripts/run-infrastructure-tests.py +84 -0
  172. package/framework/scripts/stage_loader.py +1071 -0
  173. package/framework/scripts/static-check.sh +356 -0
  174. package/framework/scripts/sync-external-agents.sh +210 -0
  175. package/framework/scripts/sync-framework-package.mjs +218 -0
  176. package/framework/scripts/task_tool_guard.py +213 -0
  177. package/framework/scripts/track-artifacts.py +140 -0
  178. package/framework/scripts/validate_stage.py +350 -0
  179. package/{README.md → framework/tools/lincoln/README.md} +4 -4
  180. package/framework/tools/lincoln/package-lock.json +3349 -0
  181. package/framework/tools/lincoln/package.json +48 -0
  182. package/framework/tools/lincoln/src/cli.tsx +60 -0
  183. package/framework/tools/lincoln/src/components/AudioMeter.tsx +34 -0
  184. package/framework/tools/lincoln/src/components/CancelledScreen.tsx +11 -0
  185. package/framework/tools/lincoln/src/components/DevicesMenu.tsx +79 -0
  186. package/framework/tools/lincoln/src/components/HintBar.tsx +25 -0
  187. package/framework/tools/lincoln/src/components/LogView.tsx +36 -0
  188. package/framework/tools/lincoln/src/components/ModelMenu.tsx +122 -0
  189. package/framework/tools/lincoln/src/components/ReadyScreen.tsx +54 -0
  190. package/framework/tools/lincoln/src/components/RecordingApp.tsx +211 -0
  191. package/framework/tools/lincoln/src/components/RecordingScreen.tsx +41 -0
  192. package/framework/tools/lincoln/src/components/SelectMenu.tsx +53 -0
  193. package/framework/tools/lincoln/src/components/StatusPanel.tsx +52 -0
  194. package/framework/tools/lincoln/src/components/StopConfirmation.tsx +32 -0
  195. package/framework/tools/lincoln/src/config/args.ts +35 -0
  196. package/framework/tools/lincoln/src/config/loadConfig.ts +81 -0
  197. package/framework/tools/lincoln/src/config/resolveConfig.ts +85 -0
  198. package/framework/tools/lincoln/src/devices/listDevices.ts +11 -0
  199. package/framework/tools/lincoln/src/devices/parseDevices.ts +25 -0
  200. package/framework/tools/lincoln/src/hooks/useKeyHandler.ts +63 -0
  201. package/framework/tools/lincoln/src/main.tsx +4 -0
  202. package/framework/tools/lincoln/src/models/models.ts +34 -0
  203. package/framework/tools/lincoln/src/models/runWarmup.ts +46 -0
  204. package/framework/tools/lincoln/src/models/warmupProgress.ts +30 -0
  205. package/framework/tools/lincoln/src/recording/spawnRecorder.ts +127 -0
  206. package/framework/tools/lincoln/src/recording/useRecorder.ts +146 -0
  207. package/framework/tools/lincoln/src/utils/amplitudeToMeter.ts +4 -0
  208. package/framework/tools/lincoln/src/utils/formatDuration.ts +6 -0
  209. package/framework/tools/lincoln/src/utils/sessionId.ts +25 -0
  210. package/framework/tools/lincoln/tests/cli.test.tsx +52 -0
  211. package/framework/tools/lincoln/tests/components/AudioMeter.test.tsx +31 -0
  212. package/framework/tools/lincoln/tests/components/CancelledScreen.test.tsx +12 -0
  213. package/framework/tools/lincoln/tests/components/ReadyScreen.test.tsx +68 -0
  214. package/framework/tools/lincoln/tests/components/RecordingApp.menus.test.tsx +158 -0
  215. package/framework/tools/lincoln/tests/components/RecordingApp.test.tsx +248 -0
  216. package/framework/tools/lincoln/tests/components/RecordingScreen.test.tsx +37 -0
  217. package/framework/tools/lincoln/tests/components/SelectMenu.test.tsx +59 -0
  218. package/framework/tools/lincoln/tests/components/StopConfirmation.test.tsx +38 -0
  219. package/framework/tools/lincoln/tests/config/args.test.ts +49 -0
  220. package/framework/tools/lincoln/tests/config/loadConfig.test.ts +59 -0
  221. package/framework/tools/lincoln/tests/config/resolveConfig.test.ts +72 -0
  222. package/framework/tools/lincoln/tests/devices/parseDevices.test.ts +21 -0
  223. package/framework/tools/lincoln/tests/hooks/useKeyHandler.enabled.test.tsx +44 -0
  224. package/framework/tools/lincoln/tests/hooks/useKeyHandler.test.tsx +130 -0
  225. package/framework/tools/lincoln/tests/models/models.test.ts +27 -0
  226. package/framework/tools/lincoln/tests/models/warmupProgress.test.ts +25 -0
  227. package/framework/tools/lincoln/tests/recording/spawnRecorder.test.ts +85 -0
  228. package/framework/tools/lincoln/tests/recording/useRecorder.test.tsx +172 -0
  229. package/framework/tools/lincoln/tests/utils/amplitudeToMeter.test.ts +22 -0
  230. package/framework/tools/lincoln/tests/utils/formatDuration.test.ts +11 -0
  231. package/framework/tools/lincoln/tests/utils/sessionId.test.ts +30 -0
  232. package/framework/tools/lincoln/tsconfig.json +22 -0
  233. package/framework/tools/lincoln/tsconfig.tests.json +10 -0
  234. package/framework/tools/lincoln/tsup.config.ts +17 -0
  235. package/framework/tools/lincoln/vitest.config.ts +20 -0
  236. package/framework/tools/lincoln-installer/package-lock.json +3611 -0
  237. package/framework/tools/lincoln-installer/package.json +53 -0
  238. package/framework/tools/lincoln-installer/src/cli.tsx +63 -0
  239. package/framework/tools/lincoln-installer/src/components/HarnessSelectScreen.tsx +43 -0
  240. package/framework/tools/lincoln-installer/src/components/InstallerApp.tsx +98 -0
  241. package/framework/tools/lincoln-installer/src/components/OptionsScreen.tsx +56 -0
  242. package/framework/tools/lincoln-installer/src/components/ProgressScreen.tsx +25 -0
  243. package/framework/tools/lincoln-installer/src/components/ResultScreen.tsx +40 -0
  244. package/framework/tools/lincoln-installer/src/components/SelectMenu.tsx +73 -0
  245. package/framework/tools/lincoln-installer/src/components/SummaryScreen.tsx +49 -0
  246. package/framework/tools/lincoln-installer/src/components/UpdateApp.tsx +135 -0
  247. package/framework/tools/lincoln-installer/src/components/WelcomeScreen.tsx +22 -0
  248. package/framework/tools/lincoln-installer/src/config/args.ts +76 -0
  249. package/framework/tools/lincoln-installer/src/config/resolveConfig.ts +33 -0
  250. package/framework/tools/lincoln-installer/src/config/updateArgs.ts +67 -0
  251. package/framework/tools/lincoln-installer/src/hooks/useSetup.ts +149 -0
  252. package/framework/tools/lincoln-installer/src/main.tsx +11 -0
  253. package/framework/tools/lincoln-installer/src/types.ts +19 -0
  254. package/framework/tools/lincoln-installer/src/update-main.tsx +11 -0
  255. package/framework/tools/lincoln-installer/src/update.tsx +90 -0
  256. package/framework/tools/lincoln-installer/src/utils/detectHarness.ts +51 -0
  257. package/framework/tools/lincoln-installer/src/utils/runSetup.ts +62 -0
  258. package/framework/tools/lincoln-installer/src/utils/updateRelease.ts +454 -0
  259. package/framework/tools/lincoln-installer/tests/components/InstallerApp.test.tsx +88 -0
  260. package/framework/tools/lincoln-installer/tests/components/SelectMenu.test.tsx +26 -0
  261. package/framework/tools/lincoln-installer/tests/components/UpdateApp.test.tsx +170 -0
  262. package/framework/tools/lincoln-installer/tests/components/screens.test.tsx +310 -0
  263. package/framework/tools/lincoln-installer/tests/config/args.test.ts +66 -0
  264. package/framework/tools/lincoln-installer/tests/config/resolveConfig.test.ts +75 -0
  265. package/framework/tools/lincoln-installer/tests/config/updateArgs.test.ts +52 -0
  266. package/framework/tools/lincoln-installer/tests/hooks/useSetup.test.tsx +213 -0
  267. package/framework/tools/lincoln-installer/tests/update.test.tsx +108 -0
  268. package/framework/tools/lincoln-installer/tests/utils/detectHarness.test.ts +40 -0
  269. package/framework/tools/lincoln-installer/tests/utils/runSetup.test.ts +45 -0
  270. package/framework/tools/lincoln-installer/tests/utils/updateRelease.test.ts +559 -0
  271. package/framework/tools/lincoln-installer/tsconfig.json +22 -0
  272. package/framework/tools/lincoln-installer/tsconfig.tests.json +8 -0
  273. package/framework/tools/lincoln-installer/tsup.config.ts +17 -0
  274. package/framework/tools/lincoln-installer/vitest.config.ts +20 -0
  275. package/framework/tools/lincoln-record/Cargo.lock +1896 -0
  276. package/framework/tools/lincoln-record/Cargo.toml +24 -0
  277. package/framework/tools/lincoln-record/diarize/diarize.py +117 -0
  278. package/framework/tools/lincoln-record/diarize/merge.py +55 -0
  279. package/framework/tools/lincoln-record/src/audio/capture/microphone.rs +194 -0
  280. package/framework/tools/lincoln-record/src/audio/capture/mod.rs +1 -0
  281. package/framework/tools/lincoln-record/src/audio/devices.rs +20 -0
  282. package/framework/tools/lincoln-record/src/audio/mod.rs +5 -0
  283. package/framework/tools/lincoln-record/src/audio/resampler.rs +44 -0
  284. package/framework/tools/lincoln-record/src/audio/saver.rs +87 -0
  285. package/framework/tools/lincoln-record/src/audio/source.rs +104 -0
  286. package/framework/tools/lincoln-record/src/cli.rs +96 -0
  287. package/framework/tools/lincoln-record/src/config.rs +135 -0
  288. package/framework/tools/lincoln-record/src/lib.rs +6 -0
  289. package/framework/tools/lincoln-record/src/main.rs +242 -0
  290. package/framework/tools/lincoln-record/src/model/mod.rs +248 -0
  291. package/framework/tools/lincoln-record/src/output/metadata.rs +30 -0
  292. package/framework/tools/lincoln-record/src/output/mod.rs +2 -0
  293. package/framework/tools/lincoln-record/src/output/transcript.rs +42 -0
  294. package/framework/tools/lincoln-record/src/transcription/diarization.rs +64 -0
  295. package/framework/tools/lincoln-record/src/transcription/mod.rs +3 -0
  296. package/framework/tools/lincoln-record/src/transcription/provider.rs +43 -0
  297. package/framework/tools/lincoln-record/src/transcription/whisper.rs +176 -0
  298. package/framework/tools/lincoln-record/tests/cli_args.rs +103 -0
  299. package/framework/tools/lincoln-record/tests/config_test.rs +70 -0
  300. package/framework/tools/lincoln-record/tests/devices_test.rs +23 -0
  301. package/framework/tools/lincoln-record/tests/diarization_tests.rs +101 -0
  302. package/framework/tools/lincoln-record/tests/integration_tests.rs +44 -0
  303. package/framework/tools/lincoln-record/tests/model_test.rs +68 -0
  304. package/framework/tools/lincoln-record/tests/transcription_tests.rs +73 -0
  305. package/package.json +19 -13
@@ -0,0 +1,818 @@
1
+ #!/usr/bin/env python3
2
+ """Metric computation for Lincoln benchmark reports."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ from collections import Counter
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ _PROJECT_ROOT = Path(__file__).resolve().parents[1]
17
+ if str(_PROJECT_ROOT) not in sys.path:
18
+ sys.path.insert(0, str(_PROJECT_ROOT))
19
+
20
+ from scripts.lincoln_paths import ( # noqa: E402
21
+ get_process_slug,
22
+ load_yaml,
23
+ )
24
+
25
+ TEST_COMMAND_RE = re.compile(
26
+ r"\b(pytest|npm test|yarn test|cargo test|go test|vitest|jest)\b",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+ STATIC_CHECK_RE = re.compile(r"\bstatic-check\.sh\b")
31
+
32
+
33
+ def _safe_path_component(value: Any) -> str:
34
+ """Sanitize a variable used as a path component to prevent traversal."""
35
+ text = re.sub(r"[\\/]", "-", str(value))
36
+ parts = [part for part in text.split("-") if part and part != "." and part != ".."]
37
+ return "-".join(parts)
38
+
39
+
40
+ def _safe_variables(variables: dict[str, Any]) -> dict[str, Any]:
41
+ """Return a copy of variables with every value sanitized for path interpolation."""
42
+ return {key: _safe_path_component(value) for key, value in variables.items()}
43
+
44
+
45
+ def _is_legacy_state(state: dict[str, Any]) -> bool:
46
+ return "stages" in state and "nodes" not in state
47
+
48
+
49
+ def _parse_iso(value: str) -> datetime:
50
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
51
+
52
+
53
+ def _load_primary_agents(project_root: Path) -> dict[str, str]:
54
+ """Build stage_id -> primary agent mapping from the actual stage definitions."""
55
+ stages_dir = project_root / ".claude" / "stages"
56
+ agents: dict[str, str] = {}
57
+ if not stages_dir.is_dir():
58
+ return agents
59
+ for path in sorted(stages_dir.glob("*.yaml")):
60
+ try:
61
+ data = load_yaml(path)
62
+ except Exception:
63
+ continue
64
+ if not isinstance(data, dict):
65
+ continue
66
+ stage_id = data.get("id") or path.stem
67
+ agent = data.get("agent")
68
+ primary = agent.get("primary", "") if isinstance(agent, dict) else ""
69
+ if stage_id and primary:
70
+ agents[str(stage_id)] = str(primary)
71
+ return agents
72
+
73
+
74
+ def _stage_order_from_trace(trace: list[dict[str, Any]]) -> list[str]:
75
+ order: list[str] = []
76
+ for entry in trace:
77
+ stage = entry.get("stage")
78
+ if stage and (not order or order[-1] != stage):
79
+ order.append(stage)
80
+ return order
81
+
82
+
83
+ def _lcs_length(a: list[str], b: list[str]) -> int:
84
+ if not a or not b:
85
+ return 0
86
+ prev = [0] * (len(b) + 1)
87
+ for i in range(1, len(a) + 1):
88
+ curr = [0] * (len(b) + 1)
89
+ for j in range(1, len(b) + 1):
90
+ if a[i - 1] == b[j - 1]:
91
+ curr[j] = prev[j - 1] + 1
92
+ else:
93
+ curr[j] = max(prev[j], curr[j - 1])
94
+ prev = curr
95
+ return prev[len(b)]
96
+
97
+
98
+ def load_trace(process_root: Path, process_slug: str) -> list[dict[str, Any]]:
99
+ trace_dir = process_root / process_slug / ".trace"
100
+ if not trace_dir.exists():
101
+ return []
102
+
103
+ entries: dict[tuple[str, str], dict[str, Any]] = {}
104
+ for trace_file in sorted(trace_dir.glob("lc-trace*.jsonl")):
105
+ try:
106
+ text = trace_file.read_text(encoding="utf-8")
107
+ except Exception as exc:
108
+ logging.warning("Cannot read trace file %s: %s", trace_file, exc)
109
+ continue
110
+ for line in text.splitlines():
111
+ line = line.strip()
112
+ if not line:
113
+ continue
114
+ try:
115
+ entry = json.loads(line)
116
+ except json.JSONDecodeError as exc:
117
+ logging.warning("Skipping malformed trace line in %s: %s", trace_file, exc)
118
+ continue
119
+ if not isinstance(entry, dict):
120
+ continue
121
+ key = (str(entry.get("run_id", "")), str(entry.get("sequence_id", "")))
122
+ if not key[1]:
123
+ continue
124
+ entries[key] = entry
125
+
126
+ return sorted(entries.values(), key=lambda e: e.get("timestamp", ""))
127
+
128
+
129
+ def _workflow_steps(state: dict[str, Any]) -> list[dict[str, Any]]:
130
+ template = (
131
+ state.get("workflow", {}).get("template")
132
+ or state.get("current_run", {}).get("workflow_template")
133
+ )
134
+ if not template:
135
+ return []
136
+ from scripts.stage_loader import load_workflow
137
+
138
+ try:
139
+ workflow = load_workflow(template)
140
+ except Exception:
141
+ return []
142
+ return workflow.get("steps", [])
143
+
144
+
145
+ def _stage_durations(trace: list[dict[str, Any]]) -> dict[str, int]:
146
+ durations: dict[str, list[datetime]] = {}
147
+ for entry in trace:
148
+ stage = entry.get("stage")
149
+ ts = entry.get("timestamp")
150
+ if not stage or not ts:
151
+ continue
152
+ try:
153
+ dt = _parse_iso(ts)
154
+ except Exception:
155
+ continue
156
+ durations.setdefault(stage, []).append(dt)
157
+ return {
158
+ stage: int((max(times) - min(times)).total_seconds())
159
+ for stage, times in durations.items()
160
+ if len(times) >= 2
161
+ }
162
+
163
+
164
+ def _node_durations(state: dict[str, Any]) -> dict[str, int]:
165
+ durations: dict[str, int] = {}
166
+ for node in state.get("nodes", []):
167
+ started = node.get("started_at")
168
+ completed = node.get("completed_at")
169
+ if started and completed:
170
+ try:
171
+ durations[str(node.get("stage_id"))] = int(
172
+ (_parse_iso(completed) - _parse_iso(started)).total_seconds()
173
+ )
174
+ except Exception:
175
+ pass
176
+ return durations
177
+
178
+
179
+ def _interpolate_placeholders(value: str, variables: dict[str, Any]) -> str:
180
+ return re.sub(r"\{([a-zA-Z0-9_-]+)\}", lambda m: str(variables.get(m.group(1), m.group(0))), value)
181
+
182
+
183
+ def _artifact_paths_for_stage(
184
+ workflow_steps: list[dict[str, Any]],
185
+ stage_id: str,
186
+ variables: dict[str, Any],
187
+ ) -> list[str]:
188
+ safe_variables = _safe_variables(variables)
189
+ for step in workflow_steps:
190
+ if step.get("id") == stage_id:
191
+ artifacts = step.get("artifacts", [])
192
+ return [_interpolate_placeholders(str(a), safe_variables) for a in artifacts]
193
+ return []
194
+
195
+
196
+ def _artifact_completion_rate(
197
+ stage_id: str | None,
198
+ workflow_steps: list[dict[str, Any]],
199
+ variables: dict[str, Any],
200
+ process_root: Path,
201
+ ) -> float:
202
+ if not stage_id:
203
+ return 0.0
204
+ paths = _artifact_paths_for_stage(workflow_steps, stage_id, variables)
205
+ if not paths:
206
+ return 1.0
207
+ existing = sum(1 for p in paths if (process_root / p).exists())
208
+ return round(existing / len(paths), 2)
209
+
210
+
211
+ def _requirements_clarity_score(process_root: Path, process_slug: str, variables: dict[str, Any]) -> int:
212
+ req_path = process_root / process_slug / "pages" / "docs" / "requirements.html"
213
+ if not req_path.exists():
214
+ return 0
215
+ text = req_path.read_text(encoding="utf-8").lower()
216
+ checks = [
217
+ "background" in text or "背景" in text,
218
+ "problem" in text or "问题" in text,
219
+ "solution" in text or "方案" in text,
220
+ "acceptance" in text or "验收" in text,
221
+ ]
222
+ return sum(checks)
223
+
224
+
225
+ def _design_doc_completeness(
226
+ workflow_steps: list[dict[str, Any]],
227
+ variables: dict[str, Any],
228
+ process_root: Path,
229
+ process_slug: str,
230
+ ) -> float:
231
+ stage_id = "product-design-docs"
232
+ artifacts = _artifact_paths_for_stage(workflow_steps, stage_id, variables)
233
+ # Only count design docs under pages/docs; handoff artifacts are tracked separately.
234
+ design_prefix = f"{process_slug}/pages/docs/"
235
+ artifacts = [p for p in artifacts if design_prefix in p and "*" not in p]
236
+ if not artifacts:
237
+ base = process_root / process_slug / "pages" / "docs"
238
+ artifacts = [
239
+ str(base / "design-review.html"),
240
+ str(base / "scenarios.html"),
241
+ str(base / "feature-catalog.html"),
242
+ str(base / "data-model.html"),
243
+ str(base / "flows.html"),
244
+ str(base / "feasibility.html"),
245
+ str(base / "page-map.html"),
246
+ ]
247
+ existing = sum(1 for p in artifacts if (process_root / p).exists())
248
+ return round(existing / len(artifacts), 2) if artifacts else 0.0
249
+
250
+
251
+ def _tdd_plan_red_green_refactor(
252
+ process_root: Path, process_slug: str, variables: dict[str, Any]
253
+ ) -> dict[str, bool]:
254
+ path = process_root / process_slug / "pages" / "docs" / "tdd-plan.html"
255
+ result = {"red": False, "green": False, "refactor": False}
256
+ if not path.exists():
257
+ return result
258
+ text = path.read_text(encoding="utf-8").lower()
259
+ result["red"] = bool(re.search(r"\bred\b|\bred phase\b|\bred-green", text))
260
+ result["green"] = bool(re.search(r"\bgreen\b|\bgreen phase\b", text))
261
+ result["refactor"] = bool(re.search(r"\brefactor\b|\brefactor phase\b", text))
262
+ return result
263
+
264
+
265
+ def _openspec_task_count(
266
+ process_root: Path, process_slug: str, variables: dict[str, Any]
267
+ ) -> int:
268
+ change_name = _safe_path_component(variables.get("change_name", "") or "")
269
+ path = process_root / process_slug / "openspec" / "changes" / change_name / "tasks.md"
270
+ if not path.exists():
271
+ return 0
272
+ text = path.read_text(encoding="utf-8")
273
+ return len(re.findall(r"^\s*-\s+\[.\]", text, re.MULTILINE))
274
+
275
+
276
+ def _static_check_pass(trace: list[dict[str, Any]], process_root: Path) -> bool | None:
277
+ # Prefer the structured result written by static-check.sh; fall back to
278
+ # scanning the trace for legacy or per-process usage.
279
+ result_file = process_root / ".context" / "static-check-result.json"
280
+ if result_file.exists():
281
+ try:
282
+ data = json.loads(result_file.read_text(encoding="utf-8"))
283
+ if isinstance(data, dict) and "passed" in data:
284
+ return bool(data["passed"])
285
+ except Exception:
286
+ pass
287
+
288
+ found = None
289
+ for entry in trace:
290
+ if entry.get("category") != "bash":
291
+ continue
292
+ command = str(entry.get("args_summary", {}).get("command", ""))
293
+ if STATIC_CHECK_RE.search(command):
294
+ if entry.get("exit_code", 0) != 0:
295
+ return False
296
+ found = True
297
+ return found
298
+
299
+
300
+ def _test_coverage(process_root: Path) -> float | None:
301
+ """Return total coverage percentage from the latest .coverage database."""
302
+ coverage_file = process_root / ".coverage"
303
+ if not coverage_file.exists():
304
+ return None
305
+ try:
306
+ output = subprocess.check_output(
307
+ [sys.executable, "-m", "coverage", "json", "-o", "-"],
308
+ cwd=process_root,
309
+ text=True,
310
+ stderr=subprocess.DEVNULL,
311
+ timeout=60,
312
+ )
313
+ data = json.loads(output)
314
+ return round(float(data["totals"]["percent_covered"]), 2)
315
+ except Exception as exc:
316
+ logging.warning("Could not read coverage data from %s: %s", coverage_file, exc)
317
+ return None
318
+
319
+
320
+ def _git_diff_stat(process_root: Path, base: str = "main") -> int:
321
+ try:
322
+ output = subprocess.check_output(
323
+ ["git", "diff", f"{base}...HEAD", "--stat"],
324
+ cwd=process_root,
325
+ text=True,
326
+ stderr=subprocess.DEVNULL,
327
+ )
328
+ except Exception:
329
+ return 0
330
+ match = re.search(
331
+ r"(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?",
332
+ output,
333
+ )
334
+ if not match:
335
+ return 0
336
+ return sum(int(g or 0) for g in match.groups()[1:])
337
+
338
+
339
+ def _changed_files_count(process_root: Path, base: str = "main") -> int:
340
+ try:
341
+ output = subprocess.check_output(
342
+ ["git", "diff", "--name-only", f"{base}...HEAD"],
343
+ cwd=process_root,
344
+ text=True,
345
+ stderr=subprocess.DEVNULL,
346
+ )
347
+ except Exception:
348
+ return 0
349
+ files = [line.strip() for line in output.splitlines() if line.strip()]
350
+ return len(files)
351
+
352
+
353
+ def _run_audit(process_root: Path) -> dict[str, int] | None:
354
+ audit_script = process_root / "scripts" / "lincoln-audit.py"
355
+ if not audit_script.exists():
356
+ return None
357
+ try:
358
+ output = subprocess.check_output(
359
+ [sys.executable, str(audit_script), "--format", "json"],
360
+ cwd=process_root,
361
+ text=True,
362
+ stderr=subprocess.DEVNULL,
363
+ timeout=60,
364
+ )
365
+ except Exception:
366
+ return None
367
+ try:
368
+ data = json.loads(output)
369
+ except json.JSONDecodeError:
370
+ return None
371
+ results = data.get("results") if isinstance(data, dict) else None
372
+ if results is None:
373
+ results = data.get("checks") if isinstance(data, dict) else []
374
+ if not isinstance(results, list):
375
+ results = []
376
+ counts = Counter()
377
+ for item in results:
378
+ status = str(item.get("status", "")).upper()
379
+ if status in ("PASS", "WARN", "FAIL"):
380
+ counts[status] += 1
381
+ return dict(counts) if counts else None
382
+
383
+
384
+ def _pr_event_timestamps(state: dict[str, Any]) -> dict[str, str]:
385
+ timestamps: dict[str, str] = {}
386
+ for node in state.get("nodes", []):
387
+ status = node.get("status")
388
+ ts = node.get("started_at") or node.get("completed_at") or node.get("timestamp")
389
+ if status in ("pr_submitted", "merged") and ts:
390
+ timestamps[status] = ts
391
+ return timestamps
392
+
393
+
394
+ def _session_duration(trace: list[dict[str, Any]]) -> tuple[int, str]:
395
+ if len(trace) < 2:
396
+ return 0, "estimated"
397
+ try:
398
+ start = _parse_iso(trace[0]["timestamp"])
399
+ end = _parse_iso(trace[-1]["timestamp"])
400
+ return int((end - start).total_seconds()), "exact"
401
+ except Exception:
402
+ return 0, "estimated"
403
+
404
+
405
+ def _unique_files_touched(trace: list[dict[str, Any]]) -> int:
406
+ unique_files = set()
407
+ for e in trace:
408
+ if e.get("category") in ("read", "write", "edit"):
409
+ target = e.get("target", "")
410
+ if target:
411
+ unique_files.add(target)
412
+ return len(unique_files)
413
+
414
+
415
+ def _retry_count(trace: list[dict[str, Any]]) -> int:
416
+ retries = 0
417
+ prev: dict[str, Any] | None = None
418
+ for e in trace:
419
+ if (
420
+ prev
421
+ and prev.get("exit_code", 0) != 0
422
+ and prev.get("tool") == e.get("tool")
423
+ and prev.get("target") == e.get("target")
424
+ ):
425
+ retries += 1
426
+ prev = e
427
+ return retries
428
+
429
+
430
+ def _test_runs(trace: list[dict[str, Any]]) -> int:
431
+ return sum(
432
+ 1
433
+ for e in trace
434
+ if e.get("category") == "bash"
435
+ and TEST_COMMAND_RE.search(
436
+ str(e.get("args_summary", {}).get("command", ""))
437
+ or str(e.get("target", ""))
438
+ )
439
+ )
440
+
441
+
442
+ def _compute_session_metrics(trace: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, str]]:
443
+ duration, duration_confidence = _session_duration(trace)
444
+ confidence = {"session_duration_seconds": duration_confidence}
445
+
446
+ session = {
447
+ "session_duration_seconds": duration,
448
+ "trace_line_count": len(trace),
449
+ "total_tool_calls": len(trace),
450
+ "tool_calls_by_category": dict(Counter(e.get("category", "other") for e in trace)),
451
+ "unique_skills": sorted(
452
+ {
453
+ e.get("target", "")
454
+ for e in trace
455
+ if e.get("category") == "skill" and e.get("target")
456
+ }
457
+ ),
458
+ "unique_files_touched": _unique_files_touched(trace),
459
+ "test_runs": _test_runs(trace),
460
+ "error_count": sum(1 for e in trace if e.get("exit_code", 0) != 0),
461
+ "retry_count": _retry_count(trace),
462
+ }
463
+ for key in session:
464
+ if key not in confidence:
465
+ confidence[key] = "exact"
466
+ return session, confidence
467
+
468
+
469
+ def _stage_completion_stats(state: dict[str, Any]) -> tuple[int, int, float]:
470
+ nodes = state.get("nodes", [])
471
+ total = len(nodes)
472
+ completed = sum(1 for n in nodes if n.get("status") == "completed")
473
+ failed = sum(1 for n in nodes if n.get("status") == "validation_failed")
474
+ rate = round(completed / total, 2) if total else 0.0
475
+ return failed, completed, rate
476
+
477
+
478
+ def _stage_rework_count(stage_order: list[str]) -> int:
479
+ seen: set[str] = set()
480
+ count = 0
481
+ for stage in stage_order:
482
+ if stage in seen:
483
+ count += 1
484
+ seen.add(stage)
485
+ return count
486
+
487
+
488
+ def _workflow_adherence_score(
489
+ stage_order: list[str], workflow_steps: list[dict[str, Any]]
490
+ ) -> float:
491
+ template_ids = [s.get("id") for s in workflow_steps if s.get("id")]
492
+ if not template_ids:
493
+ return 1.0
494
+ lcs = _lcs_length(stage_order, template_ids)
495
+ return round(lcs / len(template_ids), 2)
496
+
497
+
498
+ def _human_gate_stats(
499
+ state: dict[str, Any],
500
+ human_gate_stages: set[str],
501
+ stage_durations: dict[str, int],
502
+ ) -> tuple[int, float, int]:
503
+ passed = 0
504
+ for stage_id in human_gate_stages:
505
+ node = None
506
+ if _is_legacy_state(state):
507
+ node = state.get("stages", {}).get(stage_id, {})
508
+ else:
509
+ from scripts.stage_loader import get_latest_node_for_stage
510
+ node = get_latest_node_for_stage(state, stage_id)
511
+ if node and (
512
+ node.get("status") == "completed"
513
+ or node.get("gate_passed")
514
+ or node.get("human_gate_passed")
515
+ ):
516
+ passed += 1
517
+ count = len(human_gate_stages)
518
+ rate = round(passed / count, 2) if count else 1.0
519
+ wait = sum(stage_durations.get(s, 0) for s in human_gate_stages)
520
+ return count, rate, wait
521
+
522
+
523
+ def _combined_stage_durations(
524
+ trace: list[dict[str, Any]], state: dict[str, Any]
525
+ ) -> dict[str, int]:
526
+ durations_trace = _stage_durations(trace)
527
+ durations_node = _node_durations(state)
528
+ return {
529
+ k: durations_trace.get(k, durations_node.get(k, 0))
530
+ for k in set(durations_trace) | set(durations_node)
531
+ }
532
+
533
+
534
+ def _human_gate_stages(
535
+ stage_order: list[str], workflow_steps: list[dict[str, Any]]
536
+ ) -> set[str]:
537
+ return {
538
+ s.get("id")
539
+ for s in workflow_steps
540
+ if s.get("human_gate") and s.get("id") in stage_order
541
+ }
542
+
543
+
544
+ def _compute_workflow_metrics(
545
+ trace: list[dict[str, Any]],
546
+ state: dict[str, Any],
547
+ workflow_steps: list[dict[str, Any]],
548
+ variables: dict[str, Any],
549
+ process_root: Path,
550
+ ) -> tuple[dict[str, Any], dict[str, str]]:
551
+ stage_order = _stage_order_from_trace(trace)
552
+ stage_durations = _combined_stage_durations(trace, state)
553
+
554
+ failed_nodes, completed_nodes, stage_completion_rate = _stage_completion_stats(state)
555
+ stage_rework_count = _stage_rework_count(stage_order)
556
+
557
+ current_stage = state.get("current_run", {}).get("current_stage")
558
+ artifact_completion_rate = _artifact_completion_rate(
559
+ current_stage, workflow_steps, variables, process_root
560
+ )
561
+ workflow_adherence_score = _workflow_adherence_score(stage_order, workflow_steps)
562
+
563
+ human_gate_stages = _human_gate_stages(stage_order, workflow_steps)
564
+ human_gate_count, human_gate_pass_rate, human_gate_wait_seconds = _human_gate_stats(
565
+ state, human_gate_stages, stage_durations
566
+ )
567
+
568
+ workflow = {
569
+ "stage_transition_count": max(0, len(stage_order) - 1),
570
+ "stage_durations": stage_durations,
571
+ "stage_completion_rate": stage_completion_rate,
572
+ "stage_rework_count": stage_rework_count,
573
+ "artifact_completion_rate": artifact_completion_rate,
574
+ "validation_failure_count": failed_nodes,
575
+ "workflow_adherence_score": workflow_adherence_score,
576
+ "human_gate_count": human_gate_count,
577
+ "human_gate_wait_seconds": human_gate_wait_seconds,
578
+ "human_gate_pass_rate": human_gate_pass_rate,
579
+ "build_codebase_knowledge_duration_seconds": stage_durations.get("build-codebase-knowledge", 0),
580
+ }
581
+ confidence = {
582
+ "stage_durations": "estimated",
583
+ "human_gate_wait_seconds": "estimated",
584
+ "artifact_completion_rate": "exact",
585
+ "workflow_adherence_score": "exact",
586
+ "build_codebase_knowledge_duration_seconds": "estimated",
587
+ }
588
+ return workflow, confidence
589
+
590
+
591
+ def _session_start_metrics(process_root: Path, process_slug: str) -> dict[str, Any]:
592
+ """Load the session-start token/byte metrics written by the hook (#63)."""
593
+ path = process_root / process_slug / ".trace" / "session-start-metrics.json"
594
+ if not path.exists():
595
+ return {}
596
+ try:
597
+ data = json.loads(path.read_text(encoding="utf-8"))
598
+ except Exception:
599
+ return {}
600
+ if not isinstance(data, dict):
601
+ return {}
602
+ return {
603
+ "session_start_bytes": data.get("bytes"),
604
+ "session_start_chars": data.get("chars"),
605
+ "session_start_tokens": data.get("tokens"),
606
+ }
607
+
608
+
609
+ def _is_handoff_command(entry: dict[str, Any]) -> bool:
610
+ # Handoffs are recorded as structured LincolnHandoff trace entries by
611
+ # stage_loader, so we detect them by category rather than regex-parsing Bash text.
612
+ return entry.get("category") == "handoff"
613
+
614
+
615
+ def _handoff_count(trace: list[dict[str, Any]]) -> int:
616
+ return sum(1 for e in trace if _is_handoff_command(e))
617
+
618
+
619
+ def _time_to_first_handoff(trace: list[dict[str, Any]]) -> int:
620
+ count = _handoff_count(trace)
621
+ if not trace or not count:
622
+ return 0
623
+ first_ts = next((e.get("timestamp") for e in trace if _is_handoff_command(e)), None)
624
+ if not first_ts:
625
+ return 0
626
+ try:
627
+ return int((_parse_iso(first_ts) - _parse_iso(trace[0]["timestamp"])).total_seconds())
628
+ except Exception:
629
+ return 0
630
+
631
+
632
+ def _agent_switches(stage_order: list[str], primary_agents: dict[str, str]) -> int:
633
+ switches = 0
634
+ prev_stage: str | None = None
635
+ for stage in stage_order:
636
+ if prev_stage and primary_agents.get(prev_stage) and primary_agents.get(stage):
637
+ if primary_agents[prev_stage] != primary_agents[stage]:
638
+ switches += 1
639
+ prev_stage = stage
640
+ return switches
641
+
642
+
643
+ def _compute_collaboration_metrics(
644
+ trace: list[dict[str, Any]],
645
+ stage_order: list[str],
646
+ primary_agents: dict[str, str],
647
+ human_gate_count: int,
648
+ ) -> tuple[dict[str, Any], dict[str, str]]:
649
+ handoff_count = _handoff_count(trace)
650
+ collaboration = {
651
+ "handoff_count": handoff_count,
652
+ "agent_switches": _agent_switches(stage_order, primary_agents),
653
+ "pm_turns": human_gate_count,
654
+ "time_to_first_handoff": _time_to_first_handoff(trace),
655
+ }
656
+ confidence: dict[str, str] = {
657
+ "agent_switches": "exact" if primary_agents else "pending",
658
+ "pm_turns": "estimated",
659
+ "time_to_first_handoff": "exact" if handoff_count else "estimated",
660
+ }
661
+ return collaboration, confidence
662
+
663
+
664
+ def _compute_quality_metrics(
665
+ trace: list[dict[str, Any]],
666
+ state: dict[str, Any],
667
+ workflow_steps: list[dict[str, Any]],
668
+ variables: dict[str, Any],
669
+ process_root: Path,
670
+ ) -> tuple[dict[str, Any], dict[str, str]]:
671
+ confidence: dict[str, str] = {}
672
+ process_slug = get_process_slug(state, None)
673
+
674
+ requirements_score = _requirements_clarity_score(process_root, process_slug, variables)
675
+ design_completeness = _design_doc_completeness(
676
+ workflow_steps, variables, process_root, process_slug
677
+ )
678
+ tdd_phases = _tdd_plan_red_green_refactor(process_root, process_slug, variables)
679
+ openspec_count = _openspec_task_count(process_root, process_slug, variables)
680
+ static_pass = _static_check_pass(trace, process_root)
681
+ coverage = _test_coverage(process_root)
682
+ pr_size = _git_diff_stat(process_root)
683
+ audit_counts = _run_audit(process_root)
684
+ code_review_calls = sum(
685
+ 1
686
+ for e in trace
687
+ if e.get("category") == "skill" and "code-review" in str(e.get("target", ""))
688
+ )
689
+
690
+ quality = {
691
+ "requirements_clarity_score": requirements_score,
692
+ "design_doc_completeness": design_completeness,
693
+ "tdd_plan_red_green_refactor": tdd_phases,
694
+ "openspec_task_count": openspec_count,
695
+ "static_check_pass": static_pass,
696
+ "test_coverage": coverage,
697
+ "pr_size": pr_size,
698
+ "audit_score": audit_counts,
699
+ "code_review_calls": code_review_calls,
700
+ }
701
+ confidence["test_coverage"] = "exact" if coverage is not None else "pending"
702
+ confidence["audit_score"] = "exact" if audit_counts is not None else "pending"
703
+ confidence["pr_size"] = "exact" if pr_size > 0 else "estimated"
704
+ return quality, confidence
705
+
706
+
707
+ def _pr_merge_info(state: dict[str, Any]) -> tuple[bool, int, str | None]:
708
+ pr_timestamps = _pr_event_timestamps(state)
709
+ pr_submitted_ts = pr_timestamps.get("pr_submitted")
710
+ merged_ts = pr_timestamps.get("merged")
711
+ pr_merged = bool(merged_ts) or any(
712
+ n.get("status") == "merged" for n in state.get("nodes", [])
713
+ )
714
+ latency = 0
715
+ if pr_submitted_ts and merged_ts:
716
+ try:
717
+ latency = int((_parse_iso(merged_ts) - _parse_iso(pr_submitted_ts)).total_seconds())
718
+ except Exception:
719
+ latency = 0
720
+ return pr_merged, latency, merged_ts
721
+
722
+
723
+ def _delta_seconds(start_ts: str | None, end_ts: str | None) -> int:
724
+ if not start_ts or not end_ts:
725
+ return 0
726
+ try:
727
+ return int((_parse_iso(end_ts) - _parse_iso(start_ts)).total_seconds())
728
+ except Exception:
729
+ return 0
730
+
731
+
732
+ def _compute_outcome_metrics(trace: list[dict[str, Any]], state: dict[str, Any]) -> dict[str, Any]:
733
+ pr_merged, pr_merge_latency_seconds, merged_ts = _pr_merge_info(state)
734
+
735
+ first_clarify_ts = next(
736
+ (e.get("timestamp") for e in trace if e.get("stage") == "clarify"),
737
+ trace[0].get("timestamp") if trace else None,
738
+ )
739
+ first_pr_ts = next(
740
+ (e.get("timestamp") for e in trace if e.get("tool") == "mcp__plugin_ecc_github__create_pull_request"),
741
+ None,
742
+ )
743
+
744
+ return {
745
+ "issue_closed": None,
746
+ "pr_merged": pr_merged,
747
+ "pr_merge_latency_seconds": pr_merge_latency_seconds,
748
+ "knowledge_synced": None,
749
+ "time_to_pr_seconds": _delta_seconds(first_clarify_ts, first_pr_ts),
750
+ "time_to_merge_seconds": _delta_seconds(first_clarify_ts, merged_ts),
751
+ }
752
+
753
+
754
+ def _outcome_confidence(outcome: dict[str, Any], merged_ts: str | None) -> dict[str, str]:
755
+ return {
756
+ "issue_closed": "pending",
757
+ "knowledge_synced": "pending",
758
+ "pr_merge_latency_seconds": "exact" if merged_ts else "pending",
759
+ }
760
+
761
+
762
+ def _with_s2_ratio(
763
+ outcome: dict[str, Any], session: dict[str, Any], process_root: Path
764
+ ) -> dict[str, Any]:
765
+ changed_files = _changed_files_count(process_root)
766
+ unique_files_touched = session["unique_files_touched"]
767
+ ratio = round(unique_files_touched / max(1, changed_files), 2)
768
+ return {**outcome, "unique_files_touched_ratio": ratio}
769
+
770
+
771
+ def compute_metrics(
772
+ trace: list[dict[str, Any]],
773
+ state: dict[str, Any],
774
+ scenario: str,
775
+ process_root: Path,
776
+ ) -> tuple[dict[str, Any], dict[str, str]]:
777
+ """Compute L1-L5 benchmark metrics from trace and state."""
778
+ trace = sorted(trace, key=lambda e: e.get("timestamp", ""))
779
+ variables = state.get("current_run", {}).get("variables", {})
780
+ workflow_steps = _workflow_steps(state)
781
+ primary_agents = _load_primary_agents(process_root)
782
+
783
+ session, conf_session = _compute_session_metrics(trace)
784
+ process_slug = get_process_slug(state, None)
785
+ session_start = _session_start_metrics(process_root, process_slug)
786
+ if session_start:
787
+ session.update(session_start)
788
+ for key in session_start:
789
+ conf_session[key] = "exact"
790
+ workflow, conf_workflow = _compute_workflow_metrics(
791
+ trace, state, workflow_steps, variables, process_root
792
+ )
793
+ collaboration, conf_collab = _compute_collaboration_metrics(
794
+ trace, _stage_order_from_trace(trace), primary_agents, workflow["human_gate_count"]
795
+ )
796
+ quality, conf_quality = _compute_quality_metrics(
797
+ trace, state, workflow_steps, variables, process_root
798
+ )
799
+ outcome = _compute_outcome_metrics(trace, state)
800
+ merged_ts = _pr_event_timestamps(state).get("merged")
801
+
802
+ confidence: dict[str, str] = {}
803
+ confidence.update(conf_session)
804
+ confidence.update(conf_workflow)
805
+ confidence.update(conf_collab)
806
+ confidence.update(conf_quality)
807
+ confidence.update(_outcome_confidence(outcome, merged_ts))
808
+ outcome = _with_s2_ratio(outcome, session, process_root)
809
+ confidence["unique_files_touched_ratio"] = "estimated"
810
+
811
+ metrics = {
812
+ "session": session,
813
+ "workflow": workflow,
814
+ "collaboration": collaboration,
815
+ "quality": quality,
816
+ "outcome": outcome,
817
+ }
818
+ return metrics, confidence