oh-my-codex 0.17.3 → 0.18.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 (381) hide show
  1. package/Cargo.lock +13 -5
  2. package/Cargo.toml +2 -1
  3. package/README.md +44 -19
  4. package/crates/omx-api/Cargo.toml +19 -0
  5. package/crates/omx-api/src/lib.rs +2997 -0
  6. package/crates/omx-api/src/main.rs +10 -0
  7. package/crates/omx-api/tests/cli.rs +558 -0
  8. package/crates/omx-explore/src/main.rs +4 -0
  9. package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
  10. package/crates/omx-sparkshell/src/exec.rs +127 -1
  11. package/crates/omx-sparkshell/src/main.rs +829 -30
  12. package/crates/omx-sparkshell/src/prompt.rs +25 -3
  13. package/crates/omx-sparkshell/src/redaction.rs +241 -0
  14. package/crates/omx-sparkshell/tests/execution.rs +702 -237
  15. package/dist/cli/__tests__/api.test.d.ts +2 -0
  16. package/dist/cli/__tests__/api.test.d.ts.map +1 -0
  17. package/dist/cli/__tests__/api.test.js +175 -0
  18. package/dist/cli/__tests__/api.test.js.map +1 -0
  19. package/dist/cli/__tests__/ask.test.js +72 -5
  20. package/dist/cli/__tests__/ask.test.js.map +1 -1
  21. package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
  22. package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
  23. package/dist/cli/__tests__/codex-plugin-layout.test.js +15 -7
  24. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  25. package/dist/cli/__tests__/doctor-warning-copy.test.js +76 -3
  26. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  27. package/dist/cli/__tests__/explore.test.js +23 -0
  28. package/dist/cli/__tests__/explore.test.js.map +1 -1
  29. package/dist/cli/__tests__/index.test.js +171 -5
  30. package/dist/cli/__tests__/index.test.js.map +1 -1
  31. package/dist/cli/__tests__/install-docs-contract.test.d.ts +2 -0
  32. package/dist/cli/__tests__/install-docs-contract.test.d.ts.map +1 -0
  33. package/dist/cli/__tests__/install-docs-contract.test.js +55 -0
  34. package/dist/cli/__tests__/install-docs-contract.test.js.map +1 -0
  35. package/dist/cli/__tests__/launch-fallback.test.js +191 -0
  36. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  37. package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
  38. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  39. package/dist/cli/__tests__/question.test.js +27 -41
  40. package/dist/cli/__tests__/question.test.js.map +1 -1
  41. package/dist/cli/__tests__/setup-install-mode.test.js +232 -35
  42. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  43. package/dist/cli/__tests__/sparkshell-cli.test.js +25 -1
  44. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  45. package/dist/cli/__tests__/sparkshell-packaging.test.js +1 -0
  46. package/dist/cli/__tests__/sparkshell-packaging.test.js.map +1 -1
  47. package/dist/cli/__tests__/ultragoal.test.js +227 -4
  48. package/dist/cli/__tests__/ultragoal.test.js.map +1 -1
  49. package/dist/cli/__tests__/update.test.js +72 -1
  50. package/dist/cli/__tests__/update.test.js.map +1 -1
  51. package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
  52. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  53. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
  54. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  55. package/dist/cli/api.d.ts +26 -0
  56. package/dist/cli/api.d.ts.map +1 -0
  57. package/dist/cli/api.js +153 -0
  58. package/dist/cli/api.js.map +1 -0
  59. package/dist/cli/codex-feature-probe.d.ts +5 -0
  60. package/dist/cli/codex-feature-probe.d.ts.map +1 -1
  61. package/dist/cli/codex-feature-probe.js +13 -7
  62. package/dist/cli/codex-feature-probe.js.map +1 -1
  63. package/dist/cli/doctor.d.ts +7 -0
  64. package/dist/cli/doctor.d.ts.map +1 -1
  65. package/dist/cli/doctor.js +119 -10
  66. package/dist/cli/doctor.js.map +1 -1
  67. package/dist/cli/explore.d.ts +2 -0
  68. package/dist/cli/explore.d.ts.map +1 -1
  69. package/dist/cli/explore.js +43 -1
  70. package/dist/cli/explore.js.map +1 -1
  71. package/dist/cli/index.d.ts +12 -4
  72. package/dist/cli/index.d.ts.map +1 -1
  73. package/dist/cli/index.js +460 -87
  74. package/dist/cli/index.js.map +1 -1
  75. package/dist/cli/native-assets.d.ts +2 -1
  76. package/dist/cli/native-assets.d.ts.map +1 -1
  77. package/dist/cli/native-assets.js +1 -0
  78. package/dist/cli/native-assets.js.map +1 -1
  79. package/dist/cli/plugin-marketplace.d.ts +2 -0
  80. package/dist/cli/plugin-marketplace.d.ts.map +1 -1
  81. package/dist/cli/plugin-marketplace.js +15 -1
  82. package/dist/cli/plugin-marketplace.js.map +1 -1
  83. package/dist/cli/setup.d.ts.map +1 -1
  84. package/dist/cli/setup.js +71 -11
  85. package/dist/cli/setup.js.map +1 -1
  86. package/dist/cli/sparkshell.d.ts +7 -1
  87. package/dist/cli/sparkshell.d.ts.map +1 -1
  88. package/dist/cli/sparkshell.js +31 -4
  89. package/dist/cli/sparkshell.js.map +1 -1
  90. package/dist/cli/ultragoal.d.ts +1 -1
  91. package/dist/cli/ultragoal.d.ts.map +1 -1
  92. package/dist/cli/ultragoal.js +184 -10
  93. package/dist/cli/ultragoal.js.map +1 -1
  94. package/dist/cli/update.d.ts +2 -0
  95. package/dist/cli/update.d.ts.map +1 -1
  96. package/dist/cli/update.js +14 -3
  97. package/dist/cli/update.js.map +1 -1
  98. package/dist/compat/__tests__/doctor-contract.test.js +3 -0
  99. package/dist/compat/__tests__/doctor-contract.test.js.map +1 -1
  100. package/dist/config/__tests__/codex-feature-flags.test.js +11 -1
  101. package/dist/config/__tests__/codex-feature-flags.test.js.map +1 -1
  102. package/dist/config/__tests__/codex-hooks.test.js +19 -8
  103. package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
  104. package/dist/config/__tests__/commit-lore-guard.test.d.ts +2 -0
  105. package/dist/config/__tests__/commit-lore-guard.test.d.ts.map +1 -0
  106. package/dist/config/__tests__/commit-lore-guard.test.js +20 -0
  107. package/dist/config/__tests__/commit-lore-guard.test.js.map +1 -0
  108. package/dist/config/codex-feature-flags.d.ts +4 -0
  109. package/dist/config/codex-feature-flags.d.ts.map +1 -1
  110. package/dist/config/codex-feature-flags.js +4 -0
  111. package/dist/config/codex-feature-flags.js.map +1 -1
  112. package/dist/config/codex-hooks.js +6 -6
  113. package/dist/config/codex-hooks.js.map +1 -1
  114. package/dist/config/commit-lore-guard.d.ts +1 -0
  115. package/dist/config/commit-lore-guard.d.ts.map +1 -1
  116. package/dist/config/commit-lore-guard.js +29 -3
  117. package/dist/config/commit-lore-guard.js.map +1 -1
  118. package/dist/config/generator.d.ts +3 -1
  119. package/dist/config/generator.d.ts.map +1 -1
  120. package/dist/config/generator.js +114 -10
  121. package/dist/config/generator.js.map +1 -1
  122. package/dist/goal-workflows/codex-goal-snapshot.d.ts +1 -0
  123. package/dist/goal-workflows/codex-goal-snapshot.d.ts.map +1 -1
  124. package/dist/goal-workflows/codex-goal-snapshot.js +5 -1
  125. package/dist/goal-workflows/codex-goal-snapshot.js.map +1 -1
  126. package/dist/hooks/__tests__/autopilot-skill-contract.test.js +10 -6
  127. package/dist/hooks/__tests__/autopilot-skill-contract.test.js.map +1 -1
  128. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
  129. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
  130. package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
  131. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
  132. package/dist/hooks/__tests__/consensus-execution-handoff.test.d.ts +1 -1
  133. package/dist/hooks/__tests__/consensus-execution-handoff.test.js +13 -11
  134. package/dist/hooks/__tests__/consensus-execution-handoff.test.js.map +1 -1
  135. package/dist/hooks/__tests__/deep-interview-contract.test.js +4 -3
  136. package/dist/hooks/__tests__/deep-interview-contract.test.js.map +1 -1
  137. package/dist/hooks/__tests__/keyword-detector.test.js +15 -3
  138. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  139. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
  140. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  141. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js +33 -0
  142. package/dist/hooks/__tests__/notify-hook-team-tmux-guard.test.js.map +1 -1
  143. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
  144. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  145. package/dist/hooks/extensibility/__tests__/dispatcher.test.js +26 -3
  146. package/dist/hooks/extensibility/__tests__/dispatcher.test.js.map +1 -1
  147. package/dist/hooks/extensibility/dispatcher.d.ts.map +1 -1
  148. package/dist/hooks/extensibility/dispatcher.js +29 -14
  149. package/dist/hooks/extensibility/dispatcher.js.map +1 -1
  150. package/dist/hooks/keyword-detector.d.ts.map +1 -1
  151. package/dist/hooks/keyword-detector.js +8 -3
  152. package/dist/hooks/keyword-detector.js.map +1 -1
  153. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  154. package/dist/hooks/keyword-registry.js +1 -0
  155. package/dist/hooks/keyword-registry.js.map +1 -1
  156. package/dist/hooks/prompt-guidance-contract.d.ts.map +1 -1
  157. package/dist/hooks/prompt-guidance-contract.js +3 -2
  158. package/dist/hooks/prompt-guidance-contract.js.map +1 -1
  159. package/dist/hud/__tests__/hud-tmux-injection.test.js +14 -8
  160. package/dist/hud/__tests__/hud-tmux-injection.test.js.map +1 -1
  161. package/dist/hud/__tests__/reconcile.test.js +4 -4
  162. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  163. package/dist/hud/__tests__/resource-leak-watch.test.d.ts +2 -0
  164. package/dist/hud/__tests__/resource-leak-watch.test.d.ts.map +1 -0
  165. package/dist/hud/__tests__/resource-leak-watch.test.js +28 -0
  166. package/dist/hud/__tests__/resource-leak-watch.test.js.map +1 -0
  167. package/dist/hud/__tests__/tmux.test.js +23 -18
  168. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  169. package/dist/hud/index.d.ts +1 -1
  170. package/dist/hud/index.d.ts.map +1 -1
  171. package/dist/hud/index.js +10 -4
  172. package/dist/hud/index.js.map +1 -1
  173. package/dist/hud/tmux.d.ts.map +1 -1
  174. package/dist/hud/tmux.js +9 -8
  175. package/dist/hud/tmux.js.map +1 -1
  176. package/dist/mcp/__tests__/bootstrap.test.js +75 -1
  177. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  178. package/dist/mcp/bootstrap.d.ts +3 -1
  179. package/dist/mcp/bootstrap.d.ts.map +1 -1
  180. package/dist/mcp/bootstrap.js +71 -2
  181. package/dist/mcp/bootstrap.js.map +1 -1
  182. package/dist/notifications/__tests__/http-client-resource.test.d.ts +2 -0
  183. package/dist/notifications/__tests__/http-client-resource.test.d.ts.map +1 -0
  184. package/dist/notifications/__tests__/http-client-resource.test.js +41 -0
  185. package/dist/notifications/__tests__/http-client-resource.test.js.map +1 -0
  186. package/dist/notifications/__tests__/verbosity.test.js +20 -0
  187. package/dist/notifications/__tests__/verbosity.test.js.map +1 -1
  188. package/dist/notifications/config.d.ts.map +1 -1
  189. package/dist/notifications/config.js +6 -3
  190. package/dist/notifications/config.js.map +1 -1
  191. package/dist/notifications/http-client.d.ts.map +1 -1
  192. package/dist/notifications/http-client.js +78 -27
  193. package/dist/notifications/http-client.js.map +1 -1
  194. package/dist/notifications/types.d.ts +2 -0
  195. package/dist/notifications/types.d.ts.map +1 -1
  196. package/dist/openclaw/__tests__/dispatcher.test.js +49 -1
  197. package/dist/openclaw/__tests__/dispatcher.test.js.map +1 -1
  198. package/dist/openclaw/dispatcher.d.ts +7 -4
  199. package/dist/openclaw/dispatcher.d.ts.map +1 -1
  200. package/dist/openclaw/dispatcher.js +32 -69
  201. package/dist/openclaw/dispatcher.js.map +1 -1
  202. package/dist/pipeline/__tests__/orchestrator.test.js +65 -3
  203. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  204. package/dist/pipeline/__tests__/stages.test.js +50 -5
  205. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  206. package/dist/pipeline/index.d.ts +8 -2
  207. package/dist/pipeline/index.d.ts.map +1 -1
  208. package/dist/pipeline/index.js +5 -2
  209. package/dist/pipeline/index.js.map +1 -1
  210. package/dist/pipeline/orchestrator.d.ts +5 -4
  211. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  212. package/dist/pipeline/orchestrator.js +56 -15
  213. package/dist/pipeline/orchestrator.js.map +1 -1
  214. package/dist/pipeline/stages/code-review.d.ts +2 -2
  215. package/dist/pipeline/stages/code-review.d.ts.map +1 -1
  216. package/dist/pipeline/stages/code-review.js +5 -3
  217. package/dist/pipeline/stages/code-review.js.map +1 -1
  218. package/dist/pipeline/stages/deep-interview.d.ts +15 -0
  219. package/dist/pipeline/stages/deep-interview.d.ts.map +1 -0
  220. package/dist/pipeline/stages/deep-interview.js +32 -0
  221. package/dist/pipeline/stages/deep-interview.js.map +1 -0
  222. package/dist/pipeline/stages/ralph-verify.d.ts +5 -5
  223. package/dist/pipeline/stages/ralph-verify.d.ts.map +1 -1
  224. package/dist/pipeline/stages/ralph-verify.js +2 -2
  225. package/dist/pipeline/stages/ralph-verify.js.map +1 -1
  226. package/dist/pipeline/stages/ultragoal.d.ts +19 -0
  227. package/dist/pipeline/stages/ultragoal.d.ts.map +1 -0
  228. package/dist/pipeline/stages/ultragoal.js +38 -0
  229. package/dist/pipeline/stages/ultragoal.js.map +1 -0
  230. package/dist/pipeline/stages/ultraqa.d.ts +30 -0
  231. package/dist/pipeline/stages/ultraqa.d.ts.map +1 -0
  232. package/dist/pipeline/stages/ultraqa.js +46 -0
  233. package/dist/pipeline/stages/ultraqa.js.map +1 -0
  234. package/dist/pipeline/types.d.ts +8 -6
  235. package/dist/pipeline/types.d.ts.map +1 -1
  236. package/dist/pipeline/types.js +2 -2
  237. package/dist/scripts/__tests__/codex-native-hook.test.js +1488 -117
  238. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  239. package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
  240. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  241. package/dist/scripts/__tests__/smoke-packed-install.test.js +27 -2
  242. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  243. package/dist/scripts/__tests__/verify-native-agents.test.js +16 -1
  244. package/dist/scripts/__tests__/verify-native-agents.test.js.map +1 -1
  245. package/dist/scripts/build-api.d.ts +2 -0
  246. package/dist/scripts/build-api.d.ts.map +1 -0
  247. package/dist/scripts/build-api.js +44 -0
  248. package/dist/scripts/build-api.js.map +1 -0
  249. package/dist/scripts/cleanup-explore-harness.js +1 -0
  250. package/dist/scripts/cleanup-explore-harness.js.map +1 -1
  251. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  252. package/dist/scripts/codex-native-hook.js +364 -16
  253. package/dist/scripts/codex-native-hook.js.map +1 -1
  254. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  255. package/dist/scripts/codex-native-pre-post.js +98 -25
  256. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  257. package/dist/scripts/notify-dispatcher.js +88 -0
  258. package/dist/scripts/notify-dispatcher.js.map +1 -1
  259. package/dist/scripts/notify-hook/process-runner.d.ts.map +1 -1
  260. package/dist/scripts/notify-hook/process-runner.js +39 -17
  261. package/dist/scripts/notify-hook/process-runner.js.map +1 -1
  262. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  263. package/dist/scripts/notify-hook/team-dispatch.js +36 -14
  264. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  265. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  266. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
  267. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  268. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +2 -1
  269. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  270. package/dist/scripts/notify-hook/team-tmux-guard.js +45 -1
  271. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  272. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  273. package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
  274. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  275. package/dist/scripts/run-provider-advisor.js +9 -3
  276. package/dist/scripts/run-provider-advisor.js.map +1 -1
  277. package/dist/scripts/smoke-packed-install.d.ts +4 -1
  278. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  279. package/dist/scripts/smoke-packed-install.js +101 -1
  280. package/dist/scripts/smoke-packed-install.js.map +1 -1
  281. package/dist/scripts/sync-plugin-mirror.js +2 -2
  282. package/dist/scripts/sync-plugin-mirror.js.map +1 -1
  283. package/dist/scripts/verify-native-agents.js +2 -2
  284. package/dist/scripts/verify-native-agents.js.map +1 -1
  285. package/dist/sidecar/__tests__/resource-leak-watch.test.d.ts +2 -0
  286. package/dist/sidecar/__tests__/resource-leak-watch.test.d.ts.map +1 -0
  287. package/dist/sidecar/__tests__/resource-leak-watch.test.js +38 -0
  288. package/dist/sidecar/__tests__/resource-leak-watch.test.js.map +1 -0
  289. package/dist/sidecar/index.d.ts +1 -1
  290. package/dist/sidecar/index.d.ts.map +1 -1
  291. package/dist/sidecar/index.js +29 -12
  292. package/dist/sidecar/index.js.map +1 -1
  293. package/dist/state/__tests__/operations-ralph-phase.test.js +88 -1
  294. package/dist/state/__tests__/operations-ralph-phase.test.js.map +1 -1
  295. package/dist/state/operations.d.ts.map +1 -1
  296. package/dist/state/operations.js +11 -0
  297. package/dist/state/operations.js.map +1 -1
  298. package/dist/team/__tests__/runtime.test.js +2 -2
  299. package/dist/team/__tests__/runtime.test.js.map +1 -1
  300. package/dist/team/__tests__/tmux-session.test.js +207 -22
  301. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  302. package/dist/team/tmux-session.d.ts +1 -0
  303. package/dist/team/tmux-session.d.ts.map +1 -1
  304. package/dist/team/tmux-session.js +73 -28
  305. package/dist/team/tmux-session.js.map +1 -1
  306. package/dist/ultragoal/__tests__/artifacts.test.js +714 -10
  307. package/dist/ultragoal/__tests__/artifacts.test.js.map +1 -1
  308. package/dist/ultragoal/__tests__/docs-contract.test.js +57 -1
  309. package/dist/ultragoal/__tests__/docs-contract.test.js.map +1 -1
  310. package/dist/ultragoal/__tests__/steering-fixtures.d.ts +68 -0
  311. package/dist/ultragoal/__tests__/steering-fixtures.d.ts.map +1 -0
  312. package/dist/ultragoal/__tests__/steering-fixtures.js +259 -0
  313. package/dist/ultragoal/__tests__/steering-fixtures.js.map +1 -0
  314. package/dist/ultragoal/__tests__/steering-fixtures.test.d.ts +2 -0
  315. package/dist/ultragoal/__tests__/steering-fixtures.test.d.ts.map +1 -0
  316. package/dist/ultragoal/__tests__/steering-fixtures.test.js +65 -0
  317. package/dist/ultragoal/__tests__/steering-fixtures.test.js.map +1 -0
  318. package/dist/ultragoal/artifacts.d.ts +97 -2
  319. package/dist/ultragoal/artifacts.d.ts.map +1 -1
  320. package/dist/ultragoal/artifacts.js +811 -256
  321. package/dist/ultragoal/artifacts.js.map +1 -1
  322. package/dist/utils/__tests__/sleep-resource.test.d.ts +2 -0
  323. package/dist/utils/__tests__/sleep-resource.test.d.ts.map +1 -0
  324. package/dist/utils/__tests__/sleep-resource.test.js +39 -0
  325. package/dist/utils/__tests__/sleep-resource.test.js.map +1 -0
  326. package/dist/utils/sleep.d.ts.map +1 -1
  327. package/dist/utils/sleep.js +17 -6
  328. package/dist/utils/sleep.js.map +1 -1
  329. package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
  330. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  331. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
  332. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  333. package/package.json +5 -3
  334. package/plugins/oh-my-codex/.codex-plugin/plugin.json +4 -3
  335. package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +56 -0
  336. package/plugins/oh-my-codex/hooks/hooks.json +77 -0
  337. package/plugins/oh-my-codex/skills/autopilot/SKILL.md +77 -47
  338. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
  339. package/plugins/oh-my-codex/skills/cancel/SKILL.md +2 -2
  340. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +9 -8
  341. package/plugins/oh-my-codex/skills/omx-setup/SKILL.md +1 -1
  342. package/plugins/oh-my-codex/skills/pipeline/SKILL.md +22 -11
  343. package/plugins/oh-my-codex/skills/plan/SKILL.md +8 -8
  344. package/plugins/oh-my-codex/skills/ralph/SKILL.md +7 -0
  345. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +5 -5
  346. package/plugins/oh-my-codex/skills/team/SKILL.md +1 -1
  347. package/plugins/oh-my-codex/skills/ultragoal/SKILL.md +38 -4
  348. package/plugins/oh-my-codex/skills/ultrawork/SKILL.md +1 -1
  349. package/prompts/planner.md +1 -1
  350. package/prompts/researcher.md +15 -10
  351. package/skills/autopilot/SKILL.md +77 -47
  352. package/skills/best-practice-research/SKILL.md +83 -0
  353. package/skills/cancel/SKILL.md +2 -2
  354. package/skills/deep-interview/SKILL.md +9 -8
  355. package/skills/omx-setup/SKILL.md +1 -1
  356. package/skills/pipeline/SKILL.md +22 -11
  357. package/skills/plan/SKILL.md +8 -8
  358. package/skills/ralph/SKILL.md +7 -0
  359. package/skills/ralplan/SKILL.md +5 -5
  360. package/skills/team/SKILL.md +1 -1
  361. package/skills/ultragoal/SKILL.md +38 -4
  362. package/skills/ultrawork/SKILL.md +1 -1
  363. package/src/scripts/__tests__/codex-native-hook.test.ts +1758 -166
  364. package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
  365. package/src/scripts/__tests__/smoke-packed-install.test.ts +39 -2
  366. package/src/scripts/__tests__/verify-native-agents.test.ts +21 -1
  367. package/src/scripts/build-api.ts +48 -0
  368. package/src/scripts/cleanup-explore-harness.ts +1 -0
  369. package/src/scripts/codex-native-hook.ts +416 -18
  370. package/src/scripts/codex-native-pre-post.ts +119 -25
  371. package/src/scripts/notify-dispatcher.ts +97 -0
  372. package/src/scripts/notify-hook/process-runner.ts +40 -16
  373. package/src/scripts/notify-hook/team-dispatch.ts +36 -13
  374. package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
  375. package/src/scripts/notify-hook/team-tmux-guard.ts +49 -0
  376. package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
  377. package/src/scripts/run-provider-advisor.ts +11 -3
  378. package/src/scripts/smoke-packed-install.ts +107 -0
  379. package/src/scripts/sync-plugin-mirror.ts +3 -3
  380. package/src/scripts/verify-native-agents.ts +2 -2
  381. package/templates/catalog-manifest.json +7 -0
@@ -2,28 +2,71 @@ mod codex_bridge;
2
2
  mod error;
3
3
  mod exec;
4
4
  mod prompt;
5
+ mod redaction;
5
6
  #[cfg(test)]
6
7
  mod test_support;
7
8
  mod threshold;
8
9
 
9
10
  use crate::codex_bridge::summarize_output;
10
11
  use crate::error::SparkshellError;
11
- use crate::exec::execute_command;
12
+ use crate::exec::{execute_command, resolve_shell_argv, CommandOutput};
13
+ use crate::redaction::redact_output;
12
14
  use crate::threshold::{combined_visible_lines, read_line_threshold};
13
15
  use omx_mux::build_capture_pane_args;
16
+ use std::collections::hash_map::DefaultHasher;
17
+ use std::fs;
18
+ use std::hash::{Hash, Hasher};
14
19
  use std::io::{self, Write};
20
+ use std::path::{Path, PathBuf};
15
21
  use std::process;
22
+ use std::time::{SystemTime, UNIX_EPOCH};
16
23
 
17
24
  const DEFAULT_TMUX_TAIL_LINES: usize = 200;
18
25
  const MIN_TMUX_TAIL_LINES: usize = 100;
19
26
  const MAX_TMUX_TAIL_LINES: usize = 1000;
20
27
 
21
28
  #[derive(Debug, Clone, PartialEq, Eq)]
22
- enum SparkShellInput {
29
+ enum SparkShellTarget {
23
30
  Command(Vec<String>),
31
+ Shell(String),
24
32
  TmuxPane { pane_id: String, tail_lines: usize },
25
33
  }
26
34
 
35
+ #[derive(Debug, Clone, PartialEq, Eq)]
36
+ struct SparkShellOptions {
37
+ target: SparkShellTarget,
38
+ json: bool,
39
+ budget: usize,
40
+ team: Option<String>,
41
+ worker: Option<String>,
42
+ since_last: bool,
43
+ cache: bool,
44
+ cache_ttl_ms: u64,
45
+ }
46
+
47
+ #[derive(Debug, Clone)]
48
+ struct Evidence {
49
+ stdout_lines: usize,
50
+ stderr_lines: usize,
51
+ raw_hash: String,
52
+ pane_id: Option<String>,
53
+ tail_lines: Option<usize>,
54
+ line_range: Option<String>,
55
+ }
56
+
57
+ #[derive(Debug, Clone)]
58
+ struct CacheMeta {
59
+ cache_hit: bool,
60
+ previous_hash: Option<String>,
61
+ current_hash: String,
62
+ changed_line_ranges: Vec<String>,
63
+ }
64
+
65
+ const DEFAULT_BUDGET: usize = 1000;
66
+ const STALE_HEARTBEAT_MS: u64 = 120_000;
67
+ const DEFAULT_CACHE_TTL_MS: u64 = 10 * 60 * 1000;
68
+ const CACHE_BODY_VERSION: &str = "omx-sparkshell-cache-v2";
69
+
27
70
  fn main() {
28
71
  let args: Vec<String> = std::env::args().skip(1).collect();
29
72
  if args
@@ -40,31 +83,65 @@ fn main() {
40
83
  }
41
84
 
42
85
  fn run(args: Vec<String>) -> Result<(), SparkshellError> {
43
- let execution_argv = match parse_input(&args)? {
44
- SparkShellInput::Command(command) => command,
45
- SparkShellInput::TmuxPane {
86
+ let options = parse_input(&args)?;
87
+ let execution_argv = match &options.target {
88
+ SparkShellTarget::Command(command) => command.clone(),
89
+ SparkShellTarget::Shell(script) => resolve_shell_argv(script),
90
+ SparkShellTarget::TmuxPane {
46
91
  pane_id,
47
92
  tail_lines,
48
93
  } => {
49
94
  let mut argv = vec!["tmux".to_string()];
50
- argv.extend(build_capture_pane_args(&pane_id, tail_lines));
95
+ argv.extend(build_capture_pane_args(pane_id, *tail_lines));
51
96
  argv
52
97
  }
53
98
  };
54
99
 
55
- let output = execute_command(&execution_argv)?;
100
+ let raw_output = execute_command(&execution_argv)?;
101
+ let redacted = redact_output(&raw_output);
102
+ let output = if options.json {
103
+ &redacted.output
104
+ } else {
105
+ &raw_output
106
+ };
107
+ let summary_output = &redacted.output;
56
108
  let threshold = read_line_threshold();
57
109
  let line_count = combined_visible_lines(&output.stdout, &output.stderr);
110
+ let evidence = build_evidence(&options, output);
111
+ let cache_meta = handle_cache(&options, output, &evidence.raw_hash)?;
112
+
113
+ if options.json {
114
+ let summary = if options.since_last {
115
+ since_last_summary(output, cache_meta.as_ref(), options.budget)
116
+ } else if line_count <= threshold {
117
+ compact_text(&combined_text(output), options.budget)
118
+ } else if cache_meta.as_ref().is_some_and(|meta| meta.cache_hit) {
119
+ "unchanged since previous observation".to_string()
120
+ } else {
121
+ summarize_output(&execution_argv, output).unwrap_or_else(|error| {
122
+ format!("summary unavailable: {error}; raw output omitted from JSON report")
123
+ })
124
+ };
125
+ write_json_report(
126
+ &options,
127
+ output,
128
+ &summary,
129
+ &evidence,
130
+ cache_meta,
131
+ redacted.count,
132
+ )?;
133
+ process::exit(output.exit_code());
134
+ }
58
135
 
59
136
  if line_count <= threshold {
60
137
  write_raw_output(&output.stdout, &output.stderr)?;
61
138
  process::exit(output.exit_code());
62
139
  }
63
140
 
64
- match summarize_output(&execution_argv, &output) {
141
+ match summarize_output(&execution_argv, summary_output) {
65
142
  Ok(summary) => {
66
143
  let mut stdout = io::stdout().lock();
67
- stdout.write_all(summary.as_bytes())?;
144
+ stdout.write_all(compact_text(&summary, options.budget).as_bytes())?;
68
145
  if !summary.ends_with('\n') {
69
146
  stdout.write_all(b"\n")?;
70
147
  }
@@ -72,7 +149,7 @@ fn run(args: Vec<String>) -> Result<(), SparkshellError> {
72
149
  }
73
150
  Err(error) => {
74
151
  write_raw_output(&output.stdout, &output.stderr)?;
75
- eprintln!("omx sparkshell: summary unavailable ({error})");
152
+ eprintln!("omx sparkshell: summary unavailable ({error}); showing raw output instead");
76
153
  }
77
154
  }
78
155
 
@@ -94,9 +171,11 @@ fn usage_text() -> String {
94
171
  format!(
95
172
  concat!(
96
173
  "usage: omx-sparkshell <command> [args...]\n",
174
+ " or: omx-sparkshell --shell <shell-command>\n",
97
175
  " or: omx-sparkshell --tmux-pane <pane-id> [--tail-lines <{min}-{max}>]\n",
98
176
  "\n",
99
177
  "Direct command mode executes argv without shell metacharacter parsing.\n",
178
+ "Shell mode executes through bash -lc/sh -lc on POSIX and a native Windows shell on Windows.\n",
100
179
  "Tmux pane mode captures a larger pane tail and applies the same raw-vs-summary behavior.\n"
101
180
  ),
102
181
  min = MIN_TMUX_TAIL_LINES,
@@ -104,7 +183,7 @@ fn usage_text() -> String {
104
183
  )
105
184
  }
106
185
 
107
- fn parse_input(args: &[String]) -> Result<SparkShellInput, SparkshellError> {
186
+ fn parse_input(args: &[String]) -> Result<SparkShellOptions, SparkshellError> {
108
187
  if args.is_empty() {
109
188
  return Err(SparkshellError::InvalidArgs(usage_text()));
110
189
  }
@@ -113,10 +192,139 @@ fn parse_input(args: &[String]) -> Result<SparkShellInput, SparkshellError> {
113
192
  let mut tail_lines = DEFAULT_TMUX_TAIL_LINES;
114
193
  let mut explicit_tail_lines = false;
115
194
  let mut positional = Vec::new();
195
+ let mut json = false;
196
+ let mut budget = DEFAULT_BUDGET;
197
+ let mut team = None;
198
+ let mut worker = None;
199
+ let mut since_last = false;
200
+ let mut cache = true;
201
+ let mut cache_ttl_ms = DEFAULT_CACHE_TTL_MS;
202
+ let mut shell = None;
116
203
 
117
204
  let mut index = 0;
118
205
  while index < args.len() {
119
206
  let token = &args[index];
207
+ if !positional.is_empty() {
208
+ positional.extend(args[index..].iter().cloned());
209
+ break;
210
+ }
211
+ if token == "--" {
212
+ positional.extend(args[index + 1..].iter().cloned());
213
+ break;
214
+ }
215
+ if token == "--json" {
216
+ json = true;
217
+ index += 1;
218
+ continue;
219
+ }
220
+ if token == "--budget" {
221
+ let Some(next) = args.get(index + 1) else {
222
+ return Err(SparkshellError::InvalidArgs(
223
+ "--budget requires a numeric value".to_string(),
224
+ ));
225
+ };
226
+ budget = parse_positive_usize(next, "--budget")?;
227
+ index += 2;
228
+ continue;
229
+ }
230
+ if let Some(value) = token.strip_prefix("--budget=") {
231
+ budget = parse_positive_usize(value, "--budget")?;
232
+ index += 1;
233
+ continue;
234
+ }
235
+ if token == "--shell" {
236
+ let Some(next) = args.get(index + 1) else {
237
+ return Err(SparkshellError::InvalidArgs(
238
+ "--shell requires a command string".to_string(),
239
+ ));
240
+ };
241
+ shell = Some(next.clone());
242
+ index += 2;
243
+ continue;
244
+ }
245
+ if let Some(value) = token.strip_prefix("--shell=") {
246
+ if value.trim().is_empty() {
247
+ return Err(SparkshellError::InvalidArgs(
248
+ "--shell requires a command string".to_string(),
249
+ ));
250
+ }
251
+ shell = Some(value.to_string());
252
+ index += 1;
253
+ continue;
254
+ }
255
+ if token == "--since-last" {
256
+ since_last = true;
257
+ index += 1;
258
+ continue;
259
+ }
260
+ if token == "--cache-ttl-ms" {
261
+ let Some(next) = args.get(index + 1) else {
262
+ return Err(SparkshellError::InvalidArgs(
263
+ "--cache-ttl-ms requires a numeric value".to_string(),
264
+ ));
265
+ };
266
+ cache_ttl_ms = parse_positive_usize(next, "--cache-ttl-ms")? as u64;
267
+ index += 2;
268
+ continue;
269
+ }
270
+ if let Some(value) = token.strip_prefix("--cache-ttl-ms=") {
271
+ cache_ttl_ms = parse_positive_usize(value, "--cache-ttl-ms")? as u64;
272
+ index += 1;
273
+ continue;
274
+ }
275
+ if let Some(value) = token.strip_prefix("--cache=") {
276
+ cache = match value {
277
+ "on" => true,
278
+ "off" => false,
279
+ _ => {
280
+ return Err(SparkshellError::InvalidArgs(
281
+ "--cache must be on or off".to_string(),
282
+ ))
283
+ }
284
+ };
285
+ index += 1;
286
+ continue;
287
+ }
288
+ if token == "--team" {
289
+ let Some(next) = args.get(index + 1) else {
290
+ return Err(SparkshellError::InvalidArgs(
291
+ "--team requires a value".to_string(),
292
+ ));
293
+ };
294
+ team = Some(next.clone());
295
+ index += 2;
296
+ continue;
297
+ }
298
+ if let Some(value) = token.strip_prefix("--team=") {
299
+ if value.trim().is_empty() {
300
+ return Err(SparkshellError::InvalidArgs(
301
+ "--team requires a value".to_string(),
302
+ ));
303
+ }
304
+ team = Some(value.to_string());
305
+ index += 1;
306
+ continue;
307
+ }
308
+ if token == "--worker" {
309
+ let Some(next) = args.get(index + 1) else {
310
+ return Err(SparkshellError::InvalidArgs(
311
+ "--worker requires a value".to_string(),
312
+ ));
313
+ };
314
+ worker = Some(next.clone());
315
+ index += 2;
316
+ continue;
317
+ }
318
+ if let Some(value) = token.strip_prefix("--worker=") {
319
+ if value.trim().is_empty() {
320
+ return Err(SparkshellError::InvalidArgs(
321
+ "--worker requires a value".to_string(),
322
+ ));
323
+ }
324
+ worker = Some(value.to_string());
325
+ index += 1;
326
+ continue;
327
+ }
120
328
  if token == "--tmux-pane" {
121
329
  let Some(next) = args.get(index + 1) else {
122
330
  return Err(SparkshellError::InvalidArgs(
@@ -164,25 +372,616 @@ fn parse_input(args: &[String]) -> Result<SparkShellInput, SparkshellError> {
164
372
  index += 1;
165
373
  }
166
374
 
167
- if let Some(pane_id) = pane_id {
375
+ let target = if let Some(script) = shell {
376
+ if pane_id.is_some() || !positional.is_empty() {
377
+ return Err(SparkshellError::InvalidArgs(
378
+ "--shell does not accept --tmux-pane or additional argv".to_string(),
379
+ ));
380
+ }
381
+ SparkShellTarget::Shell(script)
382
+ } else if let Some(pane_id) = pane_id {
168
383
  if !positional.is_empty() {
169
384
  return Err(SparkshellError::InvalidArgs(
170
385
  "tmux pane mode does not accept an additional command".to_string(),
171
386
  ));
172
387
  }
173
- return Ok(SparkShellInput::TmuxPane {
388
+ SparkShellTarget::TmuxPane {
174
389
  pane_id,
175
390
  tail_lines,
176
- });
391
+ }
392
+ } else {
393
+ if explicit_tail_lines {
394
+ return Err(SparkshellError::InvalidArgs(
395
+ "--tail-lines requires --tmux-pane".to_string(),
396
+ ));
397
+ }
398
+ SparkShellTarget::Command(positional)
399
+ };
400
+
401
+ Ok(SparkShellOptions {
402
+ target,
403
+ json,
404
+ budget,
405
+ team,
406
+ worker,
407
+ since_last,
408
+ cache,
409
+ cache_ttl_ms,
410
+ })
411
+ }
412
+
413
+ fn parse_positive_usize(raw: &str, flag: &str) -> Result<usize, SparkshellError> {
414
+ raw.trim()
415
+ .parse::<usize>()
416
+ .ok()
417
+ .filter(|value| *value > 0)
418
+ .ok_or_else(|| SparkshellError::InvalidArgs(format!("{flag} requires a positive integer")))
419
+ }
420
+
421
+ fn build_evidence(options: &SparkShellOptions, output: &CommandOutput) -> Evidence {
422
+ let text = combined_text(output);
423
+ let lines = text.lines().count();
424
+ let (pane_id, tail_lines) = match &options.target {
425
+ SparkShellTarget::TmuxPane {
426
+ pane_id,
427
+ tail_lines,
428
+ } => (Some(pane_id.clone()), Some(*tail_lines)),
429
+ SparkShellTarget::Command(_) | SparkShellTarget::Shell(_) => (None, None),
430
+ };
431
+ Evidence {
432
+ stdout_lines: String::from_utf8_lossy(&output.stdout).lines().count(),
433
+ stderr_lines: String::from_utf8_lossy(&output.stderr).lines().count(),
434
+ raw_hash: hash_text(&text),
435
+ pane_id,
436
+ tail_lines,
437
+ line_range: (lines > 0).then(|| format!("1-{lines}")),
177
438
  }
439
+ }
178
440
 
179
- if explicit_tail_lines {
180
- return Err(SparkshellError::InvalidArgs(
181
- "--tail-lines requires --tmux-pane".to_string(),
182
- ));
441
+ fn combined_text(output: &CommandOutput) -> String {
442
+ format!("{}{}", output.stdout_text(), output.stderr_text())
443
+ }
444
+
445
+ fn hash_text(text: &str) -> String {
446
+ let mut hasher = DefaultHasher::new();
447
+ text.hash(&mut hasher);
448
+ format!("{:016x}", hasher.finish())
449
+ }
450
+
451
+ fn compact_text(text: &str, budget: usize) -> String {
452
+ if text.len() <= budget {
453
+ return text.to_string();
454
+ }
455
+ let end = safe_boundary(text, budget);
456
+ format!(
457
+ "{}\n[truncated: {} chars omitted]",
458
+ &text[..end],
459
+ text.len().saturating_sub(end)
460
+ )
461
+ }
462
+
463
+ fn safe_boundary(text: &str, max: usize) -> usize {
464
+ let mut end = 0;
465
+ for (index, ch) in text.char_indices() {
466
+ let next = index + ch.len_utf8();
467
+ if next > max {
468
+ break;
469
+ }
470
+ end = next;
471
+ }
472
+ end
473
+ }
474
+
475
+ fn json_escape(value: &str) -> String {
476
+ let mut escaped = String::new();
477
+ for ch in value.chars() {
478
+ match ch {
479
+ '\\' => escaped.push_str("\\\\"),
480
+ '"' => escaped.push_str("\\\""),
481
+ '\n' => escaped.push_str("\\n"),
482
+ '\r' => escaped.push_str("\\r"),
483
+ '\t' => escaped.push_str("\\t"),
484
+ '\u{08}' => escaped.push_str("\\b"),
485
+ '\u{0c}' => escaped.push_str("\\f"),
486
+ ch if ch <= '\u{1f}' => escaped.push_str(&format!("\\u{:04x}", ch as u32)),
487
+ ch => escaped.push(ch),
488
+ }
489
+ }
490
+ escaped
491
+ }
492
+
493
+ fn json_str(value: &str) -> String {
494
+ format!("\"{}\"", json_escape(value))
495
+ }
496
+
497
+ fn json_string_array(values: &[String]) -> String {
498
+ format!(
499
+ "[{}]",
500
+ values
501
+ .iter()
502
+ .map(|value| json_str(value))
503
+ .collect::<Vec<_>>()
504
+ .join(",")
505
+ )
506
+ }
507
+
508
+ fn optional_json_string(value: &Option<String>) -> String {
509
+ value
510
+ .as_ref()
511
+ .map(|value| json_str(value))
512
+ .unwrap_or_else(|| "null".to_string())
513
+ }
514
+
515
+ fn cache_dir() -> PathBuf {
516
+ std::env::var("OMX_SPARKSHELL_CACHE_DIR")
517
+ .map(PathBuf::from)
518
+ .unwrap_or_else(|_| {
519
+ std::env::var("OMX_TEAM_STATE_ROOT")
520
+ .map(|root| PathBuf::from(root).join("../cache/sparkshell"))
521
+ .unwrap_or_else(|_| PathBuf::from(".omx/cache/sparkshell"))
522
+ })
523
+ }
524
+
525
+ fn handle_cache(
526
+ options: &SparkShellOptions,
527
+ output: &CommandOutput,
528
+ current_hash: &str,
529
+ ) -> Result<Option<CacheMeta>, SparkshellError> {
530
+ if !options.cache {
531
+ return Ok(None);
532
+ }
533
+ let key = match &options.target {
534
+ SparkShellTarget::TmuxPane { pane_id, .. } => pane_cache_key(pane_id),
535
+ SparkShellTarget::Command(_) | SparkShellTarget::Shell(_) => return Ok(None),
536
+ };
537
+ let dir = cache_dir();
538
+ fs::create_dir_all(&dir)?;
539
+ handle_cache_at_path(
540
+ &dir.join(format!("{key}.txt")),
541
+ output,
542
+ current_hash,
543
+ options.cache_ttl_ms,
544
+ )
545
+ }
546
+
547
+ fn pane_cache_key(pane_id: &str) -> String {
548
+ let percent_escaped = pane_id.replace('%', "pct");
549
+ if percent_escaped
550
+ .bytes()
551
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
552
+ {
553
+ return format!("pane-{percent_escaped}");
554
+ }
555
+
556
+ format!("pane-h{:016x}", fnv1a64(pane_id.as_bytes()))
557
+ }
558
+
559
+ fn fnv1a64(bytes: &[u8]) -> u64 {
560
+ let mut hash = 0xcbf2_9ce4_8422_2325;
561
+ for byte in bytes {
562
+ hash ^= u64::from(*byte);
563
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
564
+ }
565
+ hash
566
+ }
567
+
568
+ fn handle_cache_at_path(
569
+ path: &Path,
570
+ output: &CommandOutput,
571
+ current_hash: &str,
572
+ ttl_ms: u64,
573
+ ) -> Result<Option<CacheMeta>, SparkshellError> {
574
+ let now = now_ms();
575
+ let current = combined_text(output);
576
+ let current_line_count = current.lines().count();
577
+ let mut previous_hash = None;
578
+ let mut cache_hit = false;
579
+ let mut changed_line_ranges = Vec::new();
580
+ if let Ok(previous) = fs::read_to_string(path) {
581
+ let mut parts = previous.splitn(3, '\n');
582
+ let timestamp = parts
583
+ .next()
584
+ .and_then(|value| value.parse::<u64>().ok())
585
+ .unwrap_or(0);
586
+ let old_hash = parts.next().unwrap_or("").to_string();
587
+ let old_body = parts.next().unwrap_or("");
588
+ if now.saturating_sub(timestamp) <= ttl_ms {
589
+ let old_line_count = cached_line_count(old_body);
590
+ previous_hash = Some(old_hash.clone());
591
+ cache_hit = old_hash == current_hash;
592
+ if !cache_hit {
593
+ changed_line_ranges = changed_ranges(
594
+ old_hash.as_str(),
595
+ current_hash,
596
+ old_line_count,
597
+ current_line_count,
598
+ );
599
+ }
600
+ }
601
+ }
602
+ fs::write(
603
+ path,
604
+ cache_contents(now, current_hash, output, current_line_count),
605
+ )?;
606
+ Ok(Some(CacheMeta {
607
+ cache_hit,
608
+ previous_hash,
609
+ current_hash: current_hash.to_string(),
610
+ changed_line_ranges,
611
+ }))
612
+ }
613
+
614
+ fn cache_contents(
615
+ timestamp_ms: u64,
616
+ current_hash: &str,
617
+ output: &CommandOutput,
618
+ current_line_count: usize,
619
+ ) -> String {
620
+ format!(
621
+ "{timestamp_ms}\n{current_hash}\n{CACHE_BODY_VERSION}\nlines={current_line_count}\nstdout_lines={}\nstderr_lines={}\n",
622
+ String::from_utf8_lossy(&output.stdout).lines().count(),
623
+ String::from_utf8_lossy(&output.stderr).lines().count()
624
+ )
625
+ }
626
+
627
+ fn cached_line_count(body: &str) -> usize {
628
+ if let Some(metadata) = body.strip_prefix(CACHE_BODY_VERSION) {
629
+ return metadata
630
+ .lines()
631
+ .find_map(|line| line.strip_prefix("lines="))
632
+ .and_then(|value| value.parse::<usize>().ok())
633
+ .unwrap_or(0);
634
+ }
635
+ body.lines().count()
636
+ }
637
+
638
+ fn since_last_summary(output: &CommandOutput, cache: Option<&CacheMeta>, budget: usize) -> String {
639
+ let Some(cache) = cache else {
640
+ return compact_text(&combined_text(output), budget);
641
+ };
642
+ if cache.cache_hit {
643
+ return "unchanged since previous observation".to_string();
644
+ }
645
+ if cache.changed_line_ranges.is_empty() {
646
+ return compact_text(&combined_text(output), budget);
647
+ }
648
+ let text = combined_text(output);
649
+ let lines: Vec<&str> = text.lines().collect();
650
+ let mut selected = Vec::new();
651
+ for range in &cache.changed_line_ranges {
652
+ if let Some((start, end)) = range.split_once('-') {
653
+ if let (Ok(start), Ok(end)) = (start.parse::<usize>(), end.parse::<usize>()) {
654
+ for line in lines
655
+ .iter()
656
+ .skip(start.saturating_sub(1))
657
+ .take(end.saturating_sub(start).saturating_add(1))
658
+ {
659
+ selected.push((*line).to_string());
660
+ }
661
+ }
662
+ }
663
+ }
664
+ if selected.is_empty() {
665
+ compact_text(&combined_text(output), budget)
666
+ } else {
667
+ compact_text(
668
+ &format!(
669
+ "new findings since last observation:\n{}",
670
+ selected.join("\n")
671
+ ),
672
+ budget,
673
+ )
674
+ }
675
+ }
676
+
677
+ fn changed_ranges(
678
+ old_hash: &str,
679
+ current_hash: &str,
680
+ old_line_count: usize,
681
+ current_line_count: usize,
682
+ ) -> Vec<String> {
683
+ if current_line_count > old_line_count {
684
+ vec![format!("{}-{}", old_line_count + 1, current_line_count)]
685
+ } else if old_hash != current_hash {
686
+ vec!["1-*".to_string()]
687
+ } else {
688
+ Vec::new()
689
+ }
690
+ }
691
+
692
+ #[derive(Debug, Clone)]
693
+ struct Diagnostics {
694
+ classification: String,
695
+ next_action: String,
696
+ confidence: f32,
697
+ errors: Vec<String>,
698
+ warnings: Vec<String>,
699
+ }
700
+
701
+ fn classify(options: &SparkShellOptions, output: &CommandOutput) -> Diagnostics {
702
+ let text = combined_text(output).to_ascii_lowercase();
703
+ let mut diagnostics = Diagnostics {
704
+ classification: "unknown".to_string(),
705
+ next_action: "inspect raw output".to_string(),
706
+ confidence: 0.45,
707
+ errors: Vec::new(),
708
+ warnings: Vec::new(),
709
+ };
710
+
711
+ if text.contains("authorization") || text.contains("authentication") || text.contains("401") {
712
+ diagnostics.classification = "auth_error".to_string();
713
+ diagnostics.confidence = 0.8;
714
+ diagnostics
715
+ .errors
716
+ .push("authentication-like error in output".to_string());
717
+ } else if text.contains("typeerror") || text.contains("type error") {
718
+ diagnostics.classification = "type_error".to_string();
719
+ diagnostics.confidence = 0.75;
720
+ diagnostics
721
+ .errors
722
+ .push("type error pattern in output".to_string());
723
+ } else if text.contains("test failed") || text.contains("failures:") || text.contains("failed")
724
+ {
725
+ diagnostics.classification = "test_failure".to_string();
726
+ diagnostics.confidence = 0.65;
727
+ diagnostics
728
+ .errors
729
+ .push("failure pattern in output".to_string());
730
+ } else if text.contains("press enter")
731
+ || text.contains("waiting for input")
732
+ || text.contains("continue?")
733
+ {
734
+ diagnostics.classification = "waiting_for_input".to_string();
735
+ diagnostics.confidence = 0.75;
736
+ } else if text.contains("thinking") || text.contains("running") || text.contains("building") {
737
+ diagnostics.classification = "busy_processing".to_string();
738
+ diagnostics.next_action = "wait".to_string();
739
+ diagnostics.confidence = 0.65;
740
+ diagnostics.warnings.push("do not shutdown yet".to_string());
741
+ }
742
+
743
+ if let (Some(team), Some(worker)) = (&options.team, &options.worker) {
744
+ if let Some(team_diagnostics) = classify_team(team, worker) {
745
+ diagnostics = team_diagnostics;
746
+ }
747
+ }
748
+
749
+ diagnostics
750
+ }
751
+
752
+ fn classify_team(team: &str, worker: &str) -> Option<Diagnostics> {
753
+ let state_root = std::env::var("OMX_TEAM_STATE_ROOT")
754
+ .map(PathBuf::from)
755
+ .unwrap_or_else(|_| PathBuf::from(".omx/state"));
756
+ let base = state_root
757
+ .join("team")
758
+ .join(team)
759
+ .join("workers")
760
+ .join(worker);
761
+ if !base.exists() {
762
+ return None;
763
+ }
764
+
765
+ if let Ok(heartbeat) = fs::read_to_string(base.join("heartbeat.json")) {
766
+ if let Some(timestamp) = extract_heartbeat_ms(&heartbeat) {
767
+ if now_ms().saturating_sub(timestamp) > STALE_HEARTBEAT_MS {
768
+ return Some(Diagnostics {
769
+ classification: "stale_heartbeat".to_string(),
770
+ next_action: "run omx team status".to_string(),
771
+ confidence: 0.78,
772
+ errors: Vec::new(),
773
+ warnings: vec!["heartbeat is stale".to_string()],
774
+ });
775
+ }
776
+ }
777
+ }
778
+
779
+ if let Ok(status) = fs::read_to_string(base.join("status.json")) {
780
+ if let Some(diagnostics) = classify_worker_status(&status) {
781
+ return Some(diagnostics);
782
+ }
183
783
  }
184
784
 
185
- Ok(SparkShellInput::Command(positional))
785
+ None
786
+ }
787
+
788
+ fn classify_worker_status(status: &str) -> Option<Diagnostics> {
789
+ // Keep this mapping aligned with the WorkerStatus.state union in src/team/state.ts.
790
+ let state = extract_json_string(status, "state")
791
+ .map(|value| value.to_ascii_lowercase())
792
+ .unwrap_or_else(|| status.to_ascii_lowercase());
793
+
794
+ match state.as_str() {
795
+ "working" | "busy" | "in_progress" => Some(Diagnostics {
796
+ classification: "busy_processing".to_string(),
797
+ next_action: "wait".to_string(),
798
+ confidence: 0.72,
799
+ errors: Vec::new(),
800
+ warnings: vec!["do not shutdown yet".to_string()],
801
+ }),
802
+ "blocked" | "needs_input" => Some(Diagnostics {
803
+ classification: "waiting_for_input".to_string(),
804
+ next_action: "inspect raw pane".to_string(),
805
+ confidence: 0.7,
806
+ errors: Vec::new(),
807
+ warnings: Vec::new(),
808
+ }),
809
+ "failed" => Some(Diagnostics {
810
+ classification: "test_failure".to_string(),
811
+ next_action: "inspect raw pane".to_string(),
812
+ confidence: 0.7,
813
+ errors: vec!["worker status is failed".to_string()],
814
+ warnings: Vec::new(),
815
+ }),
816
+ _ if state.contains("working")
817
+ || state.contains("busy")
818
+ || state.contains("in_progress") =>
819
+ {
820
+ Some(Diagnostics {
821
+ classification: "busy_processing".to_string(),
822
+ next_action: "wait".to_string(),
823
+ confidence: 0.72,
824
+ errors: Vec::new(),
825
+ warnings: vec!["do not shutdown yet".to_string()],
826
+ })
827
+ }
828
+ _ if state.contains("blocked") || state.contains("needs_input") => Some(Diagnostics {
829
+ classification: "waiting_for_input".to_string(),
830
+ next_action: "inspect raw pane".to_string(),
831
+ confidence: 0.7,
832
+ errors: Vec::new(),
833
+ warnings: Vec::new(),
834
+ }),
835
+ _ => None,
836
+ }
837
+ }
838
+
839
+ fn extract_heartbeat_ms(text: &str) -> Option<u64> {
840
+ extract_json_number(text, "updated_at_ms")
841
+ .or_else(|| extract_json_number(text, "timestamp"))
842
+ .or_else(|| {
843
+ extract_json_string(text, "last_turn_at")
844
+ .and_then(|value| parse_iso_timestamp_ms(&value))
845
+ })
846
+ }
847
+
848
+ fn extract_json_number(text: &str, key: &str) -> Option<u64> {
849
+ let key_pattern = format!("\"{key}\"");
850
+ let start = text.find(&key_pattern)? + key_pattern.len();
851
+ let after_colon = text[start..].split_once(':')?.1.trim_start();
852
+ let digits: String = after_colon
853
+ .chars()
854
+ .take_while(|ch| ch.is_ascii_digit())
855
+ .collect();
856
+ if digits.is_empty() {
857
+ return None;
858
+ }
859
+ digits.parse().ok()
860
+ }
861
+
862
+ fn extract_json_string(text: &str, key: &str) -> Option<String> {
863
+ let key_pattern = format!("\"{key}\"");
864
+ let start = text.find(&key_pattern)? + key_pattern.len();
865
+ let after_colon = text[start..].split_once(':')?.1.trim_start();
866
+ let after_quote = after_colon.strip_prefix('"')?;
867
+ let value = after_quote.split('"').next()?;
868
+ Some(value.to_string())
869
+ }
870
+
871
+ fn parse_iso_timestamp_ms(value: &str) -> Option<u64> {
872
+ let trimmed = value.strip_suffix('Z').unwrap_or(value);
873
+ let (date, time) = trimmed.split_once('T')?;
874
+ let mut date_parts = date.split('-').map(|part| part.parse::<i64>().ok());
875
+ let year = date_parts.next()??;
876
+ let month = date_parts.next()??;
877
+ let day = date_parts.next()??;
878
+ let mut time_parts = time.split(':');
879
+ let hour = time_parts.next()?.parse::<i64>().ok()?;
880
+ let minute = time_parts.next()?.parse::<i64>().ok()?;
881
+ let second_part = time_parts.next()?;
882
+ let second = second_part.split('.').next()?.parse::<i64>().ok()?;
883
+ let days = days_from_civil(year, month, day)?;
884
+ Some(((days * 86_400 + hour * 3_600 + minute * 60 + second) as u64) * 1000)
885
+ }
886
+
887
+ fn days_from_civil(year: i64, month: i64, day: i64) -> Option<i64> {
888
+ if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
889
+ return None;
890
+ }
891
+ let year = year - i64::from(month <= 2);
892
+ let era = if year >= 0 { year } else { year - 399 } / 400;
893
+ let yoe = year - era * 400;
894
+ let mp = month + if month > 2 { -3 } else { 9 };
895
+ let doy = (153 * mp + 2) / 5 + day - 1;
896
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
897
+ Some(era * 146_097 + doe - 719_468)
898
+ }
899
+
900
+ fn now_ms() -> u64 {
901
+ SystemTime::now()
902
+ .duration_since(UNIX_EPOCH)
903
+ .unwrap_or_default()
904
+ .as_millis() as u64
905
+ }
906
+
907
+ fn write_json_report(
908
+ options: &SparkShellOptions,
909
+ output: &CommandOutput,
910
+ summary: &str,
911
+ evidence: &Evidence,
912
+ cache: Option<CacheMeta>,
913
+ redaction_count: usize,
914
+ ) -> Result<(), SparkshellError> {
915
+ let mode = match options.target {
916
+ SparkShellTarget::Command(_) => "command",
917
+ SparkShellTarget::Shell(_) => "shell",
918
+ SparkShellTarget::TmuxPane { .. } => "tmux-pane",
919
+ };
920
+ let status = if output.status.success() {
921
+ "ok"
922
+ } else {
923
+ "failed"
924
+ };
925
+ let mut diagnostics = classify(options, output);
926
+ if !output.status.success() && diagnostics.errors.is_empty() {
927
+ diagnostics
928
+ .errors
929
+ .push(compact_text(&output.stderr_text(), options.budget));
930
+ }
931
+ let tail_lines = evidence
932
+ .tail_lines
933
+ .map(|value| value.to_string())
934
+ .unwrap_or_else(|| "null".to_string());
935
+ let cache_json = cache
936
+ .map(|cache| {
937
+ format!(
938
+ "{{\"cache_hit\":{},\"previous_hash\":{},\"current_hash\":{},\"changed_line_ranges\":{}}}",
939
+ cache.cache_hit,
940
+ optional_json_string(&cache.previous_hash),
941
+ json_str(&cache.current_hash),
942
+ json_string_array(&cache.changed_line_ranges),
943
+ )
944
+ })
945
+ .unwrap_or_else(|| "null".to_string());
946
+ let json = format!(
947
+ concat!(
948
+ "{{\n",
949
+ " \"ok\": {},\n",
950
+ " \"mode\": {},\n",
951
+ " \"status\": {},\n",
952
+ " \"exit_code\": {},\n",
953
+ " \"summary\": {},\n",
954
+ " \"errors\": {},\n",
955
+ " \"warnings\": {},\n",
956
+ " \"evidence\": {{\"stdout_lines\":{},\"stderr_lines\":{},\"raw_hash\":{},\"pane_id\":{},\"tail_lines\":{},\"line_range\":{}}},\n",
957
+ " \"next_action\": {},\n",
958
+ " \"confidence\": {:.2},\n",
959
+ " \"classification\": {},\n",
960
+ " \"cache\": {},\n",
961
+ " \"redactions\": {{\"count\": {}}}\n",
962
+ "}}\n"
963
+ ),
964
+ output.status.success(),
965
+ json_str(mode),
966
+ json_str(status),
967
+ output.exit_code(),
968
+ json_str(&compact_text(summary, options.budget)),
969
+ json_string_array(&diagnostics.errors),
970
+ json_string_array(&diagnostics.warnings),
971
+ evidence.stdout_lines,
972
+ evidence.stderr_lines,
973
+ json_str(&evidence.raw_hash),
974
+ optional_json_string(&evidence.pane_id),
975
+ tail_lines,
976
+ optional_json_string(&evidence.line_range),
977
+ json_str(&diagnostics.next_action),
978
+ diagnostics.confidence,
979
+ json_str(&diagnostics.classification),
980
+ cache_json,
981
+ redaction_count,
982
+ );
983
+ io::stdout().write_all(json.as_bytes())?;
984
+ Ok(())
186
985
  }
187
986
 
188
987
  fn parse_tail_lines(raw: &str) -> Result<usize, SparkshellError> {
@@ -201,7 +1000,7 @@ fn parse_tail_lines(raw: &str) -> Result<usize, SparkshellError> {
201
1000
 
202
1001
  #[cfg(test)]
203
1002
  mod tests {
204
- use super::{parse_input, SparkShellInput};
1003
+ use super::{parse_input, SparkShellTarget};
205
1004
 
206
1005
  fn strings(values: &[&str]) -> Vec<String> {
207
1006
  values.iter().map(|value| value.to_string()).collect()
@@ -211,8 +1010,8 @@ mod tests {
211
1010
  fn parses_direct_command_mode() {
212
1011
  let parsed = parse_input(&strings(&["git", "status"])).expect("parsed");
213
1012
  assert_eq!(
214
- parsed,
215
- SparkShellInput::Command(strings(&["git", "status"]))
1013
+ parsed.target,
1014
+ SparkShellTarget::Command(strings(&["git", "status"]))
216
1015
  );
217
1016
  }
218
1017
 
@@ -220,8 +1019,8 @@ mod tests {
220
1019
  fn parses_tmux_pane_mode_with_default_tail_lines() {
221
1020
  let parsed = parse_input(&strings(&["--tmux-pane", "%11"])).expect("parsed");
222
1021
  assert_eq!(
223
- parsed,
224
- SparkShellInput::TmuxPane {
1022
+ parsed.target,
1023
+ SparkShellTarget::TmuxPane {
225
1024
  pane_id: "%11".to_string(),
226
1025
  tail_lines: 200,
227
1026
  }
@@ -233,8 +1032,8 @@ mod tests {
233
1032
  let parsed =
234
1033
  parse_input(&strings(&["--tmux-pane=%22", "--tail-lines=400"])).expect("parsed");
235
1034
  assert_eq!(
236
- parsed,
237
- SparkShellInput::TmuxPane {
1035
+ parsed.target,
1036
+ SparkShellTarget::TmuxPane {
238
1037
  pane_id: "%22".to_string(),
239
1038
  tail_lines: 400,
240
1039
  }
@@ -301,15 +1100,15 @@ mod tests {
301
1100
  let max = parse_input(&strings(&["--tmux-pane", "%11", "--tail-lines", "1000"]))
302
1101
  .expect("max parsed");
303
1102
  assert_eq!(
304
- min,
305
- SparkShellInput::TmuxPane {
1103
+ min.target,
1104
+ SparkShellTarget::TmuxPane {
306
1105
  pane_id: "%11".to_string(),
307
1106
  tail_lines: 100
308
1107
  }
309
1108
  );
310
1109
  assert_eq!(
311
- max,
312
- SparkShellInput::TmuxPane {
1110
+ max.target,
1111
+ SparkShellTarget::TmuxPane {
313
1112
  pane_id: "%11".to_string(),
314
1113
  tail_lines: 1000
315
1114
  }