@aryacode/dsh-engineering 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/cordis.patch.yml +122 -0
  5. package/locale/en.json +6 -0
  6. package/locale/zh.json +6 -0
  7. package/package.json +77 -3
  8. package/vendor/ftview/README.md +55 -0
  9. package/vendor/ftview/SKILL.md +74 -0
  10. package/vendor/ftview/lib/checks.d.ts +27 -0
  11. package/vendor/ftview/lib/checks.js +39 -0
  12. package/vendor/ftview/lib/core.d.ts +208 -0
  13. package/vendor/ftview/lib/core.js +160 -0
  14. package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
  15. package/vendor/ftview/lib/edit/drafts.js +84 -0
  16. package/vendor/ftview/lib/edit/operations.d.ts +62 -0
  17. package/vendor/ftview/lib/edit/operations.js +126 -0
  18. package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
  19. package/vendor/ftview/lib/edit/prepare.js +133 -0
  20. package/vendor/ftview/lib/edit/tools.d.ts +25 -0
  21. package/vendor/ftview/lib/edit/tools.js +198 -0
  22. package/vendor/ftview/lib/edit.d.ts +30 -0
  23. package/vendor/ftview/lib/edit.js +38 -0
  24. package/vendor/ftview/lib/index.d.ts +31 -0
  25. package/vendor/ftview/lib/index.js +36 -0
  26. package/vendor/ftview/lib/model.d.ts +80 -0
  27. package/vendor/ftview/lib/model.js +267 -0
  28. package/vendor/ftview/lib/session.d.ts +48 -0
  29. package/vendor/ftview/lib/session.js +98 -0
  30. package/vendor/ftview/lib/skill.d.ts +29 -0
  31. package/vendor/ftview/lib/skill.js +43 -0
  32. package/vendor/ftview/lib/studio/discover.d.ts +36 -0
  33. package/vendor/ftview/lib/studio/discover.js +87 -0
  34. package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
  35. package/vendor/ftview/lib/studio/preflight.js +64 -0
  36. package/vendor/ftview/lib/studio/tools.d.ts +15 -0
  37. package/vendor/ftview/lib/studio/tools.js +83 -0
  38. package/vendor/ftview/lib/studio.d.ts +30 -0
  39. package/vendor/ftview/lib/studio.js +37 -0
  40. package/vendor/ftview/lib/tools.d.ts +14 -0
  41. package/vendor/ftview/lib/tools.js +202 -0
  42. package/vendor/l5x/README.md +62 -0
  43. package/vendor/l5x/SKILL.md +81 -0
  44. package/vendor/l5x/lib/approvals.d.ts +50 -0
  45. package/vendor/l5x/lib/approvals.js +93 -0
  46. package/vendor/l5x/lib/client.js +412 -0
  47. package/vendor/l5x/lib/client.js.map +1 -0
  48. package/vendor/l5x/lib/core/errors.d.ts +38 -0
  49. package/vendor/l5x/lib/core/errors.js +48 -0
  50. package/vendor/l5x/lib/core/params.d.ts +49 -0
  51. package/vendor/l5x/lib/core/params.js +39 -0
  52. package/vendor/l5x/lib/core/result.d.ts +46 -0
  53. package/vendor/l5x/lib/core/result.js +21 -0
  54. package/vendor/l5x/lib/core/tool.d.ts +102 -0
  55. package/vendor/l5x/lib/core/tool.js +19 -0
  56. package/vendor/l5x/lib/drafts.d.ts +66 -0
  57. package/vendor/l5x/lib/drafts.js +98 -0
  58. package/vendor/l5x/lib/editing.d.ts +30 -0
  59. package/vendor/l5x/lib/editing.js +42 -0
  60. package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
  61. package/vendor/l5x/lib/engine/acd-edit.js +115 -0
  62. package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
  63. package/vendor/l5x/lib/engine/analysis.js +258 -0
  64. package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
  65. package/vendor/l5x/lib/engine/capabilities.js +40 -0
  66. package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
  67. package/vendor/l5x/lib/engine/change-set.js +446 -0
  68. package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
  69. package/vendor/l5x/lib/engine/configuration.js +42 -0
  70. package/vendor/l5x/lib/engine/details.d.ts +47 -0
  71. package/vendor/l5x/lib/engine/details.js +173 -0
  72. package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
  73. package/vendor/l5x/lib/engine/direct-polling.js +193 -0
  74. package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
  75. package/vendor/l5x/lib/engine/download-review.js +63 -0
  76. package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
  77. package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
  78. package/vendor/l5x/lib/engine/observations.d.ts +41 -0
  79. package/vendor/l5x/lib/engine/observations.js +67 -0
  80. package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
  81. package/vendor/l5x/lib/engine/project-analysis.js +314 -0
  82. package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
  83. package/vendor/l5x/lib/engine/review-evidence.js +49 -0
  84. package/vendor/l5x/lib/engine/review.d.ts +18 -0
  85. package/vendor/l5x/lib/engine/review.js +123 -0
  86. package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
  87. package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
  88. package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
  89. package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
  90. package/vendor/l5x/lib/engine/usage.d.ts +16 -0
  91. package/vendor/l5x/lib/engine/usage.js +92 -0
  92. package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
  93. package/vendor/l5x/lib/engine/xml-regions.js +77 -0
  94. package/vendor/l5x/lib/fs.d.ts +17 -0
  95. package/vendor/l5x/lib/fs.js +36 -0
  96. package/vendor/l5x/lib/index.d.ts +36 -0
  97. package/vendor/l5x/lib/index.js +42 -0
  98. package/vendor/l5x/lib/live.d.ts +48 -0
  99. package/vendor/l5x/lib/live.js +48 -0
  100. package/vendor/l5x/lib/model/aois.d.ts +39 -0
  101. package/vendor/l5x/lib/model/aois.js +112 -0
  102. package/vendor/l5x/lib/model/errors.d.ts +15 -0
  103. package/vendor/l5x/lib/model/errors.js +18 -0
  104. package/vendor/l5x/lib/model/index.d.ts +20 -0
  105. package/vendor/l5x/lib/model/index.js +19 -0
  106. package/vendor/l5x/lib/model/module.d.ts +37 -0
  107. package/vendor/l5x/lib/model/module.js +54 -0
  108. package/vendor/l5x/lib/model/modules.d.ts +24 -0
  109. package/vendor/l5x/lib/model/modules.js +55 -0
  110. package/vendor/l5x/lib/model/parse.d.ts +32 -0
  111. package/vendor/l5x/lib/model/parse.js +116 -0
  112. package/vendor/l5x/lib/model/project.d.ts +15 -0
  113. package/vendor/l5x/lib/model/project.js +31 -0
  114. package/vendor/l5x/lib/model/routines.d.ts +71 -0
  115. package/vendor/l5x/lib/model/routines.js +183 -0
  116. package/vendor/l5x/lib/model/tags.d.ts +51 -0
  117. package/vendor/l5x/lib/model/tags.js +119 -0
  118. package/vendor/l5x/lib/model/types.d.ts +304 -0
  119. package/vendor/l5x/lib/model/types.js +12 -0
  120. package/vendor/l5x/lib/model/udts.d.ts +34 -0
  121. package/vendor/l5x/lib/model/udts.js +81 -0
  122. package/vendor/l5x/lib/model/xml.d.ts +139 -0
  123. package/vendor/l5x/lib/model/xml.js +291 -0
  124. package/vendor/l5x/lib/model/xref.d.ts +28 -0
  125. package/vendor/l5x/lib/model/xref.js +154 -0
  126. package/vendor/l5x/lib/session.d.ts +119 -0
  127. package/vendor/l5x/lib/session.js +236 -0
  128. package/vendor/l5x/lib/skill.d.ts +30 -0
  129. package/vendor/l5x/lib/skill.js +44 -0
  130. package/vendor/l5x/lib/studio-state.d.ts +77 -0
  131. package/vendor/l5x/lib/studio-state.js +79 -0
  132. package/vendor/l5x/lib/studio.d.ts +29 -0
  133. package/vendor/l5x/lib/studio.js +40 -0
  134. package/vendor/l5x/lib/tools/checks.d.ts +19 -0
  135. package/vendor/l5x/lib/tools/checks.js +140 -0
  136. package/vendor/l5x/lib/tools/common.d.ts +41 -0
  137. package/vendor/l5x/lib/tools/common.js +84 -0
  138. package/vendor/l5x/lib/tools/controller.d.ts +21 -0
  139. package/vendor/l5x/lib/tools/controller.js +170 -0
  140. package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
  141. package/vendor/l5x/lib/tools/drafts.js +270 -0
  142. package/vendor/l5x/lib/tools/live.d.ts +21 -0
  143. package/vendor/l5x/lib/tools/live.js +175 -0
  144. package/vendor/l5x/lib/tools/read.d.ts +19 -0
  145. package/vendor/l5x/lib/tools/read.js +245 -0
  146. package/vendor/l5x/lib/tools/references.d.ts +20 -0
  147. package/vendor/l5x/lib/tools/references.js +269 -0
  148. package/vendor/l5x/lib/tools/studio.d.ts +21 -0
  149. package/vendor/l5x/lib/tools/studio.js +193 -0
  150. package/vendor/l5x/poll-worker.mjs +77 -0
  151. package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
  152. package/vendor/l5x/studio-bridge/Program.cs +304 -0
  153. package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
  154. package/vendor/mwi/README.md +60 -0
  155. package/vendor/mwi/SKILL.md +87 -0
  156. package/vendor/mwi/bridge/README.md +8 -0
  157. package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
  158. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
  159. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
  160. package/vendor/mwi/bridge/start_bridge.cmd +21 -0
  161. package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
  162. package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
  163. package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
  164. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  165. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  166. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  167. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  168. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  169. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  170. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  171. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  172. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  173. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  174. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  175. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  176. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  177. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  178. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  179. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  180. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  181. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  182. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  183. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  184. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  185. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
  186. package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
  187. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
  188. package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
  189. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
  190. package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
  191. package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
  192. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
  193. package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
  194. package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
  195. package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
  196. package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
  197. package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
  198. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
  199. package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
  200. package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
  201. package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
  202. package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
  203. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
  204. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
  205. package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
  206. package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
  207. package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
  208. package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
  209. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
  210. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
  211. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
  212. package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
  213. package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
  214. package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
  215. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
  216. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
  217. package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
  218. package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
  219. package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
  220. package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
  221. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
  222. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
  223. package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
  224. package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
  225. package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
  226. package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
  227. package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
  228. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
  229. package/vendor/mwi/code/mw_code.py +1258 -0
  230. package/vendor/mwi/legacy/edit-session.js +50 -0
  231. package/vendor/mwi/legacy/fb-insertion.js +74 -0
  232. package/vendor/mwi/legacy/native-code.js +83 -0
  233. package/vendor/mwi/legacy/native-groups.js +75 -0
  234. package/vendor/mwi/legacy/native-structure.js +143 -0
  235. package/vendor/mwi/legacy/native-variables.js +77 -0
  236. package/vendor/mwi/legacy/pou-conversion.js +47 -0
  237. package/vendor/mwi/legacy/pou-package.js +66 -0
  238. package/vendor/mwi/lib/core/bridge.d.ts +71 -0
  239. package/vendor/mwi/lib/core/bridge.js +53 -0
  240. package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
  241. package/vendor/mwi/lib/core/code-engine.js +109 -0
  242. package/vendor/mwi/lib/core/errors.d.ts +37 -0
  243. package/vendor/mwi/lib/core/errors.js +47 -0
  244. package/vendor/mwi/lib/core/params.d.ts +52 -0
  245. package/vendor/mwi/lib/core/params.js +46 -0
  246. package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
  247. package/vendor/mwi/lib/core/ps-bridge.js +127 -0
  248. package/vendor/mwi/lib/core/result.d.ts +45 -0
  249. package/vendor/mwi/lib/core/result.js +20 -0
  250. package/vendor/mwi/lib/core/tool.d.ts +80 -0
  251. package/vendor/mwi/lib/core/tool.js +55 -0
  252. package/vendor/mwi/lib/edit/compare.d.ts +33 -0
  253. package/vendor/mwi/lib/edit/compare.js +70 -0
  254. package/vendor/mwi/lib/edit.d.ts +20 -0
  255. package/vendor/mwi/lib/edit.js +32 -0
  256. package/vendor/mwi/lib/index.d.ts +21 -0
  257. package/vendor/mwi/lib/index.js +33 -0
  258. package/vendor/mwi/lib/read/service.d.ts +117 -0
  259. package/vendor/mwi/lib/read/service.js +229 -0
  260. package/vendor/mwi/lib/read.d.ts +21 -0
  261. package/vendor/mwi/lib/read.js +33 -0
  262. package/vendor/mwi/lib/session/project.d.ts +34 -0
  263. package/vendor/mwi/lib/session/project.js +54 -0
  264. package/vendor/mwi/lib/session/service.d.ts +167 -0
  265. package/vendor/mwi/lib/session/service.js +566 -0
  266. package/vendor/mwi/lib/session/stage.d.ts +84 -0
  267. package/vendor/mwi/lib/session/stage.js +187 -0
  268. package/vendor/mwi/lib/session/workspace.d.ts +52 -0
  269. package/vendor/mwi/lib/session/workspace.js +90 -0
  270. package/vendor/mwi/lib/skill.d.ts +36 -0
  271. package/vendor/mwi/lib/skill.js +56 -0
  272. package/vendor/mwi/lib/tools/edit.d.ts +20 -0
  273. package/vendor/mwi/lib/tools/edit.js +286 -0
  274. package/vendor/mwi/lib/tools/read.d.ts +19 -0
  275. package/vendor/mwi/lib/tools/read.js +161 -0
  276. package/vendor/mwi/lib/tools/session.d.ts +20 -0
  277. package/vendor/mwi/lib/tools/session.js +166 -0
  278. package/vendor/mwi/lib/tools/verify.d.ts +19 -0
  279. package/vendor/mwi/lib/tools/verify.js +85 -0
  280. package/vendor/mwi/lib/verify.d.ts +17 -0
  281. package/vendor/mwi/lib/verify.js +29 -0
@@ -0,0 +1,1258 @@
1
+ """MotionWorks Use - file-level CODE engine.
2
+
3
+ WHY THIS EXISTS
4
+ ---------------
5
+ The public plugin edits code through native ExecuteDdeCommand/ChangeCodeWS in
6
+ the IDE bridge. This engine independently reads saved source, declarations,
7
+ library interfaces and verification evidence. Legacy private format writers
8
+ remain only for regression coverage; they are retired public operations and
9
+ are not an alternative to native IDE editing.
10
+
11
+ PROTOCOL
12
+ --------
13
+ python mw_code.py <verb> <request.json> <response.json>
14
+
15
+ Files rather than stdio: a confined harness cannot open named pipes, so a piped
16
+ child fails with EPERM. The response is written to a temp file and renamed into
17
+ place, and carries no BOM so the Node half can JSON.parse it.
18
+
19
+ SAFETY
20
+ ------
21
+ * Session/stage provenance is checked before project access.
22
+ * Retired offline writes are not callable public editing tools.
23
+ * This module never downloads to a controller and never commands motion.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import sys
31
+ import traceback
32
+ from pathlib import Path
33
+
34
+ # The engine lives in the sibling package; the caller passes its root so this
35
+ # file carries no hard-coded absolute path.
36
+ # The engine is VENDORED beside this file (code/engine/motionworks_iec_mcp), so the
37
+ # plugin is self-contained and does not depend on any other checkout. MW_SRC still
38
+ # overrides it, for developing against a different engine tree.
39
+ _SRC = os.environ.get("MW_SRC")
40
+ if not _SRC:
41
+ _SRC = str(Path(__file__).resolve().parent / "engine")
42
+ if _SRC and _SRC not in sys.path:
43
+ sys.path.insert(0, _SRC)
44
+
45
+
46
+ def _ok(**kw):
47
+ return {"ok": True, **kw}
48
+
49
+
50
+ def _fail(message: str, **kw):
51
+ return {"ok": False, "error": message, **kw}
52
+
53
+
54
+ def _project(root: str):
55
+ from motionworks_iec_mcp import project as P
56
+
57
+ return P.Project(root=Path(root))
58
+
59
+
60
+ def verb_worksheet_target(req):
61
+ from motionworks_iec_mcp.navigation import worksheet_target
62
+ return _ok(**worksheet_target(_project(req['project']), req['kind'], req.get('pou')))
63
+
64
+
65
+ def verb_graphical_listing(req):
66
+ from motionworks_iec_mcp.graphical_listing import inspect
67
+ return _ok(**inspect(_project(req['project']), req['pou'], req.get('start', 1), req.get('limit', 10)))
68
+
69
+
70
+ def verb_compiled_source_evidence(req):
71
+ from motionworks_iec_mcp.graphical_listing import inspect
72
+ return _ok(**inspect(_project(req['project']), req['pou'], source_only=True))
73
+
74
+
75
+ def verb_structure_snapshot(req):
76
+ from motionworks_iec_mcp.structure import snapshot
77
+ return _ok(**snapshot(Path(req['project'])))
78
+
79
+ def verb_block_interface(req):
80
+ from motionworks_iec_mcp.block_interfaces import inspect
81
+ return _ok(**inspect(Path(req['project']),req['native_libraries'],req.get('name'),req.get('library')))
82
+
83
+ def verb_installed_help(req):
84
+ from motionworks_iec_mcp.installed_help import search
85
+ return _ok(**search(req.get('module'),req.get('query',''),req.get('topic'),req.get('limit',5)))
86
+
87
+ # ── read ─────────────────────────────────────────────────────────────────────
88
+
89
+ def verb_pous(req):
90
+ """List the POUs of a project directory, straight from its files."""
91
+ proj = _project(req["project"])
92
+ out = []
93
+ for info in proj.pous():
94
+ # NOTE: `name` is a dataclass FIELD on PouInfo, while language/st_body
95
+ # are methods. Measured with inspect, not assumed.
96
+ entry = {"name": info.name}
97
+ try:
98
+ entry["language"] = info.language()
99
+ except Exception as exc:
100
+ entry["language"] = None
101
+ entry["language_error"] = str(exc)
102
+ try:
103
+ stream = info.body_stream()
104
+ entry["body_stream"] = stream[0] if stream else None
105
+ entry["stream_language"] = stream[1] if stream else None
106
+ if stream:
107
+ import hashlib
108
+ body = info.st_body() if stream[1] == 'ST' else None
109
+ raw = body.encode('utf-8') if body is not None else info.source().read_stream(stream[0])
110
+ entry["body_sha256"] = hashlib.sha256(raw).hexdigest()
111
+ # Native bodies can contain comment references whose actual text
112
+ # is in Translation.xml. Keep the raw hash for conversion guards,
113
+ # and expose a distinct hash of the exact readable ST/IL body.
114
+ if stream[1] in ('ST', 'IL'):
115
+ try:
116
+ readable = info.text_body_text()
117
+ if readable is not None:
118
+ entry["text_body_sha256"] = hashlib.sha256(readable.encode('utf-8')).hexdigest()
119
+ except Exception as exc:
120
+ entry["text_body_error"] = str(exc)
121
+ except Exception as exc:
122
+ entry["body_error"] = str(exc)
123
+ try:
124
+ entry["has_st_body"] = info.st_body() is not None
125
+ except Exception as exc:
126
+ entry["has_st_body"] = None
127
+ out.append(entry)
128
+ return _ok(project=req["project"], count=len(out), pous=out)
129
+
130
+
131
+ def verb_read_st(req, text=False):
132
+ """Read one POU: language, ST body text, and its variable declarations."""
133
+ proj = _project(req["project"])
134
+ info = proj.pou(req["pou"])
135
+ body = None
136
+ body_error = None
137
+ try:
138
+ body = info.text_body_text() if text else info.st_body_text()
139
+ except Exception as exc:
140
+ body_error = str(exc)
141
+
142
+ variables = []
143
+ try:
144
+ # DeclarationTable exposes `.variables` (a flat list of Variable), plus
145
+ # `warnings` and `source_stream`. It has NO `.blocks` — iterating that
146
+ # silently produced an empty list, so callers could read a POU's body but
147
+ # never its declarations.
148
+ table = info.declarations()
149
+ for v in getattr(table, "variables", []) or []:
150
+ variables.append({
151
+ "name": getattr(v, "name", None),
152
+ "type": getattr(v, "type_name", None),
153
+ "section": getattr(v, "section", None),
154
+ "group": getattr(v, "group", None),
155
+ "address": getattr(v, "address", None),
156
+ "initial_value": getattr(v, "initial_value", None),
157
+ "description": getattr(v, "description", None),
158
+ })
159
+ except Exception as exc:
160
+ variables = [{"error": f"{type(exc).__name__}: {exc}"}]
161
+
162
+ return _ok(
163
+ pou=req["pou"],
164
+ language=info.language() if hasattr(info, "language") else None,
165
+ body=body,
166
+ body_error=body_error,
167
+ variables=variables,
168
+ summary=info.summary() if hasattr(info, "summary") else None,
169
+ )
170
+
171
+
172
+ def verb_unsupported(req):
173
+ """Name the POUs whose bodies cannot be edited safely."""
174
+ proj = _project(req["project"])
175
+ blocked = []
176
+ for info in proj.pous():
177
+ try:
178
+ if info.text_body() is None:
179
+ stream = None
180
+ try:
181
+ bs = info.body_stream()
182
+ stream = bs[0] if bs else None
183
+ except Exception:
184
+ pass
185
+ blocked.append({
186
+ "name": info.name,
187
+ "reason": "no supported ST/IL text body (graphical LD/FBD or compressed)",
188
+ "body_stream": stream,
189
+ })
190
+ except Exception as exc:
191
+ blocked.append({"name": info.name, "reason": str(exc)})
192
+ return _ok(blocked=blocked, count=len(blocked))
193
+
194
+
195
+ # ── write ────────────────────────────────────────────────────────────────────
196
+
197
+ def verb_write_st(req):
198
+ """Replace a POU's Structured Text body. dry_run defaults to True."""
199
+ from motionworks_iec_mcp import writer as W
200
+
201
+ root = Path(req["project"])
202
+ dry = bool(req.get("dry_run", True))
203
+ from motionworks_iec_mcp.program_checks import check_project
204
+ programming_review = check_project(root, pou=req["pou"], body=req["body"])
205
+ plan = W.plan_st_body(root, req["pou"], req["body"], run_lint=bool(req.get("run_lint", True)))
206
+ result = W.apply_st_body(plan, root, dry_run=dry)
207
+ return _ok(dry_run=dry, result=_jsonable(result), programming_review=programming_review)
208
+
209
+
210
+ def verb_var_add(req):
211
+ """Add a variable declaration. dry_run defaults to True."""
212
+ from motionworks_iec_mcp import writer as W
213
+
214
+ root = Path(req["project"])
215
+ dry = bool(req.get("dry_run", True))
216
+ plan = W.plan_variable_add(
217
+ root,
218
+ req.get("pou"),
219
+ req["name"],
220
+ req["type"],
221
+ section=req.get("section", "VAR"),
222
+ address=req.get("address"),
223
+ initial_value=req.get("initial_value"),
224
+ description=req.get("description"),
225
+ donor=req.get("donor"), donor_pou=req.get("donor_pou"),
226
+ )
227
+ return _ok(dry_run=dry, result=_jsonable(W.apply_declaration(plan, root, dry_run=dry)))
228
+
229
+
230
+ def verb_var_add_many(req):
231
+ """Declare many variables in one call, atomically by default.
232
+
233
+ WHY THIS EXISTS: a port with twenty signals took twenty tool calls, and a caller declaring a
234
+ twenty-field structure has to sequence them by hand. Every item here goes through exactly the
235
+ per-item machinery verb_var_add uses - the same planner, the same donor matching, the same
236
+ grid update - so this is a loop and not a second implementation.
237
+
238
+ A failed write item raises with its index and name. The surrounding project transaction then
239
+ restores every file, including earlier items in this batch. Explicit allow_partial=True keeps
240
+ the old best-effort behavior, but still executes within one recoverable transaction.
241
+
242
+ dry_run defaults to True, and is all-or-nothing in the other direction: a dry run plans every
243
+ item and applies none, so the report shows what WOULD happen without changing the project.
244
+ """
245
+ from motionworks_iec_mcp import writer as W
246
+
247
+ root = Path(req["project"])
248
+ dry = bool(req.get("dry_run", True))
249
+ allow_partial = req.get("allow_partial", False) is True
250
+ items = req.get("variables")
251
+ if not isinstance(items, list) or not items:
252
+ raise ValueError("variables must be a non-empty array of {name, type, ...} objects")
253
+
254
+ pou = req.get("pou")
255
+ applied: list[dict] = []
256
+ failed: list[dict] = []
257
+
258
+ for index, raw in enumerate(items):
259
+ if not isinstance(raw, dict):
260
+ if not dry and not allow_partial:
261
+ raise ValueError(f"variable batch item [{index}] is not an object")
262
+ failed.append({"index": index, "name": None,
263
+ "error": "not an object; expected {name, type, section, ...}"})
264
+ continue
265
+ # Per-item overrides fall back to the call-level values, so a caller declaring twenty
266
+ # globals writes `pou` once and a caller mixing sections sets it on the items that differ.
267
+ name = raw.get("name")
268
+ type_name = raw.get("type")
269
+ # CHECKED HERE, not left to the planner. Measured: an item with no `type` reached the donor
270
+ # matcher and came back as `AttributeError: 'NoneType' object has no attribute 'casefold'`,
271
+ # which names neither the item nor the field. A batch reports per item, so the message has
272
+ # to be about the item.
273
+ missing = [field for field, value in (("name", name), ("type", type_name)) if not value]
274
+ if missing:
275
+ if not dry and not allow_partial:
276
+ raise ValueError(
277
+ f"variable batch item [{index}] {name or '(unnamed)'} missing required "
278
+ f"field(s): {', '.join(missing)}"
279
+ )
280
+ failed.append({"index": index, "name": name,
281
+ "error": f"missing required field(s): {', '.join(missing)}"})
282
+ continue
283
+ try:
284
+ plan = W.plan_variable_add(
285
+ root,
286
+ raw.get("pou", pou),
287
+ name,
288
+ type_name,
289
+ section=raw.get("section", req.get("section", "VAR")),
290
+ address=raw.get("address"),
291
+ initial_value=raw.get("initial_value"),
292
+ description=raw.get("description"),
293
+ donor=raw.get("donor", req.get("donor")),
294
+ donor_pou=raw.get("donor_pou", req.get("donor_pou")),
295
+ )
296
+ if dry:
297
+ applied.append({"index": index, "name": name, "planned": True,
298
+ **_plan_summary(plan)})
299
+ else:
300
+ result = _jsonable(W.apply_declaration(plan, root, dry_run=False))
301
+ applied.append({"index": index, "name": name, "applied": True, "result": result})
302
+ except Exception as exc:
303
+ if not dry and not allow_partial:
304
+ raise ValueError(
305
+ f"variable batch item [{index}] {name or '(unnamed)'} failed: "
306
+ f"{type(exc).__name__}: {exc}"
307
+ ) from exc
308
+ failed.append({"index": index, "name": name,
309
+ "error": f"{type(exc).__name__}: {exc}"})
310
+
311
+ return _ok(
312
+ dry_run=dry,
313
+ pou=pou,
314
+ requested=len(items),
315
+ applied=len(applied),
316
+ failed=len(failed),
317
+ results=applied,
318
+ failures=failed,
319
+ note=("partial application was explicitly requested; read failures and re-issue only "
320
+ "those items" if allow_partial else "write batches are atomic; any failure "
321
+ "rolls back the whole batch"),
322
+ )
323
+
324
+
325
+ def verb_var_edit(req):
326
+ """Edit an existing variable declaration. dry_run defaults to True."""
327
+ from motionworks_iec_mcp import writer as W
328
+
329
+ root = Path(req["project"])
330
+ dry = bool(req.get("dry_run", True))
331
+ plan = W.plan_variable_edit(
332
+ root,
333
+ req.get("pou"),
334
+ req["name"],
335
+ new_name=req.get("new_name"),
336
+ type_name=req.get("type"),
337
+ address=req.get("address"),
338
+ initial_value=req.get("initial_value"),
339
+ description=req.get("description"),
340
+ clear_address=bool(req.get("clear_address", False)),
341
+ force=bool(req.get("force", False)),
342
+ donor=req.get("donor"),
343
+ )
344
+ return _ok(dry_run=dry, result=_jsonable(W.apply_declaration(plan, root, dry_run=dry)))
345
+
346
+
347
+ def verb_var_delete(req):
348
+ """Delete a variable declaration. dry_run defaults to True."""
349
+ from motionworks_iec_mcp import writer as W
350
+
351
+ root = Path(req["project"])
352
+ dry = bool(req.get("dry_run", True))
353
+ plan = W.plan_variable_delete(
354
+ root, req.get("pou"), req["name"], force=bool(req.get("force", False))
355
+ )
356
+ return _ok(dry_run=dry, result=_jsonable(W.apply_declaration(plan, root, dry_run=dry)))
357
+
358
+
359
+ def _plan_summary(plan):
360
+ """Flatten a planner's dataclass into JSON-friendly fields (no methods)."""
361
+ out = {}
362
+ for key, value in vars(plan).items():
363
+ if callable(value):
364
+ continue
365
+ out[key] = _jsonable(value)
366
+ return out
367
+
368
+
369
+ def _backup_dir(req, root: Path) -> Path:
370
+ """Backups go beside the plugin, NOT inside the staging root.
371
+
372
+ Writing into stage/ creates a directory that a naive "newest staged project"
373
+ search can then mistake for a project, so the staging root is kept to
374
+ projects only.
375
+ """
376
+ raw = req.get("backup_dir")
377
+ from motionworks_iec_mcp.staging import workspace_root
378
+ workspace = workspace_root()
379
+ directory = Path(raw) if raw else workspace / ".motionworks" / "backups"
380
+ if not directory.resolve().is_relative_to(workspace):
381
+ raise ValueError("REFUSED: backups must remain inside the workspace")
382
+ from uuid import uuid4
383
+ directory = directory / uuid4().hex
384
+ directory.mkdir(parents=True, exist_ok=False)
385
+ return directory
386
+
387
+
388
+ def verb_pou_create(req):
389
+ """Create a new POU by cloning a template POU already in the project.
390
+
391
+ ``template`` must be the name of an existing POU: creation clones that POU's
392
+ directory and renames its streams, so there is no way to author a POU from
393
+ nothing. dry_run defaults to True and returns the plan instead of applying it.
394
+ """
395
+ from motionworks_iec_mcp import pou_writer as PW
396
+
397
+ root = Path(req["project"])
398
+ dry = bool(req.get("dry_run", True))
399
+ plan = PW.plan_pou_creation(root, req["name"], req["template"])
400
+ if dry:
401
+ return _ok(dry_run=True, plan=_plan_summary(plan))
402
+ result = PW.apply_pou_creation(
403
+ plan, backup_dir=_backup_dir(req, root), project_root=root
404
+ )
405
+ return _ok(dry_run=False, result=_jsonable(result))
406
+
407
+
408
+ def verb_pou_delete(req):
409
+ """Delete a POU, its task assignments and its registry entries.
410
+
411
+ The POU directory is moved to an archive rather than removed, so the deletion
412
+ is recoverable. Refuses when another POU still calls it unless ``force`` is set.
413
+ dry_run defaults to True and returns the plan instead of applying it.
414
+ """
415
+ from motionworks_iec_mcp import pou_writer as PW
416
+
417
+ root = Path(req["project"])
418
+ dry = bool(req.get("dry_run", True))
419
+ plan = PW.plan_pou_deletion(root, req["name"], force=bool(req.get("force", False)))
420
+ if dry:
421
+ return _ok(dry_run=True, plan=_plan_summary(plan))
422
+ result = PW.apply_pou_deletion(plan, backup_dir=_backup_dir(req, root))
423
+ return _ok(dry_run=False, result=_jsonable(result))
424
+
425
+
426
+ def verb_tasks(req):
427
+ """List exact saved instance/type bindings and settings in native order."""
428
+ from motionworks_iec_mcp.task_bindings import inspect
429
+ root = Path(req["project"])
430
+ report = inspect(root)
431
+ duplicate_names=len({t['name'].casefold() for t in report['bindings']})!=len(report['bindings'])
432
+ assignments={t['path'] if duplicate_names else t['name']:[x['name'] for x in t['instances']] for t in report['bindings']}
433
+ return _ok(
434
+ project=str(root),
435
+ tasks={k: list(v) for k, v in assignments.items()},
436
+ task_count=len(assignments),
437
+ unassigned=report['unassigned'],bindings=report['bindings'],
438
+ binding_resolution=report['binding_resolution'],source_hashes=report['source_hashes'],
439
+ unassigned_note=(
440
+ "PROGRAM types without direct saved task instances; instance names are resolved to their types. "
441
+ "Indirect calls and current unsaved IDE state still require inspection. This does not prove runtime execution."
442
+ ),
443
+ next_step="Compare mw_code_task_model for the live IDE state. Use mw_ide_task_change for authorized native assignment/settings changes; then verify saved bindings and fresh Build/Make.",
444
+ )
445
+
446
+
447
+ def verb_assign(req):
448
+ """Assign a POU to a task. dry_run defaults to True.
449
+
450
+ Without this, a created POU is inert: it is not called by anything, so it never
451
+ executes and the compiler does not check it.
452
+ """
453
+ from motionworks_iec_mcp.cfb import CompoundFile
454
+ from motionworks_iec_mcp.tree import parse_document
455
+ from motionworks_iec_mcp.tree_writer import apply_assignment, plan_assign
456
+
457
+ root = Path(req["project"])
458
+ src = root / "src.st1"
459
+ dry = bool(req.get("dry_run", True))
460
+ document = parse_document(
461
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
462
+ )
463
+ kwargs = {}
464
+ if req.get("cycle"):
465
+ kwargs["cycle"] = str(req["cycle"])
466
+ if req.get("controller"):
467
+ kwargs["controller"] = str(req["controller"])
468
+ plan = plan_assign(document, str(req["task"]), str(req["pou"]), **kwargs)
469
+ if dry:
470
+ return _ok(dry_run=True, plan=_plan_summary(plan), summary=plan.summary())
471
+ result = apply_assignment(src, root, plan, backup_dir=_backup_dir(req, root))
472
+ return _ok(dry_run=False, result=_jsonable(result))
473
+
474
+
475
+ def verb_unassign(req):
476
+ """Remove a POU's assignment from a task. dry_run defaults to True."""
477
+ from motionworks_iec_mcp.cfb import CompoundFile
478
+ from motionworks_iec_mcp.tree import parse_document
479
+ from motionworks_iec_mcp.tree_writer import apply_assignment, plan_unassign
480
+
481
+ root = Path(req["project"])
482
+ src = root / "src.st1"
483
+ dry = bool(req.get("dry_run", True))
484
+ document = parse_document(
485
+ CompoundFile(src).read_stream("PROJECT.TRE").decode("latin1")
486
+ )
487
+ plan = plan_unassign(document, str(req["task"]), str(req["pou"]))
488
+ if dry:
489
+ return _ok(dry_run=True, plan=_plan_summary(plan), summary=plan.summary())
490
+ result = apply_assignment(src, root, plan, backup_dir=_backup_dir(req, root))
491
+ return _ok(dry_run=False, result=_jsonable(result))
492
+
493
+
494
+ def verb_globals(req):
495
+ """List the project's VAR_GLOBAL declarations.
496
+
497
+ The automation API exposes NO project-level variable collection - measured:
498
+ project.Variables, project.Globals and project.VariableGroups are all absent or
499
+ empty - so the live variable model cannot show globals even though it reads
500
+ POU-scoped declarations well. Without this an agent can ADD a global and never see
501
+ it again, and cannot discover the tags several POUs share.
502
+ """
503
+ proj = _project(req["project"])
504
+ table = proj.global_variables()
505
+ out = []
506
+ for v in getattr(table, "variables", []) or []:
507
+ out.append({
508
+ "name": getattr(v, "name", None),
509
+ "type": getattr(v, "type_name", None),
510
+ "section": getattr(v, "section", None),
511
+ "group": getattr(v, "group", None),
512
+ "address": getattr(v, "address", None),
513
+ "initial_value": getattr(v, "initial_value", None),
514
+ "description": getattr(v, "description", None),
515
+ })
516
+ warnings = list(getattr(table, "warnings", []) or [])
517
+ return _ok(
518
+ project=str(req["project"]),
519
+ count=len(out),
520
+ variables=out,
521
+ source_stream=getattr(table, "source_stream", None),
522
+ warnings=warnings,
523
+ )
524
+
525
+
526
+ def verb_ide_closed(req):
527
+ """Report whether the IDE gate would let a write through."""
528
+ from motionworks_iec_mcp import ide as I
529
+
530
+ running = None
531
+ for attr in ("motionworks_running", "is_motionworks_running"):
532
+ fn = getattr(I, attr, None)
533
+ if callable(fn):
534
+ try:
535
+ running = bool(fn())
536
+ break
537
+ except Exception:
538
+ pass
539
+ return _ok(running=running, helper_names=[n for n in dir(I) if not n.startswith("_")][:40])
540
+
541
+
542
+ def verb_types(request):
543
+ """Read the project's user-defined data types, or one of them in full.
544
+
545
+ Nothing else in this plugin could see a UDT, so an agent editing a POU that declares
546
+ ``CamData : CamSegmentStruct`` had no way to learn what a CamSegmentStruct contains.
547
+ The definitions are plain text in DT/Tyllist.typ, parsed by motionworks_iec_mcp
548
+ .datatypes, whose module docstring records the field layout and how it was measured.
549
+
550
+ Returns through ``_ok`` like every other verb: the Node side rejects a response whose
551
+ ``ok`` is not true, so a bare dict here fails as "code engine failed 'types'" with no
552
+ message, which is what happened the first time this verb was wired up.
553
+ """
554
+ from motionworks_iec_mcp import datatypes as DT
555
+
556
+ project = Path(request["project"])
557
+ name = request.get("name")
558
+ if name:
559
+ t = DT.find_type(project, str(name))
560
+ return _ok(
561
+ project=str(project),
562
+ name=t.name,
563
+ kind=t.kind,
564
+ type_id=t.type_id,
565
+ container=t.container,
566
+ declared_members=t.declared_members,
567
+ element_type=t.element_type,
568
+ members=[
569
+ {"name": m.name, "type": m.type_name, "type_id": m.type_id,
570
+ "array_size": m.array_size}
571
+ for m in t.members
572
+ ],
573
+ )
574
+
575
+ types, counts = DT.read_types(project)
576
+ return _ok(
577
+ project=str(project),
578
+ defined=len(types),
579
+ header_counts=counts,
580
+ types=[
581
+ {"name": t.name, "kind": t.kind, "type_id": t.type_id,
582
+ "container": t.container, "members": len(t.members),
583
+ "declared_members": t.declared_members, "element_type": t.element_type}
584
+ for t in sorted(types, key=lambda x: (x.container, x.name))
585
+ ],
586
+ )
587
+
588
+
589
+ def verb_library(request):
590
+ """Read the library side: which blocks the project uses, and what one offers.
591
+
592
+ Two questions, two sources. ``eCLRPouDependencies.dat`` is a plain-text manifest naming
593
+ every POU and library block with its kind and what it calls, so "what does this POU use"
594
+ needs no binary at all. A block's own members come from its compiled assembly, which is
595
+ a real .NET assembly (BSJB, v4.0.30319) whose identifiers sit in the metadata #Strings
596
+ heap.
597
+
598
+ WHAT THE MEMBER LIST IS NOT: it does not record input/output DIRECTION. Execute and Done
599
+ are distinguishable by convention, not by anything in the heap, and this does not guess.
600
+ Use the native block definition or matching versioned reference to establish direction.
601
+ """
602
+ from motionworks_iec_mcp import libraries as L
603
+
604
+ project = Path(request["project"])
605
+ name = request.get("name")
606
+ if name:
607
+ m = L.read_block_members(project, str(name))
608
+ return _ok(
609
+ project=str(project),
610
+ name=m.name,
611
+ assembly=m.assembly,
612
+ runtime=m.runtime,
613
+ identifiers=m.identifiers,
614
+ filtered_out=m.filtered_out,
615
+ note=(
616
+ "these are the identifiers the assembly defines; input/output DIRECTION is "
617
+ "not recorded in them, so inspect the native function-block definition or matching versioned manual "
618
+ "(mw_code_reference) to tell an input from an output; caller declarations alone do not establish direction"
619
+ ),
620
+ )
621
+
622
+ blocks = L.read_dependencies(project)
623
+ return _ok(
624
+ project=str(project),
625
+ count=len(blocks),
626
+ blocks=[
627
+ {"index": b.index, "name": b.name, "kind": b.kind,
628
+ "library": b.is_library, "depends_on": b.depends_on}
629
+ for b in blocks
630
+ ],
631
+ )
632
+
633
+
634
+ def verb_manual(request):
635
+ """Search installed PDFs and the versioned vendor-reference catalog together."""
636
+ from motionworks_iec_mcp import manuals as M, knowledge as K
637
+ name, term = request.get('name'), request.get('term')
638
+ limit = max(1, min(int(request.get('limit', 5)), 10))
639
+ if not term:
640
+ try: listed = M.list_manuals()
641
+ except Exception: listed = []
642
+ try: topics = M.help_topics()
643
+ except Exception: topics = []
644
+ return _ok(manuals=[{'name': m.name, 'path': str(m.path), 'bytes': m.bytes,
645
+ 'readable': m.readable, 'note': m.note, 'revision': 'not_verified'} for m in listed],
646
+ help_topics=topics, references=K.search(),
647
+ note='Use mw_code_reference for reviewed revisions/page citations and mw_code_installed_help for actual installed CHM topic text; this list contains archive names only.')
648
+ try:
649
+ results = M.search(str(term), name=str(name) if name else None, limit=limit)
650
+ for result in results:
651
+ result.update(extraction='legacy_heuristic', revision='not_verified', page_citations_available=False)
652
+ except Exception as exc:
653
+ results = [{'manual': 'installed PDFs', 'hits': 0, 'error': str(exc)}]
654
+ references = K.search(str(term), limit=limit)
655
+ return _ok(term=str(term), found=sum(bool(r.get('hits')) for r in results), results=results,
656
+ references=references, note='Legacy extracted text may be imperfect. Prefer reviewed reference citations and the actual installed FB interface.')
657
+
658
+
659
+
660
+ def verb_restore_pou(req):
661
+ """Put a POU back from a snapshot. dry_run defaults to True.
662
+
663
+ Added because mw_ide_build now DETECTS a destroyed POU and says to restore it, and there was no
664
+ way to do that. A diagnosis with no remedy is half a fix.
665
+ """
666
+ from motionworks_iec_mcp import restore as R
667
+
668
+ root = Path(req["project"])
669
+ dry = bool(req.get("dry_run", True))
670
+ # pou is OPTIONAL: omitting it asks what is restorable rather than naming one. Reading it
671
+ # with [] raised KeyError before the guard below could run - the guard was right and the
672
+ # access above it was wrong, which is the pair written the wrong way round.
673
+ pou = req.get("pou")
674
+ which = int(req.get("which", 0) or 0)
675
+
676
+ if not pou:
677
+ # No POU named: report what is restorable and what looks damaged, so a caller that only
678
+ # knows something is wrong can find out what.
679
+ return _ok(dry_run=dry, result=R.list_restorable(root))
680
+
681
+ report = R.restore_pou(root, pou, which=which, dry_run=dry)
682
+ return _ok(dry_run=dry, result=_jsonable(report))
683
+
684
+ def verb_bind_mwt(req):
685
+ """Rewrite the staged .mwt so the path inside it is the staged directory.
686
+
687
+ The wrapper is a pointer. Left alone, opening it loads whatever directory
688
+ was stored in it — measured as a project outside the workspace. This only
689
+ accepts paths already inside the staging root.
690
+ """
691
+ from motionworks_iec_mcp.mwt_bind import retarget
692
+ from motionworks_iec_mcp.staging import assert_staged
693
+
694
+ mwt = assert_staged(req["mwt"], what="mwt wrapper")
695
+ directory = assert_staged(req["directory"], what="staged directory")
696
+ return _ok(**retarget(mwt, directory))
697
+
698
+
699
+ def verb_check_mwt(req):
700
+ """Reject a wrapper that would open a different project before COM sees it."""
701
+ from motionworks_iec_mcp.mwt_bind import embedded_paths
702
+ from motionworks_iec_mcp.staging import assert_proven, assert_direct_attached, StagingRefused
703
+ try:
704
+ mwt = assert_proven(req["project"])
705
+ except StagingRefused:
706
+ mwt = assert_direct_attached(req["project"])
707
+ if mwt.suffix.lower() != ".mwt":
708
+ raise ValueError("open requires a .mwt wrapper")
709
+ directory = mwt.with_suffix("").resolve()
710
+ paths = embedded_paths(mwt)
711
+ # Some valid wrappers carry no absolute path and let the IDE open the
712
+ # sibling expanded directory. Staging recorded this exact pair, and the
713
+ # caller checks the IDE's active project after opening. Reject only a
714
+ # concrete embedded path that points elsewhere.
715
+ if paths and any(Path(p).resolve() != directory for p in paths):
716
+ raise ValueError("REFUSED: wrapper is not bound to the staged workspace project; stage it again.")
717
+ import hashlib
718
+ return _ok(bound_to=str(directory), wrapper_sha256=hashlib.sha256(mwt.read_bytes()).hexdigest())
719
+
720
+
721
+ #: Reads that may look at a project outside the staging root when the caller
722
+ #: sets reference=true. Everything else still has to be a staged copy.
723
+ READ_VERBS = frozenset({
724
+ "worksheet_target",
725
+ "structure_snapshot", "block_interface",
726
+ "pous", "read_st", "read_text", "unsupported", "globals", "tasks", "types", "library",
727
+ })
728
+
729
+ # Direct attach enables these inspection verbs only. Retired offline mutation
730
+ # verbs continue through the staging guard even when a direct identity exists.
731
+ DIRECT_READ_VERBS = READ_VERBS | frozenset({
732
+ "workflow_check", "source_manifest", "check_mwt", "validate", "check_program",
733
+ "eip_map", "graphical_listing", "compiled_source_evidence",
734
+ })
735
+
736
+ def verb_sync_back(req):
737
+ """Copy the staged edit back to the real project it was staged from. dry_run defaults to True.
738
+
739
+ WHY THIS EXISTS: the release loop is close IDE -> edit the stage -> build -> copy back ->
740
+ re-stage -> verify, and every step was a tool except the copy back, which had to be a script
741
+ that knew by convention what to carry. The convention belongs here, next to the staging guard
742
+ that already knows what a staged copy is.
743
+
744
+ WHAT IT CARRIES
745
+ * the project tree, minus the .mwt WRAPPER - whose stored path is bound to the stage, so
746
+ copying it to the real project would point the real project at a temp copy of itself;
747
+ * the source project's own .mwt is NOT this function's business and is left byte-for-byte
748
+ alone;
749
+ * build outputs and caches are skipped, so a copy back does not drag the IDE's scratch state
750
+ over a project the IDE will open again.
751
+
752
+ WHAT IT REFUSES
753
+ * a staged copy with no recorded source (identity file), because there is nowhere to copy to;
754
+ * a destination outside the staging root's recorded workspace, via the same assert_staged
755
+ gate every other verb passes through;
756
+ * a destination that is itself a staged copy, which would be a copy stage-to-stage and
757
+ almost certainly not what was meant.
758
+
759
+ Verified by hash, not by the copy call returning: the report lists the files that landed and
760
+ the ones whose digest differs afterwards, so "synced" is an observation rather than an intent.
761
+ """
762
+ import hashlib
763
+
764
+ from motionworks_iec_mcp.staging import assert_proven, assert_staged
765
+
766
+ staged = assert_staged(req["project"], what="staged project")
767
+ # assert_proven is the gate that makes the identity file safe to read: it proves the copy was
768
+ # staged from this workspace and that source, wrapper and directory all agree. Reading the
769
+ # identity file without it would trust a file this engine did not necessarily write.
770
+ assert_proven(staged)
771
+ dry = bool(req.get("dry_run", True))
772
+
773
+ # The identity file sits beside the staged project and records where the copy came from.
774
+ # Without it there is no destination to guess at, and guessing is how a copy back writes into
775
+ # the wrong project.
776
+ identity_path = staged.parent / f"{staged.name}.identity.json"
777
+ if not identity_path.exists():
778
+ alt = staged.with_suffix(".identity.json")
779
+ identity_path = alt if alt.exists() else identity_path
780
+ if not identity_path.exists():
781
+ raise ValueError(
782
+ f"REFUSED: no identity file beside the staged project ({identity_path.name}). "
783
+ "sync_back needs the recorded source directory, and there is no safe way to guess it."
784
+ )
785
+ identity = json.loads(identity_path.read_text(encoding="utf-8-sig"))
786
+ source_dir = identity.get("source_directory") or identity.get("source")
787
+ if not source_dir:
788
+ raise ValueError(
789
+ f"REFUSED: {identity_path.name} records no source_directory, so there is nowhere to "
790
+ "copy back to."
791
+ )
792
+ destination = Path(source_dir).resolve()
793
+
794
+ if destination == staged.resolve():
795
+ raise ValueError("REFUSED: the recorded source is the staged copy itself.")
796
+
797
+ # THE PROTECTION THAT MATTERS, and it is NOT the staging guard.
798
+ #
799
+ # Every other verb refuses to touch anything under stage/, because a real project tree holds
800
+ # the owner's work and a mistake there is not recoverable. sync_back is the one verb whose
801
+ # PURPOSE is to write the real tree - a copy back that only wrote the stage would be a no-op
802
+ # - so assert_staged would make it impossible rather than safe, which is what it did when this
803
+ # was first written.
804
+ #
805
+ # The safety comes from the identity file instead, and it is narrower than the guard: the
806
+ # source must be a path mw_ide_stage itself recorded, and it must lie inside the workspace
807
+ # that recording names. That is provenance, not a pattern match on a path, and it is the same
808
+ # evidence assert_proven already checks on the staged side.
809
+ workspace = Path(identity.get("workspace") or "").resolve() if identity.get("workspace") else None
810
+ if workspace is None:
811
+ raise ValueError(
812
+ f"REFUSED: {identity_path.name} records no workspace, so the recorded source cannot "
813
+ "be proven to lie inside the workspace this engine is allowed to write."
814
+ )
815
+ try:
816
+ destination.relative_to(workspace)
817
+ except ValueError:
818
+ raise ValueError(
819
+ f"REFUSED: the recorded source '{destination}' lies outside the recorded workspace "
820
+ f"'{workspace}'. sync_back writes only a project this staging was taken from."
821
+ ) from None
822
+ # Writing the stage again is a no-op the caller did not ask for, and a destination inside
823
+ # stage/ would mean the identity file named the stage as its own source.
824
+ from motionworks_iec_mcp.staging import is_staged
825
+ if is_staged(destination):
826
+ raise ValueError(
827
+ f"REFUSED: the recorded source '{destination}' is itself inside the staging root. "
828
+ "There is nothing to copy back to - stage the project again from the real one."
829
+ )
830
+
831
+ if not destination.exists():
832
+ raise ValueError(f"REFUSED: the recorded source directory does not exist: {destination}")
833
+
834
+ # WHAT IS CARRIED, and why it is a list of SOURCE roles rather than a list of exclusions.
835
+ #
836
+ # Measured on the real project: a naive "everything that changed" copy back selected 90 files,
837
+ # and the first eight were `__1stResourceEx.DLL`, `__BG.DLL`, `__FastTsk.pdb` and so on - the
838
+ # compiler's own output, which the IDe regenerates and which this engine never edits. Carrying
839
+ # those back over a project the IDE will open again is the "broad sync" a session had to do by
840
+ # hand, and it was the likely cause of the build failure that followed.
841
+ #
842
+ # So the rule is: copy back the files this engine can WRITE, and only those. A blanket copy
843
+ # cannot be made safe by lengthening an exclusion list, because the next build output the IDE
844
+ # invents is not on it; an inclusion list fails safe instead, since an unlisted new artifact is
845
+ # simply not carried.
846
+ SOURCE_SUFFIXES = (
847
+ ".st1", # a POU container: the .VB declarations, .VGR grid and body streams inside it
848
+ ".vb", # a bare declaration stream, for a project that stores them separately
849
+ ".vgr", # a bare grid, likewise
850
+ ".tre", # the project tree
851
+ ".pou", # the POU registry
852
+ ".typ", # the data-type list
853
+ ".xml", # NodeProperties and the library manifest
854
+ ".dat", # the dependency manifest
855
+ ".txt", # the POU's textual sidecars
856
+ ".st", # an exported source, when one is kept in the tree
857
+ ".set", # task settings
858
+ ".ldi",
859
+ )
860
+ #: Names that are project state rather than source, even though they match a suffix above.
861
+ SOURCE_NAMES = {"list.pou", "project.tre", "nodes.lst", "tyllist.typ", "eclrpoudependencies.dat"}
862
+ SKIP_DIRS = {"__pycache__", ".git", "node_modules", "backups", ".motionworks"}
863
+
864
+ def carry_reason(name: str) -> str | None:
865
+ """None to carry the file, or the reason it is not source."""
866
+ low = name.lower()
867
+ if low.endswith(".mwt"):
868
+ # THE WRAPPER IS NEVER CARRIED. Its stored path names the stage, so copying it would
869
+ # bind the real project to a temporary copy of itself - the exact failure the staging
870
+ # guard exists to prevent, and the one thing the hand-written script also had to know.
871
+ return "the .mwt wrapper stays bound to the stage"
872
+ if low.startswith("tmp.") or low.endswith((".tmp", ".sto", ".log", ".cache")):
873
+ return "scratch or log"
874
+ if low.endswith((".dll", ".pdb", ".exe", ".obj", ".o", ".lib", ".exp", ".ilk")):
875
+ return "compiler output, not source"
876
+ if low in SOURCE_NAMES:
877
+ return None
878
+ if low.endswith(SOURCE_SUFFIXES):
879
+ return None
880
+ return "not a file this engine writes"
881
+
882
+ copied: list[dict] = []
883
+ skipped: list[dict] = []
884
+ mismatched: list[dict] = []
885
+
886
+ for path in sorted(staged.rglob("*")):
887
+ if not path.is_file():
888
+ continue
889
+ rel = path.relative_to(staged)
890
+ if any(part in SKIP_DIRS for part in rel.parts):
891
+ continue
892
+ reason = carry_reason(path.name)
893
+ if reason is not None:
894
+ skipped.append({"file": str(rel), "reason": reason})
895
+ continue
896
+ target = destination / rel
897
+ record = {
898
+ "file": str(rel),
899
+ "bytes": path.stat().st_size,
900
+ "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
901
+ }
902
+ if target.exists():
903
+ current = hashlib.sha256(target.read_bytes()).hexdigest()
904
+ if current == record["sha256"]:
905
+ skipped.append({"file": str(rel), "reason": "already identical"})
906
+ continue
907
+ record["previous_sha256"] = current
908
+ copied.append(record)
909
+
910
+ if dry:
911
+ return _ok(
912
+ dry_run=True,
913
+ staged=str(staged),
914
+ destination=str(destination),
915
+ would_copy=len(copied),
916
+ would_skip=len(skipped),
917
+ files=copied,
918
+ skipped=skipped,
919
+ note="nothing was written; pass dry_run=false to apply",
920
+ )
921
+
922
+ for record in copied:
923
+ source = staged / record["file"]
924
+ target = destination / record["file"]
925
+ target.parent.mkdir(parents=True, exist_ok=True)
926
+ target.write_bytes(source.read_bytes())
927
+ # VERIFIED, not assumed: re-read the file that landed and compare digests.
928
+ landed = hashlib.sha256(target.read_bytes()).hexdigest()
929
+ if landed != record["sha256"]:
930
+ mismatched.append({"file": record["file"], "expected": record["sha256"], "got": landed})
931
+ record["verified"] = landed == record["sha256"]
932
+
933
+ return _ok(
934
+ dry_run=False,
935
+ staged=str(staged),
936
+ destination=str(destination),
937
+ copied=len(copied),
938
+ skipped=len(skipped),
939
+ mismatched=len(mismatched),
940
+ files=copied,
941
+ failures=mismatched,
942
+ wrapper="not carried - the .mwt stays bound to the stage",
943
+ note=(
944
+ "files are verified by sha256 after the copy; a non-empty `failures` means the copy "
945
+ "did not land and the release is not synced"
946
+ ),
947
+ )
948
+
949
+
950
+ def verb_wrapper_binding(req):
951
+ """Read-only: how each staged project's .mwt wrapper is bound, and whether it is stale.
952
+
953
+ WHY THIS EXISTS: mw_ide_open refuses a wrapper whose stored path is not the staged directory,
954
+ and the refusal was reached in a session where the PROJECT had just been renumbered by a POU
955
+ creation. Re-staging to clear it is worse than the problem - it overwrites the stage and takes
956
+ the new POU with it - so the useful answer is "which wrapper is stale, and is re-binding it
957
+ enough", reported before anything tries to open it.
958
+
959
+ Reports every staged project, so a caller does not have to know which one is at fault.
960
+ """
961
+ from motionworks_iec_mcp.mwt_bind import embedded_paths
962
+ from motionworks_iec_mcp.staging import staging_root
963
+
964
+ binding: list[dict] = []
965
+ targets = []
966
+ if req.get("project"):
967
+ from motionworks_iec_mcp.staging import assert_staged
968
+ targets.append(assert_staged(req["project"], what="project"))
969
+ else:
970
+ targets = [p for p in sorted(staging_root().glob("*.mwt"))]
971
+
972
+ for mwt in targets:
973
+ if mwt.suffix.lower() != ".mwt":
974
+ continue
975
+ directory = mwt.with_suffix("")
976
+ try:
977
+ paths = embedded_paths(mwt)
978
+ except Exception as exc:
979
+ binding.append({"wrapper": str(mwt), "error": f"{type(exc).__name__}: {exc}"})
980
+ continue
981
+ stale = [p for p in paths if Path(p).resolve() != directory.resolve()]
982
+ binding.append({
983
+ "wrapper": str(mwt),
984
+ "should_name": str(directory),
985
+ "stores": paths,
986
+ "bound": not stale,
987
+ "binding_mode": "embedded_path" if paths else "native_sibling_directory",
988
+ "stale_paths": stale,
989
+ "note": (
990
+ "native sibling-directory wrapper; verify the IDE active project after opening" if not paths
991
+ else ("re-binding is enough: mw_ide_stage would overwrite the staged project and "
992
+ "take any POU created since with it" if stale else "")
993
+ ),
994
+ })
995
+
996
+ return _ok(
997
+ count=len(binding),
998
+ wrappers=binding,
999
+ stale=sum(1 for b in binding if b.get("stale_paths")),
1000
+ )
1001
+
1002
+
1003
+ def verb_rebind_wrapper(req):
1004
+ """Point a staged .mwt back at its staged directory, in place. dry_run defaults to True.
1005
+
1006
+ Idempotent and cheap when already bound - measured: an already-bound wrapper returns
1007
+ changed=false and the file digest is unchanged, so a caller may call this unconditionally
1008
+ rather than working out whether it needs to.
1009
+ """
1010
+ from motionworks_iec_mcp.mwt_bind import embedded_paths, retarget
1011
+ from motionworks_iec_mcp.staging import assert_staged
1012
+
1013
+ mwt = assert_staged(req["mwt"], what="mwt wrapper")
1014
+ if mwt.suffix.lower() != ".mwt":
1015
+ raise ValueError("rebind_wrapper needs a .mwt wrapper")
1016
+ directory = assert_staged(req["directory"] if req.get("directory") else mwt.with_suffix(""),
1017
+ what="staged directory")
1018
+ dry = bool(req.get("dry_run", True))
1019
+
1020
+ if dry:
1021
+ paths = embedded_paths(mwt)
1022
+ stale = [p for p in paths if Path(p).resolve() != directory.resolve()]
1023
+ return _ok(
1024
+ dry_run=True,
1025
+ would_change=bool(stale),
1026
+ bound_to=str(directory),
1027
+ stores=paths,
1028
+ stale_paths=stale,
1029
+ note="nothing was written; pass dry_run=false to re-bind",
1030
+ )
1031
+ return _ok(dry_run=False, **retarget(mwt, directory))
1032
+
1033
+
1034
+ def verb_eip_map(req):
1035
+ """Summarize the EtherNet/IP assembly map: declared size, words used, words left.
1036
+
1037
+ Answers "is there room for another status value?" - the question that comes up every time a
1038
+ diagnostic is added - from the two sources that hold the two halves of the answer: the L5X
1039
+ module definition for the declared size, the project's IEC addresses for what is used.
1040
+ """
1041
+ from motionworks_iec_mcp import eip as E
1042
+
1043
+ return _ok(result=E.build_map(
1044
+ project_root=req.get("project"),
1045
+ l5x=req.get("l5x"),
1046
+ module_name=req.get("module_name"),
1047
+ ))
1048
+
1049
+
1050
+ def verb_validate(req):
1051
+ from motionworks_iec_mcp.validation import validate
1052
+ return _ok(result=validate(req['project']))
1053
+
1054
+
1055
+ def verb_reference(req):
1056
+ from motionworks_iec_mcp import knowledge as K
1057
+ if req.get('block'):
1058
+ result = K.signature(req['block'])
1059
+ return _ok(result={'block': req['block'], 'signature': result,
1060
+ 'note': 'No reviewed signature found; inspect the installed FB definition.' if result is None else 'Historical vendor interface; confirm installed version.'})
1061
+ return _ok(result=K.search(req.get('query', ''), source_id=req.get('source_id'), limit=req.get('limit', 5)))
1062
+
1063
+
1064
+ def verb_reference_sync(req):
1065
+ from motionworks_iec_mcp.knowledge import sync
1066
+ return _ok(result=sync(req.get('source_ids')))
1067
+
1068
+
1069
+ def verb_diagnose(req):
1070
+ from motionworks_iec_mcp.knowledge import diagnose
1071
+ return _ok(result=diagnose(req['message']))
1072
+
1073
+
1074
+ def verb_pattern(req):
1075
+ from motionworks_iec_mcp.knowledge import patterns
1076
+ return _ok(result=patterns(req.get('name')))
1077
+
1078
+
1079
+ def verb_check_program(req):
1080
+ from motionworks_iec_mcp.program_checks import check_project
1081
+ if 'body' in req and not req.get('pou'):
1082
+ raise ValueError('A proposed body requires a target POU')
1083
+ return _ok(result=check_project(req['project'], pou=req.get('pou'), body=req.get('body'), native_libraries=req.get('native_libraries'), interface_libraries=req.get('interface_libraries'), native_compiled_interfaces=req.get('native_compiled_interfaces')))
1084
+
1085
+
1086
+ VERBS = {
1087
+ "workflow_check": lambda req: _ok(**__import__("motionworks_iec_mcp.workflow", fromlist=["check"]).check(req["project"])),
1088
+ "source_manifest": lambda req: _ok(**__import__("motionworks_iec_mcp.workflow", fromlist=["source_manifest"]).source_manifest(Path(req["project"]))),
1089
+ "reference": verb_reference,
1090
+ "reference_sync": verb_reference_sync,
1091
+ "diagnose": verb_diagnose,
1092
+ "pattern": verb_pattern,
1093
+ "check_program": verb_check_program,
1094
+ "validate": verb_validate,
1095
+ "check_mwt": verb_check_mwt,
1096
+ "eip_map": verb_eip_map,
1097
+ "types": verb_types,
1098
+ "manual": verb_manual,
1099
+ "library": verb_library,
1100
+ "pous": verb_pous,
1101
+ "read_st": verb_read_st,
1102
+ "read_text": lambda req: verb_read_st(req, text=True),
1103
+ "worksheet_target": verb_worksheet_target,
1104
+ "graphical_listing": verb_graphical_listing,
1105
+ "compiled_source_evidence": verb_compiled_source_evidence,
1106
+ "structure_snapshot": verb_structure_snapshot,
1107
+ "block_interface": verb_block_interface,
1108
+ "installed_help": verb_installed_help,
1109
+ "unsupported": verb_unsupported,
1110
+ "write_st": verb_write_st,
1111
+ "var_add": verb_var_add,
1112
+ "var_add_many": verb_var_add_many,
1113
+ "sync_back": verb_sync_back,
1114
+ "wrapper_binding": verb_wrapper_binding,
1115
+ "rebind_wrapper": verb_rebind_wrapper,
1116
+ "restore_pou": verb_restore_pou,
1117
+ "var_edit": verb_var_edit,
1118
+ "var_delete": verb_var_delete,
1119
+ "pou_create": verb_pou_create,
1120
+ "pou_delete": verb_pou_delete,
1121
+ "globals": verb_globals,
1122
+ "tasks": verb_tasks,
1123
+ "assign": verb_assign,
1124
+ "unassign": verb_unassign,
1125
+ "ide_closed": verb_ide_closed,
1126
+ "bind_mwt": verb_bind_mwt,
1127
+ }
1128
+
1129
+
1130
+ def _jsonable(value):
1131
+ """Best-effort conversion of engine results into JSON-friendly data."""
1132
+ try:
1133
+ json.dumps(value)
1134
+ return value
1135
+ except (TypeError, ValueError):
1136
+ pass
1137
+ if isinstance(value, dict):
1138
+ return {str(k): _jsonable(v) for k, v in value.items()}
1139
+ if isinstance(value, (list, tuple)):
1140
+ return [_jsonable(v) for v in value]
1141
+ if hasattr(value, "__dict__"):
1142
+ return {str(k): _jsonable(v) for k, v in vars(value).items()}
1143
+ return str(value)
1144
+
1145
+
1146
+ def main(argv):
1147
+ if len(argv) != 4:
1148
+ print("usage: mw_code.py <verb> <request.json> <response.json>", file=sys.stderr)
1149
+ return 2
1150
+ verb, req_path, res_path = argv[1], argv[2], argv[3]
1151
+ res = Path(res_path)
1152
+
1153
+ try:
1154
+ req = json.loads(Path(req_path).read_text(encoding="utf-8-sig"))
1155
+ except Exception as exc:
1156
+ _write(res, _fail(f"could not read request: {exc}"))
1157
+ return 1
1158
+
1159
+ handler = VERBS.get(verb)
1160
+ if handler is None:
1161
+ _write(res, _fail(f"unknown verb {verb!r}", allowed=sorted(VERBS)))
1162
+ return 1
1163
+
1164
+ # THE STAGING GUARD, at the one point every verb passes through.
1165
+ #
1166
+ # index.js refuses a project outside the staging root, but that only covers the plugin's TOOLS.
1167
+ # Measured: the workspace holds 25-odd scripts the agent wrote for itself, and they call this
1168
+ # engine directly - Tools/_mw_call.py, Tools/_mw_call2.py, Tools/_real_state.py and the rest -
1169
+ # so the agent routed around the tool-level guard by not using the tools, and the owner reported
1170
+ # a third time that a project outside the workspace had been opened.
1171
+ #
1172
+ # Guarding here catches all of them, because every one of them comes through this dispatch. It
1173
+ # is also the honest place for it: this engine is what writes the files.
1174
+ #
1175
+ # A request with no `project` is not refused. That is not a hole - the verbs without one do not
1176
+ # touch a project tree - and refusing them would mean a caller could not ask a question like
1177
+ # "which POUs exist" without first proving where it stands.
1178
+ try:
1179
+ from engine.motionworks_iec_mcp.staging import StagingRefused, assert_staged
1180
+ except ImportError: # running from a checkout where the engine is a sibling package
1181
+ try:
1182
+ from motionworks_iec_mcp.staging import StagingRefused, assert_staged
1183
+ except ImportError:
1184
+ assert_staged = None
1185
+ if req.get("reference") is True and verb not in READ_VERBS:
1186
+ _write(res, _fail(
1187
+ f"REFUSED: '{verb}' cannot use reference mode. Reference is read-only. "
1188
+ "A project outside the workspace can be inspected, and it is never "
1189
+ "staged, opened, or edited.",
1190
+ refused_by="reference guard",
1191
+ verb=verb,
1192
+ ))
1193
+ return 1
1194
+
1195
+ reference_read = req.get("reference") is True and verb in READ_VERBS
1196
+ if assert_staged is not None and req.get("project") and not reference_read:
1197
+ try:
1198
+ if verb in DIRECT_READ_VERBS:
1199
+ try:
1200
+ from engine.motionworks_iec_mcp.staging import assert_direct_attached
1201
+ except ImportError:
1202
+ from motionworks_iec_mcp.staging import assert_direct_attached
1203
+ try:
1204
+ assert_direct_attached(req["project"])
1205
+ direct_read = True
1206
+ except StagingRefused:
1207
+ direct_read = False
1208
+ else:
1209
+ direct_read = False
1210
+ if not direct_read:
1211
+ assert_staged(req["project"], what=f"{verb} project")
1212
+ try:
1213
+ from engine.motionworks_iec_mcp.staging import assert_proven
1214
+ except ImportError:
1215
+ from motionworks_iec_mcp.staging import assert_proven
1216
+ if verb != "workflow_check" and not direct_read:
1217
+ assert_proven(req["project"])
1218
+ except StagingRefused as exc:
1219
+ _write(res, _fail(str(exc), refused_by="staging guard", verb=verb))
1220
+ return 1
1221
+
1222
+ try:
1223
+ if verb in {"write_st", "var_add", "var_add_many", "var_edit", "var_delete", "pou_create", "pou_delete", "restore_pou"} and req.get("dry_run", True) is False:
1224
+ from motionworks_iec_mcp.transaction import run
1225
+ payload = run(Path(req["project"]), lambda: handler(req))
1226
+ else:
1227
+ payload = handler(req)
1228
+ if reference_read and isinstance(payload, dict):
1229
+ payload = dict(payload)
1230
+ payload["read_only"] = True
1231
+ workspace = req.get("workspace")
1232
+ outside = None
1233
+ if workspace and req.get("project"):
1234
+ try:
1235
+ Path(req["project"]).resolve().relative_to(Path(workspace).resolve())
1236
+ outside = False
1237
+ except ValueError:
1238
+ outside = True
1239
+ payload["outside_workspace"] = outside
1240
+ _write(res, payload)
1241
+ return 0
1242
+ except Exception as exc:
1243
+ _write(res, _fail(
1244
+ f"{type(exc).__name__}: {exc}",
1245
+ traceback=traceback.format_exc().splitlines()[-6:],
1246
+ ))
1247
+ return 1
1248
+
1249
+
1250
+ def _write(path: Path, payload) -> None:
1251
+ """Write without a BOM, via a temp file, so a reader never sees a partial file."""
1252
+ tmp = path.with_suffix(path.suffix + ".tmp")
1253
+ tmp.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
1254
+ os.replace(tmp, path)
1255
+
1256
+
1257
+ if __name__ == "__main__":
1258
+ sys.exit(main(sys.argv))