learningfoundry 0.82.0__tar.gz → 0.83.2__tar.gz

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 (125) hide show
  1. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/CHANGELOG.md +53 -0
  2. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/PKG-INFO +41 -14
  3. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/README.md +38 -11
  4. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/pyproject.toml +12 -3
  5. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/__init__.py +1 -1
  6. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/cli.py +114 -0
  7. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/exceptions.py +25 -0
  8. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/generator.py +60 -15
  9. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/integrations/nbfoundry_stub.py +1 -4
  10. learningfoundry-0.83.2/src/learningfoundry/launch.py +349 -0
  11. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/resolver.py +62 -15
  12. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/schema_v1.py +5 -0
  13. learningfoundry-0.83.2/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +124 -0
  14. learningfoundry-0.83.2/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.test.ts +133 -0
  15. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.test.ts +5 -3
  16. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +42 -0
  17. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +20 -0
  18. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +11 -23
  19. learningfoundry-0.82.0/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -128
  20. learningfoundry-0.82.0/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.test.ts +0 -135
  21. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/.gitignore +0 -0
  22. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/LICENSE +0 -0
  23. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/docs/specs/nbfoundry/README.md +0 -0
  24. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/docs/specs/pyve/README.md +0 -0
  25. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/docs/specs/quizazz/README.md +0 -0
  26. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/__main__.py +0 -0
  27. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/asset_resolver.py +0 -0
  28. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/config.py +0 -0
  29. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/directives.py +0 -0
  30. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/integrations/__init__.py +0 -0
  31. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  32. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/integrations/nbfoundry.py +0 -0
  33. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/integrations/protocols.py +0 -0
  34. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/integrations/quizazz.py +0 -0
  35. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/logging_config.py +0 -0
  36. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/parser.py +0 -0
  37. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/pipeline.py +0 -0
  38. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/py.typed +0 -0
  39. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/schema_extensions.py +0 -0
  40. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/README.md +0 -0
  41. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +0 -0
  42. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json +0 -0
  43. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/global-teardown.ts +0 -0
  44. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/lifecycle.spec.ts +0 -0
  45. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
  46. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -0
  47. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +0 -0
  48. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +0 -0
  49. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
  50. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/package.json +0 -0
  51. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
  52. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +0 -0
  53. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/pnpm-workspace.yaml +0 -0
  54. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  55. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  56. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/AssessmentBlock.svelte +0 -0
  57. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/AssessmentBlock.test.ts +0 -0
  58. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  59. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +0 -0
  60. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.test.ts +0 -0
  61. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  62. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +0 -0
  63. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/LockedLessonPlaceholder.svelte +0 -0
  64. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +0 -0
  65. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.test.ts +0 -0
  66. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  67. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  68. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  69. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  70. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +0 -0
  71. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/RecordingPausedBanner.svelte +0 -0
  72. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/RecordingPausedBanner.test.ts +0 -0
  73. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
  74. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
  75. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +0 -0
  76. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  77. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
  78. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  79. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +0 -0
  80. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  81. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  82. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
  83. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +0 -0
  84. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts +0 -0
  85. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +0 -0
  86. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
  87. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  88. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +0 -0
  89. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  90. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.test.ts +0 -0
  91. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.ts +0 -0
  92. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +0 -0
  93. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +0 -0
  94. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/stores/db-init.test.ts +0 -0
  95. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/stores/db-init.ts +0 -0
  96. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +0 -0
  97. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  98. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.test.ts +0 -0
  99. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.ts +0 -0
  100. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/duration.test.ts +0 -0
  101. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/duration.ts +0 -0
  102. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -0
  103. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +0 -0
  104. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown-directives.ts +0 -0
  105. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  106. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  107. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +0 -0
  108. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +0 -0
  109. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  110. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  111. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  112. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  113. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  114. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/page.test.ts +0 -0
  115. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/+page.svelte +0 -0
  116. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/page.test.ts +0 -0
  117. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/layout.helpers.ts +0 -0
  118. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  119. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  120. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
  121. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  122. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  123. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/test-results/.last-run.json +0 -0
  124. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
  125. {learningfoundry-0.82.0 → learningfoundry-0.83.2}/src/learningfoundry/sveltekit_template/vite.config.ts +0 -0
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.83.2] - 2026-06-18
11
+
12
+ **Single-source the package version** (Story K.k) — build hygiene.
13
+
14
+ ### Changed
15
+
16
+ - **`pyproject.toml` derives the version dynamically from `src/learningfoundry/__init__.py`** via Hatchling (`[project].dynamic = ["version"]` + `[tool.hatch.version]`). `__version__` is now the single source of truth; the version can no longer drift between the two files, and a release bumps **only** `__init__.py`. A new `tests/test_packaging.py` guards the wiring against a silent revert to a static `[project].version`.
17
+
18
+ ## [0.83.1] - 2026-06-18
19
+
20
+ **nbfoundry dependency floor pin** (Story K.j.3) — a bugfix patch after verifying the Option-C integration against the now-published real package.
21
+
22
+ ### Fixed
23
+
24
+ - **`[nbfoundry]` extra floor raised `>=0.1` → `>=0.46.0`.** `0.46.0` is the first nbfoundry release honoring the Option-C `compile_exercise` contract (returns `notebook_source` + banner metadata; consumer-dependency-spec BR-1). A pre-0.46 nbfoundry returns the retired Option-B dict, on which the resolver's `notebook_source` pop raises an opaque `KeyError: 'notebook_source'` at the first `ready` exercise — so the old floor could install a broken combination.
25
+
26
+ ### Added
27
+
28
+ - **Live nbfoundry integration test** (`tests/test_integrations/test_nbfoundry_live.py`, `importorskip`-gated) — replaces mock-only coverage of the contract: real `nbfoundry.compile_exercise` returns the Option-C banner shape with a runnable `marimo.App()` `notebook_source` (and `import torch` as *source text*, never imported at build time), then flows resolver → generator into the staged `exercises/<id>/<id>.py` + `exercises-manifest.json`. `nbfoundry>=0.46.0` added to `requirements-dev.txt`.
29
+
30
+ ### Notes
31
+
32
+ - **Integration spike outcome (clean).** Installing nbfoundry 0.46.0 pulls `marimo` + light markdown/ASGI deps but **no torch / modelfoundry** — confirming the contract's torch-free build-time codegen. The published package honors BR-1 end-to-end.
33
+
34
+ ## [0.83.0] - 2026-06-18
35
+
36
+ **Option C — live marimo exercises** (Subphase K-2, Stories K.g–K.j). The single release for the subphase: nbfoundry now compiles a `ready` exercise to a runnable **marimo notebook** instead of a static `sections`/`expected_outputs` dict (which rendered as inert source in a `<pre>` — no executed cells, plots, or metrics). learningfoundry stages the notebook + an `exercises-manifest.json` sidecar, ships a learner-side `launch`/`stop` CLI that owns the notebook's lifecycle, and the frontend `ready` renderer becomes a launch banner.
37
+
38
+ ### Added
39
+
40
+ - **`learningfoundry launch <id>` / `learningfoundry stop [<id>]` CLI** (Stories K.i.1–K.i.4) — the learner-side runtime that owns a `ready` exercise's marimo lifecycle (a static page cannot spawn an OS process). `launch` resolves the notebook path / `mode` / port from `exercises-manifest.json`, socket-probes the port, manages a port-keyed pidfile under `.learningfoundry/`, and spawns `marimo edit|run <notebook> --headless -p <port> --no-token` detached; it offers to replace a **launch-owned** marimo on the port but never blind-kills a **foreign** process. `stop <id>` tears down one exercise; bare `stop` stops all launch-owned notebooks. Cross-platform Python (`marimo` is a learner-runtime dependency found via `PATH`, never imported by the build).
41
+ - **Notebook staging + `exercises-manifest.json`** (Story K.h) — the resolver pulls `notebook_source` out of the banner content into an `ExerciseArtifact`; the generator writes each notebook to `exercises/<id>/<id>.py` (outside the web root) and the `id → {notebook_path, mode, port}` manifest sidecar at the project root.
42
+ - **`ExerciseBlock.mode`** (`edit` | `run`, default `edit`; Story K.g) — the author's per-exercise choice of how `launch` serves the notebook (editable notebook vs. read-only app).
43
+ - **`progressRepo.getExerciseStatus(exerciseRef)`** (Story K.j.1) — reads the persisted `exercise_status` so the banner derives its completed slate on load.
44
+
45
+ ### Changed
46
+
47
+ - **nbfoundry contract → Option C** (Story K.g) — `compile_exercise` returns `{title, description, hints, environment, notebook_source}` (consumer-dependency-spec BR-1); the codegen MUST be torch-free (torch is a learner-runtime dependency only). The pass-through `NbfoundryProvider` carries the new dict unchanged.
48
+ - **`ExerciseBlock.svelte` `ready` renderer → launch banner** (Story K.j.1) — Copy the `learningfoundry launch <id>` command (transient "Copied ✓") → Open Exercise ↗ (new tab to `http://localhost:<port>`) → Mark as Complete (persists `exercise_status`, fires the completion callbacks) → completed slate (derived on load). Stub status still renders the placeholder.
49
+ - **`ExerciseContent` TS type + `stub_exercise()` → banner shape** (Story K.j.1) — `description` replaces `instructions`; `mode`/`port` are ready-only optionals; the resolver injects `id`/`status`/`mode`/`port` onto `ready` content.
50
+
51
+ ### Removed
52
+
53
+ - **The Option-B static-display path** (Stories K.h, K.j.1) — `ExerciseContent.sections`/`expected_outputs`/`assets`, the `ExerciseSection`/`ExpectedOutput` TS types, and the K.e `static/exercises/<id>/` image-asset staging. The live notebook renders its own cells and outputs at run time.
54
+
55
+ ### Notes
56
+
57
+ - **Phase-bundled release.** Stories K.g–K.j ran unversioned; this single **minor** bump (v0.83.0) ships the whole subphase. Future: Marimo-WASM in-browser execution (Option A) for non-GPU exercises and graded `submission` (cell-output scoring) remain deferred — the banner/launch contract is forward-compatible.
58
+
59
+ ### Verified
60
+
61
+ - `pyve test` → 498 passed. `npx vitest run` → 289 passed. `svelte-check` → 0 errors / 0 warnings. `ruff` + `mypy src/` clean.
62
+
10
63
  ## [0.82.0] - 2026-06-18
11
64
 
12
65
  `ExerciseBlock` ready renderer + real `ExerciseContent` types (Story K.f), the third and final story of the Subphase K-1 nbfoundry bundle. A `ready` exercise now renders inline in the SvelteKit frontend (manual-completion flavor) instead of drawing only instructions + hints; graded `submission` remains deferred to `## Future`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.82.0
3
+ Version: 0.83.2
4
4
  Summary: A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application.
5
5
  Project-URL: Homepage, https://github.com/pointmatic/learningfoundry
6
6
  Project-URL: Repository, https://github.com/pointmatic/learningfoundry
@@ -24,9 +24,9 @@ Requires-Dist: click>=8.1
24
24
  Requires-Dist: pydantic>=2.0
25
25
  Requires-Dist: pyyaml>=6.0.3
26
26
  Provides-Extra: nbfoundry
27
- Requires-Dist: nbfoundry>=0.1; extra == 'nbfoundry'
27
+ Requires-Dist: nbfoundry>=0.46; extra == 'nbfoundry'
28
28
  Provides-Extra: quizazz
29
- Requires-Dist: quizazz>=0.1; extra == 'quizazz'
29
+ Requires-Dist: quizazz>=1.4; extra == 'quizazz'
30
30
  Description-Content-Type: text/markdown
31
31
 
32
32
  # learningfoundry
@@ -211,6 +211,27 @@ This serves the SvelteKit project from source via Vite's dev server; it does **n
211
211
 
212
212
  ---
213
213
 
214
+ ### `learningfoundry launch` / `learningfoundry stop`
215
+
216
+ Run a `ready` exercise's marimo notebook locally. These are **learner-side** commands — run from inside the generated app (where `build` writes `exercises-manifest.json`), not from the author's repo. The generated app is static and a browser page can't spawn a process, so the exercise's banner asks the learner to copy and run `learningfoundry launch <id>`.
217
+
218
+ ```
219
+ Usage: learningfoundry launch [OPTIONS] EXERCISE_ID
220
+ learningfoundry stop [OPTIONS] [EXERCISE_ID]
221
+
222
+ Options:
223
+ --dir PATH Directory holding exercises-manifest.json (the
224
+ generated app's root). [default: .]
225
+ --log-level LEVEL Logging verbosity. [default: INFO]
226
+ --help Show this message and exit.
227
+ ```
228
+
229
+ `launch <id>` resolves the notebook path / `mode` / port from the manifest, checks the port, then spawns `marimo edit|run <notebook> --headless -p <port> --no-token` detached (so it outlives the CLI) and prints the local URL. If the port is already held by a **launch-owned** marimo it offers to replace it; a **foreign** process on the port is never killed. `stop <id>` tears down that exercise's notebook; bare `stop` stops every launch-owned notebook.
230
+
231
+ > **`marimo` is a learner-runtime dependency**, not a learningfoundry dependency — `launch` finds it on `PATH` (it is never imported by the build). Install it alongside the exercise's other run requirements (e.g. `pip install marimo torch`); the banner lists them. A missing `marimo` yields an install hint, not a traceback.
232
+
233
+ ---
234
+
214
235
  ## Curriculum YAML Format
215
236
 
216
237
  ```yaml
@@ -701,9 +722,12 @@ questions:
701
722
 
702
723
  ### Authoring nbfoundry exercises
703
724
 
704
- [nbfoundry](https://github.com/pointmatic/nbfoundry) is the exercise provider — scaffolded, model-training exercises that render inline in a lesson with code-scaffold sections, expected outputs, hints, and local-run instructions, plus a "Mark as Complete" control. In v1 the exercise is **informational**: the learner runs the code in their own local environment (JupyterLab, Marimo, VS Code) and marks it complete. In-browser execution and graded submission are deferred to future versions; the authored YAML does not change when they land.
725
+ [nbfoundry](https://github.com/pointmatic/nbfoundry) is the exercise provider — scaffolded, model-training exercises. Under the hood (Option C) nbfoundry compiles each exercise to a **runnable marimo notebook**; learningfoundry stages it at build time and the lesson renders a **launch banner**. The learner copies the `learningfoundry launch <id>` command, runs it to start the notebook locally, opens it in a new tab, works through the cells, and clicks **Mark as Complete**. The exercise runs in the learner's own Python environment, so PyTorch and other heavy dependencies stay out of the build and the browser. In-browser (Marimo-WASM) execution and graded submission are deferred to future versions; the authored YAML does not change when they land.
726
+
727
+ **What you need.**
705
728
 
706
- **What you need.** `pip install learningfoundry[nbfoundry]` installs the Python builder side (the [`nbfoundry` PyPI package](https://pypi.org/project/nbfoundry/)) so `learningfoundry build` can compile exercise YAML. Like `[quizazz]`, it is an optional extra — plain `pip install learningfoundry` does not pull it in, and building a curriculum with a `ready` exercise that references `source: nbfoundry` without the extra fails with an `ImportError` and an install hint.
729
+ - *Build side:* `pip install learningfoundry[nbfoundry]` installs the [`nbfoundry` PyPI package](https://pypi.org/project/nbfoundry/) so `learningfoundry build` can compile exercise YAML into the notebook. Like `[quizazz]`, it is an optional extra — plain `pip install learningfoundry` does not pull it in, and a `ready` exercise referencing `source: nbfoundry` without it fails the build with an `ImportError` and an install hint.
730
+ - *Learner side:* running the exercise needs **`marimo`** (plus the exercise's own dependencies, e.g. `torch`) on the learner's `PATH`. `learningfoundry launch` finds `marimo` there — it is a **learner-runtime** dependency, never imported by the build. The banner lists the exercise's prerequisites.
707
731
 
708
732
  **Referencing an exercise.** Add an `exercise` content block to a lesson:
709
733
 
@@ -713,23 +737,26 @@ content_blocks:
713
737
  source: nbfoundry
714
738
  ref: exercises/mod-01/cnn-classifier.yml # located under --base-dir
715
739
  status: ready # "ready" (default) | "stub"
740
+ mode: edit # "edit" (default) | "run"
716
741
  # id: cnn-classifier # optional; see below
717
742
  ```
718
743
 
719
- - **`status: ready`** (the default) compiles the exercise through nbfoundry at build time. A typo'd `ref` fails the build **loud** rather than silently degrading to a placeholder.
744
+ - **`status: ready`** (the default) compiles the exercise to a notebook at build time. A typo'd `ref` fails the build **loud** rather than silently degrading to a placeholder.
720
745
  - **`status: stub`** renders the "nbfoundry integration pending" placeholder card — the explicit "not built yet" opt-in while you scaffold the curriculum. No nbfoundry call, no install needed for stub-only curricula.
746
+ - **`mode`** chooses how `learningfoundry launch` serves the notebook: **`edit`** (the default) opens marimo's editable notebook (the learner writes code into the scaffold); **`run`** opens it as a read-only app. It is the same notebook either way — the choice is pedagogical.
721
747
 
722
- **How `id` works.** Every exercise has an `id` that is two things at once: the **build-output asset namespace** (`static/exercises/<id>/…`, where expected-output images and other assets are staged) and the **progress key** (`exerciseRef`) recorded in the in-browser database. When you omit `id:`, it is auto-derived from the `ref` filename **stem** (`exercises/mod-01/cnn-classifier.yml` → `cnn-classifier`). The `id` must be **unique across the whole curriculum** — not just within a module.
748
+ **How `id` works.** Every exercise has an `id` that is two things at once: the **notebook namespace** (the build writes the marimo notebook to `exercises/<id>/<id>.py` and indexes it in `exercises-manifest.json`, which `learningfoundry launch <id>` reads) and the **progress key** (`exerciseRef`) recorded in the in-browser database. When you omit `id:`, it is auto-derived from the `ref` filename **stem** (`exercises/mod-01/cnn-classifier.yml` → `cnn-classifier`). The `id` must be **unique across the whole curriculum** — not just within a module.
723
749
 
724
- **Organizing source freely vs. the flat output.** The `id` namespaces the *output*; it does **not** constrain where you organize *source* content. You can lay out exercise YAML and its assets however you like under `--base-dir` — nbfoundry locates them via the relative `ref` and the asset paths inside the compiled dict — and learningfoundry stages every referenced asset into the flat `static/exercises/<id>/<path>` tree:
750
+ **Organizing source freely.** The `id` namespaces the *output notebook*; it does **not** constrain where you organize *source* content. Lay out exercise YAML however you like under `--base-dir` — nbfoundry locates it via the relative `ref`:
725
751
 
726
752
  ```
727
- # Source (organize however you like): # Build output (flat, id-namespaced):
728
- exercises/mod-01/cnn-classifier.yml static/exercises/cnn-classifier/loss-curve.png
729
- exercises/mod-01/assets/loss-curve.png static/exercises/cnn-classifier/sample.csv
730
- exercises/shared/sample.csv
753
+ # Source (organize however you like): # Build output (id-namespaced notebook):
754
+ exercises/mod-01/cnn-classifier.yml dist/exercises/cnn-classifier/cnn-classifier.py
755
+ exercises/mod-02/intro.yml dist/exercises/intro/intro.py
731
756
  ```
732
757
 
758
+ The notebook renders its own plots, tables, and metrics when it runs — there is no separate image-staging step (that was the retired Option-B static-display path).
759
+
733
760
  **The stem-collision case.** Because the auto-derived `id` is just the filename stem, two exercises whose `ref` files share a stem collide — even in different modules:
734
761
 
735
762
  ```yaml
@@ -737,7 +764,7 @@ exercises/shared/sample.csv
737
764
  # mod-02 lesson: ref: exercises/mod-02/intro.yml → id "intro" ❌ build error
738
765
  ```
739
766
 
740
- This is a **loud build-time error**, not a silent overwrite (two exercises would otherwise share one asset URL + progress key). Fix it by setting an explicit `id:` on at least one:
767
+ This is a **loud build-time error**, not a silent overwrite (two exercises would otherwise share one notebook path + progress key). Fix it by setting an explicit `id:` on at least one:
741
768
 
742
769
  ```yaml
743
770
  - type: exercise
@@ -746,7 +773,7 @@ This is a **loud build-time error**, not a silent overwrite (two exercises would
746
773
  id: mod-02-intro # explicit, curriculum-unique
747
774
  ```
748
775
 
749
- **Why a stable `id` matters.** Set an explicit `id:` when you expect to **reorganize source content** later. Because the `id` is both the asset URL namespace and the progress key, keeping it stable while you move or rename the underlying `ref` file preserves both the staged asset URLs *and* learners' recorded completion — whereas relying on the auto-derived stem means a rename silently changes the `id` and orphans prior progress.
776
+ **Why a stable `id` matters.** Set an explicit `id:` when you expect to **reorganize source content** later. Because the `id` is both the notebook namespace and the progress key, keeping it stable while you move or rename the underlying `ref` file preserves both the `learningfoundry launch <id>` command *and* learners' recorded completion — whereas relying on the auto-derived stem means a rename silently changes the `id` and orphans prior progress.
750
777
 
751
778
  ### Assessments
752
779
 
@@ -180,6 +180,27 @@ This serves the SvelteKit project from source via Vite's dev server; it does **n
180
180
 
181
181
  ---
182
182
 
183
+ ### `learningfoundry launch` / `learningfoundry stop`
184
+
185
+ Run a `ready` exercise's marimo notebook locally. These are **learner-side** commands — run from inside the generated app (where `build` writes `exercises-manifest.json`), not from the author's repo. The generated app is static and a browser page can't spawn a process, so the exercise's banner asks the learner to copy and run `learningfoundry launch <id>`.
186
+
187
+ ```
188
+ Usage: learningfoundry launch [OPTIONS] EXERCISE_ID
189
+ learningfoundry stop [OPTIONS] [EXERCISE_ID]
190
+
191
+ Options:
192
+ --dir PATH Directory holding exercises-manifest.json (the
193
+ generated app's root). [default: .]
194
+ --log-level LEVEL Logging verbosity. [default: INFO]
195
+ --help Show this message and exit.
196
+ ```
197
+
198
+ `launch <id>` resolves the notebook path / `mode` / port from the manifest, checks the port, then spawns `marimo edit|run <notebook> --headless -p <port> --no-token` detached (so it outlives the CLI) and prints the local URL. If the port is already held by a **launch-owned** marimo it offers to replace it; a **foreign** process on the port is never killed. `stop <id>` tears down that exercise's notebook; bare `stop` stops every launch-owned notebook.
199
+
200
+ > **`marimo` is a learner-runtime dependency**, not a learningfoundry dependency — `launch` finds it on `PATH` (it is never imported by the build). Install it alongside the exercise's other run requirements (e.g. `pip install marimo torch`); the banner lists them. A missing `marimo` yields an install hint, not a traceback.
201
+
202
+ ---
203
+
183
204
  ## Curriculum YAML Format
184
205
 
185
206
  ```yaml
@@ -670,9 +691,12 @@ questions:
670
691
 
671
692
  ### Authoring nbfoundry exercises
672
693
 
673
- [nbfoundry](https://github.com/pointmatic/nbfoundry) is the exercise provider — scaffolded, model-training exercises that render inline in a lesson with code-scaffold sections, expected outputs, hints, and local-run instructions, plus a "Mark as Complete" control. In v1 the exercise is **informational**: the learner runs the code in their own local environment (JupyterLab, Marimo, VS Code) and marks it complete. In-browser execution and graded submission are deferred to future versions; the authored YAML does not change when they land.
694
+ [nbfoundry](https://github.com/pointmatic/nbfoundry) is the exercise provider — scaffolded, model-training exercises. Under the hood (Option C) nbfoundry compiles each exercise to a **runnable marimo notebook**; learningfoundry stages it at build time and the lesson renders a **launch banner**. The learner copies the `learningfoundry launch <id>` command, runs it to start the notebook locally, opens it in a new tab, works through the cells, and clicks **Mark as Complete**. The exercise runs in the learner's own Python environment, so PyTorch and other heavy dependencies stay out of the build and the browser. In-browser (Marimo-WASM) execution and graded submission are deferred to future versions; the authored YAML does not change when they land.
695
+
696
+ **What you need.**
674
697
 
675
- **What you need.** `pip install learningfoundry[nbfoundry]` installs the Python builder side (the [`nbfoundry` PyPI package](https://pypi.org/project/nbfoundry/)) so `learningfoundry build` can compile exercise YAML. Like `[quizazz]`, it is an optional extra — plain `pip install learningfoundry` does not pull it in, and building a curriculum with a `ready` exercise that references `source: nbfoundry` without the extra fails with an `ImportError` and an install hint.
698
+ - *Build side:* `pip install learningfoundry[nbfoundry]` installs the [`nbfoundry` PyPI package](https://pypi.org/project/nbfoundry/) so `learningfoundry build` can compile exercise YAML into the notebook. Like `[quizazz]`, it is an optional extra — plain `pip install learningfoundry` does not pull it in, and a `ready` exercise referencing `source: nbfoundry` without it fails the build with an `ImportError` and an install hint.
699
+ - *Learner side:* running the exercise needs **`marimo`** (plus the exercise's own dependencies, e.g. `torch`) on the learner's `PATH`. `learningfoundry launch` finds `marimo` there — it is a **learner-runtime** dependency, never imported by the build. The banner lists the exercise's prerequisites.
676
700
 
677
701
  **Referencing an exercise.** Add an `exercise` content block to a lesson:
678
702
 
@@ -682,23 +706,26 @@ content_blocks:
682
706
  source: nbfoundry
683
707
  ref: exercises/mod-01/cnn-classifier.yml # located under --base-dir
684
708
  status: ready # "ready" (default) | "stub"
709
+ mode: edit # "edit" (default) | "run"
685
710
  # id: cnn-classifier # optional; see below
686
711
  ```
687
712
 
688
- - **`status: ready`** (the default) compiles the exercise through nbfoundry at build time. A typo'd `ref` fails the build **loud** rather than silently degrading to a placeholder.
713
+ - **`status: ready`** (the default) compiles the exercise to a notebook at build time. A typo'd `ref` fails the build **loud** rather than silently degrading to a placeholder.
689
714
  - **`status: stub`** renders the "nbfoundry integration pending" placeholder card — the explicit "not built yet" opt-in while you scaffold the curriculum. No nbfoundry call, no install needed for stub-only curricula.
715
+ - **`mode`** chooses how `learningfoundry launch` serves the notebook: **`edit`** (the default) opens marimo's editable notebook (the learner writes code into the scaffold); **`run`** opens it as a read-only app. It is the same notebook either way — the choice is pedagogical.
690
716
 
691
- **How `id` works.** Every exercise has an `id` that is two things at once: the **build-output asset namespace** (`static/exercises/<id>/…`, where expected-output images and other assets are staged) and the **progress key** (`exerciseRef`) recorded in the in-browser database. When you omit `id:`, it is auto-derived from the `ref` filename **stem** (`exercises/mod-01/cnn-classifier.yml` → `cnn-classifier`). The `id` must be **unique across the whole curriculum** — not just within a module.
717
+ **How `id` works.** Every exercise has an `id` that is two things at once: the **notebook namespace** (the build writes the marimo notebook to `exercises/<id>/<id>.py` and indexes it in `exercises-manifest.json`, which `learningfoundry launch <id>` reads) and the **progress key** (`exerciseRef`) recorded in the in-browser database. When you omit `id:`, it is auto-derived from the `ref` filename **stem** (`exercises/mod-01/cnn-classifier.yml` → `cnn-classifier`). The `id` must be **unique across the whole curriculum** — not just within a module.
692
718
 
693
- **Organizing source freely vs. the flat output.** The `id` namespaces the *output*; it does **not** constrain where you organize *source* content. You can lay out exercise YAML and its assets however you like under `--base-dir` — nbfoundry locates them via the relative `ref` and the asset paths inside the compiled dict — and learningfoundry stages every referenced asset into the flat `static/exercises/<id>/<path>` tree:
719
+ **Organizing source freely.** The `id` namespaces the *output notebook*; it does **not** constrain where you organize *source* content. Lay out exercise YAML however you like under `--base-dir` — nbfoundry locates it via the relative `ref`:
694
720
 
695
721
  ```
696
- # Source (organize however you like): # Build output (flat, id-namespaced):
697
- exercises/mod-01/cnn-classifier.yml static/exercises/cnn-classifier/loss-curve.png
698
- exercises/mod-01/assets/loss-curve.png static/exercises/cnn-classifier/sample.csv
699
- exercises/shared/sample.csv
722
+ # Source (organize however you like): # Build output (id-namespaced notebook):
723
+ exercises/mod-01/cnn-classifier.yml dist/exercises/cnn-classifier/cnn-classifier.py
724
+ exercises/mod-02/intro.yml dist/exercises/intro/intro.py
700
725
  ```
701
726
 
727
+ The notebook renders its own plots, tables, and metrics when it runs — there is no separate image-staging step (that was the retired Option-B static-display path).
728
+
702
729
  **The stem-collision case.** Because the auto-derived `id` is just the filename stem, two exercises whose `ref` files share a stem collide — even in different modules:
703
730
 
704
731
  ```yaml
@@ -706,7 +733,7 @@ exercises/shared/sample.csv
706
733
  # mod-02 lesson: ref: exercises/mod-02/intro.yml → id "intro" ❌ build error
707
734
  ```
708
735
 
709
- This is a **loud build-time error**, not a silent overwrite (two exercises would otherwise share one asset URL + progress key). Fix it by setting an explicit `id:` on at least one:
736
+ This is a **loud build-time error**, not a silent overwrite (two exercises would otherwise share one notebook path + progress key). Fix it by setting an explicit `id:` on at least one:
710
737
 
711
738
  ```yaml
712
739
  - type: exercise
@@ -715,7 +742,7 @@ This is a **loud build-time error**, not a silent overwrite (two exercises would
715
742
  id: mod-02-intro # explicit, curriculum-unique
716
743
  ```
717
744
 
718
- **Why a stable `id` matters.** Set an explicit `id:` when you expect to **reorganize source content** later. Because the `id` is both the asset URL namespace and the progress key, keeping it stable while you move or rename the underlying `ref` file preserves both the staged asset URLs *and* learners' recorded completion — whereas relying on the auto-derived stem means a rename silently changes the `id` and orphans prior progress.
745
+ **Why a stable `id` matters.** Set an explicit `id:` when you expect to **reorganize source content** later. Because the `id` is both the notebook namespace and the progress key, keeping it stable while you move or rename the underlying `ref` file preserves both the `learningfoundry launch <id>` command *and* learners' recorded completion — whereas relying on the auto-derived stem means a rename silently changes the `id` and orphans prior progress.
719
746
 
720
747
  ### Assessments
721
748
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "learningfoundry"
7
- version = "0.82.0"
7
+ dynamic = ["version"]
8
8
  description = "A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -32,10 +32,14 @@ dependencies = [
32
32
 
33
33
  [project.optional-dependencies]
34
34
  quizazz = [
35
- "quizazz>=0.1",
35
+ "quizazz>=1.4",
36
36
  ]
37
37
  nbfoundry = [
38
- "nbfoundry>=0.1",
38
+ # >=0.46.0 is the first release honoring the Option-C `compile_exercise`
39
+ # contract (returns `notebook_source` + banner metadata; consumer-
40
+ # dependency-spec BR-1). Pre-0.46 returns the retired Option-B dict, which
41
+ # the resolver's `notebook_source` pop cannot consume (Story K.j.3).
42
+ "nbfoundry>=0.46",
39
43
  ]
40
44
 
41
45
  [project.urls]
@@ -47,6 +51,11 @@ Changelog = "https://github.com/pointmatic/learningfoundry/blob/main/CHANGELOG.m
47
51
  [project.scripts]
48
52
  learningfoundry = "learningfoundry.cli:main"
49
53
 
54
+ # Single-source the package version from `__version__` in the package's
55
+ # `__init__.py` (Story K.k). The default `regex` source reads `__version__`.
56
+ [tool.hatch.version]
57
+ path = "src/learningfoundry/__init__.py"
58
+
50
59
  [tool.hatch.build.targets.wheel]
51
60
  packages = ["src/learningfoundry"]
52
61
 
@@ -1,4 +1,4 @@
1
1
  # Copyright 2026 Pointmatic
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
- __version__ = "0.82.0"
4
+ __version__ = "0.83.2"
@@ -1,6 +1,7 @@
1
1
  # Copyright 2026 Pointmatic
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ import shutil
4
5
  import sys
5
6
  from pathlib import Path
6
7
 
@@ -13,6 +14,7 @@ from learningfoundry.exceptions import (
13
14
  CurriculumValidationError,
14
15
  CurriculumVersionError,
15
16
  GenerationError,
17
+ LaunchError,
16
18
  SchemaExtensionError,
17
19
  )
18
20
  from learningfoundry.logging_config import setup_logging as _setup_logging
@@ -24,6 +26,7 @@ EXIT_VALIDATION = 1
24
26
  EXIT_RESOLUTION = 2
25
27
  EXIT_GENERATION = 3
26
28
  EXIT_CONFIG = 4
29
+ EXIT_RUNTIME = 5
27
30
 
28
31
 
29
32
  # ---------------------------------------------------------------------------
@@ -270,3 +273,114 @@ def preview(
270
273
  sys.exit(EXIT_CONFIG)
271
274
 
272
275
  click.echo(f"Preview server started at http://localhost:{port}")
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # launch
280
+ # ---------------------------------------------------------------------------
281
+
282
+ _launch_dir_option = click.option(
283
+ "--dir",
284
+ "launch_dir",
285
+ type=click.Path(file_okay=False, path_type=Path),
286
+ default=Path("."),
287
+ show_default=True,
288
+ help=(
289
+ "Directory holding `exercises-manifest.json` (the generated app's "
290
+ "root). Defaults to the current directory."
291
+ ),
292
+ )
293
+
294
+
295
+ @main.command()
296
+ @click.argument("exercise_id")
297
+ @_launch_dir_option
298
+ @_log_level_option
299
+ def launch(exercise_id: str, launch_dir: Path, log_level: str) -> None:
300
+ """Launch an exercise's marimo notebook locally."""
301
+ _setup_logging(level=log_level)
302
+
303
+ from learningfoundry import launch as _launch
304
+
305
+ try:
306
+ spec = _launch.resolve_launch_spec(launch_dir, exercise_id)
307
+ except LaunchError as exc:
308
+ click.echo(f"Launch error: {exc}", err=True)
309
+ sys.exit(EXIT_VALIDATION)
310
+
311
+ if shutil.which("marimo") is None:
312
+ click.echo(
313
+ "marimo not found on PATH. It is a learner-runtime dependency — "
314
+ "install it (e.g. `pip install marimo`) to run exercises.",
315
+ err=True,
316
+ )
317
+ sys.exit(EXIT_RUNTIME)
318
+
319
+ status = _launch.classify_port(launch_dir, spec.port)
320
+ if status == "foreign":
321
+ click.echo(
322
+ f"Port {spec.port} is in use by another process. Refusing to "
323
+ "kill it — free the port (or stop that process) and retry.",
324
+ err=True,
325
+ )
326
+ sys.exit(EXIT_RUNTIME)
327
+ if status == "ours":
328
+ if not click.confirm(
329
+ f"An exercise is already running on port {spec.port}. Replace it?"
330
+ ):
331
+ click.echo("Left the running exercise in place.")
332
+ return
333
+ _launch.stop_launch_on_port(launch_dir, spec.port)
334
+
335
+ pid = _launch.spawn_detached(_launch.marimo_argv(spec), launch_dir)
336
+ _launch.write_pidfile(
337
+ launch_dir,
338
+ _launch.PidfileEntry(
339
+ pid=pid,
340
+ exercise_id=spec.id,
341
+ port=spec.port,
342
+ mode=spec.mode,
343
+ ),
344
+ )
345
+ click.echo(
346
+ f"Launched `{spec.id}` ({spec.mode}) → http://localhost:{spec.port}"
347
+ )
348
+ click.echo(f"Stop it with: learningfoundry stop {spec.id}")
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # stop
353
+ # ---------------------------------------------------------------------------
354
+
355
+ @main.command()
356
+ @click.argument("exercise_id", required=False)
357
+ @_launch_dir_option
358
+ @_log_level_option
359
+ def stop(exercise_id: str | None, launch_dir: Path, log_level: str) -> None:
360
+ """Stop a launch-owned marimo notebook (all of them if no id is given)."""
361
+ _setup_logging(level=log_level)
362
+
363
+ from learningfoundry import launch as _launch
364
+
365
+ if exercise_id is not None:
366
+ try:
367
+ spec = _launch.resolve_launch_spec(launch_dir, exercise_id)
368
+ except LaunchError as exc:
369
+ click.echo(f"Stop error: {exc}", err=True)
370
+ sys.exit(EXIT_VALIDATION)
371
+ stopped = _launch.stop_launch_on_port(launch_dir, spec.port)
372
+ if stopped is not None:
373
+ click.echo(f"Stopped `{stopped.exercise_id}` (port {stopped.port}).")
374
+ else:
375
+ click.echo(f"No running exercise found for `{exercise_id}`.")
376
+ return
377
+
378
+ # No id → stop every launch-owned marimo.
379
+ ports = _launch.launched_ports(launch_dir)
380
+ if not ports:
381
+ click.echo("No running exercises.")
382
+ return
383
+ for port in ports:
384
+ stopped = _launch.stop_launch_on_port(launch_dir, port)
385
+ if stopped is not None:
386
+ click.echo(f"Stopped `{stopped.exercise_id}` (port {stopped.port}).")
@@ -43,3 +43,28 @@ class GenerationError(LearningFoundryError):
43
43
  class SchemaExtensionError(LearningFoundryError):
44
44
  """Project schema-extensions file is missing, malformed, or declares an
45
45
  unsupported field type / shape (Story J.h)."""
46
+
47
+
48
+ class LaunchError(LearningFoundryError):
49
+ """Base for `learningfoundry launch` / `stop` runtime errors (Story K.i)."""
50
+
51
+
52
+ class ManifestNotFoundError(LaunchError):
53
+ """The `exercises-manifest.json` sidecar was not found in the launch dir.
54
+
55
+ The learner runs `launch`/`stop` from inside the generated app's root,
56
+ where `build` writes the manifest; a missing file usually means the wrong
57
+ directory or an un-built app.
58
+ """
59
+
60
+
61
+ class UnknownExerciseError(LaunchError):
62
+ """The requested exercise id is absent from the manifest.
63
+
64
+ The message lists the ids the learner can actually launch.
65
+ """
66
+
67
+
68
+ class ManifestError(LaunchError):
69
+ """The `exercises-manifest.json` sidecar is malformed (bad JSON, not an
70
+ object, or an entry missing required fields)."""
@@ -27,10 +27,9 @@ _TEMPLATE_DIR = Path(__file__).parent / "sveltekit_template"
27
27
  # rebuild — keeps `learningfoundry preview` snappy when only markdown
28
28
  # text changed and no new images were introduced.
29
29
  #
30
- # `static/exercises` is preserved for the same reason for nbfoundry exercise
31
- # assets (staged non-hashed under `static/exercises/<id>/<path>`, Story K.e),
32
- # so a rebuild that didn't touch a given exercise's assets leaves them in
33
- # place.
30
+ # (Marimo exercise notebooks live at `exercises/<id>/<id>.py` outside the
31
+ # web root and are regenerated every build by `_write_exercises`, so they
32
+ # are intentionally NOT preserved here.)
34
33
  #
35
34
  # `static/sql-wasm.wasm` is preserved because it is gitignored and not
36
35
  # shipped in the template — `pipeline._ensure_sql_wasm` is the single
@@ -43,7 +42,6 @@ _PRESERVED_PATHS: tuple[str, ...] = (
43
42
  "build",
44
43
  ".svelte-kit",
45
44
  "static/content",
46
- "static/exercises",
47
45
  "static/sql-wasm.wasm",
48
46
  )
49
47
 
@@ -103,6 +101,7 @@ def generate_app(
103
101
 
104
102
  _atomic_copy(src, output_dir)
105
103
  _copy_assets(resolved, output_dir)
104
+ _write_exercises(resolved, output_dir)
106
105
  _write_curriculum_json(resolved, output_dir)
107
106
 
108
107
  logger.info("Generated SvelteKit project at: %s", output_dir)
@@ -216,9 +215,10 @@ def _compute_total_duration_minutes(resolved: ResolvedCurriculum) -> int | None:
216
215
  def _write_curriculum_json(resolved: ResolvedCurriculum, output_dir: Path) -> None:
217
216
  """Serialize ResolvedCurriculum to output_dir/static/curriculum.json.
218
217
 
219
- The ``assets`` list is intentionally stripped — it carries on-disk
220
- ``Path`` objects (which are not JSON-serialisable) and is consumed only
221
- by the generator's asset-copy step, never by the SvelteKit frontend.
218
+ The ``assets`` and ``exercises`` lists are intentionally stripped — they
219
+ carry build-output metadata (on-disk ``Path`` objects / notebook source)
220
+ consumed only by the generator's copy/write steps, never by the SvelteKit
221
+ frontend.
222
222
  """
223
223
  static_dir = output_dir / "static"
224
224
  static_dir.mkdir(parents=True, exist_ok=True)
@@ -227,6 +227,7 @@ def _write_curriculum_json(resolved: ResolvedCurriculum, output_dir: Path) -> No
227
227
  try:
228
228
  data = dataclasses.asdict(resolved)
229
229
  data.pop("assets", None)
230
+ data.pop("exercises", None)
230
231
  data["total_duration_minutes"] = _compute_total_duration_minutes(resolved)
231
232
  curriculum_json.write_text(
232
233
  json.dumps(data, indent=2, ensure_ascii=False) + "\n",
@@ -240,17 +241,61 @@ def _write_curriculum_json(resolved: ResolvedCurriculum, output_dir: Path) -> No
240
241
  logger.debug("Wrote curriculum.json (%d bytes)", curriculum_json.stat().st_size)
241
242
 
242
243
 
244
+ def _write_exercises(resolved: ResolvedCurriculum, output_dir: Path) -> None:
245
+ """Write each ``ready`` exercise's marimo notebook to its runnable path and
246
+ emit the ``exercises-manifest.json`` sidecar at the project root.
247
+
248
+ Notebooks live **outside** ``static/`` — the learner runs them with
249
+ ``marimo`` (via ``learningfoundry launch``), they are not web-served. The
250
+ sidecar maps ``id → {notebook_path, mode, port}``; ``learningfoundry
251
+ launch`` reads it to serve the right notebook. Both are regenerated each
252
+ build (not preserved) — only the curriculum author runs ``build``; the
253
+ learner runs the cloned output, so their marimo edits are never clobbered.
254
+ """
255
+ if not resolved.exercises:
256
+ return
257
+
258
+ manifest: dict[str, dict[str, object]] = {}
259
+ for ex in resolved.exercises:
260
+ notebook = output_dir / ex.notebook_path
261
+ try:
262
+ notebook.parent.mkdir(parents=True, exist_ok=True)
263
+ notebook.write_text(ex.notebook_source, encoding="utf-8")
264
+ except OSError as exc:
265
+ raise GenerationError(
266
+ f"Failed to write exercise notebook `{notebook}`: {exc}"
267
+ ) from exc
268
+ manifest[ex.id] = {
269
+ "notebook_path": ex.notebook_path,
270
+ "mode": ex.mode,
271
+ "port": ex.port,
272
+ }
273
+
274
+ manifest_path = output_dir / "exercises-manifest.json"
275
+ try:
276
+ manifest_path.write_text(
277
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
278
+ encoding="utf-8",
279
+ )
280
+ except OSError as exc:
281
+ raise GenerationError(
282
+ f"Failed to write exercises manifest to `{manifest_path}`: {exc}"
283
+ ) from exc
284
+
285
+ logger.info(
286
+ "Wrote %d exercise notebook(s) + manifest.", len(resolved.exercises)
287
+ )
288
+
289
+
243
290
  def _copy_assets(resolved: ResolvedCurriculum, output_dir: Path) -> None:
244
291
  """Copy each ``Asset`` to ``output_dir/static/<dest_relative>``.
245
292
 
246
293
  Idempotent: a destination file whose size matches the source is left
247
- untouched. For content-hashed image paths
248
- (``content/<sha256[:12]>/<basename>``) a matching size on a matching-hash
249
- path implies matching content, so the skip is exact. For non-hashed
250
- exercise paths (``exercises/<id>/<path>``, Story K.e) the size check is a
251
- cheap heuristic a same-size edit to a staged exercise asset would be
252
- skipped; the `static/exercises` preservation across rebuilds plus the
253
- stable `id` namespace make that an acceptable trade for fast rebuilds.
294
+ untouched. ``dest_relative`` is content-hashed
295
+ (``content/<sha256[:12]>/<basename>``), so a matching size on a
296
+ matching-hash path implies matching content re-copying would be wasted
297
+ I/O. (Marimo exercise notebooks are written separately by
298
+ ``_write_exercises``, not copied here.)
254
299
  """
255
300
  if not resolved.assets:
256
301
  return
@@ -27,13 +27,10 @@ def stub_exercise(ref_path: Path) -> dict: # type: ignore[type-arg]
27
27
  "ref": str(ref_path),
28
28
  "status": "stub",
29
29
  "title": f"Exercise: {ref_path.stem}",
30
- "instructions": (
30
+ "description": (
31
31
  f"<p>Exercise placeholder for <code>{ref_path}</code>. "
32
32
  "nbfoundry integration pending.</p>"
33
33
  ),
34
- "sections": [],
35
- "expected_outputs": [],
36
- "assets": [],
37
34
  "hints": [],
38
35
  "environment": None,
39
36
  }