stream_weaver 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 (381) hide show
  1. checksums.yaml +7 -0
  2. data/.beads/.gitignore +77 -0
  3. data/.beads/README.md +81 -0
  4. data/.beads/config.yaml +54 -0
  5. data/.beads/metadata.json +7 -0
  6. data/.rspec +3 -0
  7. data/AGENTS.md +91 -0
  8. data/CHANGELOG.md +294 -0
  9. data/CLAUDE.md +67 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +565 -0
  12. data/Rakefile +13 -0
  13. data/assets/streamweaver-hero.jpg +0 -0
  14. data/bench/baselines/ledger.rb +58 -0
  15. data/bench/baselines/warroom.rb +51 -0
  16. data/bench/fixtures/ledger.rb +76 -0
  17. data/bench/fixtures/warroom.rb +60 -0
  18. data/bench/results/6daf8b8.md +102 -0
  19. data/bench/results/c8d749b.md +102 -0
  20. data/bench/results/eb06ab9.md +102 -0
  21. data/bench/run.rb +151 -0
  22. data/bench/support.rb +84 -0
  23. data/copy_this/dto_run_sample_run_7631.html +220 -0
  24. data/docs/SERVICE_MODE.md +302 -0
  25. data/docs/architecture/how_streamweaver_works.md +772 -0
  26. data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
  27. data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
  28. data/docs/canvas-ipc-session-summary.md +127 -0
  29. data/docs/canvas-panel-workflow.md +172 -0
  30. data/docs/canvas-read.md +147 -0
  31. data/docs/canvas-roadmap.md +275 -0
  32. data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
  33. data/docs/claude-code-companion-skill-spec.md +567 -0
  34. data/docs/components_reference.md +1223 -0
  35. data/docs/crud-patterns.md +265 -0
  36. data/docs/endpoints.md +122 -0
  37. data/docs/for_llms.md +1 -0
  38. data/docs/form-patterns.md +165 -0
  39. data/docs/frontend-only.md +169 -0
  40. data/docs/html-artifact-audit.md +238 -0
  41. data/docs/ideas/2025-01-01-as-a-service.md +203 -0
  42. data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
  43. data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
  44. data/docs/ideas/dhh-review-future-refactors.md +142 -0
  45. data/docs/opal-jamstack.md +47 -0
  46. data/docs/opal-npm-direction.md +184 -0
  47. data/docs/opal-spike-findings.md +194 -0
  48. data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
  49. data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
  50. data/docs/plans/2026-01-02-charts-design.md +288 -0
  51. data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
  52. data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
  53. data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
  54. data/docs/plans/canvas-claude-project-design.md +238 -0
  55. data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
  56. data/docs/plans/org-doc-preview-surfaces.md +293 -0
  57. data/docs/plans/shadcn-polish-plan.md +417 -0
  58. data/docs/porting-artifacts.md +252 -0
  59. data/docs/reference/agent-skills-comparison.md +369 -0
  60. data/docs/reference/travel-state-prd.artifact.html +780 -0
  61. data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
  62. data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
  63. data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
  64. data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
  65. data/docs/research/frontend-only-matrix.md +278 -0
  66. data/docs/research/streamweaver-way-spike-findings.md +529 -0
  67. data/docs/resource-dsl.md +342 -0
  68. data/docs/routing.md +302 -0
  69. data/docs/ruby-ui-comparison.md +319 -0
  70. data/docs/shared-dsl-fragments.md +356 -0
  71. data/docs/streamweaver-for-ai-agents.md +401 -0
  72. data/docs/streamweaver-frontend-vision.md +123 -0
  73. data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
  74. data/docs/streamweaver_canvas/save_example.rb +9 -0
  75. data/docs/templates.md +213 -0
  76. data/docs/testing.md +155 -0
  77. data/docs/theming-hooks.md +281 -0
  78. data/docs/tutorials/the-streamweaver-way.md +882 -0
  79. data/docs/university/capability-inventory.md +137 -0
  80. data/docs/university/dependency-survey.md +114 -0
  81. data/docs/university/design-spec.md +217 -0
  82. data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
  83. data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
  84. data/docs/university/mockups/course-list-dark.png +0 -0
  85. data/docs/university/mockups/course-list-light.png +0 -0
  86. data/docs/university/mockups/course_canvas_mockup.rb +741 -0
  87. data/docs/university/mockups/step-screen-dark.png +0 -0
  88. data/docs/university/mockups/step-screen-light.png +0 -0
  89. data/docs/university/roadmap.md +72 -0
  90. data/docs/university/send-to-coworker.md +77 -0
  91. data/docs/university/worker-session-mining.md +109 -0
  92. data/docs/visual/browser-rendering-spike.md +173 -0
  93. data/docs/visual/builder-visual-plan-analysis.md +378 -0
  94. data/docs/visual/sw-plan-format-exploration.md +511 -0
  95. data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
  96. data/docs/visual-skills/PROGRESS.md +60 -0
  97. data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
  98. data/docs/visual-skills/analysis/components.md +317 -0
  99. data/docs/visual-skills/analysis/overlap.md +360 -0
  100. data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
  101. data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
  102. data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
  103. data/docs/visual-skills/analysis/visual-explainer.md +964 -0
  104. data/docs/visual-skills/blog/blog-series-outline.md +39 -0
  105. data/docs/visual-skills/blog/token-efficiency.md +73 -0
  106. data/docs/visual-skills/design/architecture.md +1725 -0
  107. data/docs/visual-skills/design/codex-review.md +340 -0
  108. data/docs/visual-skills/design/dhh-review.md +209 -0
  109. data/docs/visual-skills/design/evolution.md +100 -0
  110. data/docs/visual-skills/design/gemini-review.md +55 -0
  111. data/docs/visual-skills/design/review-synthesis.md +149 -0
  112. data/docs/visual-skills/implementation/STATE.md +22 -0
  113. data/docs/visual-skills/implementation/plan.md +347 -0
  114. data/docs/visual-skills/implementation/spike-findings.md +237 -0
  115. data/docs/visual-skills/implementation/tasks.md +702 -0
  116. data/docs/visual-skills/lessons-learned/process.md +145 -0
  117. data/examples/README.md +96 -0
  118. data/examples/advanced/all_components.rb +177 -0
  119. data/examples/advanced/examples_browser.rb +394 -0
  120. data/examples/advanced/teachables_browser.rb +261 -0
  121. data/examples/advanced/theme_tweaker.rb +366 -0
  122. data/examples/advanced/tutorial.rb +1528 -0
  123. data/examples/agentic/agentic_form.rb +30 -0
  124. data/examples/agentic/agentic_form_autoclose.rb +30 -0
  125. data/examples/agentic/cultivation_tracker.rb +69 -0
  126. data/examples/basic/hello_world.rb +22 -0
  127. data/examples/basic/opal_tabs_table.rb +44 -0
  128. data/examples/basic/todo_list.rb +39 -0
  129. data/examples/button_loading_test.rb +36 -0
  130. data/examples/canvas/mermaid_canvas_demo.sh +151 -0
  131. data/examples/charts/bar_chart_demo.rb +57 -0
  132. data/examples/charts/line_chart_demo.rb +73 -0
  133. data/examples/charts/pie_area_demo.rb +87 -0
  134. data/examples/charts/stacked_bar_chart_demo.rb +69 -0
  135. data/examples/claude_code/README.md +85 -0
  136. data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
  137. data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
  138. data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
  139. data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
  140. data/examples/claude_code/codebreaker/README.md +213 -0
  141. data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
  142. data/examples/claude_code/tutorial/README.md +102 -0
  143. data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
  144. data/examples/claude_code/verification_flow/README.md +126 -0
  145. data/examples/components/annotated_code_demo.rb +54 -0
  146. data/examples/components/callout_demo.rb +33 -0
  147. data/examples/components/checkbox_group_demo.rb +46 -0
  148. data/examples/components/design_review.css +468 -0
  149. data/examples/components/design_review_demo.rb +44 -0
  150. data/examples/components/design_review_dsl.rb +300 -0
  151. data/examples/components/diff_block_demo.rb +112 -0
  152. data/examples/components/events_demo.rb +184 -0
  153. data/examples/components/form_demo.rb +62 -0
  154. data/examples/components/lesson_demo.rb +53 -0
  155. data/examples/components/markdown_demo.rb +110 -0
  156. data/examples/components/mermaid_demo.rb +166 -0
  157. data/examples/components/pareto_set.rb +64 -0
  158. data/examples/components/prd_demo.rb +21 -0
  159. data/examples/components/prd_dsl.rb +385 -0
  160. data/examples/components/quiz_demo.rb +47 -0
  161. data/examples/components/run_viewer_demo.rb +100 -0
  162. data/examples/components/score_and_collapsible_demo.rb +82 -0
  163. data/examples/components/select_stale_value_smoke_test.rb +39 -0
  164. data/examples/components/table_demo.rb +247 -0
  165. data/examples/components/timer_with_state_demo.rb +58 -0
  166. data/examples/components/todo_due_dates.rb +63 -0
  167. data/examples/components/uat_gaps_demo.rb +112 -0
  168. data/examples/dashboard/feed_simulator.rb +60 -0
  169. data/examples/dashboard/live_dashboard.rb +80 -0
  170. data/examples/dashboard_components.rb +108 -0
  171. data/examples/deferred_fragments_demo.rb +62 -0
  172. data/examples/generate_more_spike/README.md +63 -0
  173. data/examples/generate_more_spike/app.rb +441 -0
  174. data/examples/git_health.sh +401 -0
  175. data/examples/layout/layout_components_demo.rb +174 -0
  176. data/examples/layout/modal_demo.rb +215 -0
  177. data/examples/layout/navigation_demo.rb +227 -0
  178. data/examples/layout/route_tabs_demo.rb +52 -0
  179. data/examples/layout/routing_demo.rb +52 -0
  180. data/examples/layout/scroll_box_demo.rb +154 -0
  181. data/examples/lazy_fragments_demo.rb +151 -0
  182. data/examples/my_todos/README.md +29 -0
  183. data/examples/my_todos/my_todos.rb +312 -0
  184. data/examples/my_todos/store.rb +86 -0
  185. data/examples/opal/reactive_demo.rb +62 -0
  186. data/examples/opal/scenarios/s1_counter.rb +15 -0
  187. data/examples/opal/scenarios/s2_search_filter.rb +17 -0
  188. data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
  189. data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
  190. data/examples/opal/scenarios/s5_watch.rb +39 -0
  191. data/examples/opal/scenarios/s6_on_start.rb +20 -0
  192. data/examples/opal/scenarios/s7_wizard.rb +33 -0
  193. data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
  194. data/examples/opal/scenarios/s9_dashboard.rb +27 -0
  195. data/examples/operations_dashboard_demo.rb +167 -0
  196. data/examples/panel_demo.sh +447 -0
  197. data/examples/parity/assets/tyrion_slice.css +86 -0
  198. data/examples/parity/rivet_people_slice.rb +182 -0
  199. data/examples/parity/tyrion_components.css +777 -0
  200. data/examples/parity/tyrion_warroom_components.rb +414 -0
  201. data/examples/parity/tyrion_warroom_slice.rb +549 -0
  202. data/examples/puma_dev/README.md +124 -0
  203. data/examples/puma_dev/config.ru +34 -0
  204. data/examples/puma_dev/standalone_app.rb +34 -0
  205. data/examples/scaffolding/blog.rb +49 -0
  206. data/examples/scaffolding/utf_lite.rb +105 -0
  207. data/examples/styling/feedback_demo.rb +229 -0
  208. data/examples/styling/style_showcase.rb +172 -0
  209. data/examples/styling/theme_demo.rb +508 -0
  210. data/examples/timer_health_checker.rb +111 -0
  211. data/examples/timer_showcase.rb +131 -0
  212. data/examples/tutorials/render_markdown.rb +112 -0
  213. data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
  214. data/examples/tutorials/tutorial_content.rb +1082 -0
  215. data/examples/visual_skills/design_deck_demo.rb +314 -0
  216. data/examples/visual_skills/explainer_demo.rb +499 -0
  217. data/exe/streamweaver +9 -0
  218. data/gsd/ROADMAP-1.0.md +167 -0
  219. data/gsd/STATE.md +13 -0
  220. data/gsd/research/market-positioning-research.md +139 -0
  221. data/gsd/research/production-patterns-research.md +270 -0
  222. data/gsd/research/repo-audit-1.0.md +274 -0
  223. data/lib/stream_weaver/action_token.rb +58 -0
  224. data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
  225. data/lib/stream_weaver/adapter/base.rb +591 -0
  226. data/lib/stream_weaver/adapter/opal.rb +334 -0
  227. data/lib/stream_weaver/adapter/static.rb +1118 -0
  228. data/lib/stream_weaver/admin.rb +176 -0
  229. data/lib/stream_weaver/app.rb +1821 -0
  230. data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
  231. data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
  232. data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
  233. data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
  234. data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
  235. data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
  236. data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
  237. data/lib/stream_weaver/canvas/bridge.rb +239 -0
  238. data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
  239. data/lib/stream_weaver/canvas/client.rb +298 -0
  240. data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
  241. data/lib/stream_weaver/canvas/doc_store.rb +252 -0
  242. data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
  243. data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
  244. data/lib/stream_weaver/canvas/gist_store.rb +135 -0
  245. data/lib/stream_weaver/canvas/helpers.rb +109 -0
  246. data/lib/stream_weaver/canvas/history.rb +90 -0
  247. data/lib/stream_weaver/canvas/protocol.rb +86 -0
  248. data/lib/stream_weaver/canvas/reader.rb +853 -0
  249. data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
  250. data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
  251. data/lib/stream_weaver/canvas/session.rb +132 -0
  252. data/lib/stream_weaver/cli.rb +3235 -0
  253. data/lib/stream_weaver/component_assets.rb +70 -0
  254. data/lib/stream_weaver/component_registry.rb +67 -0
  255. data/lib/stream_weaver/component_renderer.rb +48 -0
  256. data/lib/stream_weaver/components/annotated_code.rb +53 -0
  257. data/lib/stream_weaver/components/api_endpoint.rb +42 -0
  258. data/lib/stream_weaver/components/callout.rb +59 -0
  259. data/lib/stream_weaver/components/chart.rb +84 -0
  260. data/lib/stream_weaver/components/code_block.rb +77 -0
  261. data/lib/stream_weaver/components/comparison.rb +39 -0
  262. data/lib/stream_weaver/components/decision.rb +38 -0
  263. data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
  264. data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
  265. data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
  266. data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
  267. data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
  268. data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
  269. data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
  270. data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
  271. data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
  272. data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
  273. data/lib/stream_weaver/components/diff_block.rb +125 -0
  274. data/lib/stream_weaver/components/doc_header.rb +57 -0
  275. data/lib/stream_weaver/components/image_block.rb +70 -0
  276. data/lib/stream_weaver/components/implementation_map.rb +33 -0
  277. data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
  278. data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
  279. data/lib/stream_weaver/components/mermaid.rb +79 -0
  280. data/lib/stream_weaver/components/pipeline.rb +63 -0
  281. data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
  282. data/lib/stream_weaver/components/slide_container.rb +141 -0
  283. data/lib/stream_weaver/components/timeline_event.rb +58 -0
  284. data/lib/stream_weaver/components/wireframe.rb +29 -0
  285. data/lib/stream_weaver/components/wireframe_block.rb +34 -0
  286. data/lib/stream_weaver/components.rb +2711 -0
  287. data/lib/stream_weaver/css.rb +230 -0
  288. data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
  289. data/lib/stream_weaver/display_dsl.rb +1004 -0
  290. data/lib/stream_weaver/export/html_exporter.rb +478 -0
  291. data/lib/stream_weaver/feed.rb +34 -0
  292. data/lib/stream_weaver/feed_builder.rb +31 -0
  293. data/lib/stream_weaver/fonts.rb +33 -0
  294. data/lib/stream_weaver/interaction_runner.rb +487 -0
  295. data/lib/stream_weaver/iterm.rb +460 -0
  296. data/lib/stream_weaver/layout_registry.rb +92 -0
  297. data/lib/stream_weaver/opal/bridge.rb +52 -0
  298. data/lib/stream_weaver/opal/builder.rb +193 -0
  299. data/lib/stream_weaver/opal/env.rb +29 -0
  300. data/lib/stream_weaver/opal/reactive_state.rb +75 -0
  301. data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
  302. data/lib/stream_weaver/opal/renderer.rb +77 -0
  303. data/lib/stream_weaver/opal/runtime.rb +250 -0
  304. data/lib/stream_weaver/opal/shell.rb +145 -0
  305. data/lib/stream_weaver/opal/string_bridge.rb +50 -0
  306. data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
  307. data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
  308. data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
  309. data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
  310. data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
  311. data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
  312. data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
  313. data/lib/stream_weaver/opal_entry.rb +136 -0
  314. data/lib/stream_weaver/org/inline.rb +95 -0
  315. data/lib/stream_weaver/org/reader.rb +561 -0
  316. data/lib/stream_weaver/org/recording_context.rb +86 -0
  317. data/lib/stream_weaver/org/source_splitter.rb +63 -0
  318. data/lib/stream_weaver/org/writer.rb +300 -0
  319. data/lib/stream_weaver/page_shell.rb +530 -0
  320. data/lib/stream_weaver/portfile.rb +79 -0
  321. data/lib/stream_weaver/pushable.rb +42 -0
  322. data/lib/stream_weaver/resource/default_views.rb +111 -0
  323. data/lib/stream_weaver/resource/field_input.rb +25 -0
  324. data/lib/stream_weaver/resource/state_keys.rb +16 -0
  325. data/lib/stream_weaver/resource/store.rb +18 -0
  326. data/lib/stream_weaver/resource.rb +104 -0
  327. data/lib/stream_weaver/server.rb +1471 -0
  328. data/lib/stream_weaver/service.rb +1240 -0
  329. data/lib/stream_weaver/service_client.rb +104 -0
  330. data/lib/stream_weaver/session_store.rb +186 -0
  331. data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
  332. data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
  333. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
  334. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
  335. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
  336. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
  337. data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
  338. data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
  339. data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
  340. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
  341. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
  342. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
  343. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
  344. data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
  345. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
  346. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
  347. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
  348. data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
  349. data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
  350. data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
  351. data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
  352. data/lib/stream_weaver/streamer.rb +63 -0
  353. data/lib/stream_weaver/templates/choices.rb +149 -0
  354. data/lib/stream_weaver/templates/code.rb +157 -0
  355. data/lib/stream_weaver/templates/confirm.rb +110 -0
  356. data/lib/stream_weaver/templates/diff.rb +195 -0
  357. data/lib/stream_weaver/templates/info.rb +142 -0
  358. data/lib/stream_weaver/templates/table.rb +169 -0
  359. data/lib/stream_weaver/templates/wizard.rb +271 -0
  360. data/lib/stream_weaver/theme/auto_mode.rb +116 -0
  361. data/lib/stream_weaver/theme/presets.rb +555 -0
  362. data/lib/stream_weaver/theme.rb +639 -0
  363. data/lib/stream_weaver/university/canvas.rb +924 -0
  364. data/lib/stream_weaver/university/course.rb +699 -0
  365. data/lib/stream_weaver/university/demos/counter.rb +33 -0
  366. data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
  367. data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
  368. data/lib/stream_weaver/university/demos.rb +50 -0
  369. data/lib/stream_weaver/university/listener.rb +467 -0
  370. data/lib/stream_weaver/university/progress.rb +201 -0
  371. data/lib/stream_weaver/university/runner.rb +138 -0
  372. data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
  373. data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
  374. data/lib/stream_weaver/utils.rb +31 -0
  375. data/lib/stream_weaver/version.rb +5 -0
  376. data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
  377. data/lib/stream_weaver/views.rb +3940 -0
  378. data/lib/stream_weaver.rb +122 -0
  379. data/llms.txt +1269 -0
  380. data/sig/stream_weaver.rbs +4 -0
  381. metadata +598 -0
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'rbconfig'
5
+ require 'stream_weaver/canvas/client'
6
+ require 'stream_weaver/canvas/scroll_top_hint'
7
+ require 'stream_weaver/university/course'
8
+ require 'stream_weaver/university/progress'
9
+ require 'stream_weaver/university/runner'
10
+ require 'stream_weaver/university/scripts/growing_doc_state'
11
+
12
+ module StreamWeaver
13
+ module University
14
+ # Turns one canvas click event into a ledger write plus a re-push.
15
+ # Mark-done writes the ledger directly; Run/Repeat hands off to
16
+ # Runner, which sends the step's prompt to the recorded worker session
17
+ # (or records why it wouldn't), warms up that step's own demo canvas
18
+ # the moment (and only if) the send actually lands (see `.warm_up!`),
19
+ # and leaves the outcome in the ledger for the re-push to render.
20
+ #
21
+ # Button ids come from lib/stream_weaver/university/canvas.rb's `id:`
22
+ # scheme: "mark-done-N", "run-N" / "repeat-N", "hero-run-N" /
23
+ # "hero-repeat-N" on the course list; "view-N" (a row's Details/Hide
24
+ # button) toggles that row's inline expansion; "reset-course" (the
25
+ # recap screen and the course-list footer) clears the whole course.
26
+ # The rendered `id:` (e.g. "mark-done-3") becomes a
27
+ # `btn_<label-slug>_<id>` DOM/dispatch id (app.rb `button`), so every
28
+ # pattern below is anchored to the string's end, not its start.
29
+ module Listener
30
+ SESSION = 'university'
31
+
32
+ # How long run! waits before reconnecting to a bridge that went away.
33
+ RECONNECT_DELAY = 1
34
+
35
+ # name/theme pair for one step's own demo canvas session.
36
+ DemoSession = Struct.new(:name, :theme)
37
+
38
+ # Which of the course's own canvas demo sessions each step opens, and
39
+ # the theme its prompt creates that session with -- mined from
40
+ # course.rb's step prompts, and named in
41
+ # features/university-getting-started.context.md ("Course session
42
+ # names"). The theme matters here specifically because
43
+ # `Bridge#create_session` is `@sessions[name] ||= Session.new(...)`
44
+ # (canvas/bridge.rb): a session's theme is fixed by whichever `create`
45
+ # reaches the bridge FIRST, and every later `create` for that name --
46
+ # including the worker's own, e.g. `streamweaver panel doc-demo
47
+ # --theme=doc` (course.rb) or `growing_doc.rb`'s `create(...,
48
+ # theme: :doc)` -- is a no-op. `warm_up!` runs before any of that, so
49
+ # it has to create with the SAME theme the real demo will, not the
50
+ # bridge's `:default`, or the worker's own theme call would silently
51
+ # never take effect for the rest of that get-started run. Steps 2 and
52
+ # 5 never open a demo canvas of their own (a standalone `ruby app.rb`,
53
+ # and gists/org-export respectively), so neither appears here: a Run
54
+ # click on either has nothing to warm up, and "Reset course" has
55
+ # nothing of theirs to close.
56
+ STEP_DEMO_SESSIONS = {
57
+ 1 => DemoSession.new('dashboard', :default),
58
+ 3 => DemoSession.new('decision', :default),
59
+ 4 => DemoSession.new('doc-demo', :doc)
60
+ }.freeze
61
+
62
+ # "Reset course" closes exactly these, by name, and nothing else:
63
+ # never the controller session (SESSION, above) and never a session
64
+ # the user opened on their own that just happens to still exist.
65
+ # Derived from STEP_DEMO_SESSIONS so the two lists can never drift.
66
+ DEMO_SESSION_NAMES = STEP_DEMO_SESSIONS.values.map(&:name).freeze
67
+
68
+ # Applies one dispatched button token to the ledger. Returns the step
69
+ # number acted on, true for a whole-course action with no step of its
70
+ # own (reset-course), or nil if the token didn't match a known
71
+ # action.
72
+ def self.handle_token(token, progress)
73
+ case token.to_s
74
+ when /mark-done-(\d+)\z/
75
+ step = Regexp.last_match(1).to_i
76
+ mark_step_done!(progress, step)
77
+ step
78
+ when /(?:run|repeat)-(\d+)\z/ # also catches hero-run-N / hero-repeat-N
79
+ step = Regexp.last_match(1).to_i
80
+ # Dispatches to the worker session and records the outcome
81
+ # (including the refusals) in the ledger; the re-push in `step!`
82
+ # then renders whatever it wrote. The block runs once, only after
83
+ # Runner confirms the send actually landed -- warm_up! paints
84
+ # something instantly (Forrest's UAT round 5 clocked the worker's
85
+ # own first push at ~5 minutes) without ever promising "your
86
+ # agent is preparing" on a click that turned out refused or
87
+ # degraded (no recorded worker, a closed session, or the RPC
88
+ # itself failing) -- Runner is the one place that already knows
89
+ # which of those this click was.
90
+ Runner.run_step!(step, progress: progress) { warm_up!(step) }
91
+ step
92
+ when /view-(\d+)\z/
93
+ # Toggles: Details on the already-expanded row hides it again
94
+ # (Hide), Details on any other row expands that one instead --
95
+ # at most one row is ever expanded, so this is also what makes
96
+ # "expanding one collapses others" true.
97
+ step = Regexp.last_match(1).to_i
98
+ if progress.expanded_step == step
99
+ progress.collapse!
100
+ else
101
+ progress.expand_step!(step)
102
+ end
103
+ step
104
+ when /reset-course\z/
105
+ # Same effect as `streamweaver university-reset -y`: back up +
106
+ # clear the ledger, close the demo sessions the course itself
107
+ # opened. The trailing repush in `handle_event` below is what
108
+ # then renders the zero-state list -- this branch does not push.
109
+ progress.reset!
110
+ close_demo_sessions!
111
+ true
112
+ end
113
+ end
114
+
115
+ # The ledger write a "step is done" action makes: stamps `last_done`
116
+ # (rendered as an inline confirmation band) and collapses whichever
117
+ # row was expanded, so a stale expansion never sits open under the
118
+ # confirmation. Shared by handle_token's mark-done branch above
119
+ # (whose own caller, handle_event, does its own repush after every
120
+ # token) and `university_done!` below (which has no such caller and
121
+ # does its own repush) -- one write, two callers, so neither can drift
122
+ # from what "a step just got marked done" actually means.
123
+ def self.mark_step_done!(progress, step_number)
124
+ progress.mark_done!(step_number)
125
+ progress.collapse!
126
+ end
127
+
128
+ # True when `progress`'s ledger flips from "not all done" to "all
129
+ # done" somewhere inside the block -- the exact moment canvas.rb's
130
+ # completion recap (`all_done`) first appears in the very next
131
+ # repush, regardless of which step's write did it or whether the
132
+ # steps finished in order. Shared by `handle_event` (every canvas
133
+ # click, including a manual Mark-done) and `university_done!` (the
134
+ # CLI door a worker's closing ritual runs) so BOTH doors ask the same
135
+ # question the same way -- round-9 UAT first wired the scroll-to-top
136
+ # decision off the step-NUMBER argument, scoped to the CLI door alone,
137
+ # and missed the manual-click path: clicking Mark done on the last
138
+ # step from partway down the page reproduces the exact "recap appears
139
+ # below where you're looking" bug this exists to fix (code review).
140
+ def self.course_just_completed?(progress)
141
+ was_complete = progress.done_count >= Course::TOTAL_STEPS
142
+ yield
143
+ !was_complete && progress.done_count >= Course::TOTAL_STEPS
144
+ end
145
+
146
+ # The terminal door onto exactly what a Mark-done click does, for
147
+ # `streamweaver university-done` (CLI): the same ledger write plus the
148
+ # same repush, with no button event in the loop. Round-8 UAT: every
149
+ # step's closing ritual used to hand the "click Mark done" click back
150
+ # to the user; asking a worker to run this instead removes that
151
+ # hand-off entirely -- the manual button still works exactly as
152
+ # before, this is just a second door onto the same effect.
153
+ def self.university_done!(step_number, session_name: SESSION)
154
+ progress = Progress.load
155
+ just_completed = course_just_completed?(progress) { mark_step_done!(progress, step_number) }
156
+ repush(session_name: session_name, scroll_top: just_completed)
157
+ step_number
158
+ end
159
+
160
+ # Applies one event to the ledger and re-pushes the app so the
161
+ # rendered page reflects the new state. The re-push is not optional
162
+ # bookkeeping: a click swaps the page for the canvas_continue spinner
163
+ # (see canvas.rb), and this push is what puts the real page back --
164
+ # and, for Mark-done specifically, is what makes the row visibly flip
165
+ # to done AND shows the "Step N done" confirmation, both read
166
+ # straight off the ledger this same push just wrote.
167
+ # Returns the button token handled, or nil if the event carried none.
168
+ def self.handle_event(event, session_name: SESSION)
169
+ token = event.dig(:data, :button)
170
+ return nil unless token
171
+
172
+ progress = Progress.load
173
+ just_completed = course_just_completed?(progress) { handle_token(token, progress) }
174
+ repush(session_name: session_name, scroll_top: just_completed)
175
+ token
176
+ end
177
+
178
+ # "Reset course": closes DEMO_SESSION_NAMES, one `close` message each.
179
+ # Best-effort per session -- a session that was never opened (most
180
+ # courses never get all three names created) or an already-closed
181
+ # one just gets "Session not found" back, which is not a reason to
182
+ # skip the rest. Shared by the canvas's own Reset button
183
+ # (`handle_token`, above) and `streamweaver university-reset`.
184
+ def self.close_demo_sessions!
185
+ DEMO_SESSION_NAMES.each do |name|
186
+ begin
187
+ ::StreamWeaver::Canvas::Client.send_message(
188
+ ::StreamWeaver::Canvas::Protocol::Messages.close(name)
189
+ )
190
+ rescue ::StreamWeaver::Canvas::Client::NotRunningError, ::StreamWeaver::Canvas::Client::ConnectionError
191
+ nil
192
+ end
193
+ # growing_doc.rb's own persisted --extend keys (round-7 UAT) --
194
+ # otherwise a reset course still remembers last run's picks the
195
+ # next time its script runs. A no-op (FileUtils.rm_f) for every
196
+ # name but doc-demo's, which never had state to begin with.
197
+ ::StreamWeaver::University::Scripts::GrowingDocState.clear(name)
198
+ end
199
+ end
200
+
201
+ # Pushes a deterministic, no-LLM placeholder card to step `step_number`'s
202
+ # own demo canvas session -- called by Runner.run_step!'s block, which
203
+ # only runs once a send has actually landed, so the pane paints
204
+ # something in well under a second instead of sitting empty for
205
+ # however long the worker takes to reach its own first push
206
+ # (Forrest's UAT round 5: ~5 minutes), and never on a refused or
207
+ # degraded send. Creates the session first if it doesn't exist yet
208
+ # (canvas `push` errors on a session it can't find), WITH the same
209
+ # theme the step's own demo uses (STEP_DEMO_SESSIONS) -- a session's
210
+ # theme is fixed by whichever `create` reaches the bridge first, so
211
+ # creating with the wrong one here would permanently strand the
212
+ # worker's own later `--theme=doc` as a no-op for the rest of this
213
+ # get-started run. The worker's own first real push against the same
214
+ # session name replaces this outright -- nothing here persists.
215
+ # No-op for a step with no demo session of its own (steps 2 and 5).
216
+ def self.warm_up!(step_number)
217
+ demo = STEP_DEMO_SESSIONS[step_number.to_i] or return
218
+ step = Course.step(step_number) or return
219
+
220
+ ::StreamWeaver::Canvas::Client.send_message(
221
+ ::StreamWeaver::Canvas::Protocol::Messages.create(demo.name, theme: demo.theme)
222
+ )
223
+ ::StreamWeaver::Canvas::Client.send_message(
224
+ ::StreamWeaver::Canvas::Protocol::Messages.push(demo.name, warm_up_dsl(step), source_dir: nil)
225
+ )
226
+ rescue ::StreamWeaver::Canvas::Client::NotRunningError, ::StreamWeaver::Canvas::Client::ConnectionError
227
+ nil
228
+ end
229
+
230
+ # The warm-up card's own DSL source, instance_eval'd bridge-side --
231
+ # same "one string of Ruby, eval'd fresh" contract canvas.rb's own
232
+ # file header describes. Deliberately small: a title, one line of
233
+ # what's coming, a tasteful mini stat row plus a one-sentence
234
+ # callout, and the "preparing" line -- everything a terminal-only
235
+ # tool cannot show while a real worker is still thinking.
236
+ def self.warm_up_dsl(step)
237
+ <<~RUBY
238
+ use_theme :doc
239
+ use_layout :default
240
+ card(depth: :elevated) do
241
+ card_header { header2 #{step[:title].inspect} }
242
+ card_body do
243
+ phrase #{"Coming up: #{step[:payoff]}".inspect}
244
+ div(style: "display:flex; gap:14px; margin:16px 0;") do
245
+ stat_display value: #{step[:number].to_s.inspect}, label: "this step"
246
+ stat_display value: #{StreamWeaver::University::Course::GETTING_STARTED_STEPS.size.to_s.inspect}, label: "steps in the course"
247
+ end
248
+ callout "Your agent is preparing the live demo…", variant: :info
249
+ end
250
+ end
251
+ RUBY
252
+ end
253
+
254
+ # An invisible marker element, appended to canvas.rb's own pushed text
255
+ # only when `scroll_top:` is true -- never part of the file itself.
256
+ # bridge_server.rb's polling script looks for this element's id in the
257
+ # HTML a poll just swapped in and, when present, scrolls the viewer to
258
+ # the TOP of the new page instead of running its ordinary "follow the
259
+ # viewer's own near-bottom scroll position" logic (built for a growing
260
+ # doc continuing in place, not a full page reshape -- round-9 UAT).
261
+ # Carries no ledger state and needs no cleanup: it only exists in the
262
+ # one push's HTML that asked for it, and the very next poll response
263
+ # replaces the whole DOM regardless, so "honored once" is true by
264
+ # construction rather than something this code has to enforce.
265
+ #
266
+ # The id itself is interpolated from Canvas::SCROLL_TOP_HINT_ID (a
267
+ # tiny standalone file both this module and BridgeServer require --
268
+ # see its own comment for why it isn't just defined on BridgeServer)
269
+ # rather than repeated here as a second literal -- two copies of the
270
+ # same string is exactly the drift this marker's own contract cannot
271
+ # survive.
272
+ SCROLL_TOP_HINT_DSL =
273
+ %(div(id: #{Canvas::SCROLL_TOP_HINT_ID.inspect}, style: "display:none") {})
274
+
275
+ def self.repush(session_name: SESSION, scroll_top: false)
276
+ canvas_path = File.expand_path('canvas.rb', __dir__)
277
+ dsl = File.read(canvas_path)
278
+ dsl = "#{dsl}\n\n#{SCROLL_TOP_HINT_DSL}" if scroll_top
279
+ ::StreamWeaver::Canvas::Client.send_message(
280
+ ::StreamWeaver::Canvas::Protocol::Messages.push(session_name, dsl, source_dir: nil)
281
+ )
282
+ end
283
+
284
+ # Blocks for exactly one canvas event, applies it, and re-pushes.
285
+ # Returns the button token handled, or nil on timeout / no bridge.
286
+ # Kept as the single-shot form for manual verification; `run!` uses
287
+ # the persistent stream instead.
288
+ def self.step!(session_name: SESSION, timeout: 300)
289
+ result = ::StreamWeaver::Canvas::Client.send_and_wait(
290
+ { type: 'subscribe', name: session_name },
291
+ event_type: 'event',
292
+ timeout: timeout
293
+ )
294
+ return nil unless result
295
+
296
+ handle_event(result, session_name: session_name)
297
+ rescue ::StreamWeaver::Canvas::Client::NotRunningError, ::StreamWeaver::Canvas::Client::ConnectionError
298
+ nil
299
+ end
300
+
301
+ # Runs until killed, over ONE held-open connection at a time. Not a
302
+ # loop of `step!`: that reconnects after every event, and the gap
303
+ # lands exactly where the user is most likely to click again -- while
304
+ # the re-push is in flight.
305
+ #
306
+ # The outer loop is not optional robustness. `each_event` returns when
307
+ # the bridge closes the socket, which a `canvas-restart` does
308
+ # routinely; without reconnecting, the listener would exit and the
309
+ # canvas would go permanently dead -- and worse than before this
310
+ # story, because the canvas_continue marker means a click with no
311
+ # re-push leaves "Working..." on screen forever and sets
312
+ # _swFeedbackActive, which makes the adapter swallow every later
313
+ # click. Same reason a not-yet-running bridge waits rather than
314
+ # exiting: `university-listener start` must not report a pid for a
315
+ # process that is already gone.
316
+ def self.run!(session_name: SESSION)
317
+ complained = false
318
+
319
+ loop do
320
+ begin
321
+ ::StreamWeaver::Canvas::Client.each_event(session_name) do |event|
322
+ handle_event(event, session_name: session_name)
323
+ rescue StandardError => e
324
+ # One bad click must not take down a listener nobody can see.
325
+ warn "university-listener: #{e.class}: #{e.message}"
326
+ end
327
+ complained = false
328
+ rescue StandardError => e
329
+ # Deliberately every StandardError, not just the two bridge
330
+ # errors: this process must not die anywhere the user can't see
331
+ # it, and RECONNECT_DELAY below already rules out a hot spin, so
332
+ # there is no failure here from which exiting beats retrying.
333
+ #
334
+ # Logged once per outage, not once per second: the log is
335
+ # append-only with nothing rotating it, and a bridge left down
336
+ # overnight would otherwise put ~86k lines in a file the user
337
+ # doesn't know exists.
338
+ unless complained
339
+ warn "university-listener: waiting for the canvas bridge (#{e.class}: #{e.message})"
340
+ complained = true
341
+ end
342
+ end
343
+
344
+ sleep RECONNECT_DELAY
345
+ end
346
+ end
347
+
348
+ # --- Process lifecycle ------------------------------------------------
349
+ # `get-started` starts this in the background; without it every button
350
+ # on the canvas does nothing at all (UAT 2026-08-29). The env
351
+ # overrides mirror Progress/Runner so specs never touch the real files.
352
+
353
+ def self.pid_path
354
+ ENV['STREAMWEAVER_UNIVERSITY_LISTENER_PID'] ||
355
+ File.expand_path('~/.streamweaver/university/listener.pid')
356
+ end
357
+
358
+ def self.log_path
359
+ ENV['STREAMWEAVER_UNIVERSITY_LISTENER_LOG'] ||
360
+ File.expand_path('~/.streamweaver/university/listener.log')
361
+ end
362
+
363
+ # The pid recorded by `start!`, or nil if there isn't a usable one.
364
+ def self.recorded_pid
365
+ pid = File.read(pid_path).to_i
366
+ pid.positive? ? pid : nil
367
+ rescue SystemCallError, IOError
368
+ nil
369
+ end
370
+
371
+ def self.running?
372
+ pid = recorded_pid or return false
373
+ ours?(pid)
374
+ end
375
+
376
+ # Alive AND plausibly the listener we spawned. `start!` uses
377
+ # `pgroup: true`, so our listener is its own process-group leader;
378
+ # a pid recycled onto some unrelated process almost never is. Without
379
+ # this check, a crash plus enough pid churn has `get-started` sending
380
+ # TERM to whatever the user happens to be running.
381
+ def self.ours?(pid)
382
+ Process.kill(0, pid)
383
+ Process.getpgid(pid) == pid
384
+ rescue Errno::ESRCH, Errno::EPERM
385
+ false
386
+ end
387
+ private_class_method :ours?
388
+
389
+ # How long start! waits for the listener it just TERMed to actually go.
390
+ EXIT_POLL = 0.05
391
+ EXIT_WAIT = 40 # * EXIT_POLL = 2s
392
+
393
+ # Stops any listener already recorded, waits for it to actually exit,
394
+ # then spawns a fresh detached one. Stopping first is what keeps a
395
+ # second `get-started` (or a canvas-restart) from leaving two
396
+ # listeners racing to re-push -- whichever push lands second wins, so
397
+ # the loser shows stale state. Returns the new pid.
398
+ def self.start!(session_name: SESSION)
399
+ previous = recorded_pid
400
+ stop!
401
+ await_exit(previous)
402
+
403
+ FileUtils.mkdir_p(File.dirname(log_path))
404
+ FileUtils.mkdir_p(File.dirname(pid_path))
405
+
406
+ pid = Process.spawn(
407
+ RbConfig.ruby,
408
+ "-I#{File.expand_path('../..', __dir__)}",
409
+ '-r', 'stream_weaver/university/listener',
410
+ '-e', "StreamWeaver::University::Listener.run!(session_name: #{session_name.inspect})",
411
+ out: [log_path, 'a'], err: %i[child out], pgroup: true
412
+ )
413
+ Process.detach(pid)
414
+ File.write(pid_path, "#{pid}\n")
415
+ pid
416
+ end
417
+
418
+ # TERM is asynchronous, so "stopped" is a request, not a fact. Spawning
419
+ # the replacement while the old one still holds its connection gives
420
+ # two listeners racing to re-push, and whichever push lands second
421
+ # wins -- so the canvas can settle on state the user did not ask for.
422
+ def self.await_exit(pid)
423
+ return unless pid
424
+
425
+ EXIT_WAIT.times do
426
+ return unless ours?(pid)
427
+
428
+ sleep EXIT_POLL
429
+ end
430
+
431
+ # Falling through here is the very race this method exists to
432
+ # prevent, so escalate rather than spawn a rival quietly.
433
+ warn "university-listener: pid #{pid} ignored TERM, sending KILL"
434
+ Process.kill('KILL', pid)
435
+ rescue Errno::ESRCH, Errno::EPERM
436
+ nil
437
+ end
438
+ private_class_method :await_exit
439
+
440
+ # Terminates the recorded listener. Always clears the pid file, even
441
+ # when the process was already gone -- a stale pid file left by a
442
+ # crash must not look like a running listener forever.
443
+ def self.stop!
444
+ pid = recorded_pid
445
+ return false unless pid && ours?(pid)
446
+
447
+ Process.kill('TERM', pid)
448
+ true
449
+ rescue Errno::ESRCH, Errno::EPERM
450
+ false
451
+ ensure
452
+ FileUtils.rm_f(pid_path)
453
+ end
454
+
455
+ def self.status
456
+ alive = running?
457
+ { running: alive, pid: alive ? recorded_pid : nil, log: log_path }
458
+ end
459
+ end
460
+ end
461
+ end
462
+
463
+ if __FILE__ == $PROGRAM_NAME
464
+ $LOAD_PATH.unshift(File.expand_path('../..', __dir__))
465
+ require 'stream_weaver'
466
+ StreamWeaver::University::Listener.run!
467
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ module StreamWeaver
8
+ module University
9
+ # Getting Started's per-step completion ledger, persisted as YAML at
10
+ # `~/.streamweaver/university/progress.yml`. A plain file, not in-memory
11
+ # session state, so "survives a bridge restart" just means every write
12
+ # hits disk immediately and a later re-push re-reads it (progress-ledger
13
+ # criterion 5).
14
+ #
15
+ # STREAMWEAVER_UNIVERSITY_PROGRESS overrides the path -- used by specs
16
+ # (never touch the developer's real ledger) and by anyone running a
17
+ # second, isolated University instance.
18
+ class Progress
19
+ DEFAULT_PATH = '~/.streamweaver/university/progress.yml'
20
+
21
+ # Expanded per call, not at the constant, so a spec that redirects
22
+ # HOME is redirected here too -- and so this mirrors
23
+ # University::Runner.worker_path exactly, as its comment claims.
24
+ def self.path
25
+ ENV['STREAMWEAVER_UNIVERSITY_PROGRESS'] || File.expand_path(DEFAULT_PATH)
26
+ end
27
+
28
+ # Loads the ledger at the current path (honoring the env override at
29
+ # call time, not at class-load time).
30
+ def self.load
31
+ new(path)
32
+ end
33
+
34
+ def initialize(path = self.class.path)
35
+ @path = path
36
+ @data = read
37
+ end
38
+
39
+ attr_reader :path
40
+
41
+ def done?(step_number)
42
+ !!@data['done'][step_number.to_s]
43
+ end
44
+
45
+ # Clears `last_run` along the way: that field reports what the user's
46
+ # last click did, and marking a step done IS a later click. Without
47
+ # this, a run notice (in degraded mode, a whole copy-this-prompt
48
+ # block for step 1) stays pinned above the step list for days.
49
+ #
50
+ # Stamps `last_done` for the SAME reason `record_run!` stamps
51
+ # `last_run`: the next canvas render needs to say what a click just
52
+ # did, and it needs to say so reliably. An earlier version of this
53
+ # feedback used a bridge toast instead -- broken by construction,
54
+ # because the toast and this same write's re-push land in the same
55
+ # ~500ms poll response, and the client unconditionally clears any
56
+ # toast the instant new HTML arrives (bridge_server.rb's poll()).
57
+ # Putting the message IN the re-pushed HTML has no such race.
58
+ def mark_done!(step_number)
59
+ @data['done'][step_number.to_s] = true
60
+ @data['last_run'] = nil
61
+ @data['last_done'] = { 'step' => step_number.to_i, 'at' => Time.now.utc.iso8601 }
62
+ write
63
+ self
64
+ end
65
+
66
+ # Undoes a mark-done -- not exercised by the current UI, but the
67
+ # natural inverse and cheap to keep correct for tests/future use.
68
+ # Clears `last_run`/`last_done` for the same reason mark_done! sets
69
+ # them: this instance no longer reflects what either field claims.
70
+ def unmark_done!(step_number)
71
+ @data['done'].delete(step_number.to_s)
72
+ @data['last_run'] = nil
73
+ @data['last_done'] = nil
74
+ write
75
+ self
76
+ end
77
+
78
+ def done_steps
79
+ @data['done'].select { |_k, v| v }.keys.map(&:to_i).sort
80
+ end
81
+
82
+ def done_count
83
+ done_steps.size
84
+ end
85
+
86
+ # Records the outcome of one Run/Repeat click (Runner#run_step!).
87
+ #
88
+ # `requested` only gains a timestamp when the prompt actually reached
89
+ # the worker session -- a click that found no worker, or a closed
90
+ # tab, is not a send, and a `requested_at` for it would be a lie the
91
+ # ledger tells forever. `last_run` always updates: it is what the
92
+ # next canvas render reads to report what happened, including the
93
+ # failures that need a copy-the-prompt fallback.
94
+ def record_run!(step_number, status:)
95
+ now = Time.now.utc.iso8601
96
+ @data['requested'][step_number.to_s] = now if status.to_s == 'sent'
97
+ @data['last_run'] = { 'step' => step_number.to_i, 'status' => status.to_s, 'at' => now }
98
+ @data['last_done'] = nil
99
+ write
100
+ self
101
+ end
102
+
103
+ def requested_at(step_number)
104
+ @data['requested'][step_number.to_s]
105
+ end
106
+
107
+ # The last Run/Repeat outcome: {'step' =>, 'status' =>, 'at' =>}, or
108
+ # nil before the first click.
109
+ def last_run
110
+ @data['last_run']
111
+ end
112
+
113
+ # The step a Mark-done click just finished: {'step' =>, 'at' =>}, or
114
+ # nil. Mutually exclusive with `last_run` -- each write clears the
115
+ # other, so the canvas only ever has one "what just happened" band to
116
+ # show, whichever action was more recent.
117
+ def last_done
118
+ @data['last_done']
119
+ end
120
+
121
+ # Which step's row is expanded inline on the course list, or nil if
122
+ # none is. canvas-push instance_evals canvas.rb fresh on every render
123
+ # (no in-memory app state survives between pushes -- see canvas.rb's
124
+ # file header), so which row (if any) renders expanded has to live
125
+ # somewhere that does survive: this ledger, same as done/last_run.
126
+ # Also what makes a deep link work -- whoever set this before the
127
+ # canvas was last (re)pushed gets that row auto-expanded on load,
128
+ # with no separate navigation step to get there.
129
+ #
130
+ # Still the `'viewing'` key on disk -- deliberately not renamed to
131
+ # `'expanded'` alongside this method (single-mode, 2026-09-03): only
132
+ # the meaning of "which row" changed (a screen to navigate to versus
133
+ # a row to expand in place), not what's being tracked, and a silent
134
+ # key rename would orphan whatever any already-running canvas last
135
+ # wrote. Renaming the key belongs to a real migration, not this diff.
136
+ def expanded_step
137
+ @data['viewing']
138
+ end
139
+
140
+ # Expands step `step_number`'s row inline on the next render --
141
+ # "Details" on a step row. At most one step is ever expanded: this
142
+ # simply overwrites whichever was expanded before, which is what
143
+ # makes "expanding one collapses others" true by construction.
144
+ def expand_step!(step_number)
145
+ @data['viewing'] = step_number.to_i
146
+ write
147
+ self
148
+ end
149
+
150
+ # Collapses whichever row is expanded -- "Hide" on an expanded row's
151
+ # own Details button, and what mark_done! calls so a Mark-done click
152
+ # never leaves a stale expansion open under the confirmation band.
153
+ def collapse!
154
+ @data['viewing'] = nil
155
+ write
156
+ self
157
+ end
158
+
159
+ # "Reset course": backs up whatever was on disk to `<path>.bak`
160
+ # (overwriting any earlier backup -- one reset's worth of undo, not a
161
+ # history) and returns to the zero-state. Deletes rather than
162
+ # rewrites the file, so this in-memory instance and a freshly loaded
163
+ # one agree the same way every other zero-state case already does
164
+ # (`#read`, below, and the "does not create the file just by reading
165
+ # it" contract) -- there is exactly one representation of "nothing
166
+ # done yet", not two that both mean it.
167
+ def reset!
168
+ FileUtils.cp(@path, "#{@path}.bak") if File.exist?(@path)
169
+ FileUtils.rm_f(@path)
170
+ @data = blank_data
171
+ self
172
+ end
173
+
174
+ private
175
+
176
+ def read
177
+ return blank_data unless File.exist?(@path)
178
+
179
+ loaded = YAML.safe_load(File.read(@path)) || {}
180
+ {
181
+ 'done' => loaded['done'] || {},
182
+ 'requested' => loaded['requested'] || {},
183
+ 'last_run' => loaded['last_run'],
184
+ 'last_done' => loaded['last_done'],
185
+ 'viewing' => loaded['viewing']
186
+ }
187
+ rescue Psych::SyntaxError
188
+ blank_data
189
+ end
190
+
191
+ def blank_data
192
+ { 'done' => {}, 'requested' => {}, 'last_run' => nil, 'last_done' => nil, 'viewing' => nil }
193
+ end
194
+
195
+ def write
196
+ FileUtils.mkdir_p(File.dirname(@path))
197
+ File.write(@path, YAML.dump(@data))
198
+ end
199
+ end
200
+ end
201
+ end